mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-12 05:29:57 +02:00
fix(desktop): unblock protected-core startup snackbars
Run Secret Service IO on spawn_blocking so Linux zbus cannot nest Tokio runtimes during init, and wait for core initialize before experimental saved-device coordinators refresh. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -10,6 +10,9 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -45,20 +48,27 @@ class PairingPromptCoordinator(
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
preferencesRepository.preferences.collectLatest { preferences ->
|
||||
val enabled = preferences.experimentalSavedDevicesEnabled
|
||||
_state.update { it.copy(enabled = enabled) }
|
||||
if (enabled) {
|
||||
refresh()
|
||||
} else {
|
||||
_state.update { it.copy(prompt = null, busy = false) }
|
||||
// Preferences can emit before AppViewModel finishes core initialize.
|
||||
// Hitting the gateway then surfaces "Initialize the core first" snackbars.
|
||||
combine(
|
||||
preferencesRepository.preferences.map { it.experimentalSavedDevicesEnabled },
|
||||
repository.state.map { it.isInitialized },
|
||||
) { enabled, initialized -> enabled to initialized }
|
||||
.distinctUntilChanged()
|
||||
.collectLatest { (enabled, initialized) ->
|
||||
_state.update { it.copy(enabled = enabled) }
|
||||
when {
|
||||
enabled && initialized -> refresh()
|
||||
!enabled -> _state.update { it.copy(prompt = null, busy = false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
scope.launch {
|
||||
repository.signals.collect { signal ->
|
||||
when (signal) {
|
||||
CoreSignal.PairingChanged -> if (_state.value.enabled) refresh()
|
||||
CoreSignal.PairingChanged -> {
|
||||
if (_state.value.enabled && repository.state.value.isInitialized) refresh()
|
||||
}
|
||||
is CoreSignal.ApprovalChanged,
|
||||
is CoreSignal.ReceiverHistoryChanged,
|
||||
is CoreSignal.TransfersChanged,
|
||||
|
||||
@@ -20,6 +20,9 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -59,19 +62,26 @@ class SavedDevicesViewModel(
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
preferencesRepository.preferences.collectLatest { preferences ->
|
||||
val enabled = preferences.experimentalSavedDevicesEnabled
|
||||
_state.update { it.copy(enabled = enabled) }
|
||||
if (enabled) refresh() else _state.update {
|
||||
SavedDevicesState(enabled = false)
|
||||
combine(
|
||||
preferencesRepository.preferences.map { it.experimentalSavedDevicesEnabled },
|
||||
repository.state.map { it.isInitialized },
|
||||
) { enabled, initialized -> enabled to initialized }
|
||||
.distinctUntilChanged()
|
||||
.collectLatest { (enabled, initialized) ->
|
||||
_state.update { it.copy(enabled = enabled) }
|
||||
when {
|
||||
enabled && initialized -> refresh()
|
||||
!enabled -> _state.update { SavedDevicesState(enabled = false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
repository.signals.collect { signal ->
|
||||
when (signal) {
|
||||
CoreSignal.PairingChanged,
|
||||
CoreSignal.TargetedTransferChanged -> if (_state.value.enabled) refresh()
|
||||
CoreSignal.TargetedTransferChanged -> {
|
||||
if (_state.value.enabled && repository.state.value.isInitialized) refresh()
|
||||
}
|
||||
is CoreSignal.ApprovalChanged,
|
||||
is CoreSignal.ReceiverHistoryChanged,
|
||||
is CoreSignal.TransfersChanged -> Unit
|
||||
|
||||
@@ -16,6 +16,9 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import vnidrop.shared.generated.resources.Res
|
||||
@@ -49,17 +52,29 @@ class TargetedOfferCoordinator(
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
preferencesRepository.preferences.collectLatest { preferences ->
|
||||
receiveFolder = fileSystemService.effectiveReceiveFolder(preferences.receiveFolder)
|
||||
val enabled = preferences.experimentalSavedDevicesEnabled
|
||||
_state.update { it.copy(enabled = enabled) }
|
||||
if (enabled) refresh() else _state.update { it.copy(pending = emptyList()) }
|
||||
}
|
||||
// Same startup race as PairingPromptCoordinator: prefs can load while
|
||||
// core initialize still holds the lifecycle gate / core is null.
|
||||
combine(
|
||||
preferencesRepository.preferences,
|
||||
repository.state.map { it.isInitialized },
|
||||
) { preferences, initialized -> preferences to initialized }
|
||||
.distinctUntilChanged()
|
||||
.collectLatest { (preferences, initialized) ->
|
||||
receiveFolder = fileSystemService.effectiveReceiveFolder(preferences.receiveFolder)
|
||||
val enabled = preferences.experimentalSavedDevicesEnabled
|
||||
_state.update { it.copy(enabled = enabled) }
|
||||
when {
|
||||
enabled && initialized -> refresh()
|
||||
!enabled -> _state.update { it.copy(pending = emptyList()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
scope.launch {
|
||||
repository.signals.collect { signal ->
|
||||
when (signal) {
|
||||
CoreSignal.TargetedTransferChanged -> if (_state.value.enabled) refresh()
|
||||
CoreSignal.TargetedTransferChanged -> {
|
||||
if (_state.value.enabled && repository.state.value.isInitialized) refresh()
|
||||
}
|
||||
CoreSignal.PairingChanged,
|
||||
is CoreSignal.ApprovalChanged,
|
||||
is CoreSignal.ReceiverHistoryChanged,
|
||||
|
||||
@@ -11,6 +11,7 @@ import com.vnidrop.app.support.FakePreferencesRepository
|
||||
import com.vnidrop.app.ui.feedback.UiMessageController
|
||||
import com.vnidrop.app.ui.theme.ThemeMode
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
@@ -24,7 +25,7 @@ import kotlin.test.assertTrue
|
||||
class PairingPromptCoordinatorTest {
|
||||
@Test
|
||||
fun experimentalOffDoesNotPromptOnEligibility() = runTest {
|
||||
val core = FakeCoreGateway().apply {
|
||||
val core = initializedCore().apply {
|
||||
pairingEligibilities = listOf(eligibility("peer-a"))
|
||||
}
|
||||
val coordinator = PairingPromptCoordinator(
|
||||
@@ -39,10 +40,41 @@ class PairingPromptCoordinatorTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun acceptEligibilityRequestsPairing() = runTest {
|
||||
fun waitsForCoreInitializeBeforeRefreshing() = runTest {
|
||||
// Regression: experimental prefs emit before AppViewModel initialize finishes.
|
||||
val core = FakeCoreGateway().apply {
|
||||
pairingEligibilities = listOf(eligibility("peer-a"))
|
||||
}
|
||||
val messages = UiMessageController()
|
||||
val seen = mutableListOf<String>()
|
||||
backgroundScope.launch {
|
||||
messages.messages.collect { seen += it.text.toString() }
|
||||
}
|
||||
val coordinator = PairingPromptCoordinator(
|
||||
core,
|
||||
preferences(enabled = true),
|
||||
messages,
|
||||
backgroundScope,
|
||||
)
|
||||
runCurrent()
|
||||
advanceUntilIdle()
|
||||
assertEquals(0, core.listDeviceRelationshipsCount)
|
||||
assertNull(coordinator.state.value.prompt)
|
||||
assertTrue(seen.isEmpty())
|
||||
|
||||
core.mutableState.value = core.mutableState.value.copy(isInitialized = true)
|
||||
runCurrent()
|
||||
advanceUntilIdle()
|
||||
assertEquals(1, core.listDeviceRelationshipsCount)
|
||||
assertEquals(PairingPrompt.Eligibility("peer-a"), coordinator.state.value.prompt)
|
||||
assertTrue(seen.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun acceptEligibilityRequestsPairing() = runTest {
|
||||
val core = initializedCore().apply {
|
||||
pairingEligibilities = listOf(eligibility("peer-a"))
|
||||
}
|
||||
val coordinator = PairingPromptCoordinator(
|
||||
core,
|
||||
preferences(enabled = true),
|
||||
@@ -61,7 +93,7 @@ class PairingPromptCoordinatorTest {
|
||||
|
||||
@Test
|
||||
fun declineEligibilityConsumesWithoutRequest() = runTest {
|
||||
val core = FakeCoreGateway().apply {
|
||||
val core = initializedCore().apply {
|
||||
pairingEligibilities = listOf(eligibility("peer-a"))
|
||||
}
|
||||
val coordinator = PairingPromptCoordinator(
|
||||
@@ -82,7 +114,7 @@ class PairingPromptCoordinatorTest {
|
||||
|
||||
@Test
|
||||
fun dismissKeepsEligibilityForSavedDevicesArea() = runTest {
|
||||
val core = FakeCoreGateway().apply {
|
||||
val core = initializedCore().apply {
|
||||
pairingEligibilities = listOf(eligibility("peer-a"))
|
||||
}
|
||||
val coordinator = PairingPromptCoordinator(
|
||||
@@ -102,7 +134,7 @@ class PairingPromptCoordinatorTest {
|
||||
|
||||
@Test
|
||||
fun incomingPairingRequestAcceptsViaRespond() = runTest {
|
||||
val core = FakeCoreGateway().apply {
|
||||
val core = initializedCore().apply {
|
||||
deviceRelationships = listOf(incoming("peer-b"))
|
||||
}
|
||||
val coordinator = PairingPromptCoordinator(
|
||||
@@ -121,6 +153,10 @@ class PairingPromptCoordinatorTest {
|
||||
assertEquals(listOf("peer-b" to true), core.pairingResponses)
|
||||
}
|
||||
|
||||
private fun initializedCore() = FakeCoreGateway().apply {
|
||||
mutableState.value = mutableState.value.copy(isInitialized = true)
|
||||
}
|
||||
|
||||
private fun eligibility(peer: String) = PairingEligibilityModel(
|
||||
peerEndpointId = peer,
|
||||
sessionId = "session",
|
||||
|
||||
@@ -37,6 +37,7 @@ class SavedDevicesViewModelTest {
|
||||
fun labelForgetAndBlockUpdateGateway() = runTest {
|
||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||
val core = FakeCoreGateway().apply {
|
||||
mutableState.value = mutableState.value.copy(isInitialized = true)
|
||||
savedDevices = listOf(device("peer-1", label = null))
|
||||
}
|
||||
val preferences = preferences(enabled = true)
|
||||
@@ -82,6 +83,7 @@ class SavedDevicesViewModelTest {
|
||||
fun sendFromSavedDeviceCreatesTargetedTransfer() = runTest {
|
||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||
val core = FakeCoreGateway().apply {
|
||||
mutableState.value = mutableState.value.copy(isInitialized = true)
|
||||
savedDevices = listOf(device("peer-3", label = "Kitchen"))
|
||||
createTargetedResult = Result.success(
|
||||
TargetedTransferModel(
|
||||
|
||||
@@ -23,7 +23,7 @@ import kotlin.test.assertTrue
|
||||
class TargetedOfferCoordinatorTest {
|
||||
@Test
|
||||
fun acceptApprovesAndPullsByTransferId() = runTest {
|
||||
val core = FakeCoreGateway().apply {
|
||||
val core = initializedCore().apply {
|
||||
pendingTargetedOffers = listOf(offer("transfer-1"))
|
||||
respondTargetedResult = Result.success(TargetedOfferResponseModel.Approved("transfer-1"))
|
||||
receiveResult = Result.success(Unit)
|
||||
@@ -56,7 +56,7 @@ class TargetedOfferCoordinatorTest {
|
||||
override fun finishFile(relativePath: String) = error("unused")
|
||||
override fun abortFile(relativePath: String, reason: String) = error("unused")
|
||||
}
|
||||
val core = FakeCoreGateway().apply {
|
||||
val core = initializedCore().apply {
|
||||
pendingTargetedOffers = listOf(offer("transfer-sink"))
|
||||
respondTargetedResult = Result.success(TargetedOfferResponseModel.Approved("transfer-sink"))
|
||||
receiveResult = Result.success(Unit)
|
||||
@@ -82,7 +82,7 @@ class TargetedOfferCoordinatorTest {
|
||||
|
||||
@Test
|
||||
fun declineDoesNotReceive() = runTest {
|
||||
val core = FakeCoreGateway().apply {
|
||||
val core = initializedCore().apply {
|
||||
pendingTargetedOffers = listOf(offer("transfer-2"))
|
||||
respondTargetedResult = Result.success(TargetedOfferResponseModel.Declined)
|
||||
}
|
||||
@@ -104,7 +104,7 @@ class TargetedOfferCoordinatorTest {
|
||||
|
||||
@Test
|
||||
fun experimentalOffIgnoresPendingOffers() = runTest {
|
||||
val core = FakeCoreGateway().apply {
|
||||
val core = initializedCore().apply {
|
||||
pendingTargetedOffers = listOf(offer("transfer-3"))
|
||||
}
|
||||
val coordinator = TargetedOfferCoordinator(
|
||||
@@ -119,6 +119,34 @@ class TargetedOfferCoordinatorTest {
|
||||
assertTrue(coordinator.state.value.pending.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun waitsForCoreInitializeBeforeListingOffers() = runTest {
|
||||
val core = FakeCoreGateway().apply {
|
||||
pendingTargetedOffers = listOf(offer("transfer-late"))
|
||||
}
|
||||
val coordinator = TargetedOfferCoordinator(
|
||||
core,
|
||||
FakeFileSystemService(ReceiveFolder(ReceiveFolderKind.FileSystemPath, "/tmp", "tmp")),
|
||||
preferences(enabled = true),
|
||||
UiMessageController(),
|
||||
backgroundScope,
|
||||
)
|
||||
runCurrent()
|
||||
advanceUntilIdle()
|
||||
assertEquals(0, core.listPendingTargetedOffersCount)
|
||||
assertTrue(coordinator.state.value.pending.isEmpty())
|
||||
|
||||
core.mutableState.value = core.mutableState.value.copy(isInitialized = true)
|
||||
runCurrent()
|
||||
advanceUntilIdle()
|
||||
assertEquals(1, core.listPendingTargetedOffersCount)
|
||||
assertEquals("transfer-late", coordinator.state.value.current?.transferId)
|
||||
}
|
||||
|
||||
private fun initializedCore() = FakeCoreGateway().apply {
|
||||
mutableState.value = mutableState.value.copy(isInitialized = true)
|
||||
}
|
||||
|
||||
private fun offer(id: String) = PendingTargetedOfferModel(
|
||||
transferId = id,
|
||||
senderEndpointId = "sender",
|
||||
|
||||
@@ -209,6 +209,10 @@ class FakeCoreGateway : CoreGateway {
|
||||
var blockedDevices: List<String> = emptyList()
|
||||
var pendingTargetedOffers: List<PendingTargetedOfferModel> = emptyList()
|
||||
var targetedTransfers: List<TargetedTransferModel> = emptyList()
|
||||
var listPairingEligibilitiesCount = 0
|
||||
var listDeviceRelationshipsCount = 0
|
||||
var listPendingTargetedOffersCount = 0
|
||||
var listSavedDevicesCount = 0
|
||||
var respondTargetedResult: Result<TargetedOfferResponseModel> =
|
||||
Result.success(TargetedOfferResponseModel.Declined)
|
||||
var createTargetedResult: Result<TargetedTransferModel> =
|
||||
@@ -217,7 +221,10 @@ class FakeCoreGateway : CoreGateway {
|
||||
val blockedPeers = mutableListOf<String>()
|
||||
val labeledDevices = mutableListOf<Pair<String, String?>>()
|
||||
|
||||
override suspend fun listPairingEligibilities() = Result.success(pairingEligibilities)
|
||||
override suspend fun listPairingEligibilities(): Result<List<PairingEligibilityModel>> {
|
||||
listPairingEligibilitiesCount += 1
|
||||
return Result.success(pairingEligibilities)
|
||||
}
|
||||
override suspend fun declinePairingEligibility(peerEndpointId: String): Result<Unit> {
|
||||
pairingEligibilities = pairingEligibilities.filterNot { it.peerEndpointId == peerEndpointId }
|
||||
return Result.success(Unit)
|
||||
@@ -240,8 +247,14 @@ class FakeCoreGateway : CoreGateway {
|
||||
pairingResponses += peerEndpointId to accepted
|
||||
return respondPairingResult.map { accepted }
|
||||
}
|
||||
override suspend fun listDeviceRelationships() = Result.success(deviceRelationships)
|
||||
override suspend fun listSavedDevices() = Result.success(savedDevices)
|
||||
override suspend fun listDeviceRelationships(): Result<List<DeviceRelationshipModel>> {
|
||||
listDeviceRelationshipsCount += 1
|
||||
return Result.success(deviceRelationships)
|
||||
}
|
||||
override suspend fun listSavedDevices(): Result<List<SavedDeviceModel>> {
|
||||
listSavedDevicesCount += 1
|
||||
return Result.success(savedDevices)
|
||||
}
|
||||
override suspend fun setSavedDeviceLabel(peerEndpointId: String, label: String?): Result<Unit> {
|
||||
labeledDevices += peerEndpointId to label
|
||||
savedDevices = savedDevices.map {
|
||||
@@ -264,7 +277,10 @@ class FakeCoreGateway : CoreGateway {
|
||||
return Result.success(Unit)
|
||||
}
|
||||
override suspend fun listBlockedDevices() = Result.success(blockedDevices)
|
||||
override suspend fun listPendingTargetedOffers() = Result.success(pendingTargetedOffers)
|
||||
override suspend fun listPendingTargetedOffers(): Result<List<PendingTargetedOfferModel>> {
|
||||
listPendingTargetedOffersCount += 1
|
||||
return Result.success(pendingTargetedOffers)
|
||||
}
|
||||
override suspend fun respondToTargetedOffer(transferId: String, accepted: Boolean): Result<TargetedOfferResponseModel> {
|
||||
respondedTargetedOffers += transferId to accepted
|
||||
return respondTargetedResult
|
||||
|
||||
Reference in New Issue
Block a user