mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 10:29:58 +02:00
Merge pull request #15 from vnidrop/feat/ios-files-integration
fix(ios): support sandboxed file transfers
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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">Couldn’t 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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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) }
|
||||||
|
|||||||
@@ -47,9 +47,11 @@ internal fun PreferencesSettings(
|
|||||||
iconTone = SettingsIconTone.Neutral,
|
iconTone = SettingsIconTone.Neutral,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
if (state.supportsCustomReceiveFolders) {
|
||||||
PrimaryButton(stringResource(Res.string.button_choose_folder), onClick = onChooseFolder)
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
SecondaryButton(stringResource(Res.string.button_reset_default), onClick = onResetFolder)
|
PrimaryButton(stringResource(Res.string.button_choose_folder), onClick = onChooseFolder)
|
||||||
|
SecondaryButton(stringResource(Res.string.button_reset_default), onClick = onResetFolder)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user