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
- 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 contracts MVP chrome

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

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.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

View File

@@ -228,6 +228,8 @@ class FakeCoreGateway : CoreGateway {
var respondPairingResult: Result<Boolean> = Result.success(true)
val createdTargetedTransfers = mutableListOf<Triple<String, List<uniffi.vnidrop.ShareSource>, String?>>()
val receivedTargetedTransferIds = mutableListOf<String>()
val receivedTargetedPathDirs = mutableListOf<Pair<String, String>>()
val receivedTargetedViaSinkIds = mutableListOf<String>()
val respondedTargetedOffers = mutableListOf<Pair<String, 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 receiveTargetedTransfer(transferId: String, outputDir: String): Result<Unit> {
receivedTargetedTransferIds += transferId
receivedTargetedPathDirs += transferId to outputDir
return receiveResult
}
override suspend fun receiveTargetedTransferWithOutputSink(
@@ -287,6 +290,7 @@ class FakeCoreGateway : CoreGateway {
outputSink: ReceiveOutputSink,
): Result<Unit> {
receivedTargetedTransferIds += transferId
receivedTargetedViaSinkIds += transferId
return receiveResult
}
override suspend fun receiveTargetedTransferWithOutputSinkV2(
@@ -294,6 +298,7 @@ class FakeCoreGateway : CoreGateway {
outputSink: ReceiveOutputSinkV2,
): Result<Unit> {
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<Unit> {
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_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()