ReviewOS

also looking at this

craft-native/craft

fix(android): hand the launch link to the page that subscribes for it

#232
Merged glennmichael123 wants to merge fix/android-deep-link-replay into main
17 files +776 -136
packages/android/templates/CraftBridge.kt.templatemodified+118-20
Changes to packages/android/templates/CraftBridge.kt.template
@@ -221,6 +221,14 @@ class CraftBridge(
221221 private var bridgeReady = false
222222 private val pendingEvents = mutableListOf<Pair<String, Map<String, Any>>>()
223223
224 // Deep links (#215), on the UI thread only, the way iOS's DeepLinkManager
225 // holds them: a link that arrives before the first page has its bridge is
226 // the launch link, and every link waits for an injected bridge.
227 private var hasBeenReady = false
228 private var initialAssigned = false
229 private var launchURL: String? = null
230 private val pendingDeepLinks = mutableListOf<Pair<String, Boolean>>()
231
224232 // Location tracking
225233 private var fusedLocationClient: FusedLocationProviderClient? = null
226234 private val locationCallbacks = mutableMapOf<Int, LocationCallback>()
@@ -310,6 +318,10 @@ class CraftBridge(
310318 // still loading has somewhere to go rather than being counted as
311319 // dropped.
312320 installNativeDeliverer()
321 // A page is about to exist, so no later link launched the app, even
322 // one that arrives before this script's completion runs. iOS marks
323 // the same moment.
324 hasBeenReady = true
313325
314326 val script = """
315327{⁠{PROMISE_RUNTIME}}
@@ -598,7 +610,7 @@ class CraftBridge(
598610
599611 // Deep Links
600612 onDeepLink: function(callback) {
601 window.addEventListener('craftDeepLink', function(e) { callback(e.detail); });
613 return window.craft._subscribeDeepLinks(callback);
602614 },
603615
604616 // QR/Barcode Scanner
@@ -999,12 +1011,13 @@ class CraftBridge(
9991011 // Deep Links
10001012 deepLinks: {
10011013 getInitialURL: function() {
1014 window.craft._claimInitialDeepLink();
10021015 return window.__craftPromise('initial URL', '_craftDeepLinkResolve', '_craftDeepLinkReject', function() {
10031016 CraftAndroid.getInitialURL();
10041017 });
10051018 },
10061019 onLink: function(callback) {
1007 window.addEventListener('craftDeepLink', function(e) { callback(e.detail); });
1020 return window.craft._subscribeDeepLinks(callback);
10081021 }
10091022 },
10101023
@@ -1354,6 +1367,62 @@ class CraftBridge(
13541367 }
13551368 };
13561369
1370 // Links that arrive before anything is listening (#215, as iOS
1371 // does since #198).
1372 //
1373 // Native dispatches a link once this script has run, and on a cold
1374 // start that is the link the app was opened with. Only a craftReady
1375 // handler can have called onLink by then, since onLink is defined
1376 // by this very script. So such a link used to reach nobody, and a
1377 // page that subscribes later, rather than asking getInitialURL,
1378 // never learned how it was opened.
1379 //
1380 // Held here instead, and handed to the first subscriber on the
1381 // next turn, so an unsubscribe returned in the same tick still
1382 // applies. The launch link belongs to getInitialURL once the page
1383 // has called it, whether native has dispatched it yet or not, so a
1384 // page that does both in the same tick or in a craftReady handler,
1385 // in either order, gets it once. A page that asks getInitialURL
1386 // only later, after an await, should skip `initial` in onLink.
1387 //
1388 // The state lives on window, not in this function, because this
1389 // script can run twice in one document, and a second copy must
1390 // neither reset it nor buffer every link a second time.
1391 (function installDeepLinkReplay(craft) {
1392 var replay = window.__craftDeepLinkReplay;
1393 if (!replay) {
1394 replay = window.__craftDeepLinkReplay = {undelivered: [], subscribed: false, initialClaimed: false};
1395 window.addEventListener('craftDeepLink', function(e) {
1396 if (!replay.subscribed && !claimed(e.detail)) replay.undelivered.push(e.detail);
1397 });
1398 }
1399 function claimed(detail) {
1400 return replay.initialClaimed && detail && detail.initial;
1401 }
1402 craft._subscribeDeepLinks = function(callback) {
1403 var active = true;
1404 var listener = function(e) { if (!claimed(e.detail)) callback(e.detail); };
1405 window.addEventListener('craftDeepLink', listener);
1406 if (!replay.subscribed) {
1407 replay.subscribed = true;
1408 setTimeout(function() {
1409 var pending = replay.undelivered;
1410 replay.undelivered = [];
1411 if (!active) return;
1412 pending.forEach(function(detail) { callback(detail); });
1413 }, 0);
1414 }
1415 return function() {
1416 active = false;
1417 window.removeEventListener('craftDeepLink', listener);
1418 };
1419 };
1420 craft._claimInitialDeepLink = function() {
1421 replay.initialClaimed = true;
1422 replay.undelivered = replay.undelivered.filter(function(detail) { return !(detail && detail.initial); });
1423 };
1424 })(window.craft);
1425
13571426 (function installCraftMobileContract(craft) {
13581427 var legacyShare = craft.share.bind(craft);
13591428 var legacyOpenCamera = craft.openCamera.bind(craft);
@@ -1485,6 +1554,11 @@ class CraftBridge(
14851554 val events = pendingEvents.toList()
14861555 pendingEvents.clear()
14871556 events.forEach { (name, data) -> sendEvent(name, data) }
1557 // After the script, so the page's replay listener exists and
1558 // the native deliverer is installed.
1559 val links = pendingDeepLinks.toList()
1560 pendingDeepLinks.clear()
1561 links.forEach { (url, initial) -> dispatchDeepLink(url, initial) }
14881562 }
14891563 }
14901564 }
@@ -3314,18 +3388,6 @@ class CraftBridge(
33143388 return true
33153389 }
33163390
3317 // ==================== Deep Links ====================
3318
3319 fun handleDeepLink(uri: android.net.Uri) {
3320 sendEvent("craftDeepLink", mapOf(
3321 "url" to uri.toString(),
3322 "scheme" to (uri.scheme ?: ""),
3323 "host" to (uri.host ?: ""),
3324 "path" to (uri.path ?: ""),
3325 "query" to (uri.query ?: "")
3326 ))
3327 }
3328
33293391 // ==================== QR/Barcode Scanner ====================
33303392
33313393 @JavascriptInterface
@@ -3889,6 +3951,7 @@ class CraftBridge(
38893951 isKeepingAwake = false
38903952 pendingPermissionRequests.clear()
38913953 pendingEvents.clear()
3954 pendingDeepLinks.clear()
38923955 }
38933956
38943957 // ==================== Screen Capture ====================
@@ -4662,13 +4725,44 @@ class CraftBridge(
46624725 }
46634726 }
46644727
4665 fun dispatchDeepLink(url: String) {
4666 if (nativeDeepLinks && CraftNative.dispatchDeepLink(url)) return
4667
4668 // Store as initial URL if this is the first one
4669 if (initialURL == null) {
4670 initialURL = url
4728 /**
4729 * A link the activity received. The first one before any page was ready
4730 * launched the app: it becomes getInitialURL's answer and is dispatched
4731 * with `initial: true`. Every later link is `initial: false` and leaves
4732 * getInitialURL alone, as on iOS.
4733 */
4734 fun receiveDeepLink(url: String) {
4735 val initial = !hasBeenReady && !initialAssigned
4736 if (initial) {
4737 initialAssigned = true
4738 launchURL = url
4739 setInitialURL(url)
46714740 }
4741 if (bridgeReady) dispatchDeepLink(url, false) else pendingDeepLinks.add(url to initial)
4742 }
4743
4744 fun saveDeepLinks(outState: Bundle) {
4745 outState.putBoolean(STATE_LAUNCH_ASSIGNED, initialAssigned)
4746 outState.putBoolean(STATE_PAGE_WAS_READY, hasBeenReady)
4747 outState.putString(STATE_LAUNCH_URL, launchURL)
4748 }
4749
4750 /**
4751 * A recreated activity loads a new WebView and a fresh document, so the
4752 * launch link is queued again for that document's first subscriber, the
4753 * way it was for the first one; a page that called getInitialURL claims
4754 * it as before. Links that arrive now are never the launch link.
4755 */
4756 fun restoreDeepLinks(savedState: Bundle) {
4757 initialAssigned = savedState.getBoolean(STATE_LAUNCH_ASSIGNED, true)
4758 hasBeenReady = savedState.getBoolean(STATE_PAGE_WAS_READY, true)
4759 launchURL = savedState.getString(STATE_LAUNCH_URL)
4760 setInitialURL(launchURL)
4761 launchURL?.let { pendingDeepLinks.add(it to true) }
4762 }
4763
4764 private fun dispatchDeepLink(url: String, initial: Boolean) {
4765 if (nativeDeepLinks && CraftNative.dispatchDeepLink(url, initial)) return
46724766
46734767 val uri = Uri.parse(url)
46744768 val json = JSONObject().apply {
@@ -4683,6 +4777,7 @@ class CraftBridge(
46834777 queryParams.put(name, uri.getQueryParameter(name) ?: "")
46844778 }
46854779 put("queryParams", queryParams)
4780 put("initial", initial)
46864781 }
46874782
46884783 evaluateJavascriptUnlessClosed(
@@ -4753,6 +4848,9 @@ class CraftBridge(
47534848 }
47544849
47554850 companion object {
4851 private const val STATE_LAUNCH_ASSIGNED = "craft.deepLinks.launchAssigned"
4852 private const val STATE_PAGE_WAS_READY = "craft.deepLinks.pageWasReady"
4853 private const val STATE_LAUNCH_URL = "craft.deepLinks.launchURL"
47564854 private val MIME_TYPE = Regex("^[^\\s/]+/[^\\s/]+$")
47574855 const val REQUEST_CAMERA = 1001
47584856 const val REQUEST_GALLERY = 1002