feat(shared): enable experimental saved-devices on desktop

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 <cursoragent@cursor.com>
This commit is contained in:
2026-08-11 19:51:39 +02:00
parent 37cc238888
commit b9884b566a
8 changed files with 129 additions and 7 deletions

View File

@@ -10,8 +10,8 @@ Use **saved device**, **device relationship**, **targeted transfer**, **invitati
## Experimental gate ## Experimental gate
- KMP Android: Settings → Experimental → Saved devices, preference default **off**, persisted. - KMP Android / Windows / Linux: Settings → Experimental → Saved devices, preference default **off**, persisted.
- KMP desktop: experimental UI **hidden**. - 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. - Apple: gate shape is platform-owned; semantics below still apply when the feature is enabled.
## Events are wake-ups ## Events are wake-ups
@@ -35,7 +35,9 @@ After a completed invitation transfer, eligibility may exist. The user may accep
- `Declined` - `Declined`
- `AlreadySettled { transfer_id }` - `AlreadySettled { transfer_id }`
3. Never accept or display authorization/grant strings across the public binding. 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 contracts MVP chrome ## Out of this contracts MVP chrome

View File

@@ -14,6 +14,14 @@ enum class UiPlatform {
val UiPlatform.isDesktop: Boolean val UiPlatform.isDesktop: Boolean
get() = this != UiPlatform.Android 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( data class PlatformEnvironment(
val name: String, val name: String,
val appVersion: String, val appVersion: String,

View File

@@ -32,7 +32,8 @@ data class TargetedOfferState(
/** /**
* Foreground interrupt for pending targeted offers. Approve pulls by transfer id * 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( class TargetedOfferCoordinator(
private val repository: CoreGateway, private val repository: CoreGateway,

View File

@@ -4,7 +4,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle 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.rememberReceiveFolderPicker
import com.vnidrop.app.core.rememberShareFilePicker import com.vnidrop.app.core.rememberShareFilePicker
import com.vnidrop.app.feature.saveddevices.SavedDevicesEffect import com.vnidrop.app.feature.saveddevices.SavedDevicesEffect
@@ -20,7 +20,7 @@ fun SettingsRoute(
) { ) {
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
val savedDevicesState by savedDevicesViewModel.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 folderPicker = rememberReceiveFolderPicker(viewModel::onReceiveFolderPicked, viewModel::onReceiveFolderPickFailed)
val sharePicker = rememberShareFilePicker( val sharePicker = rememberShareFilePicker(
savedDevicesViewModel::onFilesPicked, savedDevicesViewModel::onFilesPicked,

View File

@@ -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))
}
}

View File

@@ -14,6 +14,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
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
import uniffi.vnidrop.ReceiveOutputSinkV2
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertTrue import kotlin.test.assertTrue
@@ -43,6 +44,40 @@ class TargetedOfferCoordinatorTest {
advanceUntilIdle() advanceUntilIdle()
assertEquals(listOf("transfer-1" to true), core.respondedTargetedOffers) assertEquals(listOf("transfer-1" to true), core.respondedTargetedOffers)
assertEquals(listOf("transfer-1"), core.receivedTargetedTransferIds) 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 @Test

View File

@@ -228,6 +228,8 @@ class FakeCoreGateway : CoreGateway {
var respondPairingResult: Result<Boolean> = Result.success(true) var respondPairingResult: Result<Boolean> = Result.success(true)
val createdTargetedTransfers = mutableListOf<Triple<String, List<uniffi.vnidrop.ShareSource>, String?>>() val createdTargetedTransfers = mutableListOf<Triple<String, List<uniffi.vnidrop.ShareSource>, String?>>()
val receivedTargetedTransferIds = mutableListOf<String>() val receivedTargetedTransferIds = mutableListOf<String>()
val receivedTargetedPathDirs = mutableListOf<Pair<String, String>>()
val receivedTargetedViaSinkIds = mutableListOf<String>()
val respondedTargetedOffers = mutableListOf<Pair<String, Boolean>>() val respondedTargetedOffers = mutableListOf<Pair<String, Boolean>>()
override suspend fun requestSavedDevicePairing(peerEndpointId: String): Result<Boolean> { override suspend fun requestSavedDevicePairing(peerEndpointId: String): Result<Boolean> {
@@ -280,6 +282,7 @@ class FakeCoreGateway : CoreGateway {
override suspend fun listTargetedTransfers() = Result.success(targetedTransfers) override suspend fun listTargetedTransfers() = Result.success(targetedTransfers)
override suspend fun receiveTargetedTransfer(transferId: String, outputDir: String): Result<Unit> { override suspend fun receiveTargetedTransfer(transferId: String, outputDir: String): Result<Unit> {
receivedTargetedTransferIds += transferId receivedTargetedTransferIds += transferId
receivedTargetedPathDirs += transferId to outputDir
return receiveResult return receiveResult
} }
override suspend fun receiveTargetedTransferWithOutputSink( override suspend fun receiveTargetedTransferWithOutputSink(
@@ -287,6 +290,7 @@ class FakeCoreGateway : CoreGateway {
outputSink: ReceiveOutputSink, outputSink: ReceiveOutputSink,
): Result<Unit> { ): Result<Unit> {
receivedTargetedTransferIds += transferId receivedTargetedTransferIds += transferId
receivedTargetedViaSinkIds += transferId
return receiveResult return receiveResult
} }
override suspend fun receiveTargetedTransferWithOutputSinkV2( override suspend fun receiveTargetedTransferWithOutputSinkV2(
@@ -294,6 +298,7 @@ class FakeCoreGateway : CoreGateway {
outputSink: ReceiveOutputSinkV2, outputSink: ReceiveOutputSinkV2,
): Result<Unit> { ): Result<Unit> {
receivedTargetedTransferIds += transferId receivedTargetedTransferIds += transferId
receivedTargetedViaSinkIds += transferId
return receiveResult return receiveResult
} }
override suspend fun resumeTargetedTransfer(id: String, outputDir: String) = receiveResult override suspend fun resumeTargetedTransfer(id: String, outputDir: String) = receiveResult
@@ -348,6 +353,7 @@ class FakeNotificationService(
class FakeFileSystemService( class FakeFileSystemService(
private val folder: ReceiveFolder, private val folder: ReceiveFolder,
private val receiveOutputSink: ReceiveOutputSinkV2? = null,
) : FileSystemService { ) : FileSystemService {
var supportsCustomFolders = true var supportsCustomFolders = true
var effectiveFolder: ReceiveFolder? = null var effectiveFolder: ReceiveFolder? = null
@@ -369,7 +375,7 @@ class FakeFileSystemService(
reclaimTemporaryStorageCount += 1 reclaimTemporaryStorageCount += 1
return reclaimedTemporaryBytes return reclaimedTemporaryBytes
} }
override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSinkV2? = null override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSinkV2? = receiveOutputSink
override fun canRevealReceiveFolder(folder: ReceiveFolder) = canRevealFolder override fun canRevealReceiveFolder(folder: ReceiveFolder) = canRevealFolder
override suspend fun revealReceiveFolder(folder: ReceiveFolder): Result<Unit> { override suspend fun revealReceiveFolder(folder: ReceiveFolder): Result<Unit> {
revealedFolders += folder revealedFolders += folder

View File

@@ -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_download_invitation
import vnidrop.shared.generated.resources.button_open_settings import vnidrop.shared.generated.resources.button_open_settings
import vnidrop.shared.generated.resources.button_receive_files 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_receive
import vnidrop.shared.generated.resources.nav_send import vnidrop.shared.generated.resources.nav_send
import vnidrop.shared.generated.resources.notifications_description import vnidrop.shared.generated.resources.notifications_description
@@ -397,6 +398,60 @@ class FoundationComposeTest {
runOnIdle { assertTrue(opened) } 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 @Test
fun snackbarDisplaysBufferedMessage() = runComposeUiTest { fun snackbarDisplaysBufferedMessage() = runComposeUiTest {
val controller = UiMessageController() val controller = UiMessageController()