mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 18:39:55 +02:00
feat: add preferences and send empty state
This commit is contained in:
@@ -5,14 +5,19 @@ import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import android.os.BatteryManager
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import java.net.NetworkInterface
|
||||
|
||||
class AndroidPlatform : Platform {
|
||||
override val name: String = "Android ${Build.VERSION.SDK_INT}"
|
||||
override val defaultCoreDataDir: String =
|
||||
System.getProperty("java.io.tmpdir") ?: "/data/local/tmp/vnidrop"
|
||||
AndroidPlatformContextHolder.context?.filesDir?.resolve("vnidrop")?.absolutePath
|
||||
?: (System.getProperty("java.io.tmpdir") ?: "/data/local/tmp/vnidrop")
|
||||
override val defaultReceiveDir: String =
|
||||
System.getProperty("java.io.tmpdir") ?: "/data/local/tmp/vnidrop-receive"
|
||||
AndroidPlatformContextHolder.context
|
||||
?.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)
|
||||
?.absolutePath
|
||||
?: (System.getProperty("java.io.tmpdir") ?: "/data/local/tmp/vnidrop-receive")
|
||||
override val deviceInfo: DeviceInfo = DeviceInfo(
|
||||
deviceName = Build.DEVICE,
|
||||
deviceModel = listOf(Build.MANUFACTURER, Build.MODEL)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.vnidrop.app.core
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
@@ -29,6 +30,38 @@ actual fun rememberShareFilePicker(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
actual fun rememberReceiveFolderPicker(
|
||||
onFolderPicked: (ReceiveFolder) -> Unit,
|
||||
onError: (String) -> Unit,
|
||||
): ReceiveFolderPicker {
|
||||
val context = LocalContext.current
|
||||
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
|
||||
if (uri != null) {
|
||||
runCatching {
|
||||
context.contentResolver.takePersistableUriPermission(
|
||||
uri,
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION,
|
||||
)
|
||||
}
|
||||
onFolderPicked(
|
||||
ReceiveFolder(
|
||||
kind = ReceiveFolderKind.AndroidTreeUri,
|
||||
value = uri.toString(),
|
||||
displayName = uri.lastPathSegment ?: "Downloads",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
return remember(launcher) {
|
||||
object : ReceiveFolderPicker {
|
||||
override fun pickFolder() {
|
||||
launcher.launch(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
actual suspend fun sharePickedFile(
|
||||
repository: CoreRepository,
|
||||
file: PickedShareFile,
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package com.vnidrop.app.core
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Environment
|
||||
import android.provider.DocumentsContract
|
||||
import androidx.core.net.toUri
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import uniffi.vnidrop.ReceiveOutputSink
|
||||
import java.io.OutputStream
|
||||
|
||||
@Composable
|
||||
actual fun rememberFileSystemService(): FileSystemService {
|
||||
val context = LocalContext.current.applicationContext
|
||||
return remember(context) { AndroidFileSystemService(context) }
|
||||
}
|
||||
|
||||
private class AndroidFileSystemService(
|
||||
private val context: Context,
|
||||
) : FileSystemService {
|
||||
override fun defaultReceiveFolder(): ReceiveFolder {
|
||||
val path = context
|
||||
.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)
|
||||
?.absolutePath
|
||||
?: (System.getProperty("java.io.tmpdir") ?: "/data/local/tmp/vnidrop-receive")
|
||||
return ReceiveFolder(
|
||||
kind = ReceiveFolderKind.FileSystemPath,
|
||||
value = path,
|
||||
displayName = "Downloads",
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun validateReceiveFolder(folder: ReceiveFolder): FolderAccessStatus =
|
||||
when (folder.kind) {
|
||||
ReceiveFolderKind.FileSystemPath -> validatePath(folder.value)
|
||||
ReceiveFolderKind.AndroidTreeUri -> validateTreeUri(folder.value)
|
||||
ReceiveFolderKind.IosSecurityScopedUrl -> FolderAccessStatus.Unavailable
|
||||
}
|
||||
|
||||
override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSink? {
|
||||
if (folder.kind != ReceiveFolderKind.AndroidTreeUri) return null
|
||||
return AndroidTreeReceiveOutputSink(context, folder.value.toUri())
|
||||
}
|
||||
|
||||
private fun validatePath(path: String): FolderAccessStatus =
|
||||
runCatching {
|
||||
val directory = java.io.File(path)
|
||||
if (!directory.exists()) directory.mkdirs()
|
||||
if (directory.isDirectory && directory.canWrite()) FolderAccessStatus.Writable else FolderAccessStatus.Unavailable
|
||||
}.getOrDefault(FolderAccessStatus.Unavailable)
|
||||
|
||||
private fun validateTreeUri(value: String): FolderAccessStatus {
|
||||
val uri = Uri.parse(value)
|
||||
val hasPermission = context.contentResolver.persistedUriPermissions.any { permission ->
|
||||
permission.uri == uri && permission.isWritePermission
|
||||
}
|
||||
if (!hasPermission) return FolderAccessStatus.PermissionRequired
|
||||
return runCatching {
|
||||
val probe = AndroidTreeReceiveOutputSink(context, uri)
|
||||
val probeName = ".vnidrop-write-test"
|
||||
probe.startFile(probeName)
|
||||
probe.writeChunk(probeName, byteArrayOf())
|
||||
probe.finishFile(probeName)
|
||||
FolderAccessStatus.Writable
|
||||
}.getOrDefault(FolderAccessStatus.Unavailable)
|
||||
}
|
||||
}
|
||||
|
||||
private class AndroidTreeReceiveOutputSink(
|
||||
private val context: Context,
|
||||
private val treeUri: Uri,
|
||||
) : ReceiveOutputSink {
|
||||
private val streams = mutableMapOf<String, OutputStream>()
|
||||
|
||||
override fun startFile(relativePath: String) {
|
||||
streams[relativePath]?.close()
|
||||
val documentUri = createDocument(relativePath)
|
||||
val stream = context.contentResolver.openOutputStream(documentUri, "w")
|
||||
?: error("Could not open output stream for $relativePath")
|
||||
streams[relativePath] = stream
|
||||
}
|
||||
|
||||
override fun writeChunk(relativePath: String, bytes: ByteArray) {
|
||||
val stream = streams[relativePath] ?: error("Output stream is not open for $relativePath")
|
||||
stream.write(bytes)
|
||||
}
|
||||
|
||||
override fun finishFile(relativePath: String) {
|
||||
streams.remove(relativePath)?.close()
|
||||
}
|
||||
|
||||
private fun createDocument(relativePath: String): Uri {
|
||||
val parts = relativePath.split('/').filter { it.isNotBlank() }
|
||||
require(parts.isNotEmpty()) { "relative path must not be empty" }
|
||||
var parent = DocumentsContract.buildDocumentUriUsingTree(
|
||||
treeUri,
|
||||
DocumentsContract.getTreeDocumentId(treeUri),
|
||||
)
|
||||
parts.dropLast(1).forEach { name ->
|
||||
parent = findChild(parent, name)
|
||||
?: DocumentsContract.createDocument(context.contentResolver, parent, DocumentsContract.Document.MIME_TYPE_DIR, name)
|
||||
?: error("Could not create directory $name")
|
||||
}
|
||||
return DocumentsContract.createDocument(context.contentResolver, parent, "application/octet-stream", parts.last())
|
||||
?: error("Could not create file ${parts.last()}")
|
||||
}
|
||||
|
||||
private fun findChild(parent: Uri, name: String): Uri? {
|
||||
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(
|
||||
treeUri,
|
||||
DocumentsContract.getDocumentId(parent),
|
||||
)
|
||||
context.contentResolver.query(
|
||||
childrenUri,
|
||||
arrayOf(DocumentsContract.Document.COLUMN_DOCUMENT_ID, DocumentsContract.Document.COLUMN_DISPLAY_NAME),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
)?.use { cursor ->
|
||||
val idIndex = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_DOCUMENT_ID)
|
||||
val nameIndex = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_DISPLAY_NAME)
|
||||
while (cursor.moveToNext()) {
|
||||
if (cursor.getString(nameIndex) == name) {
|
||||
return DocumentsContract.buildDocumentUriUsingTree(treeUri, cursor.getString(idIndex))
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user