From 9180767ba4eb0f6e742ee0601a424106d827f24d Mon Sep 17 00:00:00 2001 From: loveheaven Date: Thu, 11 Jun 2026 01:27:56 +0800 Subject: [PATCH] fix(android): deliver Open-with intents reliably on cold start and re-mount (#4527) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tapping an EPUB in the system file browser and choosing Readest could silently fail to open the book in two distinct scenarios on Android: 1. Cold launch — the system delivers the ACTION_VIEW intent to onCreate / onNewIntent before the JS layer has finished hydrating and called addPluginListener('native-bridge', 'shared-intent', ...). The upstream Tauri Plugin.trigger() drops events when the per-event listener list is empty, so the intent vanishes. Fix this in NativeBridgePlugin by queueing emits whose event has no listener, then overriding registerListener so the queue is drained whenever a listener becomes available. 2. React strict-mode re-mount — useAppUrlIngress had a one-shot listened.current ref guard meant to avoid double registration. In strict mode (and any subsequent effect re-run) the cleanup unregister()'d the underlying native plugin listener but the next mount short-circuited on the ref and never re-registered. The shared-intent listener list ended up empty for the rest of the session, so any subsequent Open-with intent went into the queue and never came out. Drop the guard and let the effect register on every mount; cleanup balances each registration. --- .../src/main/java/NativeBridgePlugin.kt | 58 ++++++++++++++++++- .../readest-app/src/hooks/useAppUrlIngress.ts | 18 ++++-- 2 files changed, 71 insertions(+), 5 deletions(-) diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/java/NativeBridgePlugin.kt b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/java/NativeBridgePlugin.kt index ca867acb..a37f5fc7 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/java/NativeBridgePlugin.kt +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/java/NativeBridgePlugin.kt @@ -243,6 +243,22 @@ class NativeBridgePlugin(private val activity: Activity): Plugin(activity) { return list ?: emptyList() } + // shared-intent events emitted before the JS side has registered its + // listener via addPluginListener("native-bridge", "shared-intent", ...) + // would otherwise vanish — the upstream Plugin.trigger() drops events + // when the per-event listener list is empty. This is exactly what + // happens on cold launch via "Open with Readest": Android delivers the + // ACTION_VIEW intent to onCreate / onNewIntent (the WebView is already + // up but the JS app is mid-hydration), we emit it through triggerEvent + // immediately, and useAppUrlIngress's addPluginListener call lands a + // few hundred ms later — too late to receive the now-discarded event. + // + // To fix this we queue events whenever no listener is registered, then + // replay the queue when a registerListener call lands for this event + // (see overridden registerListener below). + private val pendingEvents: MutableMap> = mutableMapOf() + private val pendingEventsLock = Any() + private fun emitSharedIntent(action: String, uris: List) { val payload = JSObject().apply { put("action", action) @@ -250,7 +266,47 @@ class NativeBridgePlugin(private val activity: Activity): Plugin(activity) { uris.forEach { arr.put(it.toString()) } put("urls", arr) } - triggerEvent("shared-intent", payload) + emitOrQueue("shared-intent", payload) + } + + private fun emitOrQueue(eventName: String, payload: JSObject) { + if (hasListener(eventName)) { + triggerEvent(eventName, payload) + } else { + synchronized(pendingEventsLock) { + val list = pendingEvents.getOrPut(eventName) { mutableListOf() } + list.add(payload) + } + Log.d("NativeBridgePlugin", "Queued $eventName payload (no listener yet); pending size=${pendingEvents[eventName]?.size}") + } + } + + override fun registerListener(invoke: Invoke) { + super.registerListener(invoke) + // After super.registerListener, the listener is now wired up. + // Drain any queued events for the same name so the JS side gets + // events that were emitted between native start and listener + // registration. + // The event name lives on the invoke args, not directly accessible + // post-resolve; instead, drain every queued bucket whose key has a + // listener now. Cheap because there's at most one or two events. + val toReplay = mutableListOf>() + synchronized(pendingEventsLock) { + val toRemove = mutableListOf() + for ((event, list) in pendingEvents) { + if (hasListener(event)) { + list.forEach { toReplay.add(event to it) } + toRemove.add(event) + } + } + toRemove.forEach { pendingEvents.remove(it) } + } + if (toReplay.isNotEmpty()) { + Log.d("NativeBridgePlugin", "Replaying ${toReplay.size} queued event(s) after registerListener") + for ((event, payload) in toReplay) { + triggerEvent(event, payload) + } + } } @Command diff --git a/apps/readest-app/src/hooks/useAppUrlIngress.ts b/apps/readest-app/src/hooks/useAppUrlIngress.ts index 98d9fb69..f778cdf9 100644 --- a/apps/readest-app/src/hooks/useAppUrlIngress.ts +++ b/apps/readest-app/src/hooks/useAppUrlIngress.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef } from 'react'; +import { useEffect } from 'react'; import { addPluginListener, PluginListener } from '@tauri-apps/api/core'; import { onOpenUrl } from '@tauri-apps/plugin-deep-link'; import { getCurrentWindow } from '@tauri-apps/api/window'; @@ -55,12 +55,22 @@ interface SharedIntentPayload { */ export function useAppUrlIngress() { const { appService } = useEnv(); - const listened = useRef(false); useEffect(() => { if (!isTauriAppPlatform() || !appService) return; - if (listened.current) return; - listened.current = true; + // Note: removed an old `listened.current` ref guard that tried to + // make this effect a one-shot. In React strict mode (dev) the effect + // mounts → cleans up → mounts again. With the guard, the second + // mount short-circuited (ref still true) and DID NOT re-register + // any listeners — but the previous cleanup had already + // `unregister()`ed the underlying native plugin listener + // (NativeBridgePlugin's listeners["shared-intent"] map). Net result: + // the app ended up with zero shared-intent listeners on the native + // side, so any "Open with Readest" intent that arrived AFTER cold + // start was silently dropped (event got queued by our pending-events + // workaround but, with no future register call, never replayed). + // Letting the effect re-run on every mount cycle keeps the JS-side + // and native-side listener bookkeeping in lockstep. const dispatch = (urls: string[], action?: 'VIEW' | 'SEND') => { if (!urls.length) return;