Skip to content

Commit bbdebc3

Browse files
authored
feat(mobile): deep links + system share + external browser (Phase 6a) (#36)
* docs(plan): add phase-6a deeplinks/share/browser plan * feat(mobile): deep-link intent filters + share/browser deps + deep-link handler - AndroidManifest: etherpad:// custom scheme + https /p/* with autoVerify. - main.tsx wires App.addListener('appUrlOpen') → parsePadUrl → either open in matching workspace's tab store or prompt to add the workspace. - PadActionsOverlay (share + external-browser) renders on the active iframe. Native permissions plugin (Android Kotlin, camera/mic delegation) lands separately as Phase 6b once a real device is available to validate. * test(mobile): share/external-browser overlay + deep-link smoke tests Adds two Playwright cases: - share + external-browser actions appear over the active pad - deep link to a known workspace opens the pad in that workspace main.tsx also exposes window.__test_handleUrl so Playwright can invoke the deep-link entry point without needing to fire a real appUrlOpen.
1 parent 5b35cfb commit bbdebc3

9 files changed

Lines changed: 405 additions & 12 deletions

File tree

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
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.

packages/mobile/android/app/src/main/AndroidManifest.xml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,26 @@
2222
<category android:name="android.intent.category.LAUNCHER" />
2323
</intent-filter>
2424

25+
<!-- Custom URL scheme — `etherpad://acme.example/p/hello` -->
26+
<intent-filter>
27+
<action android:name="android.intent.action.VIEW" />
28+
<category android:name="android.intent.category.DEFAULT" />
29+
<category android:name="android.intent.category.BROWSABLE" />
30+
<data android:scheme="etherpad" />
31+
</intent-filter>
32+
33+
<!-- HTTPS Etherpad pad URLs. autoVerify lets the OS skip the
34+
chooser dialog when the target host publishes a matching
35+
assetlinks.json. No `android:host` so the filter is broad;
36+
downstream forks pinning a specific instance can override.
37+
-->
38+
<intent-filter android:autoVerify="true">
39+
<action android:name="android.intent.action.VIEW" />
40+
<category android:name="android.intent.category.DEFAULT" />
41+
<category android:name="android.intent.category.BROWSABLE" />
42+
<data android:scheme="https" android:pathPattern="/p/.*" />
43+
</intent-filter>
44+
2545
</activity>
2646

2747
<provider

packages/mobile/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,10 @@
2020
"dependencies": {
2121
"@capacitor/android": "^8.0.0",
2222
"@capacitor/app": "^8.0.0",
23+
"@capacitor/browser": "^8.0.3",
2324
"@capacitor/core": "^8.0.0",
2425
"@capacitor/preferences": "^8.0.1",
26+
"@capacitor/share": "^8.0.1",
2527
"@etherpad/shell": "workspace:*",
2628
"react": "^19.2.0",
2729
"react-dom": "^19.2.0",
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import React from 'react';
2+
import { Browser } from '@capacitor/browser';
3+
import { Share } from '@capacitor/share';
4+
5+
/**
6+
* Floating top-right action group rendered over the currently-visible
7+
* iframe. Two affordances:
8+
*
9+
* - **Share** — invokes the OS share sheet via `@capacitor/share`. On the
10+
* web fallback the plugin uses `navigator.share` when available, and
11+
* falls back to writing the URL to the clipboard. This is what lets the
12+
* user send a pad to another app from inside the mobile shell.
13+
* - **Open in external browser** — invokes `@capacitor/browser` which
14+
* opens a Chrome Custom Tab on Android (SFSafariViewController on iOS).
15+
* Doubles as the user-driven X-Frame-Options DENY escape hatch: if a
16+
* pad refuses to embed, the user always has this button.
17+
*
18+
* The overlay is only mounted when there's an active tab and no shell
19+
* dialog is open — see PadIframeStack for the gating.
20+
*/
21+
export interface PadActionsOverlayProps {
22+
url: string;
23+
title: string;
24+
}
25+
26+
export function PadActionsOverlay({ url, title }: PadActionsOverlayProps): React.JSX.Element {
27+
return (
28+
<div
29+
data-testid="pad-actions-overlay"
30+
style={{
31+
position: 'absolute',
32+
top: 'calc(env(safe-area-inset-top, 0px) + 8px)',
33+
right: 'calc(env(safe-area-inset-right, 0px) + 8px)',
34+
display: 'flex',
35+
gap: 6,
36+
zIndex: 10,
37+
}}
38+
>
39+
<button
40+
type="button"
41+
aria-label="Share pad URL"
42+
title="Share pad URL"
43+
onClick={() => {
44+
void Share.share({ url, title }).catch(() => {
45+
// Share can reject when the user cancels the sheet or when no
46+
// share handler is available; we silently ignore.
47+
});
48+
}}
49+
style={actionButtonStyle}
50+
>
51+
📤
52+
</button>
53+
<button
54+
type="button"
55+
aria-label="Open in external browser"
56+
title="Open in external browser"
57+
onClick={() => {
58+
void Browser.open({ url }).catch(() => {});
59+
}}
60+
style={actionButtonStyle}
61+
>
62+
63+
</button>
64+
</div>
65+
);
66+
}
67+
68+
const actionButtonStyle: React.CSSProperties = {
69+
width: 36,
70+
height: 36,
71+
display: 'flex',
72+
alignItems: 'center',
73+
justifyContent: 'center',
74+
background: 'rgba(0,0,0,0.55)',
75+
color: 'white',
76+
border: 'none',
77+
borderRadius: 18,
78+
fontSize: 16,
79+
cursor: 'pointer',
80+
WebkitTapHighlightColor: 'transparent',
81+
};

packages/mobile/src/components/PadIframeStack.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react';
22
import { useShellStore } from '@etherpad/shell/state';
33
import { padUrl } from '@shared/url';
44
import { onReload, markLoaded, markError } from '../platform/tabs/tab-store.js';
5+
import { PadActionsOverlay } from './PadActionsOverlay.js';
56

67
/**
78
* Mobile pad rendering: one DOM `<iframe>` per open tab, exactly one visible
@@ -52,6 +53,12 @@ export function PadIframeStack(): React.JSX.Element {
5253

5354
if (!workspace) return <></>;
5455

56+
const activeTab = visibleTabs.find((t) => t.tabId === activeTabId);
57+
const activePadUrl = activeTab
58+
? `${padUrl(workspace.serverUrl, activeTab.padName)}?lang=${encodeURIComponent(lang)}`
59+
: null;
60+
const showActions = activeTab !== undefined && !dialogOpen;
61+
5562
return (
5663
<div
5764
data-testid="pad-iframe-stack"
@@ -81,6 +88,9 @@ export function PadIframeStack(): React.JSX.Element {
8188
/>
8289
);
8390
})}
91+
{showActions && activeTab && activePadUrl ? (
92+
<PadActionsOverlay url={activePadUrl} title={activeTab.title ?? activeTab.padName} />
93+
) : null}
8494
</div>
8595
);
8696
}

packages/mobile/src/main.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client';
33
import { App, setPlatform } from '@etherpad/shell';
44
import '@etherpad/shell/styles/index.css';
55
import { createCapacitorPlatform } from './platform/capacitor.js';
6+
import { handleUrl, installDeepLinkHandler } from './platform/deep-links.js';
67
import { PadIframeStack } from './components/PadIframeStack.js';
78

89
// Wire the platform adapter before App renders. App and every IPC caller
@@ -16,7 +17,16 @@ setPlatform(platform);
1617
// browser CORS). Harmless in production — mobile has no security boundary
1718
// like desktop's preload, and the underlying Preferences are inspectable
1819
// anyway via DevTools.
19-
(window as unknown as { __test_platform?: typeof platform }).__test_platform = platform;
20+
(window as unknown as {
21+
__test_platform?: typeof platform;
22+
__test_handleUrl?: typeof handleUrl;
23+
}).__test_platform = platform;
24+
(window as unknown as { __test_handleUrl?: typeof handleUrl }).__test_handleUrl = handleUrl;
25+
26+
// Register the deep-link listener once the platform is wired. The handler
27+
// reads useShellStore so it sees fresh state on every URL even after
28+
// workspaces are added later.
29+
installDeepLinkHandler();
2030

2131
const root = createRoot(document.getElementById('root')!);
2232
root.render(
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { App } from '@capacitor/app';
2+
import { parsePadUrl } from '@shared/url';
3+
import { dialogActions, useShellStore } from '@etherpad/shell/state';
4+
import * as tabStore from './tabs/tab-store.js';
5+
6+
/**
7+
* Wire up the deep-link handler. Returns a function that removes the
8+
* listener (useful for tests; in production we set-and-forget for the app
9+
* lifetime).
10+
*
11+
* Supports two URL families per spec §7:
12+
* - `etherpad://<host>/p/<pad>` — custom scheme; declared in
13+
* AndroidManifest as a `<data android:scheme="etherpad" />` intent
14+
* filter so Android dispatches it to us.
15+
* - `https://<host>/p/<pad>` — HTTPS app links; `android:autoVerify`
16+
* lets the OS skip the chooser dialog for hosts that publish a
17+
* matching `assetlinks.json`.
18+
*
19+
* Match-by-origin against existing workspaces. On match, open the pad;
20+
* on miss, prompt to add the workspace (the dialog can be pre-seeded
21+
* via `dialogContext` when AddWorkspaceDialog learns to read it).
22+
*/
23+
export function installDeepLinkHandler(): () => void {
24+
let removed = false;
25+
let off: (() => void) | undefined;
26+
void App.addListener('appUrlOpen', ({ url }) => {
27+
handleUrl(url);
28+
}).then((sub) => {
29+
if (removed) {
30+
void sub.remove();
31+
return;
32+
}
33+
off = (): void => {
34+
void sub.remove();
35+
};
36+
});
37+
return () => {
38+
removed = true;
39+
off?.();
40+
};
41+
}
42+
43+
/**
44+
* Exposed for tests. Parses an incoming URL and dispatches it through
45+
* the platform — either tab.open into an existing workspace or
46+
* openDialog('addWorkspace') seeded with the new origin.
47+
*/
48+
export function handleUrl(url: string): void {
49+
// Capacitor delivers the URL with its original scheme. Normalise
50+
// `etherpad:` → `https:` so `parsePadUrl` (which is HTTPS-only) accepts it.
51+
const normalised = url.replace(/^etherpad:/, 'https:');
52+
const parsed = parsePadUrl(normalised);
53+
if (!parsed) return;
54+
const { serverUrl, padName } = parsed;
55+
56+
const state = useShellStore.getState();
57+
const ws = state.workspaces.find(
58+
(w) => normaliseOrigin(w.serverUrl) === normaliseOrigin(serverUrl),
59+
);
60+
if (ws) {
61+
state.setActiveWorkspaceId(ws.id);
62+
tabStore.open({ workspaceId: ws.id, padName });
63+
} else {
64+
dialogActions.openDialog('addWorkspace', {
65+
initialServerUrl: serverUrl,
66+
initialPadName: padName,
67+
});
68+
}
69+
}
70+
71+
function normaliseOrigin(input: string): string {
72+
try {
73+
return new URL(input).origin;
74+
} catch {
75+
return input;
76+
}
77+
}

0 commit comments

Comments
 (0)