mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 10:29:58 +02:00
feat(send): share folders as multi-file collections
Add folder pickers on Android, iOS, and desktop. Desktop and iOS walk directory paths in Rust; Android expands SAF trees to per-file FDs with relative collection paths because directory FDs are unsupported.
This commit is contained in:
@@ -20,7 +20,7 @@ actual fun rememberShareFilePicker(
|
|||||||
onError: (String) -> Unit,
|
onError: (String) -> Unit,
|
||||||
): ShareFilePicker {
|
): ShareFilePicker {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris ->
|
val filesLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris ->
|
||||||
if (uris.isEmpty()) return@rememberLauncherForActivityResult
|
if (uris.isEmpty()) return@rememberLauncherForActivityResult
|
||||||
runCatching {
|
runCatching {
|
||||||
uris.map { uri -> context.pickedShareFile(uri) }
|
uris.map { uri -> context.pickedShareFile(uri) }
|
||||||
@@ -29,10 +29,41 @@ actual fun rememberShareFilePicker(
|
|||||||
onFailure = { onError(it.message ?: "Could not open the selected files") },
|
onFailure = { onError(it.message ?: "Could not open the selected files") },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return remember(launcher) {
|
val folderLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
|
||||||
|
if (uri == null) return@rememberLauncherForActivityResult
|
||||||
|
runCatching {
|
||||||
|
// Read permission only — we expand the tree into file FDs at share time.
|
||||||
|
context.contentResolver.takePersistableUriPermission(
|
||||||
|
uri,
|
||||||
|
Intent.FLAG_GRANT_READ_URI_PERMISSION,
|
||||||
|
)
|
||||||
|
val displayName = uri.lastPathSegment
|
||||||
|
?.substringAfterLast(':')
|
||||||
|
?.substringAfterLast('/')
|
||||||
|
?.ifBlank { null }
|
||||||
|
?: "Folder"
|
||||||
|
listOf(
|
||||||
|
PickedShareFile(
|
||||||
|
value = uri.toString(),
|
||||||
|
displayName = displayName,
|
||||||
|
sizeBytes = null,
|
||||||
|
thumbnailBytes = null,
|
||||||
|
isDirectory = true,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}.fold(
|
||||||
|
onSuccess = onFilesPicked,
|
||||||
|
onFailure = { onError(it.message ?: "Could not open the selected folder") },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return remember(filesLauncher, folderLauncher) {
|
||||||
object : ShareFilePicker {
|
object : ShareFilePicker {
|
||||||
override fun pickFiles() {
|
override fun pickFiles() {
|
||||||
launcher.launch(arrayOf("*/*"))
|
filesLauncher.launch(arrayOf("*/*"))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun pickFolder() {
|
||||||
|
folderLauncher.launch(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,12 +71,18 @@ private class AndroidFileSystemService(
|
|||||||
accessPolicy: ShareAccessPolicy,
|
accessPolicy: ShareAccessPolicy,
|
||||||
): Result<Share> = runCatching {
|
): Result<Share> = runCatching {
|
||||||
require(files.isNotEmpty()) { "Select at least one file to share" }
|
require(files.isNotEmpty()) { "Select at least one file to share" }
|
||||||
val descriptors = files.map { file ->
|
// Android cannot pass a directory as a single FD. Expand SAF trees into
|
||||||
|
// individual document files with relative collection paths, then open FDs.
|
||||||
|
val expanded = files.flatMap { file ->
|
||||||
|
if (file.isDirectory) context.expandShareDirectory(file) else listOf(file)
|
||||||
|
}
|
||||||
|
require(expanded.isNotEmpty()) { "No files found in the selected folder" }
|
||||||
|
val descriptors = expanded.map { file ->
|
||||||
context.contentResolver.openFileDescriptor(Uri.parse(file.value), "r")
|
context.contentResolver.openFileDescriptor(Uri.parse(file.value), "r")
|
||||||
?: error("Could not open selected file descriptor for ${file.displayName}")
|
?: error("Could not open selected file descriptor for ${file.displayName}")
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
val sources = files.zip(descriptors) { file, descriptor ->
|
val sources = expanded.zip(descriptors) { file, descriptor ->
|
||||||
uniffi.vnidrop.ShareSource(
|
uniffi.vnidrop.ShareSource(
|
||||||
kind = uniffi.vnidrop.SourceKind.FILE_DESCRIPTOR,
|
kind = uniffi.vnidrop.SourceKind.FILE_DESCRIPTOR,
|
||||||
value = descriptor.fd.toString(),
|
value = descriptor.fd.toString(),
|
||||||
@@ -140,6 +146,63 @@ private class AndroidFileSystemService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expand a SAF document tree into individual file documents.
|
||||||
|
*
|
||||||
|
* Rust cannot accept a directory FD. Collection paths preserve the folder
|
||||||
|
* root name so receivers see `Folder/nested/file.txt`.
|
||||||
|
*/
|
||||||
|
private fun Context.expandShareDirectory(folder: PickedShareFile): List<PickedShareFile> {
|
||||||
|
val treeUri = Uri.parse(folder.value)
|
||||||
|
val rootId = DocumentsContract.getTreeDocumentId(treeUri)
|
||||||
|
val out = mutableListOf<PickedShareFile>()
|
||||||
|
fun walk(documentId: String, relativePath: String) {
|
||||||
|
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, documentId)
|
||||||
|
contentResolver.query(
|
||||||
|
childrenUri,
|
||||||
|
arrayOf(
|
||||||
|
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
|
||||||
|
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
|
||||||
|
DocumentsContract.Document.COLUMN_MIME_TYPE,
|
||||||
|
DocumentsContract.Document.COLUMN_SIZE,
|
||||||
|
),
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
)?.use { cursor ->
|
||||||
|
val idIndex = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_DOCUMENT_ID)
|
||||||
|
val nameIndex = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_DISPLAY_NAME)
|
||||||
|
val mimeIndex = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_MIME_TYPE)
|
||||||
|
val sizeIndex = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_SIZE)
|
||||||
|
while (cursor.moveToNext()) {
|
||||||
|
val id = cursor.getString(idIndex) ?: continue
|
||||||
|
val name = cursor.getString(nameIndex) ?: continue
|
||||||
|
val mime = cursor.getString(mimeIndex)
|
||||||
|
val childRelative = if (relativePath.isEmpty()) name else "$relativePath/$name"
|
||||||
|
if (mime == DocumentsContract.Document.MIME_TYPE_DIR) {
|
||||||
|
walk(id, childRelative)
|
||||||
|
} else {
|
||||||
|
val documentUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, id)
|
||||||
|
val size = if (sizeIndex >= 0 && !cursor.isNull(sizeIndex)) {
|
||||||
|
cursor.getLong(sizeIndex).takeIf { it >= 0L }?.toULong()
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
out += PickedShareFile(
|
||||||
|
value = documentUri.toString(),
|
||||||
|
displayName = childRelative,
|
||||||
|
sizeBytes = size,
|
||||||
|
isDirectory = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Prefix paths with the folder display name so nested structure is preserved.
|
||||||
|
walk(rootId, folder.displayName)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Writes into the shared system Downloads collection via MediaStore.
|
* Writes into the shared system Downloads collection via MediaStore.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -10,8 +10,9 @@
|
|||||||
<string name="send_transfers_title">Your transfers</string>
|
<string name="send_transfers_title">Your transfers</string>
|
||||||
<string name="send_new_transfer_title">New transfer</string>
|
<string name="send_new_transfer_title">New transfer</string>
|
||||||
<string name="send_choose_file_title">Choose what to share</string>
|
<string name="send_choose_file_title">Choose what to share</string>
|
||||||
<string name="send_choose_file_body">Select one or more files from this device. You can review them before creating the transfer.</string>
|
<string name="send_choose_file_body">Select files or a folder from this device. You can review the selection before creating the transfer.</string>
|
||||||
<string name="send_selected_files_count">%1$d files selected</string>
|
<string name="send_selected_files_count">%1$d files selected</string>
|
||||||
|
<string name="send_folder_label">Folder</string>
|
||||||
<string name="button_remove_file">Remove file</string>
|
<string name="button_remove_file">Remove file</string>
|
||||||
<string name="button_choose_files">Choose files</string>
|
<string name="button_choose_files">Choose files</string>
|
||||||
<string name="button_change_files">Change files</string>
|
<string name="button_change_files">Change files</string>
|
||||||
|
|||||||
@@ -7,11 +7,20 @@ 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,
|
||||||
|
/**
|
||||||
|
* When true, [value] is a directory (filesystem path, iOS security-scoped
|
||||||
|
* folder URL, or Android document tree URI). Platform share code expands or
|
||||||
|
* walks it; Rust cannot treat an Android FD as a directory.
|
||||||
|
*/
|
||||||
|
val isDirectory: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
interface ShareFilePicker {
|
interface ShareFilePicker {
|
||||||
/** Opens a platform picker that may return one or more files. */
|
/** Opens a platform picker that may return one or more files. */
|
||||||
fun pickFiles()
|
fun pickFiles()
|
||||||
|
|
||||||
|
/** Opens a platform folder picker for sharing a directory as one transfer. */
|
||||||
|
fun pickFolder()
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ReceiveFolderPicker {
|
interface ReceiveFolderPicker {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ fun SendRoute(
|
|||||||
viewModel.effectFlow.collect { effect ->
|
viewModel.effectFlow.collect { effect ->
|
||||||
when (effect) {
|
when (effect) {
|
||||||
SendEffect.OpenFilePicker -> picker.pickFiles()
|
SendEffect.OpenFilePicker -> picker.pickFiles()
|
||||||
|
SendEffect.OpenFolderPicker -> picker.pickFolder()
|
||||||
is SendEffect.CopyTicket -> clipboard.setText(AnnotatedString(effect.ticket))
|
is SendEffect.CopyTicket -> clipboard.setText(AnnotatedString(effect.ticket))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -37,6 +38,7 @@ fun SendRoute(
|
|||||||
onOpenComposer = viewModel::openComposer,
|
onOpenComposer = viewModel::openComposer,
|
||||||
onDismissComposer = viewModel::dismissComposer,
|
onDismissComposer = viewModel::dismissComposer,
|
||||||
onSelectFile = viewModel::selectFile,
|
onSelectFile = viewModel::selectFile,
|
||||||
|
onSelectFolder = viewModel::selectFolder,
|
||||||
onClearFile = viewModel::clearSelectedSource,
|
onClearFile = viewModel::clearSelectedSource,
|
||||||
onRemoveFile = viewModel::removeSelectedFile,
|
onRemoveFile = viewModel::removeSelectedFile,
|
||||||
onTransferNameChanged = viewModel::setTransferName,
|
onTransferNameChanged = viewModel::setTransferName,
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ fun SendScreen(
|
|||||||
onOpenComposer: () -> Unit,
|
onOpenComposer: () -> Unit,
|
||||||
onDismissComposer: () -> Unit,
|
onDismissComposer: () -> Unit,
|
||||||
onSelectFile: () -> Unit,
|
onSelectFile: () -> Unit,
|
||||||
|
onSelectFolder: () -> Unit = {},
|
||||||
onClearFile: () -> Unit,
|
onClearFile: () -> Unit,
|
||||||
onRemoveFile: (String) -> Unit = {},
|
onRemoveFile: (String) -> Unit = {},
|
||||||
onTransferNameChanged: (String) -> Unit,
|
onTransferNameChanged: (String) -> Unit,
|
||||||
@@ -83,6 +84,7 @@ fun SendScreen(
|
|||||||
state = state,
|
state = state,
|
||||||
windowClass = windowClass,
|
windowClass = windowClass,
|
||||||
onSelectFile = onSelectFile,
|
onSelectFile = onSelectFile,
|
||||||
|
onSelectFolder = onSelectFolder,
|
||||||
onClearFile = onClearFile,
|
onClearFile = onClearFile,
|
||||||
onRemoveFile = onRemoveFile,
|
onRemoveFile = onRemoveFile,
|
||||||
onTransferNameChanged = onTransferNameChanged,
|
onTransferNameChanged = onTransferNameChanged,
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ enum class TransferDetailPanel { Activity, Receivers, Share }
|
|||||||
|
|
||||||
sealed interface SendEffect {
|
sealed interface SendEffect {
|
||||||
data object OpenFilePicker : SendEffect
|
data object OpenFilePicker : SendEffect
|
||||||
|
data object OpenFolderPicker : SendEffect
|
||||||
data class CopyTicket(val ticket: String) : SendEffect
|
data class CopyTicket(val ticket: String) : SendEffect
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,6 +136,7 @@ class SendViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun selectFile() = sendEffect(SendEffect.OpenFilePicker)
|
fun selectFile() = sendEffect(SendEffect.OpenFilePicker)
|
||||||
|
fun selectFolder() = sendEffect(SendEffect.OpenFolderPicker)
|
||||||
|
|
||||||
fun onFilesPicked(files: List<PickedShareFile>) {
|
fun onFilesPicked(files: List<PickedShareFile>) {
|
||||||
if (files.isEmpty()) return
|
if (files.isEmpty()) return
|
||||||
@@ -271,9 +273,11 @@ class SendViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun defaultTransferName(files: List<PickedShareFile>): String = when (files.size) {
|
private fun defaultTransferName(files: List<PickedShareFile>): String = when {
|
||||||
0 -> ""
|
files.isEmpty() -> ""
|
||||||
1 -> files.first().displayName
|
files.size == 1 && files.first().isDirectory -> files.first().displayName
|
||||||
|
files.size == 1 -> files.first().displayName
|
||||||
|
files.all { it.isDirectory } -> "${files.size} folders"
|
||||||
else -> "${files.size} files"
|
else -> "${files.size} files"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import org.jetbrains.compose.resources.stringResource
|
|||||||
import vnidrop.shared.generated.resources.Res
|
import vnidrop.shared.generated.resources.Res
|
||||||
import vnidrop.shared.generated.resources.button_change_files
|
import vnidrop.shared.generated.resources.button_change_files
|
||||||
import vnidrop.shared.generated.resources.button_choose_files
|
import vnidrop.shared.generated.resources.button_choose_files
|
||||||
|
import vnidrop.shared.generated.resources.button_choose_folder
|
||||||
import vnidrop.shared.generated.resources.button_clear
|
import vnidrop.shared.generated.resources.button_clear
|
||||||
import vnidrop.shared.generated.resources.button_remove_file
|
import vnidrop.shared.generated.resources.button_remove_file
|
||||||
import vnidrop.shared.generated.resources.button_share_file
|
import vnidrop.shared.generated.resources.button_share_file
|
||||||
@@ -56,6 +57,7 @@ import vnidrop.shared.generated.resources.send_access_title
|
|||||||
import vnidrop.shared.generated.resources.send_choose_file_body
|
import vnidrop.shared.generated.resources.send_choose_file_body
|
||||||
import vnidrop.shared.generated.resources.send_choose_file_title
|
import vnidrop.shared.generated.resources.send_choose_file_title
|
||||||
import vnidrop.shared.generated.resources.send_file_size_unknown
|
import vnidrop.shared.generated.resources.send_file_size_unknown
|
||||||
|
import vnidrop.shared.generated.resources.send_folder_label
|
||||||
import vnidrop.shared.generated.resources.send_review_title
|
import vnidrop.shared.generated.resources.send_review_title
|
||||||
import vnidrop.shared.generated.resources.send_selected_files_count
|
import vnidrop.shared.generated.resources.send_selected_files_count
|
||||||
|
|
||||||
@@ -65,6 +67,7 @@ internal fun TransferComposer(
|
|||||||
state: SendState,
|
state: SendState,
|
||||||
windowClass: WindowClass,
|
windowClass: WindowClass,
|
||||||
onSelectFile: () -> Unit,
|
onSelectFile: () -> Unit,
|
||||||
|
onSelectFolder: () -> Unit,
|
||||||
onClearFile: () -> Unit,
|
onClearFile: () -> Unit,
|
||||||
onRemoveFile: (String) -> Unit,
|
onRemoveFile: (String) -> Unit,
|
||||||
onTransferNameChanged: (String) -> Unit,
|
onTransferNameChanged: (String) -> Unit,
|
||||||
@@ -77,12 +80,13 @@ internal fun TransferComposer(
|
|||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
) {
|
) {
|
||||||
if (state.selectedFiles.isEmpty()) {
|
if (state.selectedFiles.isEmpty()) {
|
||||||
ChooseFileStep(onSelectFile)
|
ChooseFileStep(onSelectFile, onSelectFolder)
|
||||||
} else {
|
} else {
|
||||||
ReviewFileStep(
|
ReviewFileStep(
|
||||||
state = state,
|
state = state,
|
||||||
windowClass = windowClass,
|
windowClass = windowClass,
|
||||||
onSelectFile = onSelectFile,
|
onSelectFile = onSelectFile,
|
||||||
|
onSelectFolder = onSelectFolder,
|
||||||
onClearFile = onClearFile,
|
onClearFile = onClearFile,
|
||||||
onRemoveFile = onRemoveFile,
|
onRemoveFile = onRemoveFile,
|
||||||
onTransferNameChanged = onTransferNameChanged,
|
onTransferNameChanged = onTransferNameChanged,
|
||||||
@@ -96,7 +100,7 @@ internal fun TransferComposer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ChooseFileStep(onSelectFile: () -> Unit) {
|
private fun ChooseFileStep(onSelectFile: () -> Unit, onSelectFolder: () -> Unit) {
|
||||||
Text(stringResource(Res.string.send_choose_file_title), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
|
Text(stringResource(Res.string.send_choose_file_title), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
|
||||||
Text(
|
Text(
|
||||||
stringResource(Res.string.send_choose_file_body),
|
stringResource(Res.string.send_choose_file_body),
|
||||||
@@ -111,6 +115,7 @@ private fun ChooseFileStep(onSelectFile: () -> Unit) {
|
|||||||
) {
|
) {
|
||||||
Icon(SendIcons.File, contentDescription = null, tint = LocalVniDropColors.current.brandLink, modifier = Modifier.size(32.dp))
|
Icon(SendIcons.File, contentDescription = null, tint = LocalVniDropColors.current.brandLink, modifier = Modifier.size(32.dp))
|
||||||
PrimaryButton(stringResource(Res.string.button_choose_files), onClick = onSelectFile)
|
PrimaryButton(stringResource(Res.string.button_choose_files), onClick = onSelectFile)
|
||||||
|
QuietButton(stringResource(Res.string.button_choose_folder), onClick = onSelectFolder)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -120,6 +125,7 @@ private fun ReviewFileStep(
|
|||||||
state: SendState,
|
state: SendState,
|
||||||
windowClass: WindowClass,
|
windowClass: WindowClass,
|
||||||
onSelectFile: () -> Unit,
|
onSelectFile: () -> Unit,
|
||||||
|
onSelectFolder: () -> Unit,
|
||||||
onClearFile: () -> Unit,
|
onClearFile: () -> Unit,
|
||||||
onRemoveFile: (String) -> Unit,
|
onRemoveFile: (String) -> Unit,
|
||||||
onTransferNameChanged: (String) -> Unit,
|
onTransferNameChanged: (String) -> Unit,
|
||||||
@@ -164,11 +170,13 @@ private fun ReviewFileStep(
|
|||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
ShareButton(state, coreInitialized, onCreateShare, Modifier.fillMaxWidth())
|
ShareButton(state, coreInitialized, onCreateShare, Modifier.fillMaxWidth())
|
||||||
QuietButton(stringResource(Res.string.button_change_files), onClick = onSelectFile, modifier = Modifier.fillMaxWidth(), enabled = !state.isSharing)
|
QuietButton(stringResource(Res.string.button_change_files), onClick = onSelectFile, modifier = Modifier.fillMaxWidth(), enabled = !state.isSharing)
|
||||||
|
QuietButton(stringResource(Res.string.button_choose_folder), onClick = onSelectFolder, modifier = Modifier.fillMaxWidth(), enabled = !state.isSharing)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
ShareButton(state, coreInitialized, onCreateShare)
|
ShareButton(state, coreInitialized, onCreateShare)
|
||||||
QuietButton(stringResource(Res.string.button_change_files), onClick = onSelectFile, enabled = !state.isSharing)
|
QuietButton(stringResource(Res.string.button_change_files), onClick = onSelectFile, enabled = !state.isSharing)
|
||||||
|
QuietButton(stringResource(Res.string.button_choose_folder), onClick = onSelectFolder, enabled = !state.isSharing)
|
||||||
QuietButton(stringResource(Res.string.button_clear), onClick = onClearFile, enabled = !state.isSharing)
|
QuietButton(stringResource(Res.string.button_clear), onClick = onClearFile, enabled = !state.isSharing)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -199,7 +207,11 @@ private fun SelectedFileCard(
|
|||||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||||
Text(file.displayName, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
Text(file.displayName, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
Text(
|
Text(
|
||||||
file.sizeBytes?.let(::formatBytes) ?: stringResource(Res.string.send_file_size_unknown),
|
when {
|
||||||
|
file.isDirectory -> stringResource(Res.string.send_folder_label)
|
||||||
|
file.sizeBytes != null -> formatBytes(file.sizeBytes)
|
||||||
|
else -> stringResource(Res.string.send_file_size_unknown)
|
||||||
|
},
|
||||||
color = LocalVniDropColors.current.foregroundLighter,
|
color = LocalVniDropColors.current.foregroundLighter,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -246,6 +246,24 @@ class ViewModelsTest {
|
|||||||
assertTrue(viewModel.state.value.selectedFiles.isEmpty())
|
assertTrue(viewModel.state.value.selectedFiles.isEmpty())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sendViewModelNamesFolderSelectionAfterFolderDisplayName() = runTest {
|
||||||
|
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||||
|
val viewModel = SendViewModel(FakeCoreGateway(), FakeFileSystemService(folder), preferences(), FakeFilePreviewRepository(), UiMessageController())
|
||||||
|
advanceUntilIdle()
|
||||||
|
viewModel.onFilesPicked(
|
||||||
|
listOf(
|
||||||
|
com.vnidrop.app.core.PickedShareFile(
|
||||||
|
value = "/tmp/photos",
|
||||||
|
displayName = "photos",
|
||||||
|
isDirectory = true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertEquals("photos", viewModel.state.value.transferName)
|
||||||
|
assertTrue(viewModel.state.value.selectedFiles.single().isDirectory)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun sendDeletionRemovesCoreTransferAndOwnedPreview() = runTest {
|
fun sendDeletionRemovesCoreTransferAndOwnedPreview() = runTest {
|
||||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||||
|
|||||||
@@ -46,6 +46,32 @@ actual fun rememberShareFilePicker(
|
|||||||
picker.modalPresentationStyle = UIModalPresentationFormSheet
|
picker.modalPresentationStyle = UIModalPresentationFormSheet
|
||||||
presenter.presentViewController(picker, animated = true, completion = null)
|
presenter.presentViewController(picker, animated = true, completion = null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
|
override fun pickFolder() {
|
||||||
|
val presenter = UIApplication.sharedApplication.keyWindow?.rootViewController
|
||||||
|
if (presenter == null) {
|
||||||
|
onError("Could not find an iOS view controller for the folder picker")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val picker = UIDocumentPickerViewController(forOpeningContentTypes = listOf(UTTypeFolder), asCopy = false)
|
||||||
|
val delegate = DocumentPickerDelegate(
|
||||||
|
onFilesPicked = { folders ->
|
||||||
|
val folder = folders.firstOrNull() ?: return@DocumentPickerDelegate
|
||||||
|
onFilesPicked(
|
||||||
|
listOf(
|
||||||
|
folder.copy(isDirectory = true),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onError = onError,
|
||||||
|
forceDirectory = true,
|
||||||
|
)
|
||||||
|
retainedPickerDelegate = delegate
|
||||||
|
picker.delegate = delegate
|
||||||
|
picker.modalPresentationStyle = UIModalPresentationFormSheet
|
||||||
|
presenter.presentViewController(picker, animated = true, completion = null)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,6 +114,7 @@ actual fun rememberReceiveFolderPicker(
|
|||||||
private class DocumentPickerDelegate(
|
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,
|
||||||
) : 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 ->
|
||||||
@@ -95,8 +122,12 @@ private class DocumentPickerDelegate(
|
|||||||
val displayName = url.lastPathComponent ?: "transfer"
|
val displayName = url.lastPathComponent ?: "transfer"
|
||||||
val didStartAccess = url.startAccessingSecurityScopedResource()
|
val didStartAccess = url.startAccessingSecurityScopedResource()
|
||||||
val sizeBytes = try {
|
val sizeBytes = try {
|
||||||
val attributes = url.path?.let { NSFileManager.defaultManager.attributesOfItemAtPath(it, null) }
|
if (forceDirectory) {
|
||||||
(attributes?.get(NSFileSize) as? NSNumber)?.unsignedLongLongValue
|
null
|
||||||
|
} else {
|
||||||
|
val attributes = url.path?.let { NSFileManager.defaultManager.attributesOfItemAtPath(it, null) }
|
||||||
|
(attributes?.get(NSFileSize) as? NSNumber)?.unsignedLongLongValue
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (didStartAccess) url.stopAccessingSecurityScopedResource()
|
if (didStartAccess) url.stopAccessingSecurityScopedResource()
|
||||||
}
|
}
|
||||||
@@ -105,6 +136,7 @@ private class DocumentPickerDelegate(
|
|||||||
displayName,
|
displayName,
|
||||||
sizeBytes,
|
sizeBytes,
|
||||||
nativeFileIcon(url),
|
nativeFileIcon(url),
|
||||||
|
isDirectory = forceDirectory,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (files.isEmpty()) {
|
if (files.isEmpty()) {
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ private class IosFileSystemService : FileSystemService {
|
|||||||
kind = uniffi.vnidrop.SourceKind.IOS_SECURITY_SCOPED_URL,
|
kind = uniffi.vnidrop.SourceKind.IOS_SECURITY_SCOPED_URL,
|
||||||
value = file.value,
|
value = file.value,
|
||||||
displayName = file.displayName,
|
displayName = file.displayName,
|
||||||
isDirectory = false,
|
isDirectory = file.isDirectory,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return repository.shareSources(sources, transferName, senderName, accessPolicy)
|
return repository.shareSources(sources, transferName, senderName, accessPolicy)
|
||||||
|
|||||||
@@ -25,6 +25,23 @@ actual fun rememberShareFilePicker(
|
|||||||
if (selected.isNotEmpty()) onFilesPicked(selected)
|
if (selected.isNotEmpty()) onFilesPicked(selected)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun pickFolder() {
|
||||||
|
openPicker(onError) {
|
||||||
|
val selected = pickDirectory(title = "Select folder to share") ?: return@openPicker
|
||||||
|
onFilesPicked(
|
||||||
|
listOf(
|
||||||
|
PickedShareFile(
|
||||||
|
value = selected.absolutePath,
|
||||||
|
displayName = selected.name.ifBlank { selected.absolutePath },
|
||||||
|
sizeBytes = null,
|
||||||
|
thumbnailBytes = selected.systemIconPng(),
|
||||||
|
isDirectory = true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,7 +53,7 @@ actual fun rememberReceiveFolderPicker(
|
|||||||
object : ReceiveFolderPicker {
|
object : ReceiveFolderPicker {
|
||||||
override fun pickFolder() {
|
override fun pickFolder() {
|
||||||
openPicker(onError) {
|
openPicker(onError) {
|
||||||
val selected = pickDirectory() ?: return@openPicker
|
val selected = pickDirectory(title = "Select receive folder") ?: return@openPicker
|
||||||
onFolderPicked(
|
onFolderPicked(
|
||||||
ReceiveFolder(
|
ReceiveFolder(
|
||||||
kind = ReceiveFolderKind.FileSystemPath,
|
kind = ReceiveFolderKind.FileSystemPath,
|
||||||
@@ -111,10 +128,10 @@ private fun File.systemIconPng(): ByteArray? = runCatching {
|
|||||||
}
|
}
|
||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
|
|
||||||
private fun pickDirectory(): File? =
|
private fun pickDirectory(title: String): File? =
|
||||||
if (isMacOs()) {
|
if (isMacOs()) {
|
||||||
val dialog = withMacDirectoryDialog {
|
val dialog = withMacDirectoryDialog {
|
||||||
nativeFileDialog("Select receive folder").apply { isVisible = true }
|
nativeFileDialog(title).apply { isVisible = true }
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
val directory = dialog.directory ?: return null
|
val directory = dialog.directory ?: return null
|
||||||
@@ -126,7 +143,7 @@ private fun pickDirectory(): File? =
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
val chooser = JFileChooser().apply {
|
val chooser = JFileChooser().apply {
|
||||||
dialogTitle = "Select receive folder"
|
dialogTitle = title
|
||||||
fileSelectionMode = JFileChooser.DIRECTORIES_ONLY
|
fileSelectionMode = JFileChooser.DIRECTORIES_ONLY
|
||||||
isAcceptAllFileFilterUsed = false
|
isAcceptAllFileFilterUsed = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ private class JvmFileSystemService : FileSystemService {
|
|||||||
kind = uniffi.vnidrop.SourceKind.PATH,
|
kind = uniffi.vnidrop.SourceKind.PATH,
|
||||||
value = file.value,
|
value = file.value,
|
||||||
displayName = file.displayName,
|
displayName = file.displayName,
|
||||||
isDirectory = false,
|
isDirectory = file.isDirectory || File(file.value).isDirectory,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return repository.shareSources(sources, transferName, senderName, accessPolicy)
|
return repository.shareSources(sources, transferName, senderName, accessPolicy)
|
||||||
|
|||||||
Reference in New Issue
Block a user