fix(ios): support sandboxed file transfers

This commit is contained in:
2026-07-15 19:40:20 +02:00
parent 43db35e9a2
commit c3d54e9142
13 changed files with 282 additions and 28 deletions

View File

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

View File

@@ -96,6 +96,8 @@
<string name="receive_review_title">Review transfer</string>
<string name="receive_unknown_transfer">VniDrop transfer</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="button_receive">Receive</string>
<string name="button_retry">Retry</string>

View File

@@ -7,6 +7,8 @@ data class PickedShareFile(
val displayName: String,
val sizeBytes: ULong? = 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
* folder URL, or Android document tree URI). Platform share code expands or

View File

@@ -27,9 +27,18 @@ enum class FolderAccessStatus {
}
interface FileSystemService {
val supportsCustomReceiveFolders: Boolean get() = true
fun defaultReceiveFolder(): ReceiveFolder
fun effectiveReceiveFolder(configuredFolder: ReceiveFolder): ReceiveFolder =
if (supportsCustomReceiveFolders) configuredFolder else defaultReceiveFolder()
suspend fun validateReceiveFolder(folder: ReceiveFolder): FolderAccessStatus
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(
repository: CoreGateway,
file: PickedShareFile,

View File

@@ -25,7 +25,9 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import vnidrop.shared.generated.resources.Res
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.receive_open_files_failed
import vnidrop.shared.generated.resources.receive_completed
import vnidrop.shared.generated.resources.receive_history_cleared
import vnidrop.shared.generated.resources.transfer_deleted
@@ -69,11 +71,12 @@ class ReceiveViewModel(
init {
viewModelScope.launch {
preferencesRepository.preferences.collect { preferences ->
val status = fileSystemService.validateReceiveFolder(preferences.receiveFolder)
val receiveFolder = fileSystemService.effectiveReceiveFolder(preferences.receiveFolder)
val status = fileSystemService.validateReceiveFolder(receiveFolder)
_state.update { current ->
current.copy(
receiverName = current.receiverName.ifBlank { preferences.username },
receiveFolder = preferences.receiveFolder,
receiveFolder = receiveFolder,
folderAccessStatus = status,
)
}
@@ -170,7 +173,19 @@ class ReceiveViewModel(
result.fold(
onSuccess = {
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 ->
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) {
val ticket = raw.trim()
if (ticket.isBlank()) return messages.error(UiText.Resource(Res.string.error_invitation_empty))

View File

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

View File

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

View File

@@ -58,6 +58,7 @@ data class SettingsState(
val receiveFolder: ReceiveFolder? = null,
val folderAccessStatus: FolderAccessStatus = FolderAccessStatus.Unavailable,
val isValidatingFolder: Boolean = false,
val supportsCustomReceiveFolders: Boolean = true,
val themeMode: ThemeMode = ThemeMode.System,
val notificationsEnabled: Boolean = false,
val notificationPermission: NotificationPermission = NotificationPermission.NotDetermined,
@@ -88,7 +89,12 @@ class SettingsViewModel(
private val bugReports: BugReportService,
private val diagnostics: DiagnosticsCoordinator? = null,
) : 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()
private val effects = Channel<SettingsEffect>(Channel.BUFFERED)
@@ -100,20 +106,21 @@ class SettingsViewModel(
viewModelScope.launch {
preferencesRepository.preferences.collect { preferences ->
val previousFolder = _state.value.receiveFolder
val receiveFolder = fileSystemService.effectiveReceiveFolder(preferences.receiveFolder)
// While the user is typing, keep the in-progress value. DataStore
// echoes can race keystrokes and trim trailing spaces mid-edit.
val editingUsername = usernamePersistJob?.isActive == true
_state.update { current ->
current.copy(
username = if (editingUsername) current.username else preferences.username,
receiveFolder = preferences.receiveFolder,
receiveFolder = receiveFolder,
themeMode = preferences.themeMode,
notificationsEnabled = preferences.notificationsEnabled,
diagnosticsEnabled = preferences.diagnosticsEnabled,
)
}
if (preferences.receiveFolder != previousFolder) {
validateFolder(preferences.receiveFolder)
if (receiveFolder != previousFolder) {
validateFolder(receiveFolder)
}
}
}
@@ -146,6 +153,7 @@ class SettingsViewModel(
}
fun chooseReceiveFolder() {
if (!fileSystemService.supportsCustomReceiveFolders) return
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.PlatformEnvironment
import com.vnidrop.app.core.CoreState
import com.vnidrop.app.core.PickedShareFile
import com.vnidrop.app.core.ReceiveFolder
import com.vnidrop.app.core.ReceiveFolderKind
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 kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
@@ -45,9 +48,12 @@ import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertContentEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
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.receive_open_files_failed
@OptIn(ExperimentalCoroutinesApi::class)
class ViewModelsTest {
@@ -189,16 +195,40 @@ class ViewModelsTest {
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
fun sendViewModelOwnsSelectedFileState() = runTest {
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.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(42UL, viewModel.state.value.selectedFile?.sizeBytes)
viewModel.clearSelectedSource()
advanceUntilIdle()
assertEquals(null, viewModel.state.value.selectedFile)
assertEquals(listOf(selected), fileSystem.discardedPickedFiles)
}
@Test
@@ -209,13 +239,15 @@ class ViewModelsTest {
shareResult = Result.success(Share(7UL, "ticket", "photo.jpg", "hash", 1UL, 42UL))
}
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()
viewModel.openComposer()
val thumbnail = ByteArray(12).also {
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.createShare()
advanceUntilIdle()
@@ -225,6 +257,7 @@ class ViewModelsTest {
assertEquals(ShareAccessPolicy.AnyoneWithTransfer, core.lastShareAccessPolicy)
assertEquals(7UL, core.state.value.transfers.first().transferId)
assertContentEquals(thumbnail, previews.previews.value.getValue(7UL))
assertEquals(listOf(selected), fileSystem.discardedPickedFiles)
}
@Test
@@ -397,7 +430,8 @@ class ViewModelsTest {
mutableState.value = mutableState.value.copy(isInitialized = true)
inspectionResult = Result.success(sampleTicketInspection())
}
val viewModel = ReceiveViewModel(core, FakeFileSystemService(folder), preferences(), UiMessageController())
val messages = UiMessageController()
val viewModel = ReceiveViewModel(core, FakeFileSystemService(folder), preferences(), messages)
advanceUntilIdle()
viewModel.onInvitationResult(com.vnidrop.app.feature.receive.ReceiveMethod.InvitationFile, Result.success("ticket-abc"))
advanceUntilIdle()
@@ -411,6 +445,74 @@ class ViewModelsTest {
assertFalse(viewModel.state.value.isAcquisitionOpen)
assertEquals("", viewModel.state.value.ticket)
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
@@ -540,10 +642,11 @@ class ViewModelsTest {
preferences: PreferencesRepository = preferences(),
notifications: FakeNotificationService = FakeNotificationService(),
transport: DiagnosticsTransport = RecordingDiagnosticsTransport(),
fileSystem: FakeFileSystemService = FakeFileSystemService(folder),
) = SettingsViewModel(
environment(),
{ DeviceInfo("Device", "Model", "OS", "Wi-Fi", "80%") },
FakeFileSystemService(folder),
fileSystem,
preferences,
notifications,
UiMessageController(),

View File

@@ -230,9 +230,26 @@ class FakeNotificationService(
class FakeFileSystemService(
private val folder: ReceiveFolder,
) : 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 effectiveReceiveFolder(configuredFolder: ReceiveFolder) =
effectiveFolder ?: super.effectiveReceiveFolder(configuredFolder)
override suspend fun validateReceiveFolder(folder: ReceiveFolder) = FolderAccessStatus.Writable
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(
repository: CoreGateway,
files: List<PickedShareFile>,

View File

@@ -35,11 +35,13 @@ actual fun rememberShareFilePicker(
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
val delegate = DocumentPickerDelegate(
onFilesPicked = onFilesPicked,
onError = onError,
useFileSystemPaths = true,
)
retainedPickerDelegate = delegate
picker.delegate = delegate
@@ -54,7 +56,7 @@ actual fun rememberShareFilePicker(
onError("Could not find an iOS view controller for the folder picker")
return
}
val picker = UIDocumentPickerViewController(forOpeningContentTypes = listOf(UTTypeFolder), asCopy = false)
val picker = UIDocumentPickerViewController(forOpeningContentTypes = listOf(UTTypeFolder), asCopy = true)
val delegate = DocumentPickerDelegate(
onFilesPicked = { folders ->
val folder = folders.firstOrNull() ?: return@DocumentPickerDelegate
@@ -66,6 +68,7 @@ actual fun rememberShareFilePicker(
},
onError = onError,
forceDirectory = true,
useFileSystemPaths = true,
)
retainedPickerDelegate = delegate
picker.delegate = delegate
@@ -115,6 +118,7 @@ private class DocumentPickerDelegate(
private val onFilesPicked: (List<PickedShareFile>) -> Unit,
private val onError: (String) -> Unit,
private val forceDirectory: Boolean = false,
private val useFileSystemPaths: Boolean = false,
) : NSObject(), UIDocumentPickerDelegateProtocol {
override fun documentPicker(controller: UIDocumentPickerViewController, didPickDocumentsAtURLs: List<*>) {
val files = didPickDocumentsAtURLs.mapNotNull { raw ->
@@ -132,10 +136,11 @@ private class DocumentPickerDelegate(
if (didStartAccess) url.stopAccessingSecurityScopedResource()
}
PickedShareFile(
url.absoluteString ?: url.path.orEmpty(),
if (useFileSystemPaths) url.path.orEmpty() else url.absoluteString ?: url.path.orEmpty(),
displayName,
sizeBytes,
nativeFileIcon(url),
isTemporaryCopy = useFileSystemPaths,
isDirectory = forceDirectory,
)
}

View File

@@ -2,18 +2,25 @@ package com.vnidrop.app.core
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import kotlinx.coroutines.suspendCancellableCoroutine
import platform.Foundation.NSFileManager
import platform.Foundation.NSDocumentDirectory
import platform.Foundation.NSSearchPathForDirectoriesInDomains
import platform.Foundation.NSURL
import platform.Foundation.NSUserDomainMask
import platform.UIKit.UIApplication
import uniffi.vnidrop.ReceiveOutputSink
import uniffi.vnidrop.SourceKind
import kotlin.coroutines.resume
@Composable
actual fun rememberFileSystemService(): FileSystemService =
remember { IosFileSystemService() }
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 {
val path = NSSearchPathForDirectoriesInDomains(
NSDocumentDirectory,
@@ -43,6 +50,37 @@ private class IosFileSystemService : FileSystemService {
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(
repository: CoreGateway,
files: List<PickedShareFile>,
@@ -51,15 +89,7 @@ private class IosFileSystemService : FileSystemService {
accessPolicy: ShareAccessPolicy,
): Result<Share> {
require(files.isNotEmpty()) { "Select at least one file to share" }
val sources = files.map { file ->
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)
return repository.shareSources(files.map(PickedShareFile::toIosShareSource), transferName, senderName, accessPolicy)
}
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)
}
}