From b9884b566aeb7175392c07c61a9bb9afd6c6dc11 Mon Sep 17 00:00:00 2001 From: Hammed Abass Date: Tue, 11 Aug 2026 19:51:39 +0200 Subject: [PATCH] feat(shared): enable experimental saved-devices on desktop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface Settings → Experimental on Windows/Linux Compose, keep generic Desktop hosts gated off, and assert path-based targeted receive when no output sink is available. Co-authored-by: Cursor --- shared/docs/saved-devices-ui-contract.md | 8 ++- .../kotlin/com/vnidrop/app/Platform.kt | 8 +++ .../saveddevices/TargetedOfferCoordinator.kt | 3 +- .../app/feature/settings/SettingsRoute.kt | 4 +- .../app/ExperimentalSavedDevicesGateTest.kt | 15 +++++ .../TargetedOfferCoordinatorTest.kt | 35 ++++++++++++ .../kotlin/com/vnidrop/app/support/Fakes.kt | 8 ++- .../vnidrop/app/ui/FoundationComposeTest.kt | 55 +++++++++++++++++++ 8 files changed, 129 insertions(+), 7 deletions(-) create mode 100644 shared/src/commonTest/kotlin/com/vnidrop/app/ExperimentalSavedDevicesGateTest.kt diff --git a/shared/docs/saved-devices-ui-contract.md b/shared/docs/saved-devices-ui-contract.md index 99e9b81..112a03f 100644 --- a/shared/docs/saved-devices-ui-contract.md +++ b/shared/docs/saved-devices-ui-contract.md @@ -10,8 +10,8 @@ Use **saved device**, **device relationship**, **targeted transfer**, **invitati ## Experimental gate -- KMP Android: Settings → Experimental → Saved devices, preference default **off**, persisted. -- KMP desktop: experimental UI **hidden**. +- KMP Android / Windows / Linux: Settings → Experimental → Saved devices, preference default **off**, persisted. +- KMP generic desktop host (`UiPlatform.Desktop`, e.g. macOS JVM): experimental UI **hidden**. - Apple: gate shape is platform-owned; semantics below still apply when the feature is enabled. ## Events are wake-ups @@ -35,7 +35,9 @@ After a completed invitation transfer, eligibility may exist. The user may accep - `Declined` - `AlreadySettled { transfer_id }` 3. Never accept or display authorization/grant strings across the public binding. -4. Pull / resume with **transfer id + destination** (path or output sink). Android KMP reuses the invitation MediaStore Downloads sink for the experimental MVP receive. +4. Pull / resume with **transfer id + destination** (path or output sink). + - Android KMP: invitation MediaStore Downloads sink for the experimental MVP receive. + - Windows / Linux KMP: configured filesystem receive folder path when no output sink is provided. ## Out of this contract’s MVP chrome diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/Platform.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/Platform.kt index bdc2982..0a55f2b 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/Platform.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/Platform.kt @@ -14,6 +14,14 @@ enum class UiPlatform { val UiPlatform.isDesktop: Boolean get() = this != UiPlatform.Android +/** Experimental saved-devices Settings chrome for Android + Windows/Linux Compose. */ +fun showsExperimentalSavedDevices(uiPlatform: UiPlatform): Boolean = when (uiPlatform) { + UiPlatform.Android, + UiPlatform.Windows, + UiPlatform.Linux -> true + UiPlatform.Desktop -> false +} + data class PlatformEnvironment( val name: String, val appVersion: String, 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 4266f32..d1b809d 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 @@ -32,7 +32,8 @@ data class TargetedOfferState( /** * Foreground interrupt for pending targeted offers. Approve pulls by transfer id - * through the configured receive sink (MediaStore Downloads on Android). + * through the platform receive destination (MediaStore Downloads sink on Android, + * filesystem path on desktop when no sink is available). */ class TargetedOfferCoordinator( private val repository: CoreGateway, diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsRoute.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsRoute.kt index 20b8566..993be22 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsRoute.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsRoute.kt @@ -4,7 +4,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vnidrop.app.UiPlatform +import com.vnidrop.app.showsExperimentalSavedDevices import com.vnidrop.app.core.rememberReceiveFolderPicker import com.vnidrop.app.core.rememberShareFilePicker import com.vnidrop.app.feature.saveddevices.SavedDevicesEffect @@ -20,7 +20,7 @@ fun SettingsRoute( ) { val state by viewModel.state.collectAsStateWithLifecycle() val savedDevicesState by savedDevicesViewModel.state.collectAsStateWithLifecycle() - val showExperimental = LocalUiPlatform.current == UiPlatform.Android + val showExperimental = showsExperimentalSavedDevices(LocalUiPlatform.current) val folderPicker = rememberReceiveFolderPicker(viewModel::onReceiveFolderPicked, viewModel::onReceiveFolderPickFailed) val sharePicker = rememberShareFilePicker( savedDevicesViewModel::onFilesPicked, diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/ExperimentalSavedDevicesGateTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/ExperimentalSavedDevicesGateTest.kt new file mode 100644 index 0000000..e12f337 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/ExperimentalSavedDevicesGateTest.kt @@ -0,0 +1,15 @@ +package com.vnidrop.app + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ExperimentalSavedDevicesGateTest { + @Test + fun experimentalChromeIsShownOnAndroidWindowsAndLinuxOnly() { + assertTrue(showsExperimentalSavedDevices(UiPlatform.Android)) + assertTrue(showsExperimentalSavedDevices(UiPlatform.Windows)) + assertTrue(showsExperimentalSavedDevices(UiPlatform.Linux)) + assertFalse(showsExperimentalSavedDevices(UiPlatform.Desktop)) + } +} 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 3a99ca8..a4413d4 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 @@ -14,6 +14,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest +import uniffi.vnidrop.ReceiveOutputSinkV2 import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue @@ -43,6 +44,40 @@ class TargetedOfferCoordinatorTest { advanceUntilIdle() assertEquals(listOf("transfer-1" to true), core.respondedTargetedOffers) assertEquals(listOf("transfer-1"), core.receivedTargetedTransferIds) + assertEquals(listOf("transfer-1" to "/tmp"), core.receivedTargetedPathDirs) + assertTrue(core.receivedTargetedViaSinkIds.isEmpty()) + } + + @Test + fun acceptUsesOutputSinkWhenPlatformProvidesOne() = runTest { + val sink = object : ReceiveOutputSinkV2 { + override fun startFile(relativePath: String) = error("unused") + override fun writeChunk(relativePath: String, bytes: ByteArray) = error("unused") + override fun finishFile(relativePath: String) = error("unused") + override fun abortFile(relativePath: String, reason: String) = error("unused") + } + val core = FakeCoreGateway().apply { + pendingTargetedOffers = listOf(offer("transfer-sink")) + respondTargetedResult = Result.success(TargetedOfferResponseModel.Approved("transfer-sink")) + receiveResult = Result.success(Unit) + } + val coordinator = TargetedOfferCoordinator( + core, + FakeFileSystemService( + ReceiveFolder(ReceiveFolderKind.AndroidPublicDownloads, "downloads", "Downloads"), + receiveOutputSink = sink, + ), + preferences(enabled = true), + UiMessageController(), + backgroundScope, + ) + runCurrent() + advanceUntilIdle() + coordinator.accept("transfer-sink") + runCurrent() + advanceUntilIdle() + assertEquals(listOf("transfer-sink"), core.receivedTargetedViaSinkIds) + assertTrue(core.receivedTargetedPathDirs.isEmpty()) } @Test 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 6f9ed77..94d14f2 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt @@ -228,6 +228,8 @@ class FakeCoreGateway : CoreGateway { var respondPairingResult: Result = Result.success(true) val createdTargetedTransfers = mutableListOf, String?>>() val receivedTargetedTransferIds = mutableListOf() + val receivedTargetedPathDirs = mutableListOf>() + val receivedTargetedViaSinkIds = mutableListOf() val respondedTargetedOffers = mutableListOf>() override suspend fun requestSavedDevicePairing(peerEndpointId: String): Result { @@ -280,6 +282,7 @@ class FakeCoreGateway : CoreGateway { override suspend fun listTargetedTransfers() = Result.success(targetedTransfers) override suspend fun receiveTargetedTransfer(transferId: String, outputDir: String): Result { receivedTargetedTransferIds += transferId + receivedTargetedPathDirs += transferId to outputDir return receiveResult } override suspend fun receiveTargetedTransferWithOutputSink( @@ -287,6 +290,7 @@ class FakeCoreGateway : CoreGateway { outputSink: ReceiveOutputSink, ): Result { receivedTargetedTransferIds += transferId + receivedTargetedViaSinkIds += transferId return receiveResult } override suspend fun receiveTargetedTransferWithOutputSinkV2( @@ -294,6 +298,7 @@ class FakeCoreGateway : CoreGateway { outputSink: ReceiveOutputSinkV2, ): Result { receivedTargetedTransferIds += transferId + receivedTargetedViaSinkIds += transferId return receiveResult } override suspend fun resumeTargetedTransfer(id: String, outputDir: String) = receiveResult @@ -348,6 +353,7 @@ class FakeNotificationService( class FakeFileSystemService( private val folder: ReceiveFolder, + private val receiveOutputSink: ReceiveOutputSinkV2? = null, ) : FileSystemService { var supportsCustomFolders = true var effectiveFolder: ReceiveFolder? = null @@ -369,7 +375,7 @@ class FakeFileSystemService( reclaimTemporaryStorageCount += 1 return reclaimedTemporaryBytes } - override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSinkV2? = null + override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSinkV2? = receiveOutputSink override fun canRevealReceiveFolder(folder: ReceiveFolder) = canRevealFolder override suspend fun revealReceiveFolder(folder: ReceiveFolder): Result { revealedFolders += folder diff --git a/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt b/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt index d30a245..02247df 100644 --- a/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt +++ b/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt @@ -84,6 +84,7 @@ import vnidrop.shared.generated.resources.button_create_new_transfer import vnidrop.shared.generated.resources.button_download_invitation import vnidrop.shared.generated.resources.button_open_settings import vnidrop.shared.generated.resources.button_receive_files +import vnidrop.shared.generated.resources.experimental_settings_title import vnidrop.shared.generated.resources.nav_receive import vnidrop.shared.generated.resources.nav_send import vnidrop.shared.generated.resources.notifications_description @@ -397,6 +398,60 @@ class FoundationComposeTest { runOnIdle { assertTrue(opened) } } + @Test + fun desktopSettingsShowsExperimentalWhenGateEnabled() = runComposeUiTest { + setContent { + VniDropTheme(isDarkTheme = false) { + SettingsScreen( + state = SettingsState(), + windowClass = WindowClass.Desktop, + showExperimental = true, + onSectionSelected = {}, + onUsernameChanged = {}, + onThemeModeChanged = {}, + onChooseFolder = {}, + onResetFolder = {}, + onNotificationsChanged = {}, + onOpenNotificationSettings = {}, + onBugWhatChanged = {}, + onBugExpectedChanged = {}, + onBugStepsChanged = {}, + onBugContactChanged = {}, + onBugIncludeLogsChanged = {}, + onSubmitBugReport = {}, + ) + } + } + onNodeWithText(Res.string.experimental_settings_title.value).assertIsDisplayed() + } + + @Test + fun unsupportedDesktopHostHidesExperimentalSection() = runComposeUiTest { + setContent { + VniDropTheme(isDarkTheme = false) { + SettingsScreen( + state = SettingsState(), + windowClass = WindowClass.Desktop, + showExperimental = false, + onSectionSelected = {}, + onUsernameChanged = {}, + onThemeModeChanged = {}, + onChooseFolder = {}, + onResetFolder = {}, + onNotificationsChanged = {}, + onOpenNotificationSettings = {}, + onBugWhatChanged = {}, + onBugExpectedChanged = {}, + onBugStepsChanged = {}, + onBugContactChanged = {}, + onBugIncludeLogsChanged = {}, + onSubmitBugReport = {}, + ) + } + } + onAllNodesWithText(Res.string.experimental_settings_title.value).assertCountEquals(0) + } + @Test fun snackbarDisplaysBufferedMessage() = runComposeUiTest { val controller = UiMessageController()