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 } } }