fix(android): deliver Open-with intents reliably on cold start and re-mount (#4527)

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.
This commit is contained in:
loveheaven
2026-06-11 01:27:56 +08:00
committed by GitHub
parent 31176e5d47
commit 9180767ba4
2 changed files with 71 additions and 5 deletions
@@ -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<String, MutableList<JSObject>> = mutableMapOf()
private val pendingEventsLock = Any()
private fun emitSharedIntent(action: String, uris: List<Uri>) {
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<Pair<String, JSObject>>()
synchronized(pendingEventsLock) {
val toRemove = mutableListOf<String>()
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
+14 -4
View File
@@ -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;