mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 02:29:55 +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,
|
||||
|
||||
@@ -5,10 +5,15 @@ import uniffi.vnidrop.ReceiveOutputSink
|
||||
|
||||
enum class ReceiveFolderKind {
|
||||
FileSystemPath,
|
||||
/** Shared system Downloads via MediaStore (Android 10+). */
|
||||
AndroidPublicDownloads,
|
||||
AndroidTreeUri,
|
||||
IosSecurityScopedUrl,
|
||||
}
|
||||
|
||||
/** Stable token stored in preferences for [ReceiveFolderKind.AndroidPublicDownloads]. */
|
||||
const val AndroidPublicDownloadsToken = "media-store:downloads"
|
||||
|
||||
data class ReceiveFolder(
|
||||
val kind: ReceiveFolderKind,
|
||||
val value: String,
|
||||
|
||||
@@ -47,14 +47,7 @@ class AppPreferencesRepository(
|
||||
.map { prefs ->
|
||||
AppPreferences(
|
||||
username = prefs[PreferenceKeys.Username]?.takeIf { it.isNotBlank() } ?: defaults.username,
|
||||
receiveFolder = 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,
|
||||
),
|
||||
receiveFolder = resolveReceiveFolder(prefs, defaults.receiveFolder),
|
||||
themeMode = prefs[PreferenceKeys.ThemeMode]?.let { themeModeOrNull(it) } ?: defaults.themeMode,
|
||||
notificationsEnabled = prefs[PreferenceKeys.NotificationsEnabled] ?: defaults.notificationsEnabled,
|
||||
)
|
||||
@@ -105,6 +98,32 @@ private object PreferenceKeys {
|
||||
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? =
|
||||
runCatching { ReceiveFolderKind.valueOf(raw) }.getOrNull()
|
||||
|
||||
|
||||
@@ -37,7 +37,8 @@ private class IosFileSystemService : FileSystemService {
|
||||
}
|
||||
}
|
||||
ReceiveFolderKind.IosSecurityScopedUrl -> validateSecurityScopedUrl(folder.value)
|
||||
ReceiveFolderKind.AndroidTreeUri -> FolderAccessStatus.Unavailable
|
||||
ReceiveFolderKind.AndroidTreeUri,
|
||||
ReceiveFolderKind.AndroidPublicDownloads -> FolderAccessStatus.Unavailable
|
||||
}
|
||||
|
||||
override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSink? = null
|
||||
|
||||
@@ -39,13 +39,46 @@ class AppPreferencesRepositoryTest {
|
||||
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()
|
||||
return AppPreferencesRepository(
|
||||
dataStore = createAppPreferencesDataStore(directory),
|
||||
defaults = AppPreferencesDefaults(
|
||||
username = "Device Name",
|
||||
receiveFolder = defaultFolder,
|
||||
receiveFolder = default,
|
||||
themeMode = ThemeMode.System,
|
||||
),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user