mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 02:29:55 +02:00
Merge pull request #8 from vnidrop/feat/step3-multifile-receivers
feat(send): multi-file shares and receivers polish
This commit is contained in:
@@ -16,18 +16,22 @@ import androidx.compose.ui.platform.LocalContext
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
actual fun rememberShareFilePicker(
|
actual fun rememberShareFilePicker(
|
||||||
onFilePicked: (PickedShareFile) -> Unit,
|
onFilesPicked: (List<PickedShareFile>) -> Unit,
|
||||||
onError: (String) -> Unit,
|
onError: (String) -> Unit,
|
||||||
): ShareFilePicker {
|
): ShareFilePicker {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris ->
|
||||||
if (uri != null) {
|
if (uris.isEmpty()) return@rememberLauncherForActivityResult
|
||||||
onFilePicked(context.pickedShareFile(uri))
|
runCatching {
|
||||||
}
|
uris.map { uri -> context.pickedShareFile(uri) }
|
||||||
|
}.fold(
|
||||||
|
onSuccess = onFilesPicked,
|
||||||
|
onFailure = { onError(it.message ?: "Could not open the selected files") },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return remember(launcher) {
|
return remember(launcher) {
|
||||||
object : ShareFilePicker {
|
object : ShareFilePicker {
|
||||||
override fun pickFile() {
|
override fun pickFiles() {
|
||||||
launcher.launch(arrayOf("*/*"))
|
launcher.launch(arrayOf("*/*"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,22 +63,30 @@ private class AndroidFileSystemService(
|
|||||||
ReceiveFolderKind.IosSecurityScopedUrl -> null
|
ReceiveFolderKind.IosSecurityScopedUrl -> null
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun sharePickedFile(
|
override suspend fun sharePickedFiles(
|
||||||
repository: CoreGateway,
|
repository: CoreGateway,
|
||||||
file: PickedShareFile,
|
files: List<PickedShareFile>,
|
||||||
transferName: String,
|
transferName: String,
|
||||||
senderName: String,
|
senderName: String,
|
||||||
accessPolicy: ShareAccessPolicy,
|
accessPolicy: ShareAccessPolicy,
|
||||||
): Result<Share> = runCatching {
|
): Result<Share> = runCatching {
|
||||||
context.contentResolver.openFileDescriptor(Uri.parse(file.value), "r").use { descriptor ->
|
require(files.isNotEmpty()) { "Select at least one file to share" }
|
||||||
checkNotNull(descriptor) { "Could not open selected file descriptor" }
|
val descriptors = files.map { file ->
|
||||||
repository.shareFileDescriptor(
|
context.contentResolver.openFileDescriptor(Uri.parse(file.value), "r")
|
||||||
fd = descriptor.fd,
|
?: error("Could not open selected file descriptor for ${file.displayName}")
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
val sources = files.zip(descriptors) { file, descriptor ->
|
||||||
|
uniffi.vnidrop.ShareSource(
|
||||||
|
kind = uniffi.vnidrop.SourceKind.FILE_DESCRIPTOR,
|
||||||
|
value = descriptor.fd.toString(),
|
||||||
displayName = file.displayName,
|
displayName = file.displayName,
|
||||||
transferName = transferName,
|
isDirectory = false,
|
||||||
senderName = senderName,
|
)
|
||||||
accessPolicy = accessPolicy,
|
}
|
||||||
).getOrThrow()
|
repository.shareSources(sources, transferName, senderName, accessPolicy).getOrThrow()
|
||||||
|
} finally {
|
||||||
|
descriptors.forEach { it.close() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,11 @@
|
|||||||
<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 file from this device. You can review its details before creating the transfer.</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_selected_files_count">%1$d files selected</string>
|
||||||
|
<string name="button_remove_file">Remove file</string>
|
||||||
|
<string name="button_choose_files">Choose files</string>
|
||||||
|
<string name="button_change_files">Change files</string>
|
||||||
<string name="send_review_title">Review transfer</string>
|
<string name="send_review_title">Review transfer</string>
|
||||||
<string name="send_access_title">Who can receive it?</string>
|
<string name="send_access_title">Who can receive it?</string>
|
||||||
<string name="send_access_approval">Ask before each download</string>
|
<string name="send_access_approval">Ask before each download</string>
|
||||||
@@ -58,7 +62,7 @@
|
|||||||
<string name="transfer_event_updated">Transfer updated</string>
|
<string name="transfer_event_updated">Transfer updated</string>
|
||||||
<string name="button_choose_file">Choose file</string>
|
<string name="button_choose_file">Choose file</string>
|
||||||
<string name="button_change_file">Change file</string>
|
<string name="button_change_file">Change file</string>
|
||||||
<string name="button_share_file">Share file</string>
|
<string name="button_share_file">Share files</string>
|
||||||
<string name="button_sharing_file">Preparing transfer…</string>
|
<string name="button_sharing_file">Preparing transfer…</string>
|
||||||
<string name="button_copy_ticket">Copy transfer link</string>
|
<string name="button_copy_ticket">Copy transfer link</string>
|
||||||
<string name="send_new_transfer_description">Create a new transfer</string>
|
<string name="send_new_transfer_description">Create a new transfer</string>
|
||||||
|
|||||||
@@ -142,6 +142,13 @@ interface CoreGateway {
|
|||||||
senderName: String,
|
senderName: String,
|
||||||
accessPolicy: ShareAccessPolicy,
|
accessPolicy: ShareAccessPolicy,
|
||||||
): Result<Share>
|
): Result<Share>
|
||||||
|
/** Multi-source share used by multi-file pickers. */
|
||||||
|
suspend fun shareSources(
|
||||||
|
sources: List<uniffi.vnidrop.ShareSource>,
|
||||||
|
transferName: String,
|
||||||
|
senderName: String,
|
||||||
|
accessPolicy: ShareAccessPolicy,
|
||||||
|
): Result<Share>
|
||||||
suspend fun inspectTicket(ticket: String): Result<TicketInspectionModel>
|
suspend fun inspectTicket(ticket: String): Result<TicketInspectionModel>
|
||||||
suspend fun receive(ticket: String, outputDir: String, receiverName: String): Result<Unit>
|
suspend fun receive(ticket: String, outputDir: String, receiverName: String): Result<Unit>
|
||||||
suspend fun receiveWithOutputSink(ticket: String, outputSink: ReceiveOutputSink, receiverName: String): Result<Unit>
|
suspend fun receiveWithOutputSink(ticket: String, outputSink: ReceiveOutputSink, receiverName: String): Result<Unit>
|
||||||
|
|||||||
@@ -197,12 +197,13 @@ class CoreRepository(
|
|||||||
|
|
||||||
override suspend fun refresh(): Result<Unit> = runCore { refreshSnapshot() }
|
override suspend fun refresh(): Result<Unit> = runCore { refreshSnapshot() }
|
||||||
|
|
||||||
private suspend fun shareSources(
|
override suspend fun shareSources(
|
||||||
sources: List<ShareSource>,
|
sources: List<ShareSource>,
|
||||||
transferName: String,
|
transferName: String,
|
||||||
senderName: String,
|
senderName: String,
|
||||||
accessPolicy: ShareAccessPolicy,
|
accessPolicy: ShareAccessPolicy,
|
||||||
): Result<Share> = runCore {
|
): Result<Share> = runCore {
|
||||||
|
require(sources.isNotEmpty()) { "Select at least one file to share" }
|
||||||
withPlatformPathAccess(sources) {
|
withPlatformPathAccess(sources) {
|
||||||
requireCore().shareFiles(
|
requireCore().shareFiles(
|
||||||
sources = sources,
|
sources = sources,
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ data class PickedShareFile(
|
|||||||
)
|
)
|
||||||
|
|
||||||
interface ShareFilePicker {
|
interface ShareFilePicker {
|
||||||
fun pickFile()
|
/** Opens a platform picker that may return one or more files. */
|
||||||
|
fun pickFiles()
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ReceiveFolderPicker {
|
interface ReceiveFolderPicker {
|
||||||
@@ -19,7 +20,7 @@ interface ReceiveFolderPicker {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
expect fun rememberShareFilePicker(
|
expect fun rememberShareFilePicker(
|
||||||
onFilePicked: (PickedShareFile) -> Unit,
|
onFilesPicked: (List<PickedShareFile>) -> Unit,
|
||||||
onError: (String) -> Unit,
|
onError: (String) -> Unit,
|
||||||
): ShareFilePicker
|
): ShareFilePicker
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,14 @@ interface FileSystemService {
|
|||||||
transferName: String,
|
transferName: String,
|
||||||
senderName: String,
|
senderName: String,
|
||||||
accessPolicy: ShareAccessPolicy,
|
accessPolicy: ShareAccessPolicy,
|
||||||
|
): Result<Share> = sharePickedFiles(repository, listOf(file), transferName, senderName, accessPolicy)
|
||||||
|
|
||||||
|
suspend fun sharePickedFiles(
|
||||||
|
repository: CoreGateway,
|
||||||
|
files: List<PickedShareFile>,
|
||||||
|
transferName: String,
|
||||||
|
senderName: String,
|
||||||
|
accessPolicy: ShareAccessPolicy,
|
||||||
): Result<Share>
|
): Result<Share>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,13 +17,13 @@ fun SendRoute(
|
|||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
val coreState by viewModel.coreState.collectAsStateWithLifecycle()
|
val coreState by viewModel.coreState.collectAsStateWithLifecycle()
|
||||||
val clipboard = LocalClipboardManager.current
|
val clipboard = LocalClipboardManager.current
|
||||||
val picker = rememberShareFilePicker(viewModel::onFilePicked, viewModel::onFilePickFailed)
|
val picker = rememberShareFilePicker(viewModel::onFilesPicked, viewModel::onFilePickFailed)
|
||||||
val shareActions = rememberTransferShareActions()
|
val shareActions = rememberTransferShareActions()
|
||||||
|
|
||||||
LaunchedEffect(viewModel) {
|
LaunchedEffect(viewModel) {
|
||||||
viewModel.effectFlow.collect { effect ->
|
viewModel.effectFlow.collect { effect ->
|
||||||
when (effect) {
|
when (effect) {
|
||||||
SendEffect.OpenFilePicker -> picker.pickFile()
|
SendEffect.OpenFilePicker -> picker.pickFiles()
|
||||||
is SendEffect.CopyTicket -> clipboard.setText(AnnotatedString(effect.ticket))
|
is SendEffect.CopyTicket -> clipboard.setText(AnnotatedString(effect.ticket))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -38,6 +38,7 @@ fun SendRoute(
|
|||||||
onDismissComposer = viewModel::dismissComposer,
|
onDismissComposer = viewModel::dismissComposer,
|
||||||
onSelectFile = viewModel::selectFile,
|
onSelectFile = viewModel::selectFile,
|
||||||
onClearFile = viewModel::clearSelectedSource,
|
onClearFile = viewModel::clearSelectedSource,
|
||||||
|
onRemoveFile = viewModel::removeSelectedFile,
|
||||||
onTransferNameChanged = viewModel::setTransferName,
|
onTransferNameChanged = viewModel::setTransferName,
|
||||||
onSenderNameChanged = viewModel::setSenderName,
|
onSenderNameChanged = viewModel::setSenderName,
|
||||||
onAccessPolicyChanged = viewModel::setAccessPolicy,
|
onAccessPolicyChanged = viewModel::setAccessPolicy,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ fun SendScreen(
|
|||||||
onDismissComposer: () -> Unit,
|
onDismissComposer: () -> Unit,
|
||||||
onSelectFile: () -> Unit,
|
onSelectFile: () -> Unit,
|
||||||
onClearFile: () -> Unit,
|
onClearFile: () -> Unit,
|
||||||
|
onRemoveFile: (String) -> Unit = {},
|
||||||
onTransferNameChanged: (String) -> Unit,
|
onTransferNameChanged: (String) -> Unit,
|
||||||
onSenderNameChanged: (String) -> Unit,
|
onSenderNameChanged: (String) -> Unit,
|
||||||
onAccessPolicyChanged: (ShareAccessPolicy) -> Unit,
|
onAccessPolicyChanged: (ShareAccessPolicy) -> Unit,
|
||||||
@@ -83,6 +84,7 @@ fun SendScreen(
|
|||||||
windowClass = windowClass,
|
windowClass = windowClass,
|
||||||
onSelectFile = onSelectFile,
|
onSelectFile = onSelectFile,
|
||||||
onClearFile = onClearFile,
|
onClearFile = onClearFile,
|
||||||
|
onRemoveFile = onRemoveFile,
|
||||||
onTransferNameChanged = onTransferNameChanged,
|
onTransferNameChanged = onTransferNameChanged,
|
||||||
onSenderNameChanged = onSenderNameChanged,
|
onSenderNameChanged = onSenderNameChanged,
|
||||||
onAccessPolicyChanged = onAccessPolicyChanged,
|
onAccessPolicyChanged = onAccessPolicyChanged,
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import vnidrop.shared.generated.resources.transfer_nfc_written
|
|||||||
|
|
||||||
data class SendState(
|
data class SendState(
|
||||||
val isComposerOpen: Boolean = false,
|
val isComposerOpen: Boolean = false,
|
||||||
val selectedFile: PickedShareFile? = null,
|
val selectedFiles: List<PickedShareFile> = emptyList(),
|
||||||
val transferName: String = "",
|
val transferName: String = "",
|
||||||
val senderName: String = "",
|
val senderName: String = "",
|
||||||
val accessPolicy: ShareAccessPolicy = ShareAccessPolicy.RequireApproval,
|
val accessPolicy: ShareAccessPolicy = ShareAccessPolicy.RequireApproval,
|
||||||
@@ -42,8 +42,12 @@ data class SendState(
|
|||||||
val isDeleteConfirmationOpen: Boolean = false,
|
val isDeleteConfirmationOpen: Boolean = false,
|
||||||
val isDeleting: Boolean = false,
|
val isDeleting: Boolean = false,
|
||||||
) {
|
) {
|
||||||
|
val selectedFile: PickedShareFile? get() = selectedFiles.singleOrNull()
|
||||||
|
val totalSelectedBytes: ULong
|
||||||
|
get() = selectedFiles.fold(0UL) { acc, file -> acc + (file.sizeBytes ?: 0UL) }
|
||||||
|
|
||||||
fun canCreateShare(coreInitialized: Boolean): Boolean =
|
fun canCreateShare(coreInitialized: Boolean): Boolean =
|
||||||
coreInitialized && selectedFile != null && transferName.isNotBlank() && !isSharing
|
coreInitialized && selectedFiles.isNotEmpty() && transferName.isNotBlank() && !isSharing
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class TransferDetailPanel { Activity, Receivers, Share }
|
enum class TransferDetailPanel { Activity, Receivers, Share }
|
||||||
@@ -111,7 +115,7 @@ class SendViewModel(
|
|||||||
_state.update {
|
_state.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
isComposerOpen = true,
|
isComposerOpen = true,
|
||||||
selectedFile = null,
|
selectedFiles = emptyList(),
|
||||||
transferName = "",
|
transferName = "",
|
||||||
accessPolicy = ShareAccessPolicy.RequireApproval,
|
accessPolicy = ShareAccessPolicy.RequireApproval,
|
||||||
)
|
)
|
||||||
@@ -123,7 +127,7 @@ class SendViewModel(
|
|||||||
_state.update {
|
_state.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
isComposerOpen = false,
|
isComposerOpen = false,
|
||||||
selectedFile = null,
|
selectedFiles = emptyList(),
|
||||||
transferName = "",
|
transferName = "",
|
||||||
accessPolicy = ShareAccessPolicy.RequireApproval,
|
accessPolicy = ShareAccessPolicy.RequireApproval,
|
||||||
)
|
)
|
||||||
@@ -132,12 +136,13 @@ class SendViewModel(
|
|||||||
|
|
||||||
fun selectFile() = sendEffect(SendEffect.OpenFilePicker)
|
fun selectFile() = sendEffect(SendEffect.OpenFilePicker)
|
||||||
|
|
||||||
fun onFilePicked(file: PickedShareFile) {
|
fun onFilesPicked(files: List<PickedShareFile>) {
|
||||||
|
if (files.isEmpty()) return
|
||||||
_state.update {
|
_state.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
isComposerOpen = true,
|
isComposerOpen = true,
|
||||||
selectedFile = file,
|
selectedFiles = files,
|
||||||
transferName = file.displayName,
|
transferName = defaultTransferName(files),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -145,7 +150,21 @@ class SendViewModel(
|
|||||||
fun onFilePickFailed(reason: String) = messages.error(IllegalStateException(reason))
|
fun onFilePickFailed(reason: String) = messages.error(IllegalStateException(reason))
|
||||||
|
|
||||||
fun clearSelectedSource() {
|
fun clearSelectedSource() {
|
||||||
_state.update { it.copy(selectedFile = null, transferName = "") }
|
_state.update { it.copy(selectedFiles = emptyList(), transferName = "") }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeSelectedFile(value: String) {
|
||||||
|
_state.update { current ->
|
||||||
|
val remaining = current.selectedFiles.filterNot { it.value == value }
|
||||||
|
current.copy(
|
||||||
|
selectedFiles = remaining,
|
||||||
|
transferName = when {
|
||||||
|
remaining.isEmpty() -> ""
|
||||||
|
current.transferName == defaultTransferName(current.selectedFiles) -> defaultTransferName(remaining)
|
||||||
|
else -> current.transferName
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setTransferName(value: String) = _state.update { it.copy(transferName = value) }
|
fun setTransferName(value: String) = _state.update { it.copy(transferName = value) }
|
||||||
@@ -218,24 +237,25 @@ class SendViewModel(
|
|||||||
|
|
||||||
fun createShare() {
|
fun createShare() {
|
||||||
val current = state.value
|
val current = state.value
|
||||||
val file = current.selectedFile ?: return
|
if (current.selectedFiles.isEmpty()) return
|
||||||
if (!current.canCreateShare(coreState.value.isInitialized)) return
|
if (!current.canCreateShare(coreState.value.isInitialized)) return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_state.update { it.copy(isSharing = true) }
|
_state.update { it.copy(isSharing = true) }
|
||||||
val result = fileSystemService.sharePickedFile(
|
val result = fileSystemService.sharePickedFiles(
|
||||||
repository = repository,
|
repository = repository,
|
||||||
file = file,
|
files = current.selectedFiles,
|
||||||
transferName = current.transferName.trim(),
|
transferName = current.transferName.trim(),
|
||||||
senderName = current.senderName.trim(),
|
senderName = current.senderName.trim(),
|
||||||
accessPolicy = current.accessPolicy,
|
accessPolicy = current.accessPolicy,
|
||||||
)
|
)
|
||||||
result.fold(
|
result.fold(
|
||||||
onSuccess = { share ->
|
onSuccess = { share ->
|
||||||
file.thumbnailBytes?.let { filePreviewRepository.save(share.transferId, it) }
|
current.selectedFiles.firstNotNullOfOrNull { it.thumbnailBytes }
|
||||||
|
?.let { filePreviewRepository.save(share.transferId, it) }
|
||||||
_state.update {
|
_state.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
isComposerOpen = false,
|
isComposerOpen = false,
|
||||||
selectedFile = null,
|
selectedFiles = emptyList(),
|
||||||
transferName = "",
|
transferName = "",
|
||||||
accessPolicy = ShareAccessPolicy.RequireApproval,
|
accessPolicy = ShareAccessPolicy.RequireApproval,
|
||||||
isSharing = false,
|
isSharing = false,
|
||||||
@@ -251,6 +271,12 @@ class SendViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun defaultTransferName(files: List<PickedShareFile>): String = when (files.size) {
|
||||||
|
0 -> ""
|
||||||
|
1 -> files.first().displayName
|
||||||
|
else -> "${files.size} files"
|
||||||
|
}
|
||||||
|
|
||||||
private fun sendEffect(effect: SendEffect) {
|
private fun sendEffect(effect: SendEffect) {
|
||||||
viewModelScope.launch { effects.send(effect) }
|
viewModelScope.launch { effects.send(effect) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import androidx.compose.foundation.selection.selectable
|
|||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.RadioButton
|
import androidx.compose.material3.RadioButton
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
@@ -39,9 +40,10 @@ import com.vnidrop.app.ui.state.formatBytes
|
|||||||
import com.vnidrop.app.ui.theme.LocalVniDropColors
|
import com.vnidrop.app.ui.theme.LocalVniDropColors
|
||||||
import org.jetbrains.compose.resources.stringResource
|
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_file
|
import vnidrop.shared.generated.resources.button_change_files
|
||||||
import vnidrop.shared.generated.resources.button_choose_file
|
import vnidrop.shared.generated.resources.button_choose_files
|
||||||
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_share_file
|
import vnidrop.shared.generated.resources.button_share_file
|
||||||
import vnidrop.shared.generated.resources.button_sharing_file
|
import vnidrop.shared.generated.resources.button_sharing_file
|
||||||
import vnidrop.shared.generated.resources.field_sender_name
|
import vnidrop.shared.generated.resources.field_sender_name
|
||||||
@@ -55,6 +57,7 @@ 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_review_title
|
import vnidrop.shared.generated.resources.send_review_title
|
||||||
|
import vnidrop.shared.generated.resources.send_selected_files_count
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun TransferComposer(
|
internal fun TransferComposer(
|
||||||
@@ -63,6 +66,7 @@ internal fun TransferComposer(
|
|||||||
windowClass: WindowClass,
|
windowClass: WindowClass,
|
||||||
onSelectFile: () -> Unit,
|
onSelectFile: () -> Unit,
|
||||||
onClearFile: () -> Unit,
|
onClearFile: () -> Unit,
|
||||||
|
onRemoveFile: (String) -> Unit,
|
||||||
onTransferNameChanged: (String) -> Unit,
|
onTransferNameChanged: (String) -> Unit,
|
||||||
onSenderNameChanged: (String) -> Unit,
|
onSenderNameChanged: (String) -> Unit,
|
||||||
onAccessPolicyChanged: (ShareAccessPolicy) -> Unit,
|
onAccessPolicyChanged: (ShareAccessPolicy) -> Unit,
|
||||||
@@ -72,16 +76,15 @@ internal fun TransferComposer(
|
|||||||
modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(horizontal = 20.dp, vertical = 12.dp),
|
modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(horizontal = 20.dp, vertical = 12.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
) {
|
) {
|
||||||
val file = state.selectedFile
|
if (state.selectedFiles.isEmpty()) {
|
||||||
if (file == null) {
|
|
||||||
ChooseFileStep(onSelectFile)
|
ChooseFileStep(onSelectFile)
|
||||||
} else {
|
} else {
|
||||||
ReviewFileStep(
|
ReviewFileStep(
|
||||||
file = file,
|
|
||||||
state = state,
|
state = state,
|
||||||
windowClass = windowClass,
|
windowClass = windowClass,
|
||||||
onSelectFile = onSelectFile,
|
onSelectFile = onSelectFile,
|
||||||
onClearFile = onClearFile,
|
onClearFile = onClearFile,
|
||||||
|
onRemoveFile = onRemoveFile,
|
||||||
onTransferNameChanged = onTransferNameChanged,
|
onTransferNameChanged = onTransferNameChanged,
|
||||||
onSenderNameChanged = onSenderNameChanged,
|
onSenderNameChanged = onSenderNameChanged,
|
||||||
onAccessPolicyChanged = onAccessPolicyChanged,
|
onAccessPolicyChanged = onAccessPolicyChanged,
|
||||||
@@ -107,18 +110,18 @@ private fun ChooseFileStep(onSelectFile: () -> Unit) {
|
|||||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
) {
|
) {
|
||||||
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_file), onClick = onSelectFile)
|
PrimaryButton(stringResource(Res.string.button_choose_files), onClick = onSelectFile)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ReviewFileStep(
|
private fun ReviewFileStep(
|
||||||
file: PickedShareFile,
|
|
||||||
state: SendState,
|
state: SendState,
|
||||||
windowClass: WindowClass,
|
windowClass: WindowClass,
|
||||||
onSelectFile: () -> Unit,
|
onSelectFile: () -> Unit,
|
||||||
onClearFile: () -> Unit,
|
onClearFile: () -> Unit,
|
||||||
|
onRemoveFile: (String) -> Unit,
|
||||||
onTransferNameChanged: (String) -> Unit,
|
onTransferNameChanged: (String) -> Unit,
|
||||||
onSenderNameChanged: (String) -> Unit,
|
onSenderNameChanged: (String) -> Unit,
|
||||||
onAccessPolicyChanged: (ShareAccessPolicy) -> Unit,
|
onAccessPolicyChanged: (ShareAccessPolicy) -> Unit,
|
||||||
@@ -126,7 +129,20 @@ private fun ReviewFileStep(
|
|||||||
coreInitialized: Boolean,
|
coreInitialized: Boolean,
|
||||||
) {
|
) {
|
||||||
Text(stringResource(Res.string.send_review_title), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
|
Text(stringResource(Res.string.send_review_title), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
|
||||||
SelectedFileCard(file)
|
if (state.selectedFiles.size > 1) {
|
||||||
|
Text(
|
||||||
|
stringResource(Res.string.send_selected_files_count, state.selectedFiles.size),
|
||||||
|
color = LocalVniDropColors.current.foregroundLighter,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
state.selectedFiles.forEach { file ->
|
||||||
|
SelectedFileCard(
|
||||||
|
file = file,
|
||||||
|
canRemove = state.selectedFiles.size > 1 && !state.isSharing,
|
||||||
|
onRemove = { onRemoveFile(file.value) },
|
||||||
|
)
|
||||||
|
}
|
||||||
Field(state.transferName, onTransferNameChanged, stringResource(Res.string.field_transfer_name))
|
Field(state.transferName, onTransferNameChanged, stringResource(Res.string.field_transfer_name))
|
||||||
Field(state.senderName, onSenderNameChanged, stringResource(Res.string.field_sender_name))
|
Field(state.senderName, onSenderNameChanged, stringResource(Res.string.field_sender_name))
|
||||||
Text(stringResource(Res.string.send_access_title), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
Text(stringResource(Res.string.send_access_title), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||||
@@ -147,12 +163,12 @@ private fun ReviewFileStep(
|
|||||||
if (windowClass == WindowClass.Phone) {
|
if (windowClass == WindowClass.Phone) {
|
||||||
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_file), onClick = onSelectFile, modifier = Modifier.fillMaxWidth(), enabled = !state.isSharing)
|
QuietButton(stringResource(Res.string.button_change_files), onClick = onSelectFile, 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_file), onClick = onSelectFile, enabled = !state.isSharing)
|
QuietButton(stringResource(Res.string.button_change_files), onClick = onSelectFile, 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -169,7 +185,11 @@ private fun ShareButton(state: SendState, coreInitialized: Boolean, onCreateShar
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SelectedFileCard(file: PickedShareFile) {
|
private fun SelectedFileCard(
|
||||||
|
file: PickedShareFile,
|
||||||
|
canRemove: Boolean,
|
||||||
|
onRemove: () -> Unit,
|
||||||
|
) {
|
||||||
Surface(shape = RoundedCornerShape(14.dp), color = LocalVniDropColors.current.backgroundSurface200) {
|
Surface(shape = RoundedCornerShape(14.dp), color = LocalVniDropColors.current.backgroundSurface200) {
|
||||||
Row(modifier = Modifier.fillMaxWidth().padding(14.dp), verticalAlignment = Alignment.CenterVertically) {
|
Row(modifier = Modifier.fillMaxWidth().padding(14.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
Box(Modifier.size(44.dp).background(LocalVniDropColors.current.backgroundSurface300, RoundedCornerShape(11.dp))) {
|
Box(Modifier.size(44.dp).background(LocalVniDropColors.current.backgroundSurface300, RoundedCornerShape(11.dp))) {
|
||||||
@@ -184,6 +204,11 @@ private fun SelectedFileCard(file: PickedShareFile) {
|
|||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (canRemove) {
|
||||||
|
IconButton(onClick = onRemove) {
|
||||||
|
Icon(SendIcons.Delete, stringResource(Res.string.button_remove_file), tint = LocalVniDropColors.current.destructiveDefault)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -181,6 +181,9 @@ private fun ReceiverRow(receiver: ReceiverRequestModel) {
|
|||||||
Text(it, color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodySmall)
|
Text(it, color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodySmall)
|
||||||
}
|
}
|
||||||
Text(receiverStatusText(receiver.status), color = receiverStatusColor(receiver.status), style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.Medium)
|
Text(receiverStatusText(receiver.status), color = receiverStatusColor(receiver.status), style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.Medium)
|
||||||
|
receiver.reason?.takeIf { it.isNotBlank() }?.let { reason ->
|
||||||
|
Text(reason, color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ class ViewModelsTest {
|
|||||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||||
val viewModel = SendViewModel(FakeCoreGateway(), FakeFileSystemService(folder), preferences(), FakeFilePreviewRepository(), UiMessageController())
|
val viewModel = SendViewModel(FakeCoreGateway(), FakeFileSystemService(folder), preferences(), FakeFilePreviewRepository(), UiMessageController())
|
||||||
viewModel.openComposer()
|
viewModel.openComposer()
|
||||||
viewModel.onFilePicked(com.vnidrop.app.core.PickedShareFile("/tmp/photo.jpg", "photo.jpg", 42UL))
|
viewModel.onFilesPicked(listOf(com.vnidrop.app.core.PickedShareFile("/tmp/photo.jpg", "photo.jpg", 42UL)))
|
||||||
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()
|
||||||
@@ -195,7 +195,7 @@ class ViewModelsTest {
|
|||||||
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.onFilePicked(com.vnidrop.app.core.PickedShareFile("/tmp/photo.jpg", "photo.jpg", 42UL, thumbnail))
|
viewModel.onFilesPicked(listOf(com.vnidrop.app.core.PickedShareFile("/tmp/photo.jpg", "photo.jpg", 42UL, thumbnail)))
|
||||||
viewModel.setAccessPolicy(ShareAccessPolicy.AnyoneWithTransfer)
|
viewModel.setAccessPolicy(ShareAccessPolicy.AnyoneWithTransfer)
|
||||||
viewModel.createShare()
|
viewModel.createShare()
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
@@ -214,7 +214,7 @@ class ViewModelsTest {
|
|||||||
val viewModel = SendViewModel(core, FakeFileSystemService(folder), preferences(), FakeFilePreviewRepository(), UiMessageController())
|
val viewModel = SendViewModel(core, FakeFileSystemService(folder), preferences(), FakeFilePreviewRepository(), UiMessageController())
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
viewModel.openComposer()
|
viewModel.openComposer()
|
||||||
viewModel.onFilePicked(com.vnidrop.app.core.PickedShareFile("/tmp/photo.jpg", "photo.jpg", 42UL))
|
viewModel.onFilesPicked(listOf(com.vnidrop.app.core.PickedShareFile("/tmp/photo.jpg", "photo.jpg", 42UL)))
|
||||||
viewModel.createShare()
|
viewModel.createShare()
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
@@ -223,6 +223,29 @@ class ViewModelsTest {
|
|||||||
assertFalse(viewModel.state.value.isSharing)
|
assertFalse(viewModel.state.value.isSharing)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sendViewModelSupportsMultipleFilesAndDefaultName() = runTest {
|
||||||
|
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||||
|
val core = FakeCoreGateway().apply {
|
||||||
|
mutableState.value = CoreState(isInitialized = true)
|
||||||
|
shareResult = Result.success(Share(9UL, "ticket", "2 files", "hash", 2UL, 84UL))
|
||||||
|
}
|
||||||
|
val viewModel = SendViewModel(core, FakeFileSystemService(folder), preferences(), FakeFilePreviewRepository(), UiMessageController())
|
||||||
|
advanceUntilIdle()
|
||||||
|
viewModel.onFilesPicked(
|
||||||
|
listOf(
|
||||||
|
com.vnidrop.app.core.PickedShareFile("/tmp/a.jpg", "a.jpg", 40UL),
|
||||||
|
com.vnidrop.app.core.PickedShareFile("/tmp/b.jpg", "b.jpg", 44UL),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertEquals("2 files", viewModel.state.value.transferName)
|
||||||
|
assertEquals(2, viewModel.state.value.selectedFiles.size)
|
||||||
|
viewModel.createShare()
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertEquals(2, core.lastShareSourceCount)
|
||||||
|
assertTrue(viewModel.state.value.selectedFiles.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun sendDeletionRemovesCoreTransferAndOwnedPreview() = runTest {
|
fun sendDeletionRemovesCoreTransferAndOwnedPreview() = runTest {
|
||||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||||
|
|||||||
@@ -67,8 +67,43 @@ class FakeCoreGateway : CoreGateway {
|
|||||||
return Result.success(Unit)
|
return Result.success(Unit)
|
||||||
}
|
}
|
||||||
override fun shutdown() = Unit
|
override fun shutdown() = Unit
|
||||||
override suspend fun sharePath(path: String, transferName: String, senderName: String, accessPolicy: ShareAccessPolicy): Result<Share> {
|
var lastShareSourceCount: Int = 0
|
||||||
|
override suspend fun sharePath(path: String, transferName: String, senderName: String, accessPolicy: ShareAccessPolicy): Result<Share> =
|
||||||
|
shareSources(
|
||||||
|
sources = listOf(
|
||||||
|
uniffi.vnidrop.ShareSource(
|
||||||
|
kind = uniffi.vnidrop.SourceKind.PATH,
|
||||||
|
value = path,
|
||||||
|
displayName = path.substringAfterLast('/'),
|
||||||
|
isDirectory = false,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
transferName = transferName,
|
||||||
|
senderName = senderName,
|
||||||
|
accessPolicy = accessPolicy,
|
||||||
|
)
|
||||||
|
override suspend fun shareFileDescriptor(
|
||||||
|
fd: Int,
|
||||||
|
displayName: String,
|
||||||
|
transferName: String,
|
||||||
|
senderName: String,
|
||||||
|
accessPolicy: ShareAccessPolicy,
|
||||||
|
) = Result.failure<Share>(UnsupportedOperationException())
|
||||||
|
override suspend fun shareSecurityScopedFileUrl(
|
||||||
|
fileUrl: String,
|
||||||
|
displayName: String,
|
||||||
|
transferName: String,
|
||||||
|
senderName: String,
|
||||||
|
accessPolicy: ShareAccessPolicy,
|
||||||
|
) = Result.failure<Share>(UnsupportedOperationException())
|
||||||
|
override suspend fun shareSources(
|
||||||
|
sources: List<uniffi.vnidrop.ShareSource>,
|
||||||
|
transferName: String,
|
||||||
|
senderName: String,
|
||||||
|
accessPolicy: ShareAccessPolicy,
|
||||||
|
): Result<Share> {
|
||||||
lastShareAccessPolicy = accessPolicy
|
lastShareAccessPolicy = accessPolicy
|
||||||
|
lastShareSourceCount = sources.size
|
||||||
shareResult.onSuccess { share ->
|
shareResult.onSuccess { share ->
|
||||||
mutableState.value = mutableState.value.copy(
|
mutableState.value = mutableState.value.copy(
|
||||||
transfers = listOf(
|
transfers = listOf(
|
||||||
@@ -92,20 +127,6 @@ class FakeCoreGateway : CoreGateway {
|
|||||||
}
|
}
|
||||||
return shareResult
|
return shareResult
|
||||||
}
|
}
|
||||||
override suspend fun shareFileDescriptor(
|
|
||||||
fd: Int,
|
|
||||||
displayName: String,
|
|
||||||
transferName: String,
|
|
||||||
senderName: String,
|
|
||||||
accessPolicy: ShareAccessPolicy,
|
|
||||||
) = Result.failure<Share>(UnsupportedOperationException())
|
|
||||||
override suspend fun shareSecurityScopedFileUrl(
|
|
||||||
fileUrl: String,
|
|
||||||
displayName: String,
|
|
||||||
transferName: String,
|
|
||||||
senderName: String,
|
|
||||||
accessPolicy: ShareAccessPolicy,
|
|
||||||
) = Result.failure<Share>(UnsupportedOperationException())
|
|
||||||
override suspend fun inspectTicket(ticket: String) = inspectionResult
|
override suspend fun inspectTicket(ticket: String) = inspectionResult
|
||||||
override suspend fun receive(ticket: String, outputDir: String, receiverName: String): Result<Unit> {
|
override suspend fun receive(ticket: String, outputDir: String, receiverName: String): Result<Unit> {
|
||||||
receiveCount += 1
|
receiveCount += 1
|
||||||
@@ -202,13 +223,23 @@ class FakeFileSystemService(
|
|||||||
override fun defaultReceiveFolder() = folder
|
override fun defaultReceiveFolder() = folder
|
||||||
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 suspend fun sharePickedFile(
|
override suspend fun sharePickedFiles(
|
||||||
repository: CoreGateway,
|
repository: CoreGateway,
|
||||||
file: PickedShareFile,
|
files: List<PickedShareFile>,
|
||||||
transferName: String,
|
transferName: String,
|
||||||
senderName: String,
|
senderName: String,
|
||||||
accessPolicy: ShareAccessPolicy,
|
accessPolicy: ShareAccessPolicy,
|
||||||
) = repository.sharePath(file.value, transferName, senderName, accessPolicy)
|
): Result<Share> {
|
||||||
|
val sources = files.map { file ->
|
||||||
|
uniffi.vnidrop.ShareSource(
|
||||||
|
kind = uniffi.vnidrop.SourceKind.PATH,
|
||||||
|
value = file.value,
|
||||||
|
displayName = file.displayName,
|
||||||
|
isDirectory = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return repository.shareSources(sources, transferName, senderName, accessPolicy)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class FakeFilePreviewRepository : FilePreviewRepository {
|
class FakeFilePreviewRepository : FilePreviewRepository {
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ class AppUiModelsTest {
|
|||||||
@Test
|
@Test
|
||||||
fun sendStateExposesShareEligibility() {
|
fun sendStateExposesShareEligibility() {
|
||||||
val ready = SendState(
|
val ready = SendState(
|
||||||
selectedFile = PickedShareFile("/tmp/payload.txt", "payload.txt", 128UL),
|
selectedFiles = listOf(PickedShareFile("/tmp/payload.txt", "payload.txt", 128UL)),
|
||||||
transferName = "payload.txt",
|
transferName = "payload.txt",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -23,12 +23,12 @@ private var retainedPickerDelegate: DocumentPickerDelegate? = null
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
actual fun rememberShareFilePicker(
|
actual fun rememberShareFilePicker(
|
||||||
onFilePicked: (PickedShareFile) -> Unit,
|
onFilesPicked: (List<PickedShareFile>) -> Unit,
|
||||||
onError: (String) -> Unit,
|
onError: (String) -> Unit,
|
||||||
): ShareFilePicker = remember(onFilePicked, onError) {
|
): ShareFilePicker = remember(onFilesPicked, onError) {
|
||||||
object : ShareFilePicker {
|
object : ShareFilePicker {
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
override fun pickFile() {
|
override fun pickFiles() {
|
||||||
val presenter = UIApplication.sharedApplication.keyWindow?.rootViewController
|
val presenter = UIApplication.sharedApplication.keyWindow?.rootViewController
|
||||||
if (presenter == null) {
|
if (presenter == null) {
|
||||||
onError("Could not find an iOS view controller for the document picker")
|
onError("Could not find an iOS view controller for the document picker")
|
||||||
@@ -36,7 +36,11 @@ actual fun rememberShareFilePicker(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val picker = UIDocumentPickerViewController(forOpeningContentTypes = listOf(UTTypeItem), asCopy = false)
|
val picker = UIDocumentPickerViewController(forOpeningContentTypes = listOf(UTTypeItem), asCopy = false)
|
||||||
val delegate = DocumentPickerDelegate(onFilePicked, onError)
|
picker.allowsMultipleSelection = true
|
||||||
|
val delegate = DocumentPickerDelegate(
|
||||||
|
onFilesPicked = onFilesPicked,
|
||||||
|
onError = onError,
|
||||||
|
)
|
||||||
retainedPickerDelegate = delegate
|
retainedPickerDelegate = delegate
|
||||||
picker.delegate = delegate
|
picker.delegate = delegate
|
||||||
picker.modalPresentationStyle = UIModalPresentationFormSheet
|
picker.modalPresentationStyle = UIModalPresentationFormSheet
|
||||||
@@ -61,7 +65,8 @@ actual fun rememberReceiveFolderPicker(
|
|||||||
|
|
||||||
val picker = UIDocumentPickerViewController(forOpeningContentTypes = listOf(UTTypeFolder), asCopy = false)
|
val picker = UIDocumentPickerViewController(forOpeningContentTypes = listOf(UTTypeFolder), asCopy = false)
|
||||||
val delegate = DocumentPickerDelegate(
|
val delegate = DocumentPickerDelegate(
|
||||||
onFilePicked = { folder ->
|
onFilesPicked = { folders ->
|
||||||
|
val folder = folders.firstOrNull() ?: return@DocumentPickerDelegate
|
||||||
onFolderPicked(
|
onFolderPicked(
|
||||||
ReceiveFolder(
|
ReceiveFolder(
|
||||||
kind = ReceiveFolderKind.IosSecurityScopedUrl,
|
kind = ReceiveFolderKind.IosSecurityScopedUrl,
|
||||||
@@ -81,14 +86,12 @@ actual fun rememberReceiveFolderPicker(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private class DocumentPickerDelegate(
|
private class DocumentPickerDelegate(
|
||||||
private val onFilePicked: (PickedShareFile) -> Unit,
|
private val onFilesPicked: (List<PickedShareFile>) -> Unit,
|
||||||
private val onError: (String) -> Unit,
|
private val onError: (String) -> Unit,
|
||||||
) : NSObject(), UIDocumentPickerDelegateProtocol {
|
) : NSObject(), UIDocumentPickerDelegateProtocol {
|
||||||
override fun documentPicker(controller: UIDocumentPickerViewController, didPickDocumentsAtURLs: List<*>) {
|
override fun documentPicker(controller: UIDocumentPickerViewController, didPickDocumentsAtURLs: List<*>) {
|
||||||
val url = didPickDocumentsAtURLs.firstOrNull() as? NSURL
|
val files = didPickDocumentsAtURLs.mapNotNull { raw ->
|
||||||
if (url == null) {
|
val url = raw as? NSURL ?: return@mapNotNull null
|
||||||
onError("The selected iOS document URL was invalid")
|
|
||||||
} else {
|
|
||||||
val displayName = url.lastPathComponent ?: "transfer"
|
val displayName = url.lastPathComponent ?: "transfer"
|
||||||
val didStartAccess = url.startAccessingSecurityScopedResource()
|
val didStartAccess = url.startAccessingSecurityScopedResource()
|
||||||
val sizeBytes = try {
|
val sizeBytes = try {
|
||||||
@@ -97,15 +100,18 @@ private class DocumentPickerDelegate(
|
|||||||
} finally {
|
} finally {
|
||||||
if (didStartAccess) url.stopAccessingSecurityScopedResource()
|
if (didStartAccess) url.stopAccessingSecurityScopedResource()
|
||||||
}
|
}
|
||||||
onFilePicked(
|
|
||||||
PickedShareFile(
|
PickedShareFile(
|
||||||
url.absoluteString ?: url.path.orEmpty(),
|
url.absoluteString ?: url.path.orEmpty(),
|
||||||
displayName,
|
displayName,
|
||||||
sizeBytes,
|
sizeBytes,
|
||||||
nativeFileIcon(url),
|
nativeFileIcon(url),
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (files.isEmpty()) {
|
||||||
|
onError("The selected iOS document URL was invalid")
|
||||||
|
} else {
|
||||||
|
onFilesPicked(files)
|
||||||
|
}
|
||||||
retainedPickerDelegate = null
|
retainedPickerDelegate = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,19 +43,24 @@ private class IosFileSystemService : FileSystemService {
|
|||||||
|
|
||||||
override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSink? = null
|
override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSink? = null
|
||||||
|
|
||||||
override suspend fun sharePickedFile(
|
override suspend fun sharePickedFiles(
|
||||||
repository: CoreGateway,
|
repository: CoreGateway,
|
||||||
file: PickedShareFile,
|
files: List<PickedShareFile>,
|
||||||
transferName: String,
|
transferName: String,
|
||||||
senderName: String,
|
senderName: String,
|
||||||
accessPolicy: ShareAccessPolicy,
|
accessPolicy: ShareAccessPolicy,
|
||||||
): Result<Share> = repository.shareSecurityScopedFileUrl(
|
): Result<Share> {
|
||||||
file.value,
|
require(files.isNotEmpty()) { "Select at least one file to share" }
|
||||||
file.displayName,
|
val sources = files.map { file ->
|
||||||
transferName,
|
uniffi.vnidrop.ShareSource(
|
||||||
senderName,
|
kind = uniffi.vnidrop.SourceKind.IOS_SECURITY_SCOPED_URL,
|
||||||
accessPolicy,
|
value = file.value,
|
||||||
|
displayName = file.displayName,
|
||||||
|
isDirectory = false,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
return repository.shareSources(sources, transferName, senderName, accessPolicy)
|
||||||
|
}
|
||||||
|
|
||||||
private fun validateSecurityScopedUrl(value: String): FolderAccessStatus {
|
private fun validateSecurityScopedUrl(value: String): FolderAccessStatus {
|
||||||
val url = NSURL.URLWithString(value) ?: NSURL.fileURLWithPath(value)
|
val url = NSURL.URLWithString(value) ?: NSURL.fileURLWithPath(value)
|
||||||
|
|||||||
@@ -19,8 +19,9 @@ private var retainedInvitationDelegate: InvitationDocumentDelegate? = null
|
|||||||
actual fun rememberReceiveInvitationActions(): ReceiveInvitationActions = remember {
|
actual fun rememberReceiveInvitationActions(): ReceiveInvitationActions = remember {
|
||||||
object : ReceiveInvitationActions {
|
object : ReceiveInvitationActions {
|
||||||
override val fileAvailability = ReceiveMethodAvailability.Available
|
override val fileAvailability = ReceiveMethodAvailability.Available
|
||||||
override val qrAvailability = ReceiveMethodAvailability.Unavailable
|
// Hide unfinished iOS methods so the method list only shows what works.
|
||||||
override val nfcAvailability = ReceiveMethodAvailability.Unavailable
|
override val qrAvailability = ReceiveMethodAvailability.Hidden
|
||||||
|
override val nfcAvailability = ReceiveMethodAvailability.Hidden
|
||||||
|
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
override fun pickInvitation(onResult: (Result<String>) -> Unit) {
|
override fun pickInvitation(onResult: (Result<String>) -> Unit) {
|
||||||
|
|||||||
@@ -15,13 +15,14 @@ import javax.swing.filechooser.FileSystemView
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
actual fun rememberShareFilePicker(
|
actual fun rememberShareFilePicker(
|
||||||
onFilePicked: (PickedShareFile) -> Unit,
|
onFilesPicked: (List<PickedShareFile>) -> Unit,
|
||||||
onError: (String) -> Unit,
|
onError: (String) -> Unit,
|
||||||
): ShareFilePicker = remember(onFilePicked, onError) {
|
): ShareFilePicker = remember(onFilesPicked, onError) {
|
||||||
object : ShareFilePicker {
|
object : ShareFilePicker {
|
||||||
override fun pickFile() {
|
override fun pickFiles() {
|
||||||
openPicker(onError) {
|
openPicker(onError) {
|
||||||
pickShareFile()?.let(onFilePicked)
|
val selected = pickShareFiles()
|
||||||
|
if (selected.isNotEmpty()) onFilesPicked(selected)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,22 +72,24 @@ private fun activeFrame(): Frame? {
|
|||||||
?: Frame.getFrames().firstOrNull { it.isVisible }
|
?: Frame.getFrames().firstOrNull { it.isVisible }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun pickShareFile(): PickedShareFile? {
|
private fun pickShareFiles(): List<PickedShareFile> {
|
||||||
val dialog = nativeFileDialog("Select file to share")
|
val dialog = nativeFileDialog("Select files to share").apply {
|
||||||
|
isMultipleMode = true
|
||||||
|
}
|
||||||
return try {
|
return try {
|
||||||
dialog.isVisible = true
|
dialog.isVisible = true
|
||||||
val directory = dialog.directory
|
val directory = dialog.directory ?: return emptyList()
|
||||||
val file = dialog.file
|
val names = dialog.files?.map { it.name }.orEmpty().ifEmpty {
|
||||||
if (directory != null && file != null) {
|
dialog.file?.let { listOf(it) }.orEmpty()
|
||||||
val selected = File(directory, file)
|
}
|
||||||
|
names.map { name ->
|
||||||
|
val selected = File(directory, name)
|
||||||
PickedShareFile(
|
PickedShareFile(
|
||||||
selected.absolutePath,
|
selected.absolutePath,
|
||||||
selected.name,
|
selected.name,
|
||||||
selected.length().takeIf { it >= 0L }?.toULong(),
|
selected.length().takeIf { it >= 0L }?.toULong(),
|
||||||
selected.systemIconPng(),
|
selected.systemIconPng(),
|
||||||
)
|
)
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
dialog.dispose()
|
dialog.dispose()
|
||||||
|
|||||||
@@ -28,11 +28,22 @@ private class JvmFileSystemService : FileSystemService {
|
|||||||
|
|
||||||
override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSink? = null
|
override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSink? = null
|
||||||
|
|
||||||
override suspend fun sharePickedFile(
|
override suspend fun sharePickedFiles(
|
||||||
repository: CoreGateway,
|
repository: CoreGateway,
|
||||||
file: PickedShareFile,
|
files: List<PickedShareFile>,
|
||||||
transferName: String,
|
transferName: String,
|
||||||
senderName: String,
|
senderName: String,
|
||||||
accessPolicy: ShareAccessPolicy,
|
accessPolicy: ShareAccessPolicy,
|
||||||
): Result<Share> = repository.sharePath(file.value, transferName, senderName, accessPolicy)
|
): Result<Share> {
|
||||||
|
require(files.isNotEmpty()) { "Select at least one file to share" }
|
||||||
|
val sources = files.map { file ->
|
||||||
|
uniffi.vnidrop.ShareSource(
|
||||||
|
kind = uniffi.vnidrop.SourceKind.PATH,
|
||||||
|
value = file.value,
|
||||||
|
displayName = file.displayName,
|
||||||
|
isDirectory = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return repository.shareSources(sources, transferName, senderName, accessPolicy)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -227,7 +227,7 @@ class FoundationComposeTest {
|
|||||||
|
|
||||||
onNodeWithText("New transfer").performClick()
|
onNodeWithText("New transfer").performClick()
|
||||||
onNodeWithText("Choose what to share").assertIsDisplayed()
|
onNodeWithText("Choose what to share").assertIsDisplayed()
|
||||||
onNodeWithText("Choose file").assertIsDisplayed()
|
onNodeWithText("Choose files").assertIsDisplayed()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -239,7 +239,7 @@ class FoundationComposeTest {
|
|||||||
coreState = CoreState(isInitialized = true),
|
coreState = CoreState(isInitialized = true),
|
||||||
state = SendState(
|
state = SendState(
|
||||||
isComposerOpen = true,
|
isComposerOpen = true,
|
||||||
selectedFile = PickedShareFile("/tmp/photos.zip", "photos.zip", 1536UL),
|
selectedFiles = listOf(PickedShareFile("/tmp/photos.zip", "photos.zip", 1536UL)),
|
||||||
transferName = "photos.zip",
|
transferName = "photos.zip",
|
||||||
senderName = "Sender",
|
senderName = "Sender",
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user