diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml
index c7faec7..f7ba8e4 100644
--- a/androidApp/src/main/AndroidManifest.xml
+++ b/androidApp/src/main/AndroidManifest.xml
@@ -5,6 +5,10 @@
+
+
+
diff --git a/shared/src/androidMain/kotlin/com/vnidrop/app/core/FileSystemService.android.kt b/shared/src/androidMain/kotlin/com/vnidrop/app/core/FileSystemService.android.kt
index 5818c7a..49cea2c 100644
--- a/shared/src/androidMain/kotlin/com/vnidrop/app/core/FileSystemService.android.kt
+++ b/shared/src/androidMain/kotlin/com/vnidrop/app/core/FileSystemService.android.kt
@@ -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()
+ 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 {
+ 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,
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/core/FileSystemService.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/core/FileSystemService.kt
index 0b849db..1423a90 100644
--- a/shared/src/commonMain/kotlin/com/vnidrop/app/core/FileSystemService.kt
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/FileSystemService.kt
@@ -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,
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/preferences/AppPreferencesRepository.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/preferences/AppPreferencesRepository.kt
index c9eaabd..0c64aa3 100644
--- a/shared/src/commonMain/kotlin/com/vnidrop/app/preferences/AppPreferencesRepository.kt
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/preferences/AppPreferencesRepository.kt
@@ -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//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()
diff --git a/shared/src/iosMain/kotlin/com/vnidrop/app/core/FileSystemService.ios.kt b/shared/src/iosMain/kotlin/com/vnidrop/app/core/FileSystemService.ios.kt
index 4a1e901..82c956f 100644
--- a/shared/src/iosMain/kotlin/com/vnidrop/app/core/FileSystemService.ios.kt
+++ b/shared/src/iosMain/kotlin/com/vnidrop/app/core/FileSystemService.ios.kt
@@ -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
diff --git a/shared/src/jvmTest/kotlin/com/vnidrop/app/preferences/AppPreferencesRepositoryTest.kt b/shared/src/jvmTest/kotlin/com/vnidrop/app/preferences/AppPreferencesRepositoryTest.kt
index 87fa8d3..b8693d0 100644
--- a/shared/src/jvmTest/kotlin/com/vnidrop/app/preferences/AppPreferencesRepositoryTest.kt
+++ b/shared/src/jvmTest/kotlin/com/vnidrop/app/preferences/AppPreferencesRepositoryTest.kt
@@ -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,
),
)