Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 46 additions & 7 deletions android/src/main/java/com/tailscale/ipn/ui/notifier/Notifier.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -60,20 +61,58 @@ object Notifier {
val incomingFiles: StateFlow<List<Ipn.PartialFile>?> = MutableStateFlow(null)
val filesWaiting: StateFlow<Empty.Message?> = 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<List<PendingInlineShare>>(emptyList())
val inlineShareInbox: StateFlow<List<PendingInlineShare>> = _inlineShareInbox

private val pendingInboxChanges =
mutableListOf<(List<PendingInlineShare>) -> List<PendingInlineShare>>()
private val inboxWrites = Channel<Unit>(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<PendingInlineShare>) -> List<PendingInlineShare>
) {
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<String, Tailcfg.UserProfile>()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<PendingInlineShare>) {
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<PendingInlineShare>): 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<PendingInlineShare> {
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<PendingInlineShare>? {
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
}
}
}
Loading