Skip to content

Commit 1fd14c7

Browse files
ATGFAILclaude
andcommitted
feat: deep-link routing + Android share-target
- MainActivity handles ACTION_VIEW (App Link / deep-link tap) and ACTION_SEND (share to the app) — routes to AuthRedirectBus, which now falls through to an `appcask:deeplink` event when the URL isn't an in-flight auth redirect. - getInitialDeepLink() drains a link received before JS was listening (cold start). WebShell subscribes via NativeEventEmitter + checks it on mount, and only loads URLs the router classifies internal / separate-document. - features.shareTarget: { url } — `appcask android` sets @string/appcask_share_url and injects the ACTION_SEND intent-filter (removed again when the feature is off). Shared text is URL-encoded and appended. patchManifestShareTarget, 3 tests. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent ceeef55 commit 1fd14c7

13 files changed

Lines changed: 163 additions & 26 deletions

File tree

‎README.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,8 @@ See [docs/gotchas.md](./docs/gotchas.md) for the full write-up on each.
119119
- [~] iOS shell — Swift bridge written, config/materialize done; needs finishing in Xcode ([docs/ios.md](./docs/ios.md))
120120
- [~] Push (Android / FCM) — wired via `appcask android` + `google-services.json`; needs a Firebase project to verify ([docs/push.md](./docs/push.md))
121121
- [x] Biometric app-lock (`features.biometricLock`) + in-app review (`window.appcask.requestReview()`)
122-
- [ ] Drawer navigation, share-target
122+
- [x] Deep links open the app at the URL (cold start + running) + Android share-target (`features.shareTarget`)
123+
- [ ] Drawer navigation
123124

124125
## Contributing
125126

‎docs/config.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ One file describes the whole app. Only `identity` and `startUrl` are required.
6161
"downloads": true, // route downloads to the OS download manager (default true)
6262
"biometricLock": false, // require fingerprint / Face ID to open the app; exposes window.appcask.biometric()
6363
"appReviewPrompt": false, // exposes window.appcask.requestReview() (asks the OS to show its prompt)
64+
"shareTarget": { "url": "https://acme.example/new?body=" },
65+
// the app becomes a share target; shared text is encoded and appended to this URL
6466
"push": { "provider": "fcm" }, // Android FCM — also needs google-services.json (see push.md)
6567

6668
"externalBrowserAuth": [ // identity providers that BLOCK embedded WebViews.

‎packages/cli/src/native/androidPatch.test.ts‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
patchAppJson,
88
patchKotlinPackage,
99
patchManifestPermissions,
10+
patchManifestShareTarget,
1011
colorsXml,
1112
deeplinkHost,
1213
} from './androidPatch.js';
@@ -123,6 +124,34 @@ describe('patchManifestPermissions', () => {
123124
});
124125
});
125126

127+
describe('patchManifestShareTarget', () => {
128+
const manifest = `<manifest>
129+
<application>
130+
<activity android:name=".MainActivity">
131+
<intent-filter>
132+
<action android:name="android.intent.action.MAIN" />
133+
</intent-filter>
134+
</activity>
135+
</application>
136+
</manifest>`;
137+
138+
it('adds the SEND filter when enabled', () => {
139+
const out = patchManifestShareTarget(manifest, true);
140+
expect(out).toContain('android.intent.action.SEND');
141+
expect(out).toContain('android:mimeType="text/plain"');
142+
});
143+
it('is idempotent', () => {
144+
const once = patchManifestShareTarget(manifest, true);
145+
expect(patchManifestShareTarget(once, true)).toBe(once);
146+
});
147+
it('removes it when disabled', () => {
148+
const withFilter = patchManifestShareTarget(manifest, true);
149+
const out = patchManifestShareTarget(withFilter, false);
150+
expect(out).not.toContain('android.intent.action.SEND');
151+
expect(out).toContain('android.intent.action.MAIN');
152+
});
153+
});
154+
126155
describe('colorsXml', () => {
127156
it('uses the config theme colours', () => {
128157
const out = colorsXml(config);

‎packages/cli/src/native/androidPatch.ts‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export function patchBuildGradle(
2525

2626
export function patchStringsXml(
2727
content: string,
28-
opts: { appName: string; deeplinkHost: string },
28+
opts: { appName: string; deeplinkHost: string; shareUrl?: string },
2929
): string {
3030
return content
3131
.replace(
@@ -35,7 +35,32 @@ export function patchStringsXml(
3535
.replace(
3636
/(<string name="appcask_deeplink_host">)[^<]*(<\/string>)/,
3737
`$1${escapeXml(opts.deeplinkHost)}$2`,
38+
)
39+
.replace(
40+
/(<string name="appcask_share_url">)[^<]*(<\/string>)/,
41+
`$1${escapeXml(opts.shareUrl ?? '')}$2`,
42+
);
43+
}
44+
45+
const SHARE_INTENT_FILTER = ` <intent-filter>
46+
<action android:name="android.intent.action.SEND" />
47+
<category android:name="android.intent.category.DEFAULT" />
48+
<data android:mimeType="text/plain" />
49+
</intent-filter>`;
50+
51+
/** Add the ACTION_SEND intent-filter to MainActivity when features.shareTarget is set. */
52+
export function patchManifestShareTarget(content: string, enabled: boolean): string {
53+
const has = content.includes('android.intent.action.SEND');
54+
if (enabled && !has) {
55+
return content.replace(
56+
/(\n\s*<\/activity>)/,
57+
`\n\n${SHARE_INTENT_FILTER}$1`,
3858
);
59+
}
60+
if (!enabled && has) {
61+
return content.replace(/\s*<intent-filter>(?:(?!<\/intent-filter>)[\s\S])*?android\.intent\.action\.SEND[\s\S]*?<\/intent-filter>/, '');
62+
}
63+
return content;
3964
}
4065

4166
export function patchAppJson(content: string, opts: { appName: string }): string {

‎packages/cli/src/native/materialize.ts‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
patchBuildGradle,
1313
patchKotlinPackage,
1414
patchManifestPermissions,
15+
patchManifestShareTarget,
1516
patchStringsXml,
1617
} from './androidPatch.js';
1718

@@ -161,14 +162,21 @@ async function patchAndroid(
161162
);
162163

163164
await edit(join(res, 'values', 'strings.xml'), (c) =>
164-
patchStringsXml(c, { appName: config.identity.appName, deeplinkHost: deeplinkHost(config) }),
165+
patchStringsXml(c, {
166+
appName: config.identity.appName,
167+
deeplinkHost: deeplinkHost(config),
168+
shareUrl: config.features.shareTarget?.url,
169+
}),
165170
);
166171

167172
await edit(join(androidApp, 'src', 'main', 'AndroidManifest.xml'), (c) =>
168-
patchManifestPermissions(c, {
169-
fileAccess: config.features.fileAccess,
170-
downloads: config.features.downloads,
171-
}),
173+
patchManifestShareTarget(
174+
patchManifestPermissions(c, {
175+
fileAccess: config.features.fileAccess,
176+
downloads: config.features.downloads,
177+
}),
178+
config.features.shareTarget != null,
179+
),
172180
);
173181

174182
await edit(join(outDir, 'app.json'), (c) => patchAppJson(c, { appName: config.identity.appName }));

‎packages/config/schema.json‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,15 @@
111111
"offlinePage": { "type": "boolean", "default": true, "description": "Show a built-in offline screen when a load fails with no connection." },
112112
"fileAccess": { "type": "boolean", "default": true, "description": "Wire up <input type=file>, camera capture, and the native file picker." },
113113
"downloads": { "type": "boolean", "default": true, "description": "Route download responses through the OS download manager." },
114-
"shareTarget": { "type": "boolean", "default": false, "description": "Register the app as an Android share target that forwards to a URL (roadmap)." },
114+
"shareTarget": {
115+
"type": "object",
116+
"additionalProperties": false,
117+
"required": ["url"],
118+
"description": "Register the app as a share target: when the user shares text / a link to it, the app opens `url` with the shared content URL-encoded and appended.",
119+
"properties": {
120+
"url": { "type": "string", "format": "uri", "pattern": "^https://", "description": "e.g. https://acme.example/new?body= — the shared text is appended, encoded." }
121+
}
122+
},
115123
"appReviewPrompt": { "type": "boolean", "default": false, "description": "Expose window.appcask.review.request() so the site can ask the OS to show its in-app review prompt." },
116124
"biometricLock": { "type": "boolean", "default": false, "description": "Require fingerprint / Face ID to open the app (and after it's been backgrounded). Also exposes window.appcask.biometric.authenticate()." },
117125
"externalBrowserAuth": {

‎packages/config/src/defaults.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ export function applyDefaults(config: AppcaskConfig): ResolvedAppcaskConfig {
5151
offlinePage: f.offlinePage ?? true,
5252
fileAccess: f.fileAccess ?? true,
5353
downloads: f.downloads ?? true,
54-
shareTarget: f.shareTarget ?? false,
54+
shareTarget: f.shareTarget ? { url: f.shareTarget.url } : undefined,
5555
appReviewPrompt: f.appReviewPrompt ?? false,
5656
biometricLock: f.biometricLock ?? false,
5757
externalBrowserAuth: f.externalBrowserAuth ?? [],

‎packages/config/src/types.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,8 @@ export interface AppcaskFeatures {
120120
/** Route download responses through the OS download manager. Default `true`. */
121121
downloads?: boolean;
122122
/** Register the app as an Android share target that forwards to a URL (roadmap). */
123-
shareTarget?: boolean;
123+
/** Register the app as a share target; shared text is appended (encoded) to `url`. */
124+
shareTarget?: { url: string };
124125
/** Expose `window.appcask.review.request()`. */
125126
appReviewPrompt?: boolean;
126127
/** Require fingerprint / Face ID to open the app. Also exposes `biometric.authenticate()`. */
@@ -212,7 +213,7 @@ export interface ResolvedAppcaskConfig {
212213
offlinePage: boolean;
213214
fileAccess: boolean;
214215
downloads: boolean;
215-
shareTarget: boolean;
216+
shareTarget?: { url: string };
216217
appReviewPrompt: boolean;
217218
biometricLock: boolean;
218219
externalBrowserAuth: string[];

‎template/android/app/src/main/java/com/appcaskshell/AppcaskNativeModule.kt‎

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,20 @@ class AppcaskNativeModule(private val reactContext: ReactApplicationContext) :
3636

3737
private var pendingAuth: Promise? = null
3838
private var authCallbackHosts: List<String> = emptyList()
39+
/** A deep link / share received before JS was listening — drained by getInitialDeepLink(). */
40+
private var pendingDeepLink: String? = null
3941

4042
init {
4143
AuthRedirectBus.register { uri -> onAuthRedirect(uri) }
4244
}
4345

46+
@ReactMethod
47+
fun getInitialDeepLink(promise: Promise) {
48+
val url = pendingDeepLink
49+
pendingDeepLink = null
50+
promise.resolve(url)
51+
}
52+
4453
// --- haptics ---
4554
@ReactMethod
4655
fun haptic(type: String, promise: Promise) {
@@ -245,21 +254,30 @@ class AppcaskNativeModule(private val reactContext: ReactApplicationContext) :
245254
}
246255
}
247256

257+
/**
258+
* An incoming ACTION_VIEW / ACTION_SEND url. If it's the redirect for an
259+
* in-flight auth session, resolve that promise; otherwise treat it as a deep
260+
* link / share and hand it to the WebView.
261+
*/
248262
private fun onAuthRedirect(uri: Uri) {
249-
val host = uri.host ?: return
250-
val matches = authCallbackHosts.any { it == host || (it.startsWith(".") && host.endsWith(it)) }
251-
if (!matches) return
252-
val promise = pendingAuth ?: return
253-
pendingAuth = null
254-
promise.resolve(Arguments.createMap().apply { putString("redirectUrl", uri.toString()) })
263+
val host = uri.host
264+
val pending = pendingAuth
265+
if (pending != null && host != null &&
266+
authCallbackHosts.any { it == host || (it.startsWith(".") && host.endsWith(it)) }
267+
) {
268+
pendingAuth = null
269+
pending.resolve(Arguments.createMap().apply { putString("redirectUrl", uri.toString()) })
270+
return
271+
}
272+
pendingDeepLink = uri.toString()
273+
emit("appcask:deeplink", Arguments.createMap().apply { putString("url", uri.toString()) })
255274
}
256275

257276
private fun runOnUi(block: () -> Unit) {
258277
val activity = reactContext.currentActivity
259278
if (activity != null) activity.runOnUiThread(block) else block()
260279
}
261280

262-
@Suppress("unused")
263281
private fun emit(name: String, params: com.facebook.react.bridge.WritableMap) {
264282
reactContext
265283
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)

‎template/android/app/src/main/java/com/appcaskshell/MainActivity.kt‎

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.appcaskshell
22

33
import android.content.Intent
4+
import android.net.Uri
45
import android.os.Bundle
56
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
67
import com.facebook.react.ReactActivity
@@ -18,20 +19,32 @@ class MainActivity : ReactActivity() {
1819
override fun onCreate(savedInstanceState: Bundle?) {
1920
installSplashScreen()
2021
super.onCreate(savedInstanceState)
21-
forwardAuthRedirect(intent)
22+
handleIntent(intent)
2223
}
2324

2425
override fun onNewIntent(intent: Intent) {
2526
super.onNewIntent(intent)
2627
setIntent(intent)
27-
forwardAuthRedirect(intent)
28+
handleIntent(intent)
2829
}
2930

30-
/** A verified App Link that came back from a Custom Tab auth flow. */
31-
private fun forwardAuthRedirect(intent: Intent?) {
32-
val data = intent?.data ?: return
33-
if (intent.action == Intent.ACTION_VIEW && (data.scheme == "https" || data.scheme == "http")) {
34-
AuthRedirectBus.publish(data)
31+
/**
32+
* An App Link (an auth redirect, or a deep link tap) or a share to the app —
33+
* hand the URL to [AuthRedirectBus], which routes it to the auth promise or
34+
* the WebView.
35+
*/
36+
private fun handleIntent(intent: Intent?) {
37+
intent ?: return
38+
when (intent.action) {
39+
Intent.ACTION_VIEW -> {
40+
val data = intent.data ?: return
41+
if (data.scheme == "https" || data.scheme == "http") AuthRedirectBus.publish(data)
42+
}
43+
Intent.ACTION_SEND -> {
44+
val text = intent.getStringExtra(Intent.EXTRA_TEXT) ?: return
45+
val base = getString(R.string.appcask_share_url)
46+
if (base.isNotEmpty()) AuthRedirectBus.publish(Uri.parse(base + Uri.encode(text)))
47+
}
3548
}
3649
}
3750
}

0 commit comments

Comments
 (0)