Add native file picker to core test UI

This commit is contained in:
2026-07-03 00:30:21 +02:00
parent 3cdbf7e8f9
commit 1c525288a5
8 changed files with 240 additions and 5 deletions

View File

@@ -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()

View File

@@ -34,6 +34,7 @@ kotlin {
sourceSets {
androidMain.dependencies {
implementation(libs.androidx.activity.compose)
implementation(libs.compose.uiToolingPreview)
}
commonMain.dependencies {

View File

@@ -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 <T : Any> errorIfNull(value: T?, message: String): T =
value ?: error(message)

View File

@@ -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<PickedShareFile?>(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)
}
}
},
) {

View File

@@ -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 {

View File

@@ -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,
)

View File

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

View File

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