ReviewOS

also looking at this

craft-native/craft

fix(android): skip the whole launch intent on a relaunch from Recents

#240
Merged glennmichael123 wants to merge fix/android-recents-relaunch into main
2 files +51 -9
packages/android/src/index.test.tsmodified+36-1
Changes to packages/android/src/index.test.ts
@@ -339,7 +339,7 @@ describe('Craft Android builder', () => {
339339 const holder = readFileSync(join(output, 'app/src/main/java/com/craft/runtime/CraftNative.kt'), 'utf8')
340340
341341 expect(activity).not.toContain('dispatch = false')
342 expect(activity).toContain('craftBridge.receiveDeepLink(launchLink)')
342 expect(activity).toContain('intent.data?.toString()?.let(craftBridge::receiveDeepLink)')
343343 expect(activity).toContain('Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY')
344344 expect(activity).toContain('intent.data?.toString()?.let(craftBridge::receiveDeepLink)')
345345 // A recreated activity restores what the bridge saved, not getIntent(),
@@ -438,6 +438,41 @@ describe('Craft Android builder', () => {
438438 expect(bridge).toContain('if (!window.__craftBridgeInstalled)')
439439 })
440440
441 it('ignores the launch intent on a relaunch from Recents, and the saved one on a recreate', async () => {
442 // #229, two halves. The shortcut path had no history check, so the
443 // shortcut the app was first opened with was dispatched again every time
444 // the task was reopened from Recents. And a recreated activity must read
445 // what the bridge saved, not getIntent(), which onNewIntent replaces with
446 // the latest warm link — that half landed with #232 and is pinned here so
447 // it cannot quietly go back.
448 const output = mkdtempSync(join(tmpdir(), 'craft-android-recents-'))
449 await init({ name: 'WildLoop', packageName: 'org.wildloop.app', output, config: { enableDeepLinks: true, urlSchemes: ['wildloop'] } })
450
451 const activity = readFileSync(join(output, 'app/src/main/java/org/wildloop/app/MainActivity.kt'), 'utf8')
452 const onCreate = activity.slice(activity.indexOf('override fun onCreate'), activity.indexOf('private fun setupWebView'))
453
454 // One flag, guarding both, rather than the link alone.
455 expect(onCreate).toContain('Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY')
456 expect(onCreate.match(/launchedFromHistory/g)?.length).toBe(2)
457 // Bounded by the block's own closing brace: a slice that ran to the end
458 // of onCreate would still find a shortcut call that had moved back out.
459 const guardStart = onCreate.indexOf('if (!launchedFromHistory) {')
460 const guard = onCreate.slice(guardStart, onCreate.indexOf('\n }', guardStart))
461 expect(guard).toContain('craftBridge::receiveDeepLink')
462 expect(guard).toContain('handleIncomingShortcut(intent)')
463 // And exactly once, so it cannot also be dispatched outside the guard.
464 expect(onCreate.match(/handleIncomingShortcut\(intent\)/g)?.length).toBe(1)
465
466 // A genuinely new intent still carries both through.
467 const onNewIntent = activity.slice(activity.indexOf('override fun onNewIntent'), activity.indexOf('override fun onBackPressed'))
468 expect(onNewIntent).toContain('craftBridge::receiveDeepLink')
469 expect(onNewIntent).toContain('handleIncomingShortcut(intent)')
470
471 // And a recreate reads the bundle, never the intent.
472 expect(onCreate).toContain('craftBridge.restoreDeepLinks(savedInstanceState)')
473 expect(activity).toContain('craftBridge.saveDeepLinks(outState)')
474 })
475
441476 it('routes external Activity results back to every pending media promise', async () => {
442477 const output = mkdtempSync(join(tmpdir(), 'craft-android-activity-results-'))
443478 await init({ name: 'WildLoop', packageName: 'org.wildloop.app', output })
packages/android/templates/MainActivity.kt.templatemodified+15-8
Changes to packages/android/templates/MainActivity.kt.template
@@ -46,18 +46,25 @@ class MainActivity : AppCompatActivity() {
4646 craftBridge = CraftBridge(this, webView, CraftHealthConnect(this, webView))
4747 // The link that launched the app (#215). Queued until the page's bridge
4848 // is injected, then dispatched once with `initial: true`, so a page that
49 // subscribes rather than calling getInitialURL still hears it. A
50 // relaunch from Recents carries the old intent, and was not opened by it.
51 // A recreated activity restores what the bridge saved: its intent may
52 // be a later warm link that onNewIntent set.
49 // subscribes rather than calling getInitialURL still hears it.
50 //
51 // A relaunch from Recents carries the intent the app was first opened
52 // with. The person picked the task out of a list, not the link or the
53 // shortcut inside it, and neither is news the second time — so both
54 // are skipped, not just the link (#229).
55 //
56 // A recreated activity restores what the bridge saved rather than
57 // reading its intent, which onNewIntent may have replaced with a
58 // later warm link.
59 val launchedFromHistory = intent.flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY != 0
5360 if (savedInstanceState == null) {
54 val launchLink = intent.data?.toString()
55 val launchedFromHistory = intent.flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY != 0
56 if (launchLink != null && !launchedFromHistory) craftBridge.receiveDeepLink(launchLink)
61 if (!launchedFromHistory) {
62 intent.data?.toString()?.let(craftBridge::receiveDeepLink)
63 handleIncomingShortcut(intent)
64 }
5765 } else {
5866 craftBridge.restoreDeepLinks(savedInstanceState)
5967 }
60 if (savedInstanceState == null) handleIncomingShortcut(intent)
6168
6269 setupWebView()
6370 loadContent()