From 85156d83eccd16823160a78ecc8b5735380ee1a3 Mon Sep 17 00:00:00 2001 From: kari-ts Date: Wed, 21 Oct 2026 11:10:50 -0700 Subject: [PATCH] android: handle keystore failures when loading pending InlineShareInboxStore android keystore can become temporarily unavailable, causing encrypted pref reads to fail and crash the app during Notifier initialization. This moves inline share inbox loading and persistence off the initialization path and onto an io coroutine. We serialize persistence operations to avoid concurrent writes, and preserve pending changes when encrypted storage is unavailable rather than treating a failed read as an empty inbox. Updates tailscale/tailscale#21803 Signed-off-by: kari-ts --- .../com/tailscale/ipn/ui/notifier/Notifier.kt | 53 ++++++++++++++++--- .../ipn/util/InlineShareInboxStore.kt | 29 +++++----- 2 files changed, 61 insertions(+), 21 deletions(-) diff --git a/android/src/main/java/com/tailscale/ipn/ui/notifier/Notifier.kt b/android/src/main/java/com/tailscale/ipn/ui/notifier/Notifier.kt index 8844ae078d..63592e1e61 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/notifier/Notifier.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/notifier/Notifier.kt @@ -19,9 +19,10 @@ import com.tailscale.ipn.util.PendingInlineShare import com.tailscale.ipn.util.TSLog import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.Json @@ -60,20 +61,58 @@ object Notifier { val incomingFiles: StateFlow?> = MutableStateFlow(null) val filesWaiting: StateFlow = MutableStateFlow(null) - // Seeded from disk so pending shares survive process death, e.g. if the app is killed + // Saved to disk so pending shares survive process death, e.g. if the app is killed // in the background before the user acts on a share that couldn't be surfaced as a // notification because POST_NOTIFICATIONS was denied. - private val _inlineShareInbox = MutableStateFlow(InlineShareInboxStore.load()) + private val _inlineShareInbox = MutableStateFlow>(emptyList()) val inlineShareInbox: StateFlow> = _inlineShareInbox + private val pendingInboxChanges = + mutableListOf<(List) -> List>() + private val inboxWrites = Channel(Channel.CONFLATED) + private val inboxScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + init { + inboxScope.launch { + inboxWrites.trySend(Unit) + for (ignored in inboxWrites) { + val stored = InlineShareInboxStore.load() ?: continue + val changes = synchronized(this@Notifier) { pendingInboxChanges.toList() } + if (changes.isEmpty()) { + synchronized(this@Notifier) { + _inlineShareInbox.value = + pendingInboxChanges.fold(stored) { inbox, change -> change(inbox) } + } + continue + } + + val merged = changes.fold(stored) { inbox, change -> change(inbox) } + if (InlineShareInboxStore.save(merged)) { + synchronized(this@Notifier) { + pendingInboxChanges.subList(0, changes.size).clear() + _inlineShareInbox.value = + pendingInboxChanges.fold(merged) { inbox, change -> change(inbox) } + } + } + } + } + } + + @Synchronized + private fun changeInlineShareInbox( + change: (List) -> List + ) { + pendingInboxChanges.add(change) + _inlineShareInbox.value = change(_inlineShareInbox.value) + inboxWrites.trySend(Unit) + } + fun appendInlineShare(p: PendingInlineShare) { - _inlineShareInbox.update { it + p } - InlineShareInboxStore.save(_inlineShareInbox.value) + changeInlineShareInbox { it + p } } fun removeInlineShare(id: String) { - _inlineShareInbox.update { list -> list.filterNot { it.id == id } } - InlineShareInboxStore.save(_inlineShareInbox.value) + changeInlineShareInbox { list -> list.filterNot { it.id == id } } } private val userProfiles = mutableMapOf() diff --git a/android/src/main/java/com/tailscale/ipn/util/InlineShareInboxStore.kt b/android/src/main/java/com/tailscale/ipn/util/InlineShareInboxStore.kt index ae2a6bbd9d..0415eb633b 100644 --- a/android/src/main/java/com/tailscale/ipn/util/InlineShareInboxStore.kt +++ b/android/src/main/java/com/tailscale/ipn/util/InlineShareInboxStore.kt @@ -10,31 +10,32 @@ import kotlinx.serialization.json.Json // Persists the inline-share inbox so pending shares survive process death. // -// UninitializedApp.get() rather than App.get(): loaded from Notifier's field initializer, -// which runs inside App's initOnce() before isInitialized is set. App.get() would re-enter -// initOnce() and start Libtailscale a second time. +// Use UninitializedApp.get() rather than App.get() to avoid re-entering app +// initialization when Notifier starts before App.initOnce() completes. object InlineShareInboxStore { private const val PREF_KEY_INBOX = "inline_share_inbox" private val json = Json { ignoreUnknownKeys = true } private fun encryptedPrefs() = (UninitializedApp.get() as App).getEncryptedPrefs() - // apply(), not commit(): the saved file is the durable record, this is just UI state. - fun save(inbox: List) { - val encoded = json.encodeToString(inbox) - runCatching { encryptedPrefs().edit().putString(PREF_KEY_INBOX, encoded).apply() } - .onFailure { TSLog.w("InlineShareInboxStore", "save failed: $it") } + fun save(inbox: List): Boolean { + return try { + encryptedPrefs().edit().putString(PREF_KEY_INBOX, json.encodeToString(inbox)).commit() + } catch (e: Exception) { + TSLog.w("InlineShareInboxStore", "save failed: $e") + false + } } - fun load(): List { - val prefs = encryptedPrefs() - val raw = prefs.getString(PREF_KEY_INBOX, null) ?: return emptyList() + // null means storage was unavailable; do not treat it as an empty inbox. + fun load(): List? { return try { + val prefs = encryptedPrefs() + val raw = prefs.getString(PREF_KEY_INBOX, null) ?: return emptyList() json.decodeFromString(raw) } catch (e: Exception) { - TSLog.w("InlineShareInboxStore", "load: invalid inbox in prefs; clearing") - prefs.edit().remove(PREF_KEY_INBOX).apply() - emptyList() + TSLog.w("InlineShareInboxStore", "load failed: $e") + null } } }