mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 10:29:58 +02:00
feat(android): receive into system Downloads via MediaStore
Default Android receive destination is now the shared system Downloads collection (like desktop), using MediaStore on API 29+ instead of app-private storage. Legacy app-private defaults are promoted back to public Downloads so existing installs pick up the fix.
This commit is contained in:
@@ -2,4 +2,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission
|
||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="28" />
|
||||
</manifest>
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
package com.vnidrop.app.core
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.provider.DocumentsContract
|
||||
import androidx.core.net.toUri
|
||||
import android.provider.MediaStore
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.net.toUri
|
||||
import uniffi.vnidrop.ReceiveOutputSink
|
||||
import java.io.File
|
||||
import java.io.OutputStream
|
||||
import java.net.URLConnection
|
||||
import java.util.UUID
|
||||
|
||||
@Composable
|
||||
@@ -22,31 +28,40 @@ private class AndroidFileSystemService(
|
||||
private val context: Context,
|
||||
) : FileSystemService {
|
||||
override fun defaultReceiveFolder(): ReceiveFolder {
|
||||
// App-specific external storage is always writable without SAF or
|
||||
// legacy storage permissions. It is NOT the shared system Downloads
|
||||
// gallery — that still requires "Choose folder" (tree URI).
|
||||
val path = context
|
||||
.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)
|
||||
?.absolutePath
|
||||
?: context.filesDir.resolve("Downloads").apply { mkdirs() }.absolutePath
|
||||
return ReceiveFolder(
|
||||
kind = ReceiveFolderKind.FileSystemPath,
|
||||
value = path,
|
||||
displayName = "App downloads",
|
||||
)
|
||||
// Match desktop: shared system Downloads. On Android 10+ this is MediaStore,
|
||||
// not a raw filesystem path (scoped storage). Older APIs fall back to the
|
||||
// public Downloads directory when legacy storage writes are allowed.
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
ReceiveFolder(
|
||||
kind = ReceiveFolderKind.AndroidPublicDownloads,
|
||||
value = AndroidPublicDownloadsToken,
|
||||
displayName = "Downloads",
|
||||
)
|
||||
} else {
|
||||
val publicDownloads = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
|
||||
ReceiveFolder(
|
||||
kind = ReceiveFolderKind.FileSystemPath,
|
||||
value = publicDownloads.absolutePath,
|
||||
displayName = "Downloads",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun validateReceiveFolder(folder: ReceiveFolder): FolderAccessStatus =
|
||||
when (folder.kind) {
|
||||
ReceiveFolderKind.FileSystemPath -> validatePath(folder.value)
|
||||
ReceiveFolderKind.AndroidPublicDownloads -> validatePublicDownloads()
|
||||
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())
|
||||
}
|
||||
override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSink? =
|
||||
when (folder.kind) {
|
||||
ReceiveFolderKind.AndroidPublicDownloads -> AndroidMediaStoreDownloadsSink(context)
|
||||
ReceiveFolderKind.AndroidTreeUri -> AndroidTreeReceiveOutputSink(context, folder.value.toUri())
|
||||
ReceiveFolderKind.FileSystemPath,
|
||||
ReceiveFolderKind.IosSecurityScopedUrl -> null
|
||||
}
|
||||
|
||||
override suspend fun sharePickedFile(
|
||||
repository: CoreGateway,
|
||||
@@ -68,19 +83,19 @@ private class AndroidFileSystemService(
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe a real create/write/delete instead of [java.io.File.canWrite].
|
||||
* Probe a real create/write/delete instead of [File.canWrite].
|
||||
*
|
||||
* Scoped storage often reports public directories as writable even when
|
||||
* the process cannot create files there. A probe matches what receive needs.
|
||||
*/
|
||||
private fun validatePath(path: String): FolderAccessStatus =
|
||||
runCatching {
|
||||
val directory = java.io.File(path)
|
||||
val directory = File(path)
|
||||
if (!directory.exists() && !directory.mkdirs()) {
|
||||
return FolderAccessStatus.Unavailable
|
||||
}
|
||||
if (!directory.isDirectory) return FolderAccessStatus.Unavailable
|
||||
val probe = java.io.File(directory, ".vnidrop-write-test-${UUID.randomUUID()}")
|
||||
val probe = File(directory, ".vnidrop-write-test-${UUID.randomUUID()}")
|
||||
try {
|
||||
probe.outputStream().use { stream -> stream.write(1) }
|
||||
if (!probe.exists()) return FolderAccessStatus.Unavailable
|
||||
@@ -90,6 +105,16 @@ private class AndroidFileSystemService(
|
||||
}
|
||||
}.getOrDefault(FolderAccessStatus.Unavailable)
|
||||
|
||||
private fun validatePublicDownloads(): FolderAccessStatus =
|
||||
runCatching {
|
||||
val probeName = ".vnidrop-write-test-${UUID.randomUUID()}"
|
||||
val sink = AndroidMediaStoreDownloadsSink(context)
|
||||
sink.startFile(probeName)
|
||||
sink.writeChunk(probeName, byteArrayOf(1))
|
||||
sink.abortFile(probeName, "write probe complete")
|
||||
FolderAccessStatus.Writable
|
||||
}.getOrDefault(FolderAccessStatus.Unavailable)
|
||||
|
||||
private fun validateTreeUri(value: String): FolderAccessStatus {
|
||||
val uri = Uri.parse(value)
|
||||
val hasPermission = context.contentResolver.persistedUriPermissions.any { permission ->
|
||||
@@ -107,6 +132,134 @@ private class AndroidFileSystemService(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes into the shared system Downloads collection via MediaStore.
|
||||
*
|
||||
* Files appear in the user's Downloads app / Files UI the same way a browser
|
||||
* download would. Nested relative paths become subfolders under Download/.
|
||||
*/
|
||||
private class AndroidMediaStoreDownloadsSink(
|
||||
private val context: Context,
|
||||
) : ReceiveOutputSink {
|
||||
private data class PendingDocument(
|
||||
val stream: OutputStream,
|
||||
val uri: Uri,
|
||||
)
|
||||
|
||||
private val pending = mutableMapOf<String, PendingDocument>()
|
||||
private val resolver = context.contentResolver
|
||||
|
||||
override fun startFile(relativePath: String) {
|
||||
check(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
"MediaStore Downloads requires Android 10 or newer"
|
||||
}
|
||||
check(relativePath !in pending) { "Output stream is already open for $relativePath" }
|
||||
val parts = relativePath.split('/').filter { it.isNotBlank() }
|
||||
require(parts.isNotEmpty()) { "relative path must not be empty" }
|
||||
val finalName = parts.last()
|
||||
val relativeDir = mediaStoreRelativePath(parts.dropLast(1))
|
||||
check(!mediaStoreItemExists(finalName, relativeDir)) {
|
||||
"Destination already exists: $relativePath"
|
||||
}
|
||||
|
||||
val values = ContentValues().apply {
|
||||
put(MediaStore.MediaColumns.DISPLAY_NAME, finalName)
|
||||
put(MediaStore.MediaColumns.MIME_TYPE, mimeTypeFor(finalName))
|
||||
put(MediaStore.MediaColumns.RELATIVE_PATH, relativeDir)
|
||||
put(MediaStore.MediaColumns.IS_PENDING, 1)
|
||||
}
|
||||
val uri = resolver.insert(downloadsCollection(), values)
|
||||
?: error("Could not create Downloads entry for $relativePath")
|
||||
val stream = runCatching { resolver.openOutputStream(uri, "w") }
|
||||
.getOrElse { error ->
|
||||
resolver.delete(uri, null, null)
|
||||
throw error
|
||||
} ?: run {
|
||||
resolver.delete(uri, null, null)
|
||||
error("Could not open output stream for $relativePath")
|
||||
}
|
||||
pending[relativePath] = PendingDocument(stream, uri)
|
||||
}
|
||||
|
||||
override fun writeChunk(relativePath: String, bytes: ByteArray) {
|
||||
val document = pending[relativePath] ?: error("Output stream is not open for $relativePath")
|
||||
document.stream.write(bytes)
|
||||
}
|
||||
|
||||
override fun finishFile(relativePath: String) {
|
||||
val document = pending.remove(relativePath) ?: error("Output stream is not open for $relativePath")
|
||||
try {
|
||||
document.stream.flush()
|
||||
document.stream.close()
|
||||
val published = ContentValues().apply {
|
||||
put(MediaStore.MediaColumns.IS_PENDING, 0)
|
||||
}
|
||||
val updated = resolver.update(document.uri, published, null, null)
|
||||
check(updated == 1) { "Could not publish received file $relativePath" }
|
||||
} catch (error: Throwable) {
|
||||
runCatching { document.stream.close() }
|
||||
resolver.delete(document.uri, null, null)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
override fun abortFile(relativePath: String, reason: String) {
|
||||
val document = pending.remove(relativePath) ?: return
|
||||
runCatching { document.stream.close() }
|
||||
resolver.delete(document.uri, null, null)
|
||||
}
|
||||
|
||||
private fun downloadsCollection(): Uri =
|
||||
MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
|
||||
|
||||
private fun mediaStoreRelativePath(subdirs: List<String>): String {
|
||||
val base = Environment.DIRECTORY_DOWNLOADS
|
||||
return if (subdirs.isEmpty()) {
|
||||
"$base/"
|
||||
} else {
|
||||
"$base/${subdirs.joinToString("/")}/"
|
||||
}
|
||||
}
|
||||
|
||||
private fun mediaStoreItemExists(displayName: String, relativePath: String): Boolean {
|
||||
val projection = arrayOf(MediaStore.MediaColumns._ID)
|
||||
val selection =
|
||||
"${MediaStore.MediaColumns.DISPLAY_NAME}=? AND ${MediaStore.MediaColumns.RELATIVE_PATH}=?"
|
||||
resolver.query(
|
||||
downloadsCollection(),
|
||||
projection,
|
||||
selection,
|
||||
arrayOf(displayName, relativePath),
|
||||
null,
|
||||
)?.use { cursor ->
|
||||
return cursor.moveToFirst()
|
||||
}
|
||||
// Some providers omit the trailing slash; check the alternate form.
|
||||
val altPath = relativePath.trimEnd('/')
|
||||
if (altPath != relativePath) {
|
||||
resolver.query(
|
||||
downloadsCollection(),
|
||||
projection,
|
||||
selection,
|
||||
arrayOf(displayName, altPath),
|
||||
null,
|
||||
)?.use { cursor ->
|
||||
return cursor.moveToFirst()
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun mimeTypeFor(fileName: String): String {
|
||||
val extension = fileName.substringAfterLast('.', missingDelimiterValue = "").lowercase()
|
||||
if (extension.isNotEmpty()) {
|
||||
MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension)?.let { return it }
|
||||
URLConnection.guessContentTypeFromName(fileName)?.let { return it }
|
||||
}
|
||||
return "application/octet-stream"
|
||||
}
|
||||
}
|
||||
|
||||
private class AndroidTreeReceiveOutputSink(
|
||||
private val context: Context,
|
||||
private val treeUri: Uri,
|
||||
|
||||
Reference in New Issue
Block a user