mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-12 05:29:57 +02:00
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:
@@ -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 contract’s MVP chrome
|
## Out of this contract’s MVP chrome
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user