Merge pull request #15 from vnidrop/feat/ios-files-integration

fix(ios): support sandboxed file transfers
This commit is contained in:
Hammed Abass
2026-07-15 20:09:33 +02:00
committed by GitHub
13 changed files with 282 additions and 28 deletions

View File

@@ -43,6 +43,8 @@
</array> </array>
<key>LSSupportsOpeningDocumentsInPlace</key> <key>LSSupportsOpeningDocumentsInPlace</key>
<true/> <true/>
<key>UIFileSharingEnabled</key>
<true/>
<key>NSCameraUsageDescription</key> <key>NSCameraUsageDescription</key>
<string>VniDrop uses the camera to scan transfer QR codes.</string> <string>VniDrop uses the camera to scan transfer QR codes.</string>
<key>NFCReaderUsageDescription</key> <key>NFCReaderUsageDescription</key>

View File

@@ -96,6 +96,8 @@
<string name="receive_review_title">Review transfer</string> <string name="receive_review_title">Review transfer</string>
<string name="receive_unknown_transfer">VniDrop transfer</string> <string name="receive_unknown_transfer">VniDrop transfer</string>
<string name="receive_completed">Transfer received.</string> <string name="receive_completed">Transfer received.</string>
<string name="button_show_in_files">Show in Files</string>
<string name="receive_open_files_failed">Couldnt open VniDrop in Files.</string>
<string name="field_receiver_name">Receiver name</string> <string name="field_receiver_name">Receiver name</string>
<string name="button_receive">Receive</string> <string name="button_receive">Receive</string>
<string name="button_retry">Retry</string> <string name="button_retry">Retry</string>

View File

@@ -7,6 +7,8 @@ data class PickedShareFile(
val displayName: String, val displayName: String,
val sizeBytes: ULong? = null, val sizeBytes: ULong? = null,
val thumbnailBytes: ByteArray? = null, val thumbnailBytes: ByteArray? = null,
/** App-owned picker copy that may be deleted after import or when selection is abandoned. */
val isTemporaryCopy: Boolean = false,
/** /**
* When true, [value] is a directory (filesystem path, iOS security-scoped * When true, [value] is a directory (filesystem path, iOS security-scoped
* folder URL, or Android document tree URI). Platform share code expands or * folder URL, or Android document tree URI). Platform share code expands or

View File

@@ -27,9 +27,18 @@ enum class FolderAccessStatus {
} }
interface FileSystemService { interface FileSystemService {
val supportsCustomReceiveFolders: Boolean get() = true
fun defaultReceiveFolder(): ReceiveFolder fun defaultReceiveFolder(): ReceiveFolder
fun effectiveReceiveFolder(configuredFolder: ReceiveFolder): ReceiveFolder =
if (supportsCustomReceiveFolders) configuredFolder else defaultReceiveFolder()
suspend fun validateReceiveFolder(folder: ReceiveFolder): FolderAccessStatus suspend fun validateReceiveFolder(folder: ReceiveFolder): FolderAccessStatus
fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSink? fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSink?
fun canRevealReceiveFolder(folder: ReceiveFolder): Boolean = false
suspend fun revealReceiveFolder(folder: ReceiveFolder): Result<Unit> =
Result.failure(UnsupportedOperationException("Revealing the receive folder is not supported"))
/** Releases only app-owned picker copies; implementations must never delete original user sources. */
suspend fun discardPickedFiles(files: List<PickedShareFile>) = Unit
suspend fun sharePickedFile( suspend fun sharePickedFile(
repository: CoreGateway, repository: CoreGateway,
file: PickedShareFile, file: PickedShareFile,

View File

@@ -25,7 +25,9 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import vnidrop.shared.generated.resources.Res import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.button_retry import vnidrop.shared.generated.resources.button_retry
import vnidrop.shared.generated.resources.button_show_in_files
import vnidrop.shared.generated.resources.error_invitation_empty import vnidrop.shared.generated.resources.error_invitation_empty
import vnidrop.shared.generated.resources.receive_open_files_failed
import vnidrop.shared.generated.resources.receive_completed import vnidrop.shared.generated.resources.receive_completed
import vnidrop.shared.generated.resources.receive_history_cleared import vnidrop.shared.generated.resources.receive_history_cleared
import vnidrop.shared.generated.resources.transfer_deleted import vnidrop.shared.generated.resources.transfer_deleted
@@ -69,11 +71,12 @@ class ReceiveViewModel(
init { init {
viewModelScope.launch { viewModelScope.launch {
preferencesRepository.preferences.collect { preferences -> preferencesRepository.preferences.collect { preferences ->
val status = fileSystemService.validateReceiveFolder(preferences.receiveFolder) val receiveFolder = fileSystemService.effectiveReceiveFolder(preferences.receiveFolder)
val status = fileSystemService.validateReceiveFolder(receiveFolder)
_state.update { current -> _state.update { current ->
current.copy( current.copy(
receiverName = current.receiverName.ifBlank { preferences.username }, receiverName = current.receiverName.ifBlank { preferences.username },
receiveFolder = preferences.receiveFolder, receiveFolder = receiveFolder,
folderAccessStatus = status, folderAccessStatus = status,
) )
} }
@@ -170,7 +173,19 @@ class ReceiveViewModel(
result.fold( result.fold(
onSuccess = { onSuccess = {
resetAcquisition() resetAcquisition()
messages.tryShow(UiMessage(UiText.Resource(Res.string.receive_completed), UiMessageTone.Success)) val canRevealFolder = fileSystemService.canRevealReceiveFolder(folder)
messages.tryShow(
UiMessage(
text = UiText.Resource(Res.string.receive_completed),
tone = UiMessageTone.Success,
actionLabel = if (canRevealFolder) UiText.Resource(Res.string.button_show_in_files) else null,
onAction = if (canRevealFolder) {
{ revealReceiveFolder(folder) }
} else {
null
},
),
)
}, },
onFailure = { error -> onFailure = { error ->
if (error.isUserCancellation()) { if (error.isUserCancellation()) {
@@ -226,6 +241,16 @@ class ReceiveViewModel(
} }
} }
private fun revealReceiveFolder(folder: ReceiveFolder) {
viewModelScope.launch {
fileSystemService.revealReceiveFolder(folder).onFailure {
messages.show(
UiMessage(UiText.Resource(Res.string.receive_open_files_failed), UiMessageTone.Error),
)
}
}
}
private fun inspectInvitation(method: ReceiveMethod, raw: String) { private fun inspectInvitation(method: ReceiveMethod, raw: String) {
val ticket = raw.trim() val ticket = raw.trim()
if (ticket.isBlank()) return messages.error(UiText.Resource(Res.string.error_invitation_empty)) if (ticket.isBlank()) return messages.error(UiText.Resource(Res.string.error_invitation_empty))

View File

@@ -114,6 +114,7 @@ class SendViewModel(
fun openComposer() { fun openComposer() {
if (_state.value.isSharing) return if (_state.value.isSharing) return
val discardedFiles = _state.value.selectedFiles
_state.update { _state.update {
it.copy( it.copy(
isComposerOpen = true, isComposerOpen = true,
@@ -122,10 +123,12 @@ class SendViewModel(
accessPolicy = ShareAccessPolicy.RequireApproval, accessPolicy = ShareAccessPolicy.RequireApproval,
) )
} }
discardPickedFiles(discardedFiles)
} }
fun dismissComposer() { fun dismissComposer() {
if (_state.value.isSharing) return if (_state.value.isSharing) return
val discardedFiles = _state.value.selectedFiles
_state.update { _state.update {
it.copy( it.copy(
isComposerOpen = false, isComposerOpen = false,
@@ -134,6 +137,7 @@ class SendViewModel(
accessPolicy = ShareAccessPolicy.RequireApproval, accessPolicy = ShareAccessPolicy.RequireApproval,
) )
} }
discardPickedFiles(discardedFiles)
} }
fun selectFile() = sendEffect(SendEffect.OpenFilePicker) fun selectFile() = sendEffect(SendEffect.OpenFilePicker)
@@ -141,6 +145,8 @@ class SendViewModel(
fun onFilesPicked(files: List<PickedShareFile>) { fun onFilesPicked(files: List<PickedShareFile>) {
if (files.isEmpty()) return if (files.isEmpty()) return
val selectedValues = files.mapTo(mutableSetOf(), PickedShareFile::value)
val discardedFiles = _state.value.selectedFiles.filterNot { it.value in selectedValues }
_state.update { _state.update {
it.copy( it.copy(
isComposerOpen = true, isComposerOpen = true,
@@ -148,15 +154,19 @@ class SendViewModel(
transferName = defaultTransferName(files), transferName = defaultTransferName(files),
) )
} }
discardPickedFiles(discardedFiles)
} }
fun onFilePickFailed(reason: String) = messages.error(IllegalStateException(reason.takeIf(String::isNotBlank) ?: "selection failed")) fun onFilePickFailed(reason: String) = messages.error(IllegalStateException(reason.takeIf(String::isNotBlank) ?: "selection failed"))
fun clearSelectedSource() { fun clearSelectedSource() {
val discardedFiles = _state.value.selectedFiles
_state.update { it.copy(selectedFiles = emptyList(), transferName = "") } _state.update { it.copy(selectedFiles = emptyList(), transferName = "") }
discardPickedFiles(discardedFiles)
} }
fun removeSelectedFile(value: String) { fun removeSelectedFile(value: String) {
val discardedFiles = _state.value.selectedFiles.filter { it.value == value }
_state.update { current -> _state.update { current ->
val remaining = current.selectedFiles.filterNot { it.value == value } val remaining = current.selectedFiles.filterNot { it.value == value }
current.copy( current.copy(
@@ -168,6 +178,7 @@ class SendViewModel(
}, },
) )
} }
discardPickedFiles(discardedFiles)
} }
fun setTransferName(value: String) = _state.update { it.copy(transferName = value) } fun setTransferName(value: String) = _state.update { it.copy(transferName = value) }
@@ -252,6 +263,7 @@ class SendViewModel(
senderName = current.senderName.trim(), senderName = current.senderName.trim(),
accessPolicy = current.accessPolicy, accessPolicy = current.accessPolicy,
) )
if (result.isSuccess) fileSystemService.discardPickedFiles(current.selectedFiles)
result.fold( result.fold(
onSuccess = { share -> onSuccess = { share ->
current.selectedFiles.firstNotNullOfOrNull { it.thumbnailBytes } current.selectedFiles.firstNotNullOfOrNull { it.thumbnailBytes }
@@ -287,6 +299,11 @@ class SendViewModel(
viewModelScope.launch { effects.send(effect) } viewModelScope.launch { effects.send(effect) }
} }
private fun discardPickedFiles(files: List<PickedShareFile>) {
if (files.isEmpty()) return
viewModelScope.launch { fileSystemService.discardPickedFiles(files) }
}
private fun refreshReceivers(transferId: ULong) { private fun refreshReceivers(transferId: ULong) {
viewModelScope.launch { viewModelScope.launch {
_state.update { it.copy(isLoadingReceivers = true) } _state.update { it.copy(isLoadingReceivers = true) }

View File

@@ -47,11 +47,13 @@ internal fun PreferencesSettings(
iconTone = SettingsIconTone.Neutral, iconTone = SettingsIconTone.Neutral,
) )
} }
if (state.supportsCustomReceiveFolders) {
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
PrimaryButton(stringResource(Res.string.button_choose_folder), onClick = onChooseFolder) PrimaryButton(stringResource(Res.string.button_choose_folder), onClick = onChooseFolder)
SecondaryButton(stringResource(Res.string.button_reset_default), onClick = onResetFolder) SecondaryButton(stringResource(Res.string.button_reset_default), onClick = onResetFolder)
} }
} }
}
} }
@Composable @Composable

View File

@@ -58,6 +58,7 @@ data class SettingsState(
val receiveFolder: ReceiveFolder? = null, val receiveFolder: ReceiveFolder? = null,
val folderAccessStatus: FolderAccessStatus = FolderAccessStatus.Unavailable, val folderAccessStatus: FolderAccessStatus = FolderAccessStatus.Unavailable,
val isValidatingFolder: Boolean = false, val isValidatingFolder: Boolean = false,
val supportsCustomReceiveFolders: Boolean = true,
val themeMode: ThemeMode = ThemeMode.System, val themeMode: ThemeMode = ThemeMode.System,
val notificationsEnabled: Boolean = false, val notificationsEnabled: Boolean = false,
val notificationPermission: NotificationPermission = NotificationPermission.NotDetermined, val notificationPermission: NotificationPermission = NotificationPermission.NotDetermined,
@@ -88,7 +89,12 @@ class SettingsViewModel(
private val bugReports: BugReportService, private val bugReports: BugReportService,
private val diagnostics: DiagnosticsCoordinator? = null, private val diagnostics: DiagnosticsCoordinator? = null,
) : ViewModel() { ) : ViewModel() {
private val _state = MutableStateFlow(SettingsState(appVersion = environment.appVersion)) private val _state = MutableStateFlow(
SettingsState(
appVersion = environment.appVersion,
supportsCustomReceiveFolders = fileSystemService.supportsCustomReceiveFolders,
),
)
val state: StateFlow<SettingsState> = _state.asStateFlow() val state: StateFlow<SettingsState> = _state.asStateFlow()
private val effects = Channel<SettingsEffect>(Channel.BUFFERED) private val effects = Channel<SettingsEffect>(Channel.BUFFERED)
@@ -100,20 +106,21 @@ class SettingsViewModel(
viewModelScope.launch { viewModelScope.launch {
preferencesRepository.preferences.collect { preferences -> preferencesRepository.preferences.collect { preferences ->
val previousFolder = _state.value.receiveFolder val previousFolder = _state.value.receiveFolder
val receiveFolder = fileSystemService.effectiveReceiveFolder(preferences.receiveFolder)
// While the user is typing, keep the in-progress value. DataStore // While the user is typing, keep the in-progress value. DataStore
// echoes can race keystrokes and trim trailing spaces mid-edit. // echoes can race keystrokes and trim trailing spaces mid-edit.
val editingUsername = usernamePersistJob?.isActive == true val editingUsername = usernamePersistJob?.isActive == true
_state.update { current -> _state.update { current ->
current.copy( current.copy(
username = if (editingUsername) current.username else preferences.username, username = if (editingUsername) current.username else preferences.username,
receiveFolder = preferences.receiveFolder, receiveFolder = receiveFolder,
themeMode = preferences.themeMode, themeMode = preferences.themeMode,
notificationsEnabled = preferences.notificationsEnabled, notificationsEnabled = preferences.notificationsEnabled,
diagnosticsEnabled = preferences.diagnosticsEnabled, diagnosticsEnabled = preferences.diagnosticsEnabled,
) )
} }
if (preferences.receiveFolder != previousFolder) { if (receiveFolder != previousFolder) {
validateFolder(preferences.receiveFolder) validateFolder(receiveFolder)
} }
} }
} }
@@ -146,6 +153,7 @@ class SettingsViewModel(
} }
fun chooseReceiveFolder() { fun chooseReceiveFolder() {
if (!fileSystemService.supportsCustomReceiveFolders) return
viewModelScope.launch { effects.send(SettingsEffect.OpenReceiveFolderPicker) } viewModelScope.launch { effects.send(SettingsEffect.OpenReceiveFolderPicker) }
} }

View File

@@ -3,6 +3,7 @@ package com.vnidrop.app.feature
import com.vnidrop.app.DeviceInfo import com.vnidrop.app.DeviceInfo
import com.vnidrop.app.PlatformEnvironment import com.vnidrop.app.PlatformEnvironment
import com.vnidrop.app.core.CoreState import com.vnidrop.app.core.CoreState
import com.vnidrop.app.core.PickedShareFile
import com.vnidrop.app.core.ReceiveFolder import com.vnidrop.app.core.ReceiveFolder
import com.vnidrop.app.core.ReceiveFolderKind import com.vnidrop.app.core.ReceiveFolderKind
import com.vnidrop.app.core.Share import com.vnidrop.app.core.Share
@@ -35,6 +36,8 @@ import com.vnidrop.app.ui.navigation.AppDestination
import com.vnidrop.app.ui.theme.ThemeMode import com.vnidrop.app.ui.theme.ThemeMode
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.resetMain
@@ -45,9 +48,12 @@ import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertContentEquals import kotlin.test.assertContentEquals
import kotlin.test.assertFalse import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertTrue import kotlin.test.assertTrue
import vnidrop.shared.generated.resources.Res import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.button_show_in_files
import vnidrop.shared.generated.resources.error_permission import vnidrop.shared.generated.resources.error_permission
import vnidrop.shared.generated.resources.receive_open_files_failed
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
class ViewModelsTest { class ViewModelsTest {
@@ -189,16 +195,40 @@ class ViewModelsTest {
assertFalse(viewModel.state.value.isSubmittingBugReport) assertFalse(viewModel.state.value.isSubmittingBugReport)
} }
@Test
fun settingsUsesDefaultReceiveFolderWhenPlatformDoesNotSupportCustomFolders() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val appDocuments = ReceiveFolder(ReceiveFolderKind.FileSystemPath, "/app/Documents", "Documents")
val externalFolder = ReceiveFolder(ReceiveFolderKind.IosSecurityScopedUrl, "file:///external", "External")
val preferences = preferences().apply {
mutablePreferences.value = mutablePreferences.value.copy(receiveFolder = externalFolder)
}
val fileSystem = FakeFileSystemService(appDocuments).apply { supportsCustomFolders = false }
val viewModel = settingsViewModel(preferences = preferences, fileSystem = fileSystem)
advanceUntilIdle()
assertFalse(viewModel.state.value.supportsCustomReceiveFolders)
assertEquals(appDocuments, viewModel.state.value.receiveFolder)
assertEquals(com.vnidrop.app.core.FolderAccessStatus.Writable, viewModel.state.value.folderAccessStatus)
viewModel.chooseReceiveFolder()
assertEquals(null, withTimeoutOrNull(1) { viewModel.effectFlow.first() })
}
@Test @Test
fun sendViewModelOwnsSelectedFileState() = runTest { fun sendViewModelOwnsSelectedFileState() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler)) Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val viewModel = SendViewModel(FakeCoreGateway(), FakeFileSystemService(folder), preferences(), FakeFilePreviewRepository(), UiMessageController()) val fileSystem = FakeFileSystemService(folder)
val viewModel = SendViewModel(FakeCoreGateway(), fileSystem, preferences(), FakeFilePreviewRepository(), UiMessageController())
viewModel.openComposer() viewModel.openComposer()
viewModel.onFilesPicked(listOf(com.vnidrop.app.core.PickedShareFile("/tmp/photo.jpg", "photo.jpg", 42UL))) val selected = PickedShareFile("/tmp/photo.jpg", "photo.jpg", 42UL, isTemporaryCopy = true)
viewModel.onFilesPicked(listOf(selected))
assertEquals("photo.jpg", viewModel.state.value.transferName) assertEquals("photo.jpg", viewModel.state.value.transferName)
assertEquals(42UL, viewModel.state.value.selectedFile?.sizeBytes) assertEquals(42UL, viewModel.state.value.selectedFile?.sizeBytes)
viewModel.clearSelectedSource() viewModel.clearSelectedSource()
advanceUntilIdle()
assertEquals(null, viewModel.state.value.selectedFile) assertEquals(null, viewModel.state.value.selectedFile)
assertEquals(listOf(selected), fileSystem.discardedPickedFiles)
} }
@Test @Test
@@ -209,13 +239,15 @@ class ViewModelsTest {
shareResult = Result.success(Share(7UL, "ticket", "photo.jpg", "hash", 1UL, 42UL)) shareResult = Result.success(Share(7UL, "ticket", "photo.jpg", "hash", 1UL, 42UL))
} }
val previews = FakeFilePreviewRepository() val previews = FakeFilePreviewRepository()
val viewModel = SendViewModel(core, FakeFileSystemService(folder), preferences(), previews, UiMessageController()) val fileSystem = FakeFileSystemService(folder)
val viewModel = SendViewModel(core, fileSystem, preferences(), previews, UiMessageController())
advanceUntilIdle() advanceUntilIdle()
viewModel.openComposer() viewModel.openComposer()
val thumbnail = ByteArray(12).also { val thumbnail = ByteArray(12).also {
it[0] = 0x89.toByte(); it[1] = 'P'.code.toByte(); it[2] = 'N'.code.toByte(); it[3] = 'G'.code.toByte() it[0] = 0x89.toByte(); it[1] = 'P'.code.toByte(); it[2] = 'N'.code.toByte(); it[3] = 'G'.code.toByte()
} }
viewModel.onFilesPicked(listOf(com.vnidrop.app.core.PickedShareFile("/tmp/photo.jpg", "photo.jpg", 42UL, thumbnail))) val selected = PickedShareFile("/tmp/photo.jpg", "photo.jpg", 42UL, thumbnail, isTemporaryCopy = true)
viewModel.onFilesPicked(listOf(selected))
viewModel.setAccessPolicy(ShareAccessPolicy.AnyoneWithTransfer) viewModel.setAccessPolicy(ShareAccessPolicy.AnyoneWithTransfer)
viewModel.createShare() viewModel.createShare()
advanceUntilIdle() advanceUntilIdle()
@@ -225,6 +257,7 @@ class ViewModelsTest {
assertEquals(ShareAccessPolicy.AnyoneWithTransfer, core.lastShareAccessPolicy) assertEquals(ShareAccessPolicy.AnyoneWithTransfer, core.lastShareAccessPolicy)
assertEquals(7UL, core.state.value.transfers.first().transferId) assertEquals(7UL, core.state.value.transfers.first().transferId)
assertContentEquals(thumbnail, previews.previews.value.getValue(7UL)) assertContentEquals(thumbnail, previews.previews.value.getValue(7UL))
assertEquals(listOf(selected), fileSystem.discardedPickedFiles)
} }
@Test @Test
@@ -397,7 +430,8 @@ class ViewModelsTest {
mutableState.value = mutableState.value.copy(isInitialized = true) mutableState.value = mutableState.value.copy(isInitialized = true)
inspectionResult = Result.success(sampleTicketInspection()) inspectionResult = Result.success(sampleTicketInspection())
} }
val viewModel = ReceiveViewModel(core, FakeFileSystemService(folder), preferences(), UiMessageController()) val messages = UiMessageController()
val viewModel = ReceiveViewModel(core, FakeFileSystemService(folder), preferences(), messages)
advanceUntilIdle() advanceUntilIdle()
viewModel.onInvitationResult(com.vnidrop.app.feature.receive.ReceiveMethod.InvitationFile, Result.success("ticket-abc")) viewModel.onInvitationResult(com.vnidrop.app.feature.receive.ReceiveMethod.InvitationFile, Result.success("ticket-abc"))
advanceUntilIdle() advanceUntilIdle()
@@ -411,6 +445,74 @@ class ViewModelsTest {
assertFalse(viewModel.state.value.isAcquisitionOpen) assertFalse(viewModel.state.value.isAcquisitionOpen)
assertEquals("", viewModel.state.value.ticket) assertEquals("", viewModel.state.value.ticket)
assertFalse(viewModel.state.value.isReceiving) assertFalse(viewModel.state.value.isReceiving)
val completed = messages.messages.first()
assertEquals(null, completed.actionLabel)
assertEquals(null, completed.onAction)
}
@Test
fun receiveViewModelOffersCompletedFolderInFilesWhenPlatformSupportsIt() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val core = FakeCoreGateway().apply {
mutableState.value = mutableState.value.copy(isInitialized = true)
inspectionResult = Result.success(sampleTicketInspection())
}
val fileSystem = FakeFileSystemService(folder).apply { canRevealFolder = true }
val messages = UiMessageController()
val viewModel = ReceiveViewModel(core, fileSystem, preferences(), messages)
advanceUntilIdle()
viewModel.onInvitationResult(com.vnidrop.app.feature.receive.ReceiveMethod.InvitationFile, Result.success("ticket"))
advanceUntilIdle()
viewModel.receive()
advanceUntilIdle()
val completed = messages.messages.first()
assertEquals(UiText.Resource(Res.string.button_show_in_files), completed.actionLabel)
assertNotNull(completed.onAction).invoke()
advanceUntilIdle()
assertEquals(listOf(folder), fileSystem.revealedFolders)
}
@Test
fun receiveViewModelUsesPlatformEffectiveFolderInsteadOfPersistedExternalFolder() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val internalFolder = ReceiveFolder(ReceiveFolderKind.FileSystemPath, "/app/Documents", "Documents")
val fileSystem = FakeFileSystemService(folder).apply { effectiveFolder = internalFolder }
val viewModel = ReceiveViewModel(FakeCoreGateway(), fileSystem, preferences(), UiMessageController())
advanceUntilIdle()
assertEquals(internalFolder, viewModel.state.value.receiveFolder)
}
@Test
fun receiveViewModelReportsWhenCompletedFolderCannotOpen() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val core = FakeCoreGateway().apply {
mutableState.value = mutableState.value.copy(isInitialized = true)
inspectionResult = Result.success(sampleTicketInspection())
}
val fileSystem = FakeFileSystemService(folder).apply {
canRevealFolder = true
revealFolderResult = Result.failure(IllegalStateException("Files unavailable"))
}
val messages = UiMessageController()
val viewModel = ReceiveViewModel(core, fileSystem, preferences(), messages)
advanceUntilIdle()
viewModel.onInvitationResult(com.vnidrop.app.feature.receive.ReceiveMethod.InvitationFile, Result.success("ticket"))
advanceUntilIdle()
viewModel.receive()
advanceUntilIdle()
val completed = messages.messages.first()
assertNotNull(completed.onAction).invoke()
advanceUntilIdle()
assertEquals(
UiText.Resource(Res.string.receive_open_files_failed),
messages.messages.first().text,
)
} }
@Test @Test
@@ -540,10 +642,11 @@ class ViewModelsTest {
preferences: PreferencesRepository = preferences(), preferences: PreferencesRepository = preferences(),
notifications: FakeNotificationService = FakeNotificationService(), notifications: FakeNotificationService = FakeNotificationService(),
transport: DiagnosticsTransport = RecordingDiagnosticsTransport(), transport: DiagnosticsTransport = RecordingDiagnosticsTransport(),
fileSystem: FakeFileSystemService = FakeFileSystemService(folder),
) = SettingsViewModel( ) = SettingsViewModel(
environment(), environment(),
{ DeviceInfo("Device", "Model", "OS", "Wi-Fi", "80%") }, { DeviceInfo("Device", "Model", "OS", "Wi-Fi", "80%") },
FakeFileSystemService(folder), fileSystem,
preferences, preferences,
notifications, notifications,
UiMessageController(), UiMessageController(),

View File

@@ -230,9 +230,26 @@ class FakeNotificationService(
class FakeFileSystemService( class FakeFileSystemService(
private val folder: ReceiveFolder, private val folder: ReceiveFolder,
) : FileSystemService { ) : FileSystemService {
var supportsCustomFolders = true
var effectiveFolder: ReceiveFolder? = null
var canRevealFolder = false
var revealFolderResult: Result<Unit> = Result.success(Unit)
val revealedFolders = mutableListOf<ReceiveFolder>()
val discardedPickedFiles = mutableListOf<PickedShareFile>()
override val supportsCustomReceiveFolders: Boolean get() = supportsCustomFolders
override fun defaultReceiveFolder() = folder override fun defaultReceiveFolder() = folder
override fun effectiveReceiveFolder(configuredFolder: ReceiveFolder) =
effectiveFolder ?: super.effectiveReceiveFolder(configuredFolder)
override suspend fun validateReceiveFolder(folder: ReceiveFolder) = FolderAccessStatus.Writable override suspend fun validateReceiveFolder(folder: ReceiveFolder) = FolderAccessStatus.Writable
override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSink? = null override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSink? = null
override fun canRevealReceiveFolder(folder: ReceiveFolder) = canRevealFolder
override suspend fun revealReceiveFolder(folder: ReceiveFolder): Result<Unit> {
revealedFolders += folder
return revealFolderResult
}
override suspend fun discardPickedFiles(files: List<PickedShareFile>) {
discardedPickedFiles += files
}
override suspend fun sharePickedFiles( override suspend fun sharePickedFiles(
repository: CoreGateway, repository: CoreGateway,
files: List<PickedShareFile>, files: List<PickedShareFile>,

View File

@@ -35,11 +35,13 @@ actual fun rememberShareFilePicker(
return return
} }
val picker = UIDocumentPickerViewController(forOpeningContentTypes = listOf(UTTypeItem), asCopy = false) // The composer outlives this callback, so Rust imports a sandbox copy instead of a short-lived provider URL.
val picker = UIDocumentPickerViewController(forOpeningContentTypes = listOf(UTTypeItem), asCopy = true)
picker.allowsMultipleSelection = true picker.allowsMultipleSelection = true
val delegate = DocumentPickerDelegate( val delegate = DocumentPickerDelegate(
onFilesPicked = onFilesPicked, onFilesPicked = onFilesPicked,
onError = onError, onError = onError,
useFileSystemPaths = true,
) )
retainedPickerDelegate = delegate retainedPickerDelegate = delegate
picker.delegate = delegate picker.delegate = delegate
@@ -54,7 +56,7 @@ actual fun rememberShareFilePicker(
onError("Could not find an iOS view controller for the folder picker") onError("Could not find an iOS view controller for the folder picker")
return return
} }
val picker = UIDocumentPickerViewController(forOpeningContentTypes = listOf(UTTypeFolder), asCopy = false) val picker = UIDocumentPickerViewController(forOpeningContentTypes = listOf(UTTypeFolder), asCopy = true)
val delegate = DocumentPickerDelegate( val delegate = DocumentPickerDelegate(
onFilesPicked = { folders -> onFilesPicked = { folders ->
val folder = folders.firstOrNull() ?: return@DocumentPickerDelegate val folder = folders.firstOrNull() ?: return@DocumentPickerDelegate
@@ -66,6 +68,7 @@ actual fun rememberShareFilePicker(
}, },
onError = onError, onError = onError,
forceDirectory = true, forceDirectory = true,
useFileSystemPaths = true,
) )
retainedPickerDelegate = delegate retainedPickerDelegate = delegate
picker.delegate = delegate picker.delegate = delegate
@@ -115,6 +118,7 @@ private class DocumentPickerDelegate(
private val onFilesPicked: (List<PickedShareFile>) -> Unit, private val onFilesPicked: (List<PickedShareFile>) -> Unit,
private val onError: (String) -> Unit, private val onError: (String) -> Unit,
private val forceDirectory: Boolean = false, private val forceDirectory: Boolean = false,
private val useFileSystemPaths: Boolean = false,
) : NSObject(), UIDocumentPickerDelegateProtocol { ) : NSObject(), UIDocumentPickerDelegateProtocol {
override fun documentPicker(controller: UIDocumentPickerViewController, didPickDocumentsAtURLs: List<*>) { override fun documentPicker(controller: UIDocumentPickerViewController, didPickDocumentsAtURLs: List<*>) {
val files = didPickDocumentsAtURLs.mapNotNull { raw -> val files = didPickDocumentsAtURLs.mapNotNull { raw ->
@@ -132,10 +136,11 @@ private class DocumentPickerDelegate(
if (didStartAccess) url.stopAccessingSecurityScopedResource() if (didStartAccess) url.stopAccessingSecurityScopedResource()
} }
PickedShareFile( PickedShareFile(
url.absoluteString ?: url.path.orEmpty(), if (useFileSystemPaths) url.path.orEmpty() else url.absoluteString ?: url.path.orEmpty(),
displayName, displayName,
sizeBytes, sizeBytes,
nativeFileIcon(url), nativeFileIcon(url),
isTemporaryCopy = useFileSystemPaths,
isDirectory = forceDirectory, isDirectory = forceDirectory,
) )
} }

View File

@@ -2,18 +2,25 @@ package com.vnidrop.app.core
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import kotlinx.coroutines.suspendCancellableCoroutine
import platform.Foundation.NSFileManager import platform.Foundation.NSFileManager
import platform.Foundation.NSDocumentDirectory import platform.Foundation.NSDocumentDirectory
import platform.Foundation.NSSearchPathForDirectoriesInDomains import platform.Foundation.NSSearchPathForDirectoriesInDomains
import platform.Foundation.NSURL import platform.Foundation.NSURL
import platform.Foundation.NSUserDomainMask import platform.Foundation.NSUserDomainMask
import platform.UIKit.UIApplication
import uniffi.vnidrop.ReceiveOutputSink import uniffi.vnidrop.ReceiveOutputSink
import uniffi.vnidrop.SourceKind
import kotlin.coroutines.resume
@Composable @Composable
actual fun rememberFileSystemService(): FileSystemService = actual fun rememberFileSystemService(): FileSystemService =
remember { IosFileSystemService() } remember { IosFileSystemService() }
private class IosFileSystemService : FileSystemService { private class IosFileSystemService : FileSystemService {
// App-owned Documents remains durable across launches; raw external picker URLs do not.
override val supportsCustomReceiveFolders: Boolean = false
override fun defaultReceiveFolder(): ReceiveFolder { override fun defaultReceiveFolder(): ReceiveFolder {
val path = NSSearchPathForDirectoriesInDomains( val path = NSSearchPathForDirectoriesInDomains(
NSDocumentDirectory, NSDocumentDirectory,
@@ -43,6 +50,37 @@ private class IosFileSystemService : FileSystemService {
override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSink? = null override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSink? = null
override suspend fun discardPickedFiles(files: List<PickedShareFile>) {
files.asSequence()
.filter(PickedShareFile::isTemporaryCopy)
.map(PickedShareFile::value)
.distinct()
.forEach { path -> NSFileManager.defaultManager.removeItemAtPath(path, null) }
}
override fun canRevealReceiveFolder(folder: ReceiveFolder): Boolean =
folder.kind == ReceiveFolderKind.FileSystemPath &&
folder.value.trimEnd('/') == defaultReceiveFolder().value.trimEnd('/')
override suspend fun revealReceiveFolder(folder: ReceiveFolder): Result<Unit> {
if (!canRevealReceiveFolder(folder)) {
return Result.failure(IllegalArgumentException("The receive folder is not VniDrop Documents"))
}
// Files can reveal app-owned Documents after the sharing keys in Info.plist are enabled.
val url = NSURL.URLWithString("shareddocuments://${folder.value}")
?: return Result.failure(IllegalStateException("The Files location URL is unavailable"))
val opened = suspendCancellableCoroutine { continuation ->
UIApplication.sharedApplication.openURL(url, emptyMap<Any?, Any>()) { success ->
if (continuation.isActive) continuation.resume(success)
}
}
return if (opened) {
Result.success(Unit)
} else {
Result.failure(IllegalStateException("Could not open VniDrop Documents in Files"))
}
}
override suspend fun sharePickedFiles( override suspend fun sharePickedFiles(
repository: CoreGateway, repository: CoreGateway,
files: List<PickedShareFile>, files: List<PickedShareFile>,
@@ -51,15 +89,7 @@ private class IosFileSystemService : FileSystemService {
accessPolicy: ShareAccessPolicy, accessPolicy: ShareAccessPolicy,
): Result<Share> { ): Result<Share> {
require(files.isNotEmpty()) { "Select at least one file to share" } require(files.isNotEmpty()) { "Select at least one file to share" }
val sources = files.map { file -> return repository.shareSources(files.map(PickedShareFile::toIosShareSource), transferName, senderName, accessPolicy)
uniffi.vnidrop.ShareSource(
kind = uniffi.vnidrop.SourceKind.IOS_SECURITY_SCOPED_URL,
value = file.value,
displayName = file.displayName,
isDirectory = file.isDirectory,
)
}
return repository.shareSources(sources, transferName, senderName, accessPolicy)
} }
private fun validateSecurityScopedUrl(value: String): FolderAccessStatus { private fun validateSecurityScopedUrl(value: String): FolderAccessStatus {
@@ -77,3 +107,10 @@ private class IosFileSystemService : FileSystemService {
} }
} }
} }
internal fun PickedShareFile.toIosShareSource() = uniffi.vnidrop.ShareSource(
kind = SourceKind.PATH,
value = value,
displayName = displayName,
isDirectory = isDirectory,
)

View File

@@ -0,0 +1,25 @@
package com.vnidrop.app.core
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import uniffi.vnidrop.SourceKind
class FileSystemServiceIosTest {
@Test
fun sandboxPickerCopyMapsToPathSource() {
val picked = PickedShareFile(
value = "/tmp/VniDrop/photos",
displayName = "photos",
isTemporaryCopy = true,
isDirectory = true,
)
val source = picked.toIosShareSource()
assertEquals(SourceKind.PATH, source.kind)
assertEquals(picked.value, source.value)
assertEquals(picked.displayName, source.displayName)
assertTrue(source.isDirectory)
}
}