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:
2026-08-11 21:39:49 +02:00
parent b9884b566a
commit ebdff3df4b
12 changed files with 266 additions and 79 deletions

View File

@@ -87,11 +87,14 @@ open domain stores via `persistence::open_all`.
4. **Cancel:** signal active-transfer oneshot **synchronously** before async DB 4. **Cancel:** signal active-transfer oneshot **synchronously** before async DB
work. Use existing `take_active_transfer` / facade cancel path. Do not reintroduce work. Use existing `take_active_transfer` / facade cancel path. Do not reintroduce
nested exclusive `Runtime::block_on` deadlocks. nested exclusive `Runtime::block_on` deadlocks.
5. **No lock across await:** Clippy `await_holding_lock` fails CI. 5. **SecureSecretStore:** never call the sync store from an async task body.
6. **ReceiveOutputSink:** after successful `start_file`, exactly one of 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). `finish_file` or `abort_file` (see `OutputSinkFile` Drop).
7. **No-overwrite publish** for path receives (temp + hard link / exclusive rename). 8. **No-overwrite publish** for path receives (temp + hard link / exclusive rename).
8. Integration tests must use the **public** API + `tests/support/` only. 9. Integration tests must use the **public** API + `tests/support/` only.
--- ---

View File

@@ -395,12 +395,10 @@ impl SecretCustody {
) -> Result<SecretHandle, VnidropError> { ) -> Result<SecretHandle, VnidropError> {
validate_material(kind, &material, expected_identity)?; validate_material(kind, &material, expected_identity)?;
let handle = SecretHandle::generate(kind); let handle = SecretHandle::generate(kind);
self.store self.store_put(handle.clone(), material.clone()).await?;
.put(&handle, material.clone())
.map_err(map_store_error)?;
#[cfg(test)] #[cfg(test)]
self.maybe_crash(CustodyCrashPoint::StoreWrite)?; 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 { if stored != material {
return Err(VnidropError::SecureStorageCorrupted { return Err(VnidropError::SecureStorageCorrupted {
reason: "credential store did not preserve protected material".to_string(), reason: "credential store did not preserve protected material".to_string(),
@@ -408,7 +406,7 @@ impl SecretCustody {
} }
validate_material(kind, &stored, expected_identity)?; validate_material(kind, &stored, expected_identity)?;
if let Err(error) = self.metadata.stage(&handle, kind, expected_identity).await { 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); return Err(error);
} }
#[cfg(test)] #[cfg(test)]
@@ -430,7 +428,7 @@ impl SecretCustody {
reason: "protected secret is not active".to_string(), 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( validate_material(
metadata.kind, metadata.kind,
&material, &material,
@@ -444,7 +442,7 @@ impl SecretCustody {
if self.metadata.find(handle).await?.is_some() { if self.metadata.find(handle).await?.is_some() {
self.metadata.disable(handle).await?; self.metadata.disable(handle).await?;
} }
self.delete_if_present(handle) self.delete_if_present(handle).await
} }
pub(crate) async fn list_active_handles( pub(crate) async fn list_active_handles(
@@ -522,9 +520,14 @@ impl SecretCustody {
.contains_kind(SecretKind::EndpointIdentity) .contains_kind(SecretKind::EndpointIdentity)
.await? .await?
{ {
return Err(VnidropError::SecureStorageUnavailable { // Concurrent first-start may have staged (not yet active) metadata.
reason: "protected endpoint identity is disabled".to_string(), // 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 { match tokio::fs::try_exists(legacy_path).await {
Ok(true) => { Ok(true) => {
@@ -544,34 +547,38 @@ impl SecretCustody {
.await .await
{ {
Ok(handle) => self.load(&handle).await, Ok(handle) => self.load(&handle).await,
Err(error) => { Err(error) => match self.wait_for_active_endpoint_identity().await {
let winner = tokio::time::timeout(Duration::from_secs(1), async { Ok(handle) => self.load(&handle).await,
loop { Err(_) => Err(error),
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) => Err(VnidropError::filesystem(error)), Err(error) => Err(VnidropError::filesystem(error)),
} }
} }
async fn wait_for_active_endpoint_identity(&self) -> Result<SecretHandle, VnidropError> {
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<ReconciliationSummary, VnidropError> { pub(crate) async fn reconcile(&self) -> Result<ReconciliationSummary, VnidropError> {
let metadata = self.metadata.list().await?; 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 let known_handles = metadata
.iter() .iter()
.map(|entry| entry.handle.clone()) .map(|entry| entry.handle.clone())
@@ -580,16 +587,16 @@ impl SecretCustody {
for entry in metadata { for entry in metadata {
if entry.state == SecretMetadataState::Disabled { if entry.state == SecretMetadataState::Disabled {
self.delete_if_present(&entry.handle)?; self.delete_if_present(&entry.handle).await?;
continue; continue;
} }
match self.store.get(&entry.handle) { match self.store_get_raw(entry.handle.clone()).await? {
Ok(material) => { Ok(material) => {
if validate_material(entry.kind, &material, entry.expected_identity.as_deref()) if validate_material(entry.kind, &material, entry.expected_identity.as_deref())
.is_err() .is_err()
{ {
self.metadata.disable(&entry.handle).await?; self.metadata.disable(&entry.handle).await?;
self.delete_if_present(&entry.handle)?; self.delete_if_present(&entry.handle).await?;
summary.disabled += 1; summary.disabled += 1;
} else if entry.state == SecretMetadataState::Staged { } else if entry.state == SecretMetadataState::Staged {
self.metadata.activate(&entry.handle).await?; self.metadata.activate(&entry.handle).await?;
@@ -598,7 +605,7 @@ impl SecretCustody {
} }
Err(SecureSecretStoreError::Missing | SecureSecretStoreError::Corrupted) => { Err(SecureSecretStoreError::Missing | SecureSecretStoreError::Corrupted) => {
self.metadata.disable(&entry.handle).await?; self.metadata.disable(&entry.handle).await?;
self.delete_if_present(&entry.handle)?; self.delete_if_present(&entry.handle).await?;
summary.disabled += 1; summary.disabled += 1;
} }
Err(error) => return Err(map_store_error(error)), Err(error) => return Err(map_store_error(error)),
@@ -607,20 +614,74 @@ impl SecretCustody {
for handle in stored_handles { for handle in stored_handles {
if !known_handles.contains(&handle) { if !known_handles.contains(&handle) {
self.store.delete(&handle).map_err(map_store_error)?; self.store_delete(handle).await?;
summary.orphans_deleted += 1; summary.orphans_deleted += 1;
} }
} }
Ok(summary) Ok(summary)
} }
fn delete_if_present(&self, handle: &SecretHandle) -> Result<(), VnidropError> { async fn delete_if_present(&self, handle: &SecretHandle) -> Result<(), VnidropError> {
match self.store.delete(handle) { match self.store_delete_raw(handle.clone()).await? {
Ok(()) | Err(SecureSecretStoreError::Missing) => Ok(()), Ok(()) | Err(SecureSecretStoreError::Missing) => Ok(()),
Err(error) => Err(map_store_error(error)), 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<SecretMaterial, VnidropError> {
self.store_get_raw(handle).await?.map_err(map_store_error)
}
async fn store_get_raw(
&self,
handle: SecretHandle,
) -> Result<Result<SecretMaterial, SecureSecretStoreError>, 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<Result<(), SecureSecretStoreError>, 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<Vec<SecretHandle>, 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)] #[cfg(test)]
pub(crate) fn crash_once_at(&self, point: CustodyCrashPoint) { pub(crate) fn crash_once_at(&self, point: CustodyCrashPoint) {
*self.crash_point.lock().unwrap() = Some(point); *self.crash_point.lock().unwrap() = Some(point);

View File

@@ -1,5 +1,8 @@
use std::{collections::HashMap, sync::Arc}; 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 secret_service::{blocking::SecretService, EncryptionType, Error};
use super::{ use super::{

View File

@@ -47,12 +47,12 @@ fn lock_exclusive_nonblocking(file: &File) -> Result<(), VnidropError> {
return Ok(()); return Ok(());
} }
let err = io::Error::last_os_error(); let err = io::Error::last_os_error();
return Err(match err.kind() { Err(match err.kind() {
io::ErrorKind::WouldBlock => VnidropError::SecureStorageUnavailable { io::ErrorKind::WouldBlock => VnidropError::SecureStorageUnavailable {
reason: "another protected core is already using this profile".to_string(), reason: "another protected core is already using this profile".to_string(),
}, },
_ => VnidropError::filesystem(err), _ => VnidropError::filesystem(err),
}); })
} }
#[cfg(windows)] #[cfg(windows)]
{ {

View File

@@ -509,6 +509,9 @@ fn secret_service_identity_survives_core_restart() {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
#[test] #[test]
fn experimental_secret_service_identity_survives_core_restart_on_linux() { 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 data_dir = tempfile::tempdir().unwrap();
let sink = Arc::new(RecordingSink { let sink = Arc::new(RecordingSink {
events: Mutex::new(Vec::new()), events: Mutex::new(Vec::new()),

View File

@@ -10,6 +10,9 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest 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.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -45,20 +48,27 @@ class PairingPromptCoordinator(
init { init {
scope.launch { scope.launch {
preferencesRepository.preferences.collectLatest { preferences -> // Preferences can emit before AppViewModel finishes core initialize.
val enabled = preferences.experimentalSavedDevicesEnabled // Hitting the gateway then surfaces "Initialize the core first" snackbars.
_state.update { it.copy(enabled = enabled) } combine(
if (enabled) { preferencesRepository.preferences.map { it.experimentalSavedDevicesEnabled },
refresh() repository.state.map { it.isInitialized },
} else { ) { enabled, initialized -> enabled to initialized }
_state.update { it.copy(prompt = null, busy = false) } .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 { scope.launch {
repository.signals.collect { signal -> repository.signals.collect { signal ->
when (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.ApprovalChanged,
is CoreSignal.ReceiverHistoryChanged, is CoreSignal.ReceiverHistoryChanged,
is CoreSignal.TransfersChanged, is CoreSignal.TransfersChanged,

View File

@@ -20,6 +20,9 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest 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.receiveAsFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -59,19 +62,26 @@ class SavedDevicesViewModel(
init { init {
viewModelScope.launch { viewModelScope.launch {
preferencesRepository.preferences.collectLatest { preferences -> combine(
val enabled = preferences.experimentalSavedDevicesEnabled preferencesRepository.preferences.map { it.experimentalSavedDevicesEnabled },
_state.update { it.copy(enabled = enabled) } repository.state.map { it.isInitialized },
if (enabled) refresh() else _state.update { ) { enabled, initialized -> enabled to initialized }
SavedDevicesState(enabled = false) .distinctUntilChanged()
.collectLatest { (enabled, initialized) ->
_state.update { it.copy(enabled = enabled) }
when {
enabled && initialized -> refresh()
!enabled -> _state.update { SavedDevicesState(enabled = false) }
}
} }
}
} }
viewModelScope.launch { viewModelScope.launch {
repository.signals.collect { signal -> repository.signals.collect { signal ->
when (signal) { when (signal) {
CoreSignal.PairingChanged, 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.ApprovalChanged,
is CoreSignal.ReceiverHistoryChanged, is CoreSignal.ReceiverHistoryChanged,
is CoreSignal.TransfersChanged -> Unit is CoreSignal.TransfersChanged -> Unit

View File

@@ -16,6 +16,9 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest 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.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import vnidrop.shared.generated.resources.Res import vnidrop.shared.generated.resources.Res
@@ -49,17 +52,29 @@ class TargetedOfferCoordinator(
init { init {
scope.launch { scope.launch {
preferencesRepository.preferences.collectLatest { preferences -> // Same startup race as PairingPromptCoordinator: prefs can load while
receiveFolder = fileSystemService.effectiveReceiveFolder(preferences.receiveFolder) // core initialize still holds the lifecycle gate / core is null.
val enabled = preferences.experimentalSavedDevicesEnabled combine(
_state.update { it.copy(enabled = enabled) } preferencesRepository.preferences,
if (enabled) refresh() else _state.update { it.copy(pending = emptyList()) } 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 { scope.launch {
repository.signals.collect { signal -> repository.signals.collect { signal ->
when (signal) { when (signal) {
CoreSignal.TargetedTransferChanged -> if (_state.value.enabled) refresh() CoreSignal.TargetedTransferChanged -> {
if (_state.value.enabled && repository.state.value.isInitialized) refresh()
}
CoreSignal.PairingChanged, CoreSignal.PairingChanged,
is CoreSignal.ApprovalChanged, is CoreSignal.ApprovalChanged,
is CoreSignal.ReceiverHistoryChanged, is CoreSignal.ReceiverHistoryChanged,

View File

@@ -11,6 +11,7 @@ import com.vnidrop.app.support.FakePreferencesRepository
import com.vnidrop.app.ui.feedback.UiMessageController import com.vnidrop.app.ui.feedback.UiMessageController
import com.vnidrop.app.ui.theme.ThemeMode import com.vnidrop.app.ui.theme.ThemeMode
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@@ -24,7 +25,7 @@ import kotlin.test.assertTrue
class PairingPromptCoordinatorTest { class PairingPromptCoordinatorTest {
@Test @Test
fun experimentalOffDoesNotPromptOnEligibility() = runTest { fun experimentalOffDoesNotPromptOnEligibility() = runTest {
val core = FakeCoreGateway().apply { val core = initializedCore().apply {
pairingEligibilities = listOf(eligibility("peer-a")) pairingEligibilities = listOf(eligibility("peer-a"))
} }
val coordinator = PairingPromptCoordinator( val coordinator = PairingPromptCoordinator(
@@ -39,10 +40,41 @@ class PairingPromptCoordinatorTest {
} }
@Test @Test
fun acceptEligibilityRequestsPairing() = runTest { fun waitsForCoreInitializeBeforeRefreshing() = runTest {
// Regression: experimental prefs emit before AppViewModel initialize finishes.
val core = FakeCoreGateway().apply { val core = FakeCoreGateway().apply {
pairingEligibilities = listOf(eligibility("peer-a")) 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( val coordinator = PairingPromptCoordinator(
core, core,
preferences(enabled = true), preferences(enabled = true),
@@ -61,7 +93,7 @@ class PairingPromptCoordinatorTest {
@Test @Test
fun declineEligibilityConsumesWithoutRequest() = runTest { fun declineEligibilityConsumesWithoutRequest() = runTest {
val core = FakeCoreGateway().apply { val core = initializedCore().apply {
pairingEligibilities = listOf(eligibility("peer-a")) pairingEligibilities = listOf(eligibility("peer-a"))
} }
val coordinator = PairingPromptCoordinator( val coordinator = PairingPromptCoordinator(
@@ -82,7 +114,7 @@ class PairingPromptCoordinatorTest {
@Test @Test
fun dismissKeepsEligibilityForSavedDevicesArea() = runTest { fun dismissKeepsEligibilityForSavedDevicesArea() = runTest {
val core = FakeCoreGateway().apply { val core = initializedCore().apply {
pairingEligibilities = listOf(eligibility("peer-a")) pairingEligibilities = listOf(eligibility("peer-a"))
} }
val coordinator = PairingPromptCoordinator( val coordinator = PairingPromptCoordinator(
@@ -102,7 +134,7 @@ class PairingPromptCoordinatorTest {
@Test @Test
fun incomingPairingRequestAcceptsViaRespond() = runTest { fun incomingPairingRequestAcceptsViaRespond() = runTest {
val core = FakeCoreGateway().apply { val core = initializedCore().apply {
deviceRelationships = listOf(incoming("peer-b")) deviceRelationships = listOf(incoming("peer-b"))
} }
val coordinator = PairingPromptCoordinator( val coordinator = PairingPromptCoordinator(
@@ -121,6 +153,10 @@ class PairingPromptCoordinatorTest {
assertEquals(listOf("peer-b" to true), core.pairingResponses) 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( private fun eligibility(peer: String) = PairingEligibilityModel(
peerEndpointId = peer, peerEndpointId = peer,
sessionId = "session", sessionId = "session",

View File

@@ -37,6 +37,7 @@ class SavedDevicesViewModelTest {
fun labelForgetAndBlockUpdateGateway() = runTest { fun labelForgetAndBlockUpdateGateway() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler)) Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val core = FakeCoreGateway().apply { val core = FakeCoreGateway().apply {
mutableState.value = mutableState.value.copy(isInitialized = true)
savedDevices = listOf(device("peer-1", label = null)) savedDevices = listOf(device("peer-1", label = null))
} }
val preferences = preferences(enabled = true) val preferences = preferences(enabled = true)
@@ -82,6 +83,7 @@ class SavedDevicesViewModelTest {
fun sendFromSavedDeviceCreatesTargetedTransfer() = runTest { fun sendFromSavedDeviceCreatesTargetedTransfer() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler)) Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val core = FakeCoreGateway().apply { val core = FakeCoreGateway().apply {
mutableState.value = mutableState.value.copy(isInitialized = true)
savedDevices = listOf(device("peer-3", label = "Kitchen")) savedDevices = listOf(device("peer-3", label = "Kitchen"))
createTargetedResult = Result.success( createTargetedResult = Result.success(
TargetedTransferModel( TargetedTransferModel(

View File

@@ -23,7 +23,7 @@ import kotlin.test.assertTrue
class TargetedOfferCoordinatorTest { class TargetedOfferCoordinatorTest {
@Test @Test
fun acceptApprovesAndPullsByTransferId() = runTest { fun acceptApprovesAndPullsByTransferId() = runTest {
val core = FakeCoreGateway().apply { val core = initializedCore().apply {
pendingTargetedOffers = listOf(offer("transfer-1")) pendingTargetedOffers = listOf(offer("transfer-1"))
respondTargetedResult = Result.success(TargetedOfferResponseModel.Approved("transfer-1")) respondTargetedResult = Result.success(TargetedOfferResponseModel.Approved("transfer-1"))
receiveResult = Result.success(Unit) receiveResult = Result.success(Unit)
@@ -56,7 +56,7 @@ class TargetedOfferCoordinatorTest {
override fun finishFile(relativePath: String) = error("unused") override fun finishFile(relativePath: String) = error("unused")
override fun abortFile(relativePath: String, reason: 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")) pendingTargetedOffers = listOf(offer("transfer-sink"))
respondTargetedResult = Result.success(TargetedOfferResponseModel.Approved("transfer-sink")) respondTargetedResult = Result.success(TargetedOfferResponseModel.Approved("transfer-sink"))
receiveResult = Result.success(Unit) receiveResult = Result.success(Unit)
@@ -82,7 +82,7 @@ class TargetedOfferCoordinatorTest {
@Test @Test
fun declineDoesNotReceive() = runTest { fun declineDoesNotReceive() = runTest {
val core = FakeCoreGateway().apply { val core = initializedCore().apply {
pendingTargetedOffers = listOf(offer("transfer-2")) pendingTargetedOffers = listOf(offer("transfer-2"))
respondTargetedResult = Result.success(TargetedOfferResponseModel.Declined) respondTargetedResult = Result.success(TargetedOfferResponseModel.Declined)
} }
@@ -104,7 +104,7 @@ class TargetedOfferCoordinatorTest {
@Test @Test
fun experimentalOffIgnoresPendingOffers() = runTest { fun experimentalOffIgnoresPendingOffers() = runTest {
val core = FakeCoreGateway().apply { val core = initializedCore().apply {
pendingTargetedOffers = listOf(offer("transfer-3")) pendingTargetedOffers = listOf(offer("transfer-3"))
} }
val coordinator = TargetedOfferCoordinator( val coordinator = TargetedOfferCoordinator(
@@ -119,6 +119,34 @@ class TargetedOfferCoordinatorTest {
assertTrue(coordinator.state.value.pending.isEmpty()) 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( private fun offer(id: String) = PendingTargetedOfferModel(
transferId = id, transferId = id,
senderEndpointId = "sender", senderEndpointId = "sender",

View File

@@ -209,6 +209,10 @@ class FakeCoreGateway : CoreGateway {
var blockedDevices: List<String> = emptyList() var blockedDevices: List<String> = emptyList()
var pendingTargetedOffers: List<PendingTargetedOfferModel> = emptyList() var pendingTargetedOffers: List<PendingTargetedOfferModel> = emptyList()
var targetedTransfers: List<TargetedTransferModel> = emptyList() var targetedTransfers: List<TargetedTransferModel> = emptyList()
var listPairingEligibilitiesCount = 0
var listDeviceRelationshipsCount = 0
var listPendingTargetedOffersCount = 0
var listSavedDevicesCount = 0
var respondTargetedResult: Result<TargetedOfferResponseModel> = var respondTargetedResult: Result<TargetedOfferResponseModel> =
Result.success(TargetedOfferResponseModel.Declined) Result.success(TargetedOfferResponseModel.Declined)
var createTargetedResult: Result<TargetedTransferModel> = var createTargetedResult: Result<TargetedTransferModel> =
@@ -217,7 +221,10 @@ class FakeCoreGateway : CoreGateway {
val blockedPeers = mutableListOf<String>() val blockedPeers = mutableListOf<String>()
val labeledDevices = mutableListOf<Pair<String, 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> { override suspend fun declinePairingEligibility(peerEndpointId: String): Result<Unit> {
pairingEligibilities = pairingEligibilities.filterNot { it.peerEndpointId == peerEndpointId } pairingEligibilities = pairingEligibilities.filterNot { it.peerEndpointId == peerEndpointId }
return Result.success(Unit) return Result.success(Unit)
@@ -240,8 +247,14 @@ class FakeCoreGateway : CoreGateway {
pairingResponses += peerEndpointId to accepted pairingResponses += peerEndpointId to accepted
return respondPairingResult.map { accepted } return respondPairingResult.map { accepted }
} }
override suspend fun listDeviceRelationships() = Result.success(deviceRelationships) override suspend fun listDeviceRelationships(): Result<List<DeviceRelationshipModel>> {
override suspend fun listSavedDevices() = Result.success(savedDevices) 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> { override suspend fun setSavedDeviceLabel(peerEndpointId: String, label: String?): Result<Unit> {
labeledDevices += peerEndpointId to label labeledDevices += peerEndpointId to label
savedDevices = savedDevices.map { savedDevices = savedDevices.map {
@@ -264,7 +277,10 @@ class FakeCoreGateway : CoreGateway {
return Result.success(Unit) return Result.success(Unit)
} }
override suspend fun listBlockedDevices() = Result.success(blockedDevices) 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> { override suspend fun respondToTargetedOffer(transferId: String, accepted: Boolean): Result<TargetedOfferResponseModel> {
respondedTargetedOffers += transferId to accepted respondedTargetedOffers += transferId to accepted
return respondTargetedResult return respondTargetedResult