From 547595041eb661b9038245c082f65e5fb19a6f73 Mon Sep 17 00:00:00 2001 From: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com> Date: Fri, 19 Jun 2026 22:36:28 +0000 Subject: [PATCH] ios: open SimpleX links in chat messages via in-app connect flow (#7101) * ios: open SimpleX links in chat messages via in-app connect flow Tapping an inline SimpleX connection link in message text was dispatched through UIApplication.shared.open. iOS drops an open() of a URL owned by the same app while it is in the foreground (the simplex: scheme and the simplex.chat universal links both belong to this app), so the tap was ignored and never reached the connection flow. Web links (Safari) and mailto:/tel: (other apps) were unaffected, which is why only SimpleX links appeared dead. Route SimpleX links to ChatModel.appOpenUrl instead - the same sink onOpenURL feeds, leading to connectViaUrl/planAndConnect. This matches the connection-link card and the multiplatform clients, which connect in-process rather than via an OS round-trip. Also fixes the same problem for the "Send questions and ideas" and "connect to SimpleX Chat developers" buttons, which open simplexTeamURL (a simplex: link) the same broken way. * docs: plan - justify iOS in-app dispatch for SimpleX links in messages Root cause and justification for opening inline SimpleX links via the in-app connect flow instead of UIApplication.shared.open (undefined re-entry of the same foreground app for a self-owned simplex: URL). --- .../Views/Chat/ChatItem/MsgContentView.swift | 16 ++++- apps/ios/Shared/Views/ChatList/ChatHelp.swift | 4 +- .../Views/UserSettings/SettingsView.swift | 4 +- ...6-19-ios-open-simplex-links-in-messages.md | 65 +++++++++++++++++++ 4 files changed, 84 insertions(+), 5 deletions(-) create mode 100644 plans/2026-06-19-ios-open-simplex-links-in-messages.md diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift index 9aaff57cc5..11c3c4c3f4 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift @@ -191,9 +191,14 @@ private func handleTextTaps( } } } - if let index, let (uri, browser) = attributedStringLink(s, for: index) { + if let index, let (uri, browser, simplex) = attributedStringLink(s, for: index) { if browser { openBrowserAlert(uri: uri) + } else if simplex, let url = URL(string: uri) { + // SimpleX links target this same app (simplex: scheme / simplex.chat universal link), + // so UIApplication.shared.open is dropped by iOS while the app is in the foreground. + // Route to the in-app connect flow instead (same sink onOpenURL feeds). + ChatModel.shared.appOpenUrl = url } else if let url = URL(string: uri) { UIApplication.shared.open(url) } else { @@ -203,9 +208,10 @@ private func handleTextTaps( }) } - func attributedStringLink(_ s: NSAttributedString, for index: CFIndex) -> (String, Bool)? { + func attributedStringLink(_ s: NSAttributedString, for index: CFIndex) -> (String, Bool, Bool)? { var linkURL: String? var browser: Bool = false + var simplex: Bool = false s.enumerateAttributes(in: NSRange(location: 0, length: s.length)) { attrs, range, stop in if index >= range.location && index < range.location + range.length { if let nameInfo = attrs[nameAttrKey] as? SimplexNameInfo { @@ -213,6 +219,7 @@ private func handleTextTaps( } else if let url = attrs[linkAttrKey] as? String { linkURL = url browser = attrs[webLinkAttrKey] != nil + simplex = attrs[simplexLinkAttrKey] != nil } else if let showSecrets, let i = attrs[secretAttrKey] as? Int { if showSecrets.wrappedValue.contains(i) { showSecrets.wrappedValue.remove(i) @@ -225,7 +232,7 @@ private func handleTextTaps( stop.pointee = true } } - return if let linkURL { (linkURL, browser) } else { nil } + return if let linkURL { (linkURL, browser, simplex) } else { nil } } } @@ -250,6 +257,8 @@ private let linkAttrKey = NSAttributedString.Key("chat.simplex.app.link") private let webLinkAttrKey = NSAttributedString.Key("chat.simplex.app.webLink") +private let simplexLinkAttrKey = NSAttributedString.Key("chat.simplex.app.simplexLink") + private let secretAttrKey = NSAttributedString.Key("chat.simplex.app.secret") private let commandAttrKey = NSAttributedString.Key("chat.simplex.app.command") @@ -392,6 +401,7 @@ func messageText( attrs = linkAttrs() if !preview { attrs[linkAttrKey] = simplexUri + attrs[simplexLinkAttrKey] = true handleTaps = true } if let s = text ?? (privacySimplexLinkModeDefault.get() == .description ? linkType.description : nil) { diff --git a/apps/ios/Shared/Views/ChatList/ChatHelp.swift b/apps/ios/Shared/Views/ChatList/ChatHelp.swift index 3047572236..5844fd3ff9 100644 --- a/apps/ios/Shared/Views/ChatList/ChatHelp.swift +++ b/apps/ios/Shared/Views/ChatList/ChatHelp.swift @@ -26,7 +26,9 @@ struct ChatHelp: View { Button("connect to SimpleX Chat developers.") { dismissSettingsSheet() DispatchQueue.main.async { - UIApplication.shared.open(simplexTeamURL) + // simplexTeamURL targets this same app; route to the in-app connect flow + // (UIApplication.shared.open is dropped for self-owned URLs in the foreground) + ChatModel.shared.appOpenUrl = simplexTeamURL } } .padding(.top, 2) diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index c1bc699261..483ca6aea8 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -390,7 +390,9 @@ struct SettingsView: View { Button("Send questions and ideas") { dismiss() DispatchQueue.main.async { - UIApplication.shared.open(simplexTeamURL) + // simplexTeamURL targets this same app; route to the in-app connect flow + // (UIApplication.shared.open is dropped for self-owned URLs in the foreground) + ChatModel.shared.appOpenUrl = simplexTeamURL } } } diff --git a/plans/2026-06-19-ios-open-simplex-links-in-messages.md b/plans/2026-06-19-ios-open-simplex-links-in-messages.md new file mode 100644 index 0000000000..f7c89b303a --- /dev/null +++ b/plans/2026-06-19-ios-open-simplex-links-in-messages.md @@ -0,0 +1,65 @@ +# iOS: open SimpleX links in chat messages via in-app connect flow + +## Problem + +On iOS, tapping a **SimpleX connection/invitation link inside message text** does nothing — it never reaches the connection flow. Reproduced on iPhone 17 (v6.5.2 and v6.5.5). On the same screens, tapping a web link (opens browser), a `mailto:`/`tel:` link, and the connection-link **card** all work. Notably it was **device-specific**: dead on an iPhone 17 but working on an iPhone 12 running the **same iOS version**, with only **one** SimpleX app installed. + +## Root cause + +Inline links are dispatched in `MsgContentView.handleTextTaps` (`apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift`): + +- web links (`webLinkAttrKey`) → `openBrowserAlert` → `UIApplication.shared.open` (Safari) +- everything else → `UIApplication.shared.open(url)` + +SimpleX links fell into the second branch. Two facts make this the bug: + +1. **The URI is always the `simplex:` custom scheme.** The core markdown parser normalizes every connection link to the `simplex:` scheme via `simplexConnReqUri` / `simplexShortLink` (`src/Simplex/Chat/Markdown.hs:344,353`), regardless of whether the message contained `https://simplex.chat/…` or `simplex:/…` (see `tests/MarkdownTests.hs`). So the tap always calls `UIApplication.shared.open("simplex:/contact#…")`. + +2. **`simplex:` is registered to this app, and the app is in the foreground.** `UIApplication.shared.open` is an OS app-launch API: it asks iOS (LaunchServices) to resolve the scheme to its registered app and activate it. Here the registered app is SimpleX itself, already foregrounded. **Re-entering the same foreground app through `open()` is not a supported operation** — `open()` exists to hand a URL to a *different* app or the system. When the resolved target is the calling foreground app, the outcome is undefined: on some devices iOS still delivers the URL to `onOpenURL`, on others it is a silent no-op (`open` returns `false`, no error, no UI). + +That undefined outcome is decided by device-local OS state (scheme resolution / launch services), which is why identical code + identical OS + identical single app behaved differently on the iPhone 12 (delivered → connected) and the iPhone 17 (no-op → dead). It is **not** an OS-version rule and **not** a multiple-handler conflict — both were ruled out (same OS; single install). + +This also explains the full symptom matrix — only the path that re-enters the same app via `open()` is affected: + +| Tapped | Dispatch | Target | Result | +|---|---|---|---| +| Web link | `openBrowserAlert` → `open()` | Safari (other app) | works | +| `mailto:` / `tel:` | `open()` | Mail / Phone (other apps) | works | +| Invite card | `planAndConnect` in-process | this app, no `open()` | works | +| Inline SimpleX link | `open("simplex:…")` | this app (self), foreground | undefined → dead | + +The underlying cause is using the **wrong mechanism**: an OS hand-off API to perform an **in-app** action. Every other connect path handles the connection in-process and never leaves the app: + +- the card: `planAndConnect` directly (`FramedItemView.swift`) +- the share extension: `ShareSheet.openExternalLink` sets `ChatModel.appOpenUrl` +- multiplatform: `openVerifiedSimplexUri` → `connectIfOpenedViaUri` → `planAndConnect` + +Inline links were the lone exception delegating to the OS, making them hostage to undefined self-open behavior. + +## Fix + +Restore the three-way dispatch the multiplatform clients use (`WEB_URL` / `OTHER_URL` / `SIMPLEX_URL`): + +- web → `openBrowserAlert` (unchanged) +- `mailto:` / `tel:` → `UIApplication.shared.open` (unchanged — these target other apps) +- **SimpleX → `ChatModel.appOpenUrl`** — the same sink `onOpenURL` feeds, leading to `connectViaUrl` → `planAndConnect`, entirely **in-process** with no OS round-trip + +SimpleX links are identified by a dedicated attribute key (`simplexLinkAttrKey`) set on the `.simplexLink` format, mirroring the multiplatform `SIMPLEX_URL` annotation tag, rather than sniffing the URL string — so all link types (contact, invitation, group, channel, relay) are covered. + +This is correct regardless of the exact device-local trigger, because it removes the dependency on iOS re-delivering a self-owned URL. The invite card already proves the in-process path works on the affected device. + +Also fixes the same issue for the **"Send questions and ideas"** (Settings) and **"connect to SimpleX Chat developers"** (chat help) buttons, which opened `simplexTeamURL` (a `simplex:` link) the same broken way. + +## Scope + +- `apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift` — three-way tap dispatch + `simplexLinkAttrKey` +- `apps/ios/Shared/Views/UserSettings/SettingsView.swift`, `apps/ios/Shared/Views/ChatList/ChatHelp.swift` — route `simplexTeamURL` in-process + +No behavior change for web / `mailto:` / `tel:` links. + +## Verification + +- Tap an inline SimpleX invitation/contact link in a received message → the connection sheet opens (on iPhone 17, where it was previously dead). +- The two developer-contact buttons open the connect flow. +- Web links still open the browser; `mailto:`/`tel:` still open Mail/Phone. +- Optional, to confirm the device-local nature: open a `simplex:/contact#…` link from another app (e.g. Notes) on the affected device — if that is also dead there but works on a second device, it confirms the difference is device-local scheme resolution rather than app code.