diff --git a/androidApp/src/main/kotlin/com/vnidrop/app/MainActivity.kt b/androidApp/src/main/kotlin/com/vnidrop/app/MainActivity.kt index dc47617..6fe4dbf 100644 --- a/androidApp/src/main/kotlin/com/vnidrop/app/MainActivity.kt +++ b/androidApp/src/main/kotlin/com/vnidrop/app/MainActivity.kt @@ -6,11 +6,13 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.runtime.Composable import androidx.compose.ui.tooling.preview.Preview +import com.vnidrop.app.core.attachAndroidFilePickerContext class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() super.onCreate(savedInstanceState) + attachAndroidFilePickerContext(this) setContent { App() diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 8c08aaf..b862bb8 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -34,6 +34,7 @@ kotlin { sourceSets { androidMain.dependencies { + implementation(libs.androidx.activity.compose) implementation(libs.compose.uiToolingPreview) } commonMain.dependencies { diff --git a/shared/src/androidMain/kotlin/com/vnidrop/app/core/FilePicker.android.kt b/shared/src/androidMain/kotlin/com/vnidrop/app/core/FilePicker.android.kt new file mode 100644 index 0000000..62b4a9e --- /dev/null +++ b/shared/src/androidMain/kotlin/com/vnidrop/app/core/FilePicker.android.kt @@ -0,0 +1,72 @@ +package com.vnidrop.app.core + +import android.content.Context +import android.net.Uri +import android.provider.OpenableColumns +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext + +@Composable +actual fun rememberShareFilePicker( + onFilePicked: (PickedShareFile) -> Unit, + onError: (String) -> Unit, +): ShareFilePicker { + val context = LocalContext.current + val launcher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + if (uri != null) { + onFilePicked(PickedShareFile(uri.toString(), context.displayName(uri))) + } + } + return remember(launcher) { + object : ShareFilePicker { + override fun pickFile() { + launcher.launch(arrayOf("*/*")) + } + } + } +} + +actual suspend fun sharePickedFile( + repository: CoreRepository, + file: PickedShareFile, + transferName: String, + senderName: String, +) { + val context = AndroidContextHolder.context + errorIfNull(context, "Android context has not been attached") + .contentResolver + .openFileDescriptor(Uri.parse(file.value), "r") + .use { descriptor -> + checkNotNull(descriptor) { "Could not open selected file descriptor" } + repository.shareFileDescriptor( + fd = descriptor.fd, + displayName = file.displayName, + transferName = transferName, + senderName = senderName, + ) + } +} + +private object AndroidContextHolder { + var context: Context? = null +} + +fun attachAndroidFilePickerContext(context: Context) { + AndroidContextHolder.context = context.applicationContext +} + +private fun Context.displayName(uri: Uri): String { + contentResolver.query(uri, null, null, null, null)?.use { cursor -> + val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (nameIndex >= 0 && cursor.moveToFirst()) { + return cursor.getString(nameIndex) + } + } + return uri.lastPathSegment ?: "transfer" +} + +private fun errorIfNull(value: T?, message: String): T = + value ?: error(message) diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt index 7a62537..46a2215 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt @@ -21,6 +21,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.vnidrop.app.core.CoreRepository +import com.vnidrop.app.core.PickedShareFile +import com.vnidrop.app.core.rememberShareFilePicker +import com.vnidrop.app.core.sharePickedFile import kotlinx.coroutines.launch @Composable @@ -32,11 +35,22 @@ fun App() { val scope = rememberCoroutineScope() var appDataDir by remember { mutableStateOf(platform.defaultCoreDataDir) } var sourcePath by remember { mutableStateOf("") } + var selectedFile by remember { mutableStateOf(null) } var transferName by remember { mutableStateOf("VniDrop transfer") } var senderName by remember { mutableStateOf("") } var ticket by remember { mutableStateOf("") } var receiveDir by remember { mutableStateOf(platform.defaultReceiveDir) } var receiverName by remember { mutableStateOf("") } + val shareFilePicker = rememberShareFilePicker( + onFilePicked = { file -> + selectedFile = file + sourcePath = file.value + if (transferName.isBlank() || transferName == "VniDrop transfer") { + transferName = file.displayName + } + }, + onError = { error -> scope.launch { repository.setError(error) } }, + ) MaterialTheme { Surface( @@ -73,13 +87,19 @@ fun App() { } item { - CoreCard(title = "Share path") { + CoreCard(title = "Share file") { OutlinedTextField( value = sourcePath, - onValueChange = { sourcePath = it }, - label = { Text("Source file path") }, + onValueChange = { + sourcePath = it + selectedFile = null + }, + label = { Text("Selected file") }, modifier = Modifier.fillMaxWidth(), ) + Button(onClick = { shareFilePicker.pickFile() }) { + Text("Select file") + } OutlinedTextField( value = transferName, onValueChange = { transferName = it }, @@ -93,10 +113,15 @@ fun App() { modifier = Modifier.fillMaxWidth(), ) Button( - enabled = state.isInitialized && sourcePath.isNotBlank(), + enabled = state.isInitialized && (selectedFile != null || sourcePath.isNotBlank()), onClick = { scope.launch { - repository.sharePath(sourcePath, transferName, senderName) + val picked = selectedFile + if (picked != null) { + sharePickedFile(repository, picked, transferName, senderName) + } else { + repository.sharePath(sourcePath, transferName, senderName) + } } }, ) { diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt index 69d659d..a538842 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt @@ -135,6 +135,10 @@ class CoreRepository( refreshStatus() } + suspend fun setError(message: String) { + _state.update { it.copy(error = message) } + } + private suspend fun runCore(block: suspend () -> Unit) { withContext(dispatcher) { try { diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/core/FilePicker.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/core/FilePicker.kt new file mode 100644 index 0000000..bce6356 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/FilePicker.kt @@ -0,0 +1,25 @@ +package com.vnidrop.app.core + +import androidx.compose.runtime.Composable + +data class PickedShareFile( + val value: String, + val displayName: String, +) + +interface ShareFilePicker { + fun pickFile() +} + +@Composable +expect fun rememberShareFilePicker( + onFilePicked: (PickedShareFile) -> Unit, + onError: (String) -> Unit, +): ShareFilePicker + +expect suspend fun sharePickedFile( + repository: CoreRepository, + file: PickedShareFile, + transferName: String, + senderName: String, +) diff --git a/shared/src/iosMain/kotlin/com/vnidrop/app/core/FilePicker.ios.kt b/shared/src/iosMain/kotlin/com/vnidrop/app/core/FilePicker.ios.kt new file mode 100644 index 0000000..134dac9 --- /dev/null +++ b/shared/src/iosMain/kotlin/com/vnidrop/app/core/FilePicker.ios.kt @@ -0,0 +1,67 @@ +package com.vnidrop.app.core + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import kotlinx.cinterop.ExperimentalForeignApi +import platform.Foundation.NSURL +import platform.UIKit.UIApplication +import platform.UIKit.UIDocumentPickerDelegateProtocol +import platform.UIKit.UIDocumentPickerViewController +import platform.UIKit.UIModalPresentationFormSheet +import platform.UniformTypeIdentifiers.UTTypeItem +import platform.darwin.NSObject + +private var retainedPickerDelegate: DocumentPickerDelegate? = null + +@Composable +actual fun rememberShareFilePicker( + onFilePicked: (PickedShareFile) -> Unit, + onError: (String) -> Unit, +): ShareFilePicker = remember(onFilePicked, onError) { + object : ShareFilePicker { + @OptIn(ExperimentalForeignApi::class) + override fun pickFile() { + val presenter = UIApplication.sharedApplication.keyWindow?.rootViewController + if (presenter == null) { + onError("Could not find an iOS view controller for the document picker") + return + } + + val picker = UIDocumentPickerViewController(forOpeningContentTypes = listOf(UTTypeItem), asCopy = false) + val delegate = DocumentPickerDelegate(onFilePicked, onError) + retainedPickerDelegate = delegate + picker.delegate = delegate + picker.modalPresentationStyle = UIModalPresentationFormSheet + presenter.presentViewController(picker, animated = true, completion = null) + } + } +} + +actual suspend fun sharePickedFile( + repository: CoreRepository, + file: PickedShareFile, + transferName: String, + senderName: String, +) { + repository.shareSecurityScopedFileUrl(file.value, file.displayName, transferName, senderName) +} + +private class DocumentPickerDelegate( + private val onFilePicked: (PickedShareFile) -> Unit, + private val onError: (String) -> Unit, +) : NSObject(), UIDocumentPickerDelegateProtocol { + override fun documentPicker(controller: UIDocumentPickerViewController, didPickDocumentsAtURLs: List<*>) { + val url = didPickDocumentsAtURLs.firstOrNull() as? NSURL + if (url == null) { + onError("The selected iOS document URL was invalid") + } else { + val displayName = url.lastPathComponent ?: "transfer" + onFilePicked(PickedShareFile(url.absoluteString ?: url.path.orEmpty(), displayName)) + } + retainedPickerDelegate = null + } + + override fun documentPickerWasCancelled(controller: UIDocumentPickerViewController) { + retainedPickerDelegate = null + } +} diff --git a/shared/src/jvmMain/kotlin/com/vnidrop/app/core/FilePicker.jvm.kt b/shared/src/jvmMain/kotlin/com/vnidrop/app/core/FilePicker.jvm.kt new file mode 100644 index 0000000..03a9bb9 --- /dev/null +++ b/shared/src/jvmMain/kotlin/com/vnidrop/app/core/FilePicker.jvm.kt @@ -0,0 +1,39 @@ +package com.vnidrop.app.core + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import java.awt.FileDialog +import java.awt.Frame +import java.io.File + +@Composable +actual fun rememberShareFilePicker( + onFilePicked: (PickedShareFile) -> Unit, + onError: (String) -> Unit, +): ShareFilePicker = remember(onFilePicked, onError) { + object : ShareFilePicker { + override fun pickFile() { + try { + val dialog = FileDialog(null as Frame?, "Select file to share", FileDialog.LOAD) + dialog.isVisible = true + val directory = dialog.directory + val file = dialog.file + if (directory != null && file != null) { + val selected = File(directory, file) + onFilePicked(PickedShareFile(selected.absolutePath, selected.name)) + } + } catch (error: Throwable) { + onError(error.message ?: error.toString()) + } + } + } +} + +actual suspend fun sharePickedFile( + repository: CoreRepository, + file: PickedShareFile, + transferName: String, + senderName: String, +) { + repository.sharePath(file.value, transferName, senderName) +}