|
| 1 | +# Phase 6a — Deep links + system share + external browser |
| 2 | + |
| 3 | +> Phase 6 in the spec bundles native permissions, deep links, share, and the X-Frame fallback into one PR. The native permissions plugin (Android Kotlin) can't be CI-verified without a real device, so this PR (6a) lands everything that is web-testable; the native permissions plugin lands separately as 6b once a device is available. |
| 4 | +
|
| 5 | +**Goal:** Mobile gains three system-integration affordances visible on any device: |
| 6 | + |
| 7 | +1. **System share** — "Share pad URL" floating action in `PadIframeStack` calls `@capacitor/share`. Web fallback (`navigator.share`) makes the action exerciseable in the dev/preview browser too. |
| 8 | +2. **Open in external browser** — adjacent action calls `@capacitor/browser`. Used as the user-driven X-Frame fallback (if an embed is blocked the user has an obvious escape hatch). Auto-detection of X-Frame DENY requires a native hook on the WebChromeClient and lands in 6b. |
| 9 | +3. **Deep links** — `etherpad://` scheme and `https://*/p/...` URLs handed to the app by Android (or by clicking a link in another in-app browser) parse to `(serverOrigin, padId)`. If a workspace matches the origin, open the pad; otherwise pre-seed `AddWorkspaceDialog` with the server URL. |
| 10 | + |
| 11 | +**Architecture:** |
| 12 | +- All three concerns are *mobile-only* — they don't touch the shell's `Platform` interface. Share + browser plugins are imported directly inside the mobile-only `PadIframeStack` overlay. Deep links register their listener in `main.tsx` and dispatch through `useShellStore` + `platform.tab.open`. |
| 13 | +- The shell's existing `parsePadUrl` (in `@etherpad/shell/url`) is the source of truth for URL → `{serverUrl, padName}` parsing — no mobile duplicate. |
| 14 | +- AndroidManifest intent filters declare `etherpad://` (custom scheme) plus HTTPS app links with `android:autoVerify="true"` so users can configure asset-link verification when packaging. |
| 15 | + |
| 16 | +--- |
| 17 | + |
| 18 | +## Task 1: Add `@capacitor/share` + `@capacitor/browser` deps |
| 19 | + |
| 20 | +- [ ] **Step 1: Install** |
| 21 | + |
| 22 | +```bash |
| 23 | +pnpm --filter @etherpad/mobile add @capacitor/share@^8.0.0 @capacitor/browser@^8.0.0 |
| 24 | +``` |
| 25 | + |
| 26 | +- [ ] **Step 2: Commit** |
| 27 | + |
| 28 | +--- |
| 29 | + |
| 30 | +## Task 2: Tab-actions overlay in `PadIframeStack` |
| 31 | + |
| 32 | +- [ ] **Step 1:** Add an absolutely-positioned floating action group in the top-right of `PadIframeStack` that's only visible when there's an active tab in the current workspace. Two buttons: |
| 33 | + - **Share** (📤) — calls `Share.share({ url: padUrl })`. Web fallback uses `navigator.share` if available, else falls back to clipboard. |
| 34 | + - **External browser** (↗) — calls `Browser.open({ url: padUrl })`. |
| 35 | + |
| 36 | +```tsx |
| 37 | +// New file: packages/mobile/src/components/PadActionsOverlay.tsx |
| 38 | +import { Share } from '@capacitor/share'; |
| 39 | +import { Browser } from '@capacitor/browser'; |
| 40 | + |
| 41 | +export function PadActionsOverlay({ url, title }: { url: string; title: string }): React.JSX.Element { |
| 42 | + return ( |
| 43 | + <div className="pad-actions-overlay" /* top-right floating */> |
| 44 | + <button aria-label="Share pad" onClick={() => void Share.share({ url, title })}>📤</button> |
| 45 | + <button aria-label="Open in external browser" onClick={() => void Browser.open({ url })}>↗</button> |
| 46 | + </div> |
| 47 | + ); |
| 48 | +} |
| 49 | +``` |
| 50 | + |
| 51 | +- [ ] **Step 2:** Wire into `PadIframeStack` — for the active tab (when not behind a dialog), render the overlay alongside. |
| 52 | + |
| 53 | +- [ ] **Step 3:** Smoke test: assert both buttons visible after `tab.open`, hidden when a dialog is open or when no active tab. |
| 54 | + |
| 55 | +--- |
| 56 | + |
| 57 | +## Task 3: Deep link handler |
| 58 | + |
| 59 | +- [ ] **Step 1:** New file `packages/mobile/src/platform/deep-links.ts`: |
| 60 | + |
| 61 | +```typescript |
| 62 | +import { App } from '@capacitor/app'; |
| 63 | +import { parsePadUrl } from '@shared/url'; |
| 64 | +import { useShellStore, dialogActions } from '@etherpad/shell/state'; |
| 65 | +import * as tabStore from './tabs/tab-store.js'; |
| 66 | + |
| 67 | +export function installDeepLinkHandler(): () => void { |
| 68 | + let cleanup: (() => void) | undefined; |
| 69 | + void App.addListener('appUrlOpen', ({ url }) => { |
| 70 | + handleUrl(url); |
| 71 | + }).then((sub) => { |
| 72 | + cleanup = (): void => void sub.remove(); |
| 73 | + }); |
| 74 | + return () => cleanup?.(); |
| 75 | +} |
| 76 | + |
| 77 | +export function handleUrl(url: string): void { |
| 78 | + // Accept etherpad://<host>/p/<pad> and https://<host>/p/<pad> |
| 79 | + const normalised = url.replace(/^etherpad:/, 'https:'); |
| 80 | + const parsed = parsePadUrl(normalised); |
| 81 | + if (!parsed) return; |
| 82 | + const { serverUrl, padName } = parsed; |
| 83 | + const state = useShellStore.getState(); |
| 84 | + const ws = state.workspaces.find( |
| 85 | + (w) => normaliseOrigin(w.serverUrl) === normaliseOrigin(serverUrl), |
| 86 | + ); |
| 87 | + if (ws) { |
| 88 | + tabStore.open({ workspaceId: ws.id, padName }); |
| 89 | + useShellStore.getState().setActiveWorkspaceId(ws.id); |
| 90 | + } else { |
| 91 | + dialogActions.openDialog('addWorkspace', { initialServerUrl: serverUrl, initialPadName: padName }); |
| 92 | + } |
| 93 | +} |
| 94 | + |
| 95 | +function normaliseOrigin(u: string): string { |
| 96 | + try { |
| 97 | + return new URL(u).origin; |
| 98 | + } catch { |
| 99 | + return u; |
| 100 | + } |
| 101 | +} |
| 102 | +``` |
| 103 | + |
| 104 | +(Note: `dialogActions.openDialog` may not accept arbitrary payload — if so, drop the pre-seed args and just open the dialog. Pre-seeding is nice-to-have.) |
| 105 | + |
| 106 | +- [ ] **Step 2:** Wire `installDeepLinkHandler()` into `main.tsx` after `setPlatform()`. |
| 107 | + |
| 108 | +- [ ] **Step 3:** Vitest unit test (or Playwright via `__test_platform`-like hook) for `handleUrl`: feed `etherpad://acme/p/hello`, assert tab store mutates with the matching workspace. |
| 109 | + |
| 110 | +--- |
| 111 | + |
| 112 | +## Task 4: AndroidManifest intent filters |
| 113 | + |
| 114 | +- [ ] **Step 1:** Edit `packages/mobile/android/app/src/main/AndroidManifest.xml` and add intent filters inside the main `<activity>`: |
| 115 | + |
| 116 | +```xml |
| 117 | +<intent-filter android:autoVerify="true"> |
| 118 | + <action android:name="android.intent.action.VIEW" /> |
| 119 | + <category android:name="android.intent.category.DEFAULT" /> |
| 120 | + <category android:name="android.intent.category.BROWSABLE" /> |
| 121 | + <data android:scheme="https" android:pathPattern="/p/.*" /> |
| 122 | +</intent-filter> |
| 123 | + |
| 124 | +<intent-filter> |
| 125 | + <action android:name="android.intent.action.VIEW" /> |
| 126 | + <category android:name="android.intent.category.DEFAULT" /> |
| 127 | + <category android:name="android.intent.category.BROWSABLE" /> |
| 128 | + <data android:scheme="etherpad" /> |
| 129 | +</intent-filter> |
| 130 | +``` |
| 131 | + |
| 132 | +(HTTPS intent filter is intentionally broad — `data android:host="*"` would be more permissive than Android wants. F-Droid metadata can list specific hosts users have asked us to autoVerify; default ships without `android:host` so Android prompts the user.) |
| 133 | + |
| 134 | +--- |
| 135 | + |
| 136 | +## Task 5: PR |
| 137 | + |
| 138 | +- [ ] **Step 1:** Open Phase 6a PR. Note in body that 6b (native permissions plugin) is pending hardware validation. |
0 commit comments