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