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

View File

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