mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 02:29:55 +02:00
Add native file picker to core test UI
This commit is contained in:
@@ -6,11 +6,13 @@ import androidx.activity.compose.setContent
|
|||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import com.vnidrop.app.core.attachAndroidFilePickerContext
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
attachAndroidFilePickerContext(this)
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
App()
|
App()
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ kotlin {
|
|||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
androidMain.dependencies {
|
androidMain.dependencies {
|
||||||
|
implementation(libs.androidx.activity.compose)
|
||||||
implementation(libs.compose.uiToolingPreview)
|
implementation(libs.compose.uiToolingPreview)
|
||||||
}
|
}
|
||||||
commonMain.dependencies {
|
commonMain.dependencies {
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -21,6 +21,9 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.vnidrop.app.core.CoreRepository
|
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
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -32,11 +35,22 @@ fun App() {
|
|||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var appDataDir by remember { mutableStateOf(platform.defaultCoreDataDir) }
|
var appDataDir by remember { mutableStateOf(platform.defaultCoreDataDir) }
|
||||||
var sourcePath by remember { mutableStateOf("") }
|
var sourcePath by remember { mutableStateOf("") }
|
||||||
|
var selectedFile by remember { mutableStateOf<PickedShareFile?>(null) }
|
||||||
var transferName by remember { mutableStateOf("VniDrop transfer") }
|
var transferName by remember { mutableStateOf("VniDrop transfer") }
|
||||||
var senderName by remember { mutableStateOf("") }
|
var senderName by remember { mutableStateOf("") }
|
||||||
var ticket by remember { mutableStateOf("") }
|
var ticket by remember { mutableStateOf("") }
|
||||||
var receiveDir by remember { mutableStateOf(platform.defaultReceiveDir) }
|
var receiveDir by remember { mutableStateOf(platform.defaultReceiveDir) }
|
||||||
var receiverName by remember { mutableStateOf("") }
|
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 {
|
MaterialTheme {
|
||||||
Surface(
|
Surface(
|
||||||
@@ -73,13 +87,19 @@ fun App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
item {
|
item {
|
||||||
CoreCard(title = "Share path") {
|
CoreCard(title = "Share file") {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = sourcePath,
|
value = sourcePath,
|
||||||
onValueChange = { sourcePath = it },
|
onValueChange = {
|
||||||
label = { Text("Source file path") },
|
sourcePath = it
|
||||||
|
selectedFile = null
|
||||||
|
},
|
||||||
|
label = { Text("Selected file") },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
|
Button(onClick = { shareFilePicker.pickFile() }) {
|
||||||
|
Text("Select file")
|
||||||
|
}
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = transferName,
|
value = transferName,
|
||||||
onValueChange = { transferName = it },
|
onValueChange = { transferName = it },
|
||||||
@@ -93,10 +113,15 @@ fun App() {
|
|||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
Button(
|
Button(
|
||||||
enabled = state.isInitialized && sourcePath.isNotBlank(),
|
enabled = state.isInitialized && (selectedFile != null || sourcePath.isNotBlank()),
|
||||||
onClick = {
|
onClick = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
repository.sharePath(sourcePath, transferName, senderName)
|
val picked = selectedFile
|
||||||
|
if (picked != null) {
|
||||||
|
sharePickedFile(repository, picked, transferName, senderName)
|
||||||
|
} else {
|
||||||
|
repository.sharePath(sourcePath, transferName, senderName)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -135,6 +135,10 @@ class CoreRepository(
|
|||||||
refreshStatus()
|
refreshStatus()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun setError(message: String) {
|
||||||
|
_state.update { it.copy(error = message) }
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun runCore(block: suspend () -> Unit) {
|
private suspend fun runCore(block: suspend () -> Unit) {
|
||||||
withContext(dispatcher) {
|
withContext(dispatcher) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user