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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user