mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 10:29:58 +02:00
fix(ios): support sandboxed file transfers
This commit is contained in:
@@ -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">Couldn’t 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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user