mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 10:29:58 +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:
@@ -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
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user