From ebdff3df4bb95a6b8fdac9060b588ff626e93417 Mon Sep 17 00:00:00 2001 From: Hammed Abass Date: Tue, 11 Aug 2026 21:39:49 +0200 Subject: [PATCH] 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 --- crates/vnidrop/AGENTS.md | 11 +- crates/vnidrop/src/secure_secret.rs | 135 +++++++++++++----- crates/vnidrop/src/secure_secret/linux.rs | 3 + crates/vnidrop/src/secure_secret/platform.rs | 4 +- .../src/tests/platform_contract_linux.rs | 3 + .../saveddevices/PairingPromptCoordinator.kt | 28 ++-- .../saveddevices/SavedDevicesViewModel.kt | 24 +++- .../saveddevices/TargetedOfferCoordinator.kt | 29 +++- .../PairingPromptCoordinatorTest.kt | 46 +++++- .../saveddevices/SavedDevicesViewModelTest.kt | 2 + .../TargetedOfferCoordinatorTest.kt | 36 ++++- .../kotlin/com/vnidrop/app/support/Fakes.kt | 24 +++- 12 files changed, 266 insertions(+), 79 deletions(-) diff --git a/crates/vnidrop/AGENTS.md b/crates/vnidrop/AGENTS.md index 006b4b4..fe47d15 100644 --- a/crates/vnidrop/AGENTS.md +++ b/crates/vnidrop/AGENTS.md @@ -87,11 +87,14 @@ open domain stores via `persistence::open_all`. 4. **Cancel:** signal active-transfer oneshot **synchronously** before async DB work. Use existing `take_active_transfer` / facade cancel path. Do not reintroduce nested exclusive `Runtime::block_on` deadlocks. -5. **No lock across await:** Clippy `await_holding_lock` fails CI. -6. **ReceiveOutputSink:** after successful `start_file`, exactly one of +5. **SecureSecretStore:** never call the sync store from an async task body. + Linux Secret Service / zbus blocking nests Tokio `block_on`; `SecretCustody` + must keep those calls on `spawn_blocking`. +6. **No lock across await:** Clippy `await_holding_lock` fails CI. +7. **ReceiveOutputSink:** after successful `start_file`, exactly one of `finish_file` or `abort_file` (see `OutputSinkFile` Drop). -7. **No-overwrite publish** for path receives (temp + hard link / exclusive rename). -8. Integration tests must use the **public** API + `tests/support/` only. +8. **No-overwrite publish** for path receives (temp + hard link / exclusive rename). +9. Integration tests must use the **public** API + `tests/support/` only. --- diff --git a/crates/vnidrop/src/secure_secret.rs b/crates/vnidrop/src/secure_secret.rs index 0d6961f..92c9101 100644 --- a/crates/vnidrop/src/secure_secret.rs +++ b/crates/vnidrop/src/secure_secret.rs @@ -395,12 +395,10 @@ impl SecretCustody { ) -> Result { validate_material(kind, &material, expected_identity)?; let handle = SecretHandle::generate(kind); - self.store - .put(&handle, material.clone()) - .map_err(map_store_error)?; + self.store_put(handle.clone(), material.clone()).await?; #[cfg(test)] self.maybe_crash(CustodyCrashPoint::StoreWrite)?; - let stored = self.store.get(&handle).map_err(map_store_error)?; + let stored = self.store_get(handle.clone()).await?; if stored != material { return Err(VnidropError::SecureStorageCorrupted { reason: "credential store did not preserve protected material".to_string(), @@ -408,7 +406,7 @@ impl SecretCustody { } validate_material(kind, &stored, expected_identity)?; if let Err(error) = self.metadata.stage(&handle, kind, expected_identity).await { - self.delete_if_present(&handle)?; + self.delete_if_present(&handle).await?; return Err(error); } #[cfg(test)] @@ -430,7 +428,7 @@ impl SecretCustody { reason: "protected secret is not active".to_string(), }); } - let material = self.store.get(handle).map_err(map_store_error)?; + let material = self.store_get(handle.clone()).await?; validate_material( metadata.kind, &material, @@ -444,7 +442,7 @@ impl SecretCustody { if self.metadata.find(handle).await?.is_some() { self.metadata.disable(handle).await?; } - self.delete_if_present(handle) + self.delete_if_present(handle).await } pub(crate) async fn list_active_handles( @@ -522,9 +520,14 @@ impl SecretCustody { .contains_kind(SecretKind::EndpointIdentity) .await? { - return Err(VnidropError::SecureStorageUnavailable { - reason: "protected endpoint identity is disabled".to_string(), - }); + // Concurrent first-start may have staged (not yet active) metadata. + // Wait for activation before treating leftover rows as disabled. + return match self.wait_for_active_endpoint_identity().await { + Ok(handle) => self.load(&handle).await, + Err(_) => Err(VnidropError::SecureStorageUnavailable { + reason: "protected endpoint identity is disabled".to_string(), + }), + }; } match tokio::fs::try_exists(legacy_path).await { Ok(true) => { @@ -544,34 +547,38 @@ impl SecretCustody { .await { Ok(handle) => self.load(&handle).await, - Err(error) => { - let winner = tokio::time::timeout(Duration::from_secs(1), async { - loop { - if let Some(active) = self - .metadata - .find_active_kind(SecretKind::EndpointIdentity) - .await? - { - return self.load(&active.handle).await; - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) - .await; - match winner { - Ok(result) => result, - Err(_) => Err(error), - } - } + Err(error) => match self.wait_for_active_endpoint_identity().await { + Ok(handle) => self.load(&handle).await, + Err(_) => Err(error), + }, } } Err(error) => Err(VnidropError::filesystem(error)), } } + async fn wait_for_active_endpoint_identity(&self) -> Result { + tokio::time::timeout(Duration::from_secs(1), async { + loop { + if let Some(active) = self + .metadata + .find_active_kind(SecretKind::EndpointIdentity) + .await? + { + return Ok(active.handle); + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .map_err(|_| VnidropError::SecureStorageUnavailable { + reason: "timed out waiting for protected endpoint identity".to_string(), + })? + } + pub(crate) async fn reconcile(&self) -> Result { let metadata = self.metadata.list().await?; - let stored_handles = self.store.list_handles().map_err(map_store_error)?; + let stored_handles = self.store_list_handles().await?; let known_handles = metadata .iter() .map(|entry| entry.handle.clone()) @@ -580,16 +587,16 @@ impl SecretCustody { for entry in metadata { if entry.state == SecretMetadataState::Disabled { - self.delete_if_present(&entry.handle)?; + self.delete_if_present(&entry.handle).await?; continue; } - match self.store.get(&entry.handle) { + match self.store_get_raw(entry.handle.clone()).await? { Ok(material) => { if validate_material(entry.kind, &material, entry.expected_identity.as_deref()) .is_err() { self.metadata.disable(&entry.handle).await?; - self.delete_if_present(&entry.handle)?; + self.delete_if_present(&entry.handle).await?; summary.disabled += 1; } else if entry.state == SecretMetadataState::Staged { self.metadata.activate(&entry.handle).await?; @@ -598,7 +605,7 @@ impl SecretCustody { } Err(SecureSecretStoreError::Missing | SecureSecretStoreError::Corrupted) => { self.metadata.disable(&entry.handle).await?; - self.delete_if_present(&entry.handle)?; + self.delete_if_present(&entry.handle).await?; summary.disabled += 1; } Err(error) => return Err(map_store_error(error)), @@ -607,20 +614,74 @@ impl SecretCustody { for handle in stored_handles { if !known_handles.contains(&handle) { - self.store.delete(&handle).map_err(map_store_error)?; + self.store_delete(handle).await?; summary.orphans_deleted += 1; } } Ok(summary) } - fn delete_if_present(&self, handle: &SecretHandle) -> Result<(), VnidropError> { - match self.store.delete(handle) { + async fn delete_if_present(&self, handle: &SecretHandle) -> Result<(), VnidropError> { + match self.store_delete_raw(handle.clone()).await? { Ok(()) | Err(SecureSecretStoreError::Missing) => Ok(()), Err(error) => Err(map_store_error(error)), } } + // Platform credential stores (especially Linux Secret Service via zbus + // blocking) nest their own Tokio `block_on`. Calling them on a worker + // already inside Vnidrop's runtime panics with "Cannot start a runtime + // from within a runtime" and breaks protected-core desktop startup. + async fn store_put( + &self, + handle: SecretHandle, + material: SecretMaterial, + ) -> Result<(), VnidropError> { + let store = Arc::clone(&self.store); + tokio::task::spawn_blocking(move || store.put(&handle, material)) + .await + .map_err(VnidropError::internal)? + .map_err(map_store_error) + } + + async fn store_get(&self, handle: SecretHandle) -> Result { + self.store_get_raw(handle).await?.map_err(map_store_error) + } + + async fn store_get_raw( + &self, + handle: SecretHandle, + ) -> Result, VnidropError> { + let store = Arc::clone(&self.store); + tokio::task::spawn_blocking(move || store.get(&handle)) + .await + .map_err(VnidropError::internal) + } + + async fn store_delete(&self, handle: SecretHandle) -> Result<(), VnidropError> { + self.store_delete_raw(handle) + .await? + .map_err(map_store_error) + } + + async fn store_delete_raw( + &self, + handle: SecretHandle, + ) -> Result, VnidropError> { + let store = Arc::clone(&self.store); + tokio::task::spawn_blocking(move || store.delete(&handle)) + .await + .map_err(VnidropError::internal) + } + + async fn store_list_handles(&self) -> Result, VnidropError> { + let store = Arc::clone(&self.store); + tokio::task::spawn_blocking(move || store.list_handles()) + .await + .map_err(VnidropError::internal)? + .map_err(map_store_error) + } + #[cfg(test)] pub(crate) fn crash_once_at(&self, point: CustodyCrashPoint) { *self.crash_point.lock().unwrap() = Some(point); diff --git a/crates/vnidrop/src/secure_secret/linux.rs b/crates/vnidrop/src/secure_secret/linux.rs index c0c2ee3..e404de8 100644 --- a/crates/vnidrop/src/secure_secret/linux.rs +++ b/crates/vnidrop/src/secure_secret/linux.rs @@ -1,5 +1,8 @@ use std::{collections::HashMap, sync::Arc}; +// Blocking Secret Service / zbus owns a nested Tokio runtime. Never call this +// adapter from a thread already inside Vnidrop's runtime — SecretCustody routes +// store IO through `spawn_blocking` for that reason. use secret_service::{blocking::SecretService, EncryptionType, Error}; use super::{ diff --git a/crates/vnidrop/src/secure_secret/platform.rs b/crates/vnidrop/src/secure_secret/platform.rs index 8c104c0..9e29ebe 100644 --- a/crates/vnidrop/src/secure_secret/platform.rs +++ b/crates/vnidrop/src/secure_secret/platform.rs @@ -47,12 +47,12 @@ fn lock_exclusive_nonblocking(file: &File) -> Result<(), VnidropError> { return Ok(()); } let err = io::Error::last_os_error(); - return Err(match err.kind() { + Err(match err.kind() { io::ErrorKind::WouldBlock => VnidropError::SecureStorageUnavailable { reason: "another protected core is already using this profile".to_string(), }, _ => VnidropError::filesystem(err), - }); + }) } #[cfg(windows)] { diff --git a/crates/vnidrop/src/tests/platform_contract_linux.rs b/crates/vnidrop/src/tests/platform_contract_linux.rs index be6d276..56ebdf8 100644 --- a/crates/vnidrop/src/tests/platform_contract_linux.rs +++ b/crates/vnidrop/src/tests/platform_contract_linux.rs @@ -509,6 +509,9 @@ fn secret_service_identity_survives_core_restart() { #[cfg(target_os = "linux")] #[test] fn experimental_secret_service_identity_survives_core_restart_on_linux() { + // Regression: protected init used to call blocking Secret Service on the + // Tokio worker that drives `CoreInner::start`, which nested `block_on` and + // aborted desktop startup with "Cannot start a runtime from within a runtime". let data_dir = tempfile::tempdir().unwrap(); let sink = Arc::new(RecordingSink { events: Mutex::new(Vec::new()), diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/saveddevices/PairingPromptCoordinator.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/saveddevices/PairingPromptCoordinator.kt index feee50f..c02ef97 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/saveddevices/PairingPromptCoordinator.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/saveddevices/PairingPromptCoordinator.kt @@ -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, diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/saveddevices/SavedDevicesViewModel.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/saveddevices/SavedDevicesViewModel.kt index 0e6be1e..e668611 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/saveddevices/SavedDevicesViewModel.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/saveddevices/SavedDevicesViewModel.kt @@ -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 diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/saveddevices/TargetedOfferCoordinator.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/saveddevices/TargetedOfferCoordinator.kt index d1b809d..a03872e 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/saveddevices/TargetedOfferCoordinator.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/saveddevices/TargetedOfferCoordinator.kt @@ -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, diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/saveddevices/PairingPromptCoordinatorTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/saveddevices/PairingPromptCoordinatorTest.kt index 9121abe..4bedd95 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/saveddevices/PairingPromptCoordinatorTest.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/saveddevices/PairingPromptCoordinatorTest.kt @@ -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() + 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", diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/saveddevices/SavedDevicesViewModelTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/saveddevices/SavedDevicesViewModelTest.kt index 0c1687c..b78658f 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/saveddevices/SavedDevicesViewModelTest.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/saveddevices/SavedDevicesViewModelTest.kt @@ -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( diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/saveddevices/TargetedOfferCoordinatorTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/saveddevices/TargetedOfferCoordinatorTest.kt index a4413d4..f2f900b 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/saveddevices/TargetedOfferCoordinatorTest.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/saveddevices/TargetedOfferCoordinatorTest.kt @@ -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", diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt index 94d14f2..d009cb9 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt @@ -209,6 +209,10 @@ class FakeCoreGateway : CoreGateway { var blockedDevices: List = emptyList() var pendingTargetedOffers: List = emptyList() var targetedTransfers: List = emptyList() + var listPairingEligibilitiesCount = 0 + var listDeviceRelationshipsCount = 0 + var listPendingTargetedOffersCount = 0 + var listSavedDevicesCount = 0 var respondTargetedResult: Result = Result.success(TargetedOfferResponseModel.Declined) var createTargetedResult: Result = @@ -217,7 +221,10 @@ class FakeCoreGateway : CoreGateway { val blockedPeers = mutableListOf() val labeledDevices = mutableListOf>() - override suspend fun listPairingEligibilities() = Result.success(pairingEligibilities) + override suspend fun listPairingEligibilities(): Result> { + listPairingEligibilitiesCount += 1 + return Result.success(pairingEligibilities) + } override suspend fun declinePairingEligibility(peerEndpointId: String): Result { 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> { + listDeviceRelationshipsCount += 1 + return Result.success(deviceRelationships) + } + override suspend fun listSavedDevices(): Result> { + listSavedDevicesCount += 1 + return Result.success(savedDevices) + } override suspend fun setSavedDeviceLabel(peerEndpointId: String, label: String?): Result { 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> { + listPendingTargetedOffersCount += 1 + return Result.success(pendingTargetedOffers) + } override suspend fun respondToTargetedOffer(transferId: String, accepted: Boolean): Result { respondedTargetedOffers += transferId to accepted return respondTargetedResult