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

@@ -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,9 +47,11 @@ internal fun PreferencesSettings(
iconTone = SettingsIconTone.Neutral,
)
}
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
PrimaryButton(stringResource(Res.string.button_choose_folder), onClick = onChooseFolder)
SecondaryButton(stringResource(Res.string.button_reset_default), onClick = onResetFolder)
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)
}
}
}
}

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) }
}