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

@@ -5,6 +5,10 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/> <uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.NFC"/> <uses-permission android:name="android.permission.NFC"/>
<!-- Pre-Android 10 public Downloads writes. Android 10+ uses MediaStore. -->
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28"/>
<uses-feature android:name="android.hardware.nfc" android:required="false"/> <uses-feature android:name="android.hardware.nfc" android:required="false"/>
<application <application

View File

@@ -65,10 +65,12 @@ bytes through Kotlin memory.
falls back to an exclusive rename (`renameat2(RENAME_NOREPLACE)` / falls back to an exclusive rename (`renameat2(RENAME_NOREPLACE)` /
`renamex_np(RENAME_EXCL)`). Failure or cancellation removes the temporary `renamex_np(RENAME_EXCL)`). Failure or cancellation removes the temporary
file. Stale VniDrop temporary files are cleaned on later writes. file. Stale VniDrop temporary files are cleaned on later writes.
- Android defaults to the app-specific external Downloads directory - Android defaults to the shared system Downloads collection via MediaStore
(`getExternalFilesDir`), which is always writable by the process. Shared (`ReceiveFolderKind.AndroidPublicDownloads` on API 29+). Files show up in the
system folders require a SAF tree URI via the folder picker; those receives user's Downloads UI like a browser download. Custom folders still use a SAF
stream through `ReceiveOutputSink` instead of raw filesystem paths. tree URI from the folder picker. Both Android sinks stream through
`ReceiveOutputSink` instead of raw filesystem paths. Pre-Android 10 falls
back to the public Downloads path with legacy storage permission.
- Foreign output sinks receive exactly one terminal callback after a successful - Foreign output sinks receive exactly one terminal callback after a successful
`start_file`: `finish_file` or `abort_file`. `start_file`: `finish_file` or `abort_file`.

View File

@@ -2,4 +2,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <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.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
</manifest> </manifest>

View File

@@ -1,15 +1,21 @@
package com.vnidrop.app.core package com.vnidrop.app.core
import android.content.ContentValues
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import android.os.Build
import android.os.Environment import android.os.Environment
import android.provider.DocumentsContract 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.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.core.net.toUri
import uniffi.vnidrop.ReceiveOutputSink import uniffi.vnidrop.ReceiveOutputSink
import java.io.File
import java.io.OutputStream import java.io.OutputStream
import java.net.URLConnection
import java.util.UUID import java.util.UUID
@Composable @Composable
@@ -22,31 +28,40 @@ private class AndroidFileSystemService(
private val context: Context, private val context: Context,
) : FileSystemService { ) : FileSystemService {
override fun defaultReceiveFolder(): ReceiveFolder { override fun defaultReceiveFolder(): ReceiveFolder {
// App-specific external storage is always writable without SAF or // Match desktop: shared system Downloads. On Android 10+ this is MediaStore,
// legacy storage permissions. It is NOT the shared system Downloads // not a raw filesystem path (scoped storage). Older APIs fall back to the
// gallery — that still requires "Choose folder" (tree URI). // public Downloads directory when legacy storage writes are allowed.
val path = context return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) ReceiveFolder(
?.absolutePath kind = ReceiveFolderKind.AndroidPublicDownloads,
?: context.filesDir.resolve("Downloads").apply { mkdirs() }.absolutePath value = AndroidPublicDownloadsToken,
return ReceiveFolder( displayName = "Downloads",
kind = ReceiveFolderKind.FileSystemPath, )
value = path, } else {
displayName = "App downloads", val publicDownloads = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
) ReceiveFolder(
kind = ReceiveFolderKind.FileSystemPath,
value = publicDownloads.absolutePath,
displayName = "Downloads",
)
}
} }
override suspend fun validateReceiveFolder(folder: ReceiveFolder): FolderAccessStatus = override suspend fun validateReceiveFolder(folder: ReceiveFolder): FolderAccessStatus =
when (folder.kind) { when (folder.kind) {
ReceiveFolderKind.FileSystemPath -> validatePath(folder.value) ReceiveFolderKind.FileSystemPath -> validatePath(folder.value)
ReceiveFolderKind.AndroidPublicDownloads -> validatePublicDownloads()
ReceiveFolderKind.AndroidTreeUri -> validateTreeUri(folder.value) ReceiveFolderKind.AndroidTreeUri -> validateTreeUri(folder.value)
ReceiveFolderKind.IosSecurityScopedUrl -> FolderAccessStatus.Unavailable ReceiveFolderKind.IosSecurityScopedUrl -> FolderAccessStatus.Unavailable
} }
override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSink? { override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSink? =
if (folder.kind != ReceiveFolderKind.AndroidTreeUri) return null when (folder.kind) {
return AndroidTreeReceiveOutputSink(context, folder.value.toUri()) ReceiveFolderKind.AndroidPublicDownloads -> AndroidMediaStoreDownloadsSink(context)
} ReceiveFolderKind.AndroidTreeUri -> AndroidTreeReceiveOutputSink(context, folder.value.toUri())
ReceiveFolderKind.FileSystemPath,
ReceiveFolderKind.IosSecurityScopedUrl -> null
}
override suspend fun sharePickedFile( override suspend fun sharePickedFile(
repository: CoreGateway, 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 * Scoped storage often reports public directories as writable even when
* the process cannot create files there. A probe matches what receive needs. * the process cannot create files there. A probe matches what receive needs.
*/ */
private fun validatePath(path: String): FolderAccessStatus = private fun validatePath(path: String): FolderAccessStatus =
runCatching { runCatching {
val directory = java.io.File(path) val directory = File(path)
if (!directory.exists() && !directory.mkdirs()) { if (!directory.exists() && !directory.mkdirs()) {
return FolderAccessStatus.Unavailable return FolderAccessStatus.Unavailable
} }
if (!directory.isDirectory) 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 { try {
probe.outputStream().use { stream -> stream.write(1) } probe.outputStream().use { stream -> stream.write(1) }
if (!probe.exists()) return FolderAccessStatus.Unavailable if (!probe.exists()) return FolderAccessStatus.Unavailable
@@ -90,6 +105,16 @@ private class AndroidFileSystemService(
} }
}.getOrDefault(FolderAccessStatus.Unavailable) }.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 { private fun validateTreeUri(value: String): FolderAccessStatus {
val uri = Uri.parse(value) val uri = Uri.parse(value)
val hasPermission = context.contentResolver.persistedUriPermissions.any { permission -> 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 class AndroidTreeReceiveOutputSink(
private val context: Context, private val context: Context,
private val treeUri: Uri, private val treeUri: Uri,

View File

@@ -5,10 +5,15 @@ import uniffi.vnidrop.ReceiveOutputSink
enum class ReceiveFolderKind { enum class ReceiveFolderKind {
FileSystemPath, FileSystemPath,
/** Shared system Downloads via MediaStore (Android 10+). */
AndroidPublicDownloads,
AndroidTreeUri, AndroidTreeUri,
IosSecurityScopedUrl, IosSecurityScopedUrl,
} }
/** Stable token stored in preferences for [ReceiveFolderKind.AndroidPublicDownloads]. */
const val AndroidPublicDownloadsToken = "media-store:downloads"
data class ReceiveFolder( data class ReceiveFolder(
val kind: ReceiveFolderKind, val kind: ReceiveFolderKind,
val value: String, val value: String,

View File

@@ -47,14 +47,7 @@ class AppPreferencesRepository(
.map { prefs -> .map { prefs ->
AppPreferences( AppPreferences(
username = prefs[PreferenceKeys.Username]?.takeIf { it.isNotBlank() } ?: defaults.username, username = prefs[PreferenceKeys.Username]?.takeIf { it.isNotBlank() } ?: defaults.username,
receiveFolder = ReceiveFolder( receiveFolder = resolveReceiveFolder(prefs, defaults.receiveFolder),
kind = prefs[PreferenceKeys.ReceiveFolderKind]?.let { receiveFolderKindOrNull(it) }
?: defaults.receiveFolder.kind,
value = prefs[PreferenceKeys.ReceiveFolderValue]?.takeIf { it.isNotBlank() }
?: defaults.receiveFolder.value,
displayName = prefs[PreferenceKeys.ReceiveFolderDisplayName]?.takeIf { it.isNotBlank() }
?: defaults.receiveFolder.displayName,
),
themeMode = prefs[PreferenceKeys.ThemeMode]?.let { themeModeOrNull(it) } ?: defaults.themeMode, themeMode = prefs[PreferenceKeys.ThemeMode]?.let { themeModeOrNull(it) } ?: defaults.themeMode,
notificationsEnabled = prefs[PreferenceKeys.NotificationsEnabled] ?: defaults.notificationsEnabled, notificationsEnabled = prefs[PreferenceKeys.NotificationsEnabled] ?: defaults.notificationsEnabled,
) )
@@ -105,6 +98,32 @@ private object PreferenceKeys {
val NotificationsEnabled = booleanPreferencesKey("notifications_enabled") val NotificationsEnabled = booleanPreferencesKey("notifications_enabled")
} }
private fun resolveReceiveFolder(prefs: Preferences, defaults: ReceiveFolder): ReceiveFolder {
val kind = prefs[PreferenceKeys.ReceiveFolderKind]?.let { receiveFolderKindOrNull(it) }
?: defaults.kind
val value = prefs[PreferenceKeys.ReceiveFolderValue]?.takeIf { it.isNotBlank() }
?: defaults.value
val displayName = prefs[PreferenceKeys.ReceiveFolderDisplayName]?.takeIf { it.isNotBlank() }
?: defaults.displayName
// Older Android builds defaulted to app-private "Downloads" paths that are
// invisible in the system Downloads UI. Promote those back to the shared
// public Downloads default so receive matches desktop expectations.
if (kind == ReceiveFolderKind.FileSystemPath && isLegacyAndroidAppDownloadsPath(value)) {
return defaults
}
return ReceiveFolder(kind = kind, value = value, displayName = displayName)
}
private fun isLegacyAndroidAppDownloadsPath(path: String): Boolean {
// Typical: /storage/emulated/0/Android/data/<pkg>/files/Download[s]
val normalized = path.replace('\\', '/')
return normalized.contains("/Android/data/") &&
(normalized.endsWith("/files/Download") ||
normalized.endsWith("/files/Downloads") ||
normalized.contains("/files/Download/") ||
normalized.contains("/files/Downloads/"))
}
private fun receiveFolderKindOrNull(raw: String): ReceiveFolderKind? = private fun receiveFolderKindOrNull(raw: String): ReceiveFolderKind? =
runCatching { ReceiveFolderKind.valueOf(raw) }.getOrNull() runCatching { ReceiveFolderKind.valueOf(raw) }.getOrNull()

View File

@@ -37,7 +37,8 @@ private class IosFileSystemService : FileSystemService {
} }
} }
ReceiveFolderKind.IosSecurityScopedUrl -> validateSecurityScopedUrl(folder.value) ReceiveFolderKind.IosSecurityScopedUrl -> validateSecurityScopedUrl(folder.value)
ReceiveFolderKind.AndroidTreeUri -> FolderAccessStatus.Unavailable ReceiveFolderKind.AndroidTreeUri,
ReceiveFolderKind.AndroidPublicDownloads -> FolderAccessStatus.Unavailable
} }
override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSink? = null override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSink? = null

View File

@@ -39,13 +39,46 @@ class AppPreferencesRepositoryTest {
assertEquals(true, repository.preferences.first().notificationsEnabled) assertEquals(true, repository.preferences.first().notificationsEnabled)
} }
private fun repositoryForTest(): AppPreferencesRepository { @Test
fun legacyAndroidAppDownloadsPathIsPromotedToDefault() = runBlocking {
val publicDefault = ReceiveFolder(
kind = ReceiveFolderKind.AndroidPublicDownloads,
value = "media-store:downloads",
displayName = "Downloads",
)
val repository = repositoryForTest(default = publicDefault)
repository.setReceiveFolder(
ReceiveFolder(
kind = ReceiveFolderKind.FileSystemPath,
value = "/storage/emulated/0/Android/data/com.vnidrop.app/files/Downloads",
displayName = "App downloads",
),
)
assertEquals(publicDefault, repository.preferences.first().receiveFolder)
}
@Test
fun customFileSystemFolderIsNotPromotedAway() = runBlocking {
val repository = repositoryForTest()
val custom = ReceiveFolder(
kind = ReceiveFolderKind.FileSystemPath,
value = "/tmp/custom-receive",
displayName = "Custom",
)
repository.setReceiveFolder(custom)
assertEquals(custom, repository.preferences.first().receiveFolder)
}
private fun repositoryForTest(
default: ReceiveFolder = defaultFolder,
): AppPreferencesRepository {
val directory = Files.createTempDirectory("vnidrop-preferences-test").toString() val directory = Files.createTempDirectory("vnidrop-preferences-test").toString()
return AppPreferencesRepository( return AppPreferencesRepository(
dataStore = createAppPreferencesDataStore(directory), dataStore = createAppPreferencesDataStore(directory),
defaults = AppPreferencesDefaults( defaults = AppPreferencesDefaults(
username = "Device Name", username = "Device Name",
receiveFolder = defaultFolder, receiveFolder = default,
themeMode = ThemeMode.System, themeMode = ThemeMode.System,
), ),
) )