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:
2026-07-12 04:43:34 +02:00
parent d5d4e37d1c
commit a79c61b9d7
8 changed files with 255 additions and 35 deletions

View File

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

View File

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