From 0863b37d70719387de73665ebc838ac2903deccb Mon Sep 17 00:00:00 2001 From: Hadrian Burkhardt Date: Wed, 14 Oct 2026 15:17:51 +0200 Subject: [PATCH] M2: storage layer (all-files + SAF backends, volumes, access policy) + 32 JVM tests --- CHANGELOG.md | 11 ++ README.md | 9 +- .../filemanager/data/AllFilesSource.kt | 102 +++++++++++ .../filemanager/data/SafDocumentIds.kt | 40 +++++ .../filemanager/data/SafGrant.kt | 41 +++++ .../filemanager/data/SafSource.kt | 161 ++++++++++++++++++ .../filemanager/data/StorageAccess.kt | 80 +++++++++ .../filemanager/data/StorageAccessPolicy.kt | 56 ++++++ .../filemanager/data/StorageSource.kt | 65 +++++++ .../filemanager/data/VolumeRepository.kt | 121 +++++++++++++ app/src/main/res/raw/keep.xml | 8 + .../filemanager/data/AllFilesSourceTest.kt | 121 +++++++++++++ .../filemanager/data/SafDocumentIdsTest.kt | 51 ++++++ .../filemanager/data/SafSourceTest.kt | 90 ++++++++++ .../data/StorageAccessPolicyTest.kt | 58 +++++++ .../filemanager/data/VolumeRepositoryTest.kt | 74 ++++++++ .../data/testutil/FakeDocumentsProvider.kt | 89 ++++++++++ docs/ROADMAP.md | 2 +- 18 files changed, 1174 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/de/softwareapp_hb/filemanager/data/AllFilesSource.kt create mode 100644 app/src/main/java/de/softwareapp_hb/filemanager/data/SafDocumentIds.kt create mode 100644 app/src/main/java/de/softwareapp_hb/filemanager/data/SafGrant.kt create mode 100644 app/src/main/java/de/softwareapp_hb/filemanager/data/SafSource.kt create mode 100644 app/src/main/java/de/softwareapp_hb/filemanager/data/StorageAccess.kt create mode 100644 app/src/main/java/de/softwareapp_hb/filemanager/data/StorageAccessPolicy.kt create mode 100644 app/src/main/java/de/softwareapp_hb/filemanager/data/StorageSource.kt create mode 100644 app/src/main/java/de/softwareapp_hb/filemanager/data/VolumeRepository.kt create mode 100644 app/src/main/res/raw/keep.xml create mode 100644 app/src/test/java/de/softwareapp_hb/filemanager/data/AllFilesSourceTest.kt create mode 100644 app/src/test/java/de/softwareapp_hb/filemanager/data/SafDocumentIdsTest.kt create mode 100644 app/src/test/java/de/softwareapp_hb/filemanager/data/SafSourceTest.kt create mode 100644 app/src/test/java/de/softwareapp_hb/filemanager/data/StorageAccessPolicyTest.kt create mode 100644 app/src/test/java/de/softwareapp_hb/filemanager/data/VolumeRepositoryTest.kt create mode 100644 app/src/test/java/de/softwareapp_hb/filemanager/data/testutil/FakeDocumentsProvider.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index ab7807f..dc91794 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,17 @@ upload needs a **higher `versionCode`** than every previous upload. ### Added +- **M2 — storage layer.** The two storage backends behind one interface. `AllFilesSource` + is `java.io.File` over every mounted volume (the "All files access" path); + `SafSource` is the Storage Access Framework fallback, reading a persisted tree URI + through `DocumentsContract` with `SafDocumentIds` doing the tree↔path mapping and + `SafGrant` owning the folder picker and its persistence. `VolumeRepository` enumerates + volumes via `StorageManager`/`StorageVolume` and reports free/total space through + `StatFs`; `StorageAccess` and `StorageAccessPolicy` decide between the two backends. + Both sources confine their blocking I/O to `Dispatchers.IO` and expose a `suspend` + contract. 32 new JVM tests (76 total), including a Robolectric run of `SafSource` + against a stand-in `DocumentsProvider`. + - **M1 — pure-Kotlin core engine.** An Android-free `core/` package holding the logic every other layer builds on: `StoragePath` (normalised absolute paths that can never escape the root), `FileEntry` with extension-based `FileKind` classification and a diff --git a/README.md b/README.md index 203293b..830dbf9 100644 --- a/README.md +++ b/README.md @@ -12,10 +12,11 @@ no advertising or tracking SDKs. ## Status -**M1 — pure-Kotlin core: done.** The Android-free `core/` engine (paths, classification, -sorting, formatting, search, name allocation, transfer planning) is implemented and -covered by 44 JVM unit tests. See [docs/ROADMAP.md](docs/ROADMAP.md) for the full -milestone plan. +**M2 — storage layer: done.** Two backends behind one interface — `AllFilesSource` +(`java.io.File` under all-files access) and the `SafSource` fallback — plus volume/space +discovery and the access-mode state machine. Covered by 76 JVM unit tests (32 added +here), including a Robolectric run of the SAF backend. See +[docs/ROADMAP.md](docs/ROADMAP.md) for the full milestone plan. ## Tech stack diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/data/AllFilesSource.kt b/app/src/main/java/de/softwareapp_hb/filemanager/data/AllFilesSource.kt new file mode 100644 index 0000000..6265d00 --- /dev/null +++ b/app/src/main/java/de/softwareapp_hb/filemanager/data/AllFilesSource.kt @@ -0,0 +1,102 @@ +package de.softwareapp_hb.filemanager.data + +import android.annotation.SuppressLint +import de.softwareapp_hb.filemanager.core.FileEntry +import de.softwareapp_hb.filemanager.core.StoragePath +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.io.InputStream +import java.io.OutputStream + +/** + * The "All files access" backend: plain `java.io.File` over absolute paths. + * + * This is the primary backend. Its roots are supplied by [VolumeRepository] rather than + * read from [android.content.Context] here, which keeps the whole class testable against + * any ordinary directory tree. + * + * @param roots the browsable roots, in display order. + * @param freeSpace a free-space probe per path; injected so tests need no `StatFs`. + */ +class AllFilesSource( + override val roots: List, + private val freeSpace: (String) -> Long? = { null }, +) : StorageSource { + + override val backendName: String = BACKEND_NAME + + override suspend fun list(directory: StoragePath): List = withContext(Dispatchers.IO) { + val dir = File(directory.value) + val children = dir.listFiles() + if (children == null) emptyList() else children.map { it.toEntry() } + } + + override suspend fun stat(path: StoragePath): FileEntry? = withContext(Dispatchers.IO) { + File(path.value).takeIf { it.exists() }?.toEntry() + } + + override suspend fun exists(path: StoragePath): Boolean = withContext(Dispatchers.IO) { + File(path.value).exists() + } + + override suspend fun createDirectory(path: StoragePath): Boolean = withContext(Dispatchers.IO) { + val dir = File(path.value) + dir.isDirectory || dir.mkdirs() + } + + override suspend fun createFile(path: StoragePath): Boolean = withContext(Dispatchers.IO) { + val file = File(path.value) + runCatching { + file.parentFile?.mkdirs() + file.createNewFile() + }.getOrDefault(false) + } + + override suspend fun delete(path: StoragePath): Boolean = withContext(Dispatchers.IO) { + val file = File(path.value) + if (!file.exists()) return@withContext false + file.deleteRecursively() + } + + override suspend fun rename(from: StoragePath, to: StoragePath): Boolean = withContext(Dispatchers.IO) { + val source = File(from.value) + val target = File(to.value) + if (!source.exists() || target.exists()) return@withContext false + target.parentFile?.mkdirs() + source.renameTo(target) + } + + // The caller owns the returned stream and closes it; lint's "Recycle" heuristic + // assumes a factory method should close whatever it hands back. + @SuppressLint("Recycle") + override suspend fun openRead(path: StoragePath): InputStream = withContext(Dispatchers.IO) { + FileInputStream(File(path.value)) + } + + @SuppressLint("Recycle") + override suspend fun openWrite(path: StoragePath, append: Boolean): OutputStream = + withContext(Dispatchers.IO) { + val file = File(path.value) + file.parentFile?.mkdirs() + FileOutputStream(file, append) + } + + override suspend fun freeSpaceBytes(path: StoragePath): Long? = withContext(Dispatchers.IO) { + freeSpace(path.value) + } + + companion object { + const val BACKEND_NAME = "all-files" + } +} + +/** Map a `java.io.File` onto the backend-neutral [FileEntry]. */ +internal fun File.toEntry(): FileEntry = FileEntry( + path = StoragePath.of(absolutePath), + isDirectory = isDirectory, + sizeBytes = if (isDirectory) 0L else length(), + lastModifiedMillis = lastModified(), +) diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/data/SafDocumentIds.kt b/app/src/main/java/de/softwareapp_hb/filemanager/data/SafDocumentIds.kt new file mode 100644 index 0000000..a39a4fc --- /dev/null +++ b/app/src/main/java/de/softwareapp_hb/filemanager/data/SafDocumentIds.kt @@ -0,0 +1,40 @@ +package de.softwareapp_hb.filemanager.data + +import de.softwareapp_hb.filemanager.core.StoragePath + +/** + * Maps a Storage Access Framework **tree** onto [StoragePath]s. + * + * A SAF tree is identified by a *tree document id* — `primary:Download` for the + * Downloads folder on internal storage. Every document inside it has an id that is the + * tree id plus the relative path (`primary:Download/notes/todo.txt`), which means the + * whole mapping is a string operation and needs none of the provider machinery. That is + * what makes it worth isolating here: it is the one piece of SAF handling that can be + * tested exhaustively without a device. + * + * The root of a tree maps to [StoragePath.Root]; children hang off it as usual. + */ +object SafDocumentIds { + + /** The document id that [path] refers to inside the tree. */ + fun documentId(treeDocumentId: String, path: StoragePath): String = + if (path.isRoot) treeDocumentId else treeDocumentId + path.value + + /** + * The reverse mapping, or `null` when [documentId] lies outside the tree (which + * would mean the provider answered with something we did not ask for). + */ + fun path(treeDocumentId: String, documentId: String): StoragePath? { + if (documentId == treeDocumentId) return StoragePath.Root + val prefix = "$treeDocumentId/" + if (!documentId.startsWith(prefix)) return null + return StoragePath.of("/" + documentId.substring(prefix.length)) + } + + /** + * A human label for a tree, for the volume list: `primary:Download` becomes + * `Download`, and a bare tree id becomes itself. + */ + fun label(treeDocumentId: String): String = + treeDocumentId.substringAfterLast(':').ifEmpty { treeDocumentId } +} diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/data/SafGrant.kt b/app/src/main/java/de/softwareapp_hb/filemanager/data/SafGrant.kt new file mode 100644 index 0000000..1a0a559 --- /dev/null +++ b/app/src/main/java/de/softwareapp_hb/filemanager/data/SafGrant.kt @@ -0,0 +1,41 @@ +package de.softwareapp_hb.filemanager.data + +import android.content.ContentResolver +import android.content.Intent +import android.net.Uri + +/** + * Picking and keeping a folder tree for the SAF fallback. + * + * A tree grant is only valid for the lifetime of the process unless it is *persisted*, + * so the URI the picker returns must be handed to [persist] immediately or the app + * forgets the user's folder on the next launch. + */ +object SafGrant { + + /** The flags a tree grant must carry to be both usable and persistable. */ + const val GRANT_FLAGS = + Intent.FLAG_GRANT_READ_URI_PERMISSION or + Intent.FLAG_GRANT_WRITE_URI_PERMISSION or + Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION + + /** The system folder picker, launched for result. */ + fun openTreeIntent(): Intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply { + addFlags(GRANT_FLAGS) + } + + /** Persist a grant the picker returned. `false` when the provider refused. */ + fun persist(resolver: ContentResolver, treeUri: Uri): Boolean = runCatching { + resolver.takePersistableUriPermission( + treeUri, + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION, + ) + true + }.getOrDefault(false) + + /** The trees the user has already granted and that survived a restart. */ + fun persistedTrees(resolver: ContentResolver): List = + resolver.persistedUriPermissions + .filter { it.isReadPermission } + .map { it.uri } +} diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/data/SafSource.kt b/app/src/main/java/de/softwareapp_hb/filemanager/data/SafSource.kt new file mode 100644 index 0000000..1f4a464 --- /dev/null +++ b/app/src/main/java/de/softwareapp_hb/filemanager/data/SafSource.kt @@ -0,0 +1,161 @@ +package de.softwareapp_hb.filemanager.data + +import android.annotation.SuppressLint +import android.content.ContentResolver +import android.database.Cursor +import android.net.Uri +import android.provider.DocumentsContract +import de.softwareapp_hb.filemanager.core.FileEntry +import de.softwareapp_hb.filemanager.core.MimeTypes +import de.softwareapp_hb.filemanager.core.StoragePath +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.InputStream +import java.io.OutputStream + +/** + * The Storage Access Framework fallback backend. + * + * Used when the user declines "All files access". It can only see the folder tree the + * user granted through `ACTION_OPEN_DOCUMENT_TREE` and that was persisted with + * `takePersistableUriPermission`, so its [roots] is a single tree. + * + * Document ids are built with [SafDocumentIds] rather than by walking the tree one + * level at a time, so listing a deep folder still costs a single provider query. + * + * @param resolver used for every provider call. + * @param treeUri the persisted tree URI. + */ +class SafSource( + private val resolver: ContentResolver, + private val treeUri: Uri, +) : StorageSource { + + private val treeDocumentId: String = runCatching { DocumentsContract.getTreeDocumentId(treeUri) } + .getOrElse { DocumentsContract.getDocumentId(treeUri) } + + /** A display name for the granted tree, e.g. "Download". */ + val treeLabel: String = SafDocumentIds.label(treeDocumentId) + + override val backendName: String = BACKEND_NAME + + override val roots: List = listOf( + FileEntry( + path = StoragePath.Root, + isDirectory = true, + sizeBytes = 0L, + lastModifiedMillis = 0L, + ), + ) + + override suspend fun list(directory: StoragePath): List = withContext(Dispatchers.IO) { + val parentId = SafDocumentIds.documentId(treeDocumentId, directory) + val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, parentId) + + val entries = ArrayList() + resolver.query(childrenUri, PROJECTION, null, null, null)?.use { cursor -> + while (cursor.moveToNext()) { + val name = cursor.getString(NAME) ?: continue + entries += cursor.toEntry(directory.child(name)) + } + } + entries + } + + override suspend fun stat(path: StoragePath): FileEntry? = withContext(Dispatchers.IO) { + resolver.query(documentUri(path), PROJECTION, null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) cursor.toEntry(path) else null + } + } + + override suspend fun exists(path: StoragePath): Boolean = stat(path) != null + + override suspend fun createDirectory(path: StoragePath): Boolean = withContext(Dispatchers.IO) { + val parent = path.parent ?: return@withContext false + DocumentsContract.createDocument( + resolver, + documentUri(parent), + DocumentsContract.Document.MIME_TYPE_DIR, + path.name, + ) != null + } + + override suspend fun createFile(path: StoragePath): Boolean = withContext(Dispatchers.IO) { + val parent = path.parent ?: return@withContext false + DocumentsContract.createDocument( + resolver, + documentUri(parent), + MimeTypes.forName(path.name), + path.name, + ) != null + } + + override suspend fun delete(path: StoragePath): Boolean = withContext(Dispatchers.IO) { + DocumentsContract.deleteDocument(resolver, documentUri(path)) + } + + /** + * Only a rename inside the same directory is handled here. Moving between + * directories goes through the operations engine, which copies and then deletes — + * that also works when the move crosses a volume boundary, which + * [DocumentsContract.moveDocument] does not. + */ + override suspend fun rename(from: StoragePath, to: StoragePath): Boolean = withContext(Dispatchers.IO) { + if (from.parent != to.parent) return@withContext false + DocumentsContract.renameDocument(resolver, documentUri(from), to.name) != null + } + + // The caller owns the returned stream and closes it; lint's "Recycle" heuristic + // assumes a factory method should close whatever it hands back. + @SuppressLint("Recycle") + override suspend fun openRead(path: StoragePath): InputStream = withContext(Dispatchers.IO) { + resolver.openInputStream(documentUri(path)) + ?: throw StorageException("The document provider refused to open $path") + } + + @SuppressLint("Recycle") + override suspend fun openWrite(path: StoragePath, append: Boolean): OutputStream = + withContext(Dispatchers.IO) { + resolver.openOutputStream(documentUri(path), if (append) "wa" else "w") + ?: throw StorageException("The document provider refused to write $path") + } + + /** + * The Storage Access Framework exposes no dependable free-space figure for a tree, + * so the caller falls back to the volume statistics it already has. + */ + override suspend fun freeSpaceBytes(path: StoragePath): Long? = null + + private fun documentUri(path: StoragePath): Uri = + DocumentsContract.buildDocumentUriUsingTree(treeUri, SafDocumentIds.documentId(treeDocumentId, path)) + + private fun Cursor.toEntry(path: StoragePath): FileEntry { + val isDirectory = getString(MIME) == DocumentsContract.Document.MIME_TYPE_DIR + val size = if (isNull(SIZE)) 0L else getLong(SIZE) + val modified = if (isNull(MODIFIED)) 0L else getLong(MODIFIED) + return FileEntry( + path = path, + isDirectory = isDirectory, + sizeBytes = if (isDirectory) 0L else size, + lastModifiedMillis = modified, + ) + } + + companion object { + const val BACKEND_NAME = "saf" + + private const val NAME = 1 + private const val MIME = 2 + private const val SIZE = 3 + private const val MODIFIED = 4 + + /** The columns [SafSource] reads, shared so test providers cannot drift from it. */ + val PROJECTION = arrayOf( + DocumentsContract.Document.COLUMN_DOCUMENT_ID, + DocumentsContract.Document.COLUMN_DISPLAY_NAME, + DocumentsContract.Document.COLUMN_MIME_TYPE, + DocumentsContract.Document.COLUMN_SIZE, + DocumentsContract.Document.COLUMN_LAST_MODIFIED, + ) + } +} diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/data/StorageAccess.kt b/app/src/main/java/de/softwareapp_hb/filemanager/data/StorageAccess.kt new file mode 100644 index 0000000..e66ec51 --- /dev/null +++ b/app/src/main/java/de/softwareapp_hb/filemanager/data/StorageAccess.kt @@ -0,0 +1,80 @@ +package de.softwareapp_hb.filemanager.data + +import android.annotation.SuppressLint +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.provider.Settings +import androidx.core.content.ContextCompat + +/** + * The Android-facing side of the storage-access decision. + * + * All policy lives in [StorageAccessPolicy]; this class only reads the platform state, + * hands the rules its answers, and builds the intents the UI needs. The probes are + * injectable so [StorageAccess] itself can be exercised without a device. + */ +class StorageAccess( + private val sdkInt: Int, + private val packageName: String, + private val isExternalStorageManager: () -> Boolean, + private val isGranted: (String) -> Boolean, +) { + + /** Whether the full-power backend or the SAF fallback is in effect right now. */ + fun mode(): StorageAccessMode = StorageAccessPolicy.mode( + sdkInt = sdkInt, + isExternalStorageManager = isExternalStorageManager(), + hasLegacyWriteAccess = isGranted(StorageAccessPolicy.WRITE_EXTERNAL_STORAGE), + ) + + /** Runtime permissions that are still missing for the current mode. */ + fun missingRuntimePermissions(): List = + StorageAccessPolicy.missingPermissions(sdkInt) { isGranted(it) } + + /** `true` when the user has to be sent to Settings to grant "All files access". */ + fun needsAllFilesAccessRequest(): Boolean = + sdkInt >= StorageAccessPolicy.API_R && !isExternalStorageManager() + + /** + * The page where the user grants "All files access" (Android 11+), or the app's + * details page on older releases where that switch does not exist. + * + * `InlinedApi` is expected here: the action string is a compile-time constant that + * javac inlines, and the branch is already guarded by the API check — lint cannot see + * through the injected [sdkInt]. + */ + @SuppressLint("InlinedApi") + fun allFilesAccessIntent(): Intent = + if (sdkInt >= StorageAccessPolicy.API_R) { + Intent( + Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION, + Uri.fromParts("package", packageName, null), + ) + } else { + Intent( + Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.fromParts("package", packageName, null), + ) + } + + companion object { + fun forContext(context: Context): StorageAccess { + val app = context.applicationContext + return StorageAccess( + sdkInt = Build.VERSION.SDK_INT, + packageName = app.packageName, + isExternalStorageManager = { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && + Environment.isExternalStorageManager() + }, + isGranted = { permission -> + ContextCompat.checkSelfPermission(app, permission) == + android.content.pm.PackageManager.PERMISSION_GRANTED + }, + ) + } + } +} diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/data/StorageAccessPolicy.kt b/app/src/main/java/de/softwareapp_hb/filemanager/data/StorageAccessPolicy.kt new file mode 100644 index 0000000..2a1f773 --- /dev/null +++ b/app/src/main/java/de/softwareapp_hb/filemanager/data/StorageAccessPolicy.kt @@ -0,0 +1,56 @@ +package de.softwareapp_hb.filemanager.data + +/** Which storage backend the app is currently able to use. */ +enum class StorageAccessMode { + /** "All files access" is granted — the whole device is browsable. */ + ALL_FILES, + + /** Declined — only folder trees the user granted through the SAF are browsable. */ + SAF_FALLBACK, +} + +/** + * The decision rules behind [StorageAccess]. + * + * Kept in its own object, with every Android API reduced to a plain parameter, so the + * rules can be tested for each API level on the JVM instead of on a matrix of devices. + * The `API_*` constants are spelled out rather than read from `Build` for the same + * reason. + */ +object StorageAccessPolicy { + + const val API_R = 30 + const val API_TIRAMISU = 33 + + const val READ_EXTERNAL_STORAGE = "android.permission.READ_EXTERNAL_STORAGE" + const val WRITE_EXTERNAL_STORAGE = "android.permission.WRITE_EXTERNAL_STORAGE" + + /** + * "All files access" is a special app-op from Android 11; on Android 10 and below + * the equivalent capability came from the `WRITE_EXTERNAL_STORAGE` runtime grant. + */ + fun mode( + sdkInt: Int, + isExternalStorageManager: Boolean, + hasLegacyWriteAccess: Boolean, + ): StorageAccessMode { + val granted = if (sdkInt >= API_R) isExternalStorageManager else hasLegacyWriteAccess + return if (granted) StorageAccessMode.ALL_FILES else StorageAccessMode.SAF_FALLBACK + } + + /** + * The runtime permissions still needed for the current mode. + * + * From Android 13 the media permissions are a separate concern (thumbnails and the + * built-in viewers, M5), and "all files access" itself is a special permission + * rather than a runtime one — so browsing needs nothing extra there. + */ + fun missingPermissions(sdkInt: Int, isGranted: (String) -> Boolean): List { + val missing = ArrayList() + if (sdkInt >= API_TIRAMISU) return missing + + if (!isGranted(READ_EXTERNAL_STORAGE)) missing += READ_EXTERNAL_STORAGE + if (sdkInt < API_R && !isGranted(WRITE_EXTERNAL_STORAGE)) missing += WRITE_EXTERNAL_STORAGE + return missing + } +} diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/data/StorageSource.kt b/app/src/main/java/de/softwareapp_hb/filemanager/data/StorageSource.kt new file mode 100644 index 0000000..e1345ef --- /dev/null +++ b/app/src/main/java/de/softwareapp_hb/filemanager/data/StorageSource.kt @@ -0,0 +1,65 @@ +package de.softwareapp_hb.filemanager.data + +import de.softwareapp_hb.filemanager.core.FileEntry +import de.softwareapp_hb.filemanager.core.StoragePath +import java.io.InputStream +import java.io.OutputStream + +/** A backend operation failed for a reason worth surfacing to the user. */ +class StorageException(message: String, cause: Throwable? = null) : Exception(message, cause) + +/** + * The primitive file operations a storage backend must provide. + * + * There are two implementations, and the app switches between them at runtime: + * + * - [AllFilesSource] — plain `java.io.File`, used when "All files access" is granted. + * This is the full-power path: every file on every mounted volume. + * - [SafSource] — the Storage Access Framework, used as a **fallback** when the user + * declines "All files access". It only sees the folder trees the user granted. + * + * Everything above this interface (sorting, searching, the operations engine, the UI) + * is written against [StoragePath] and [FileEntry] so it never has to know which + * backend is underneath. + * + * All methods are `suspend` and implementations confine their blocking I/O to + * [kotlinx.coroutines.Dispatchers.IO], so a caller on the main dispatcher cannot + * accidentally block the UI thread. + */ +interface StorageSource { + + /** Short identifier for diagnostics and tests ("all-files", "saf"). */ + val backendName: String + + /** The browsable roots of this backend, in display order. */ + val roots: List + + /** Children of [directory], in whatever order the backend returns them (unsorted). */ + suspend fun list(directory: StoragePath): List + + /** Metadata for [path], or `null` when it does not exist. */ + suspend fun stat(path: StoragePath): FileEntry? + + suspend fun exists(path: StoragePath): Boolean + + /** Create [path] as a directory (including missing parents where the backend allows). */ + suspend fun createDirectory(path: StoragePath): Boolean + + /** Create an empty file at [path]. */ + suspend fun createFile(path: StoragePath): Boolean + + /** Delete [path], recursively for directories. */ + suspend fun delete(path: StoragePath): Boolean + + /** Rename or move [from] to [to] within this backend. */ + suspend fun rename(from: StoragePath, to: StoragePath): Boolean + + /** Open [path] for reading. The caller closes the stream. */ + suspend fun openRead(path: StoragePath): InputStream + + /** Open [path] for writing, optionally appending. The caller closes the stream. */ + suspend fun openWrite(path: StoragePath, append: Boolean = false): OutputStream + + /** Free bytes on the volume holding [path], or `null` when unknown. */ + suspend fun freeSpaceBytes(path: StoragePath): Long? +} diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/data/VolumeRepository.kt b/app/src/main/java/de/softwareapp_hb/filemanager/data/VolumeRepository.kt new file mode 100644 index 0000000..ee26d77 --- /dev/null +++ b/app/src/main/java/de/softwareapp_hb/filemanager/data/VolumeRepository.kt @@ -0,0 +1,121 @@ +package de.softwareapp_hb.filemanager.data + +import android.content.Context +import android.os.Build +import android.os.Environment +import android.os.StatFs +import android.os.storage.StorageManager +import android.os.storage.StorageVolume +import de.softwareapp_hb.filemanager.core.FileEntry +import de.softwareapp_hb.filemanager.core.StoragePath +import java.io.File + +/** + * A mountable storage location the app can browse. + * + * @param label the provider's name for the volume, or `null` for the primary volume — + * the UI then substitutes its own localised "internal storage" string rather than + * baking English into the data layer. + */ +data class VolumeInfo( + val root: StoragePath, + val label: String?, + val isRemovable: Boolean, + val isPrimary: Boolean, +) + +/** Total and free bytes for a volume. */ +data class SpaceInfo(val totalBytes: Long, val freeBytes: Long) { + val usedBytes: Long get() = (totalBytes - freeBytes).coerceAtLeast(0L) +} + +/** + * Discovers the device's storage volumes and reports their space. + * + * The probe functions are injected so the class itself is a thin, predictable wrapper: + * the discovery rule ("primary, then every additional mounted volume, once") is the part + * worth testing, and it does not need a real device filesystem. + */ +class VolumeRepository( + private val discoverVolumes: () -> List, + private val probeSpace: (String) -> SpaceInfo?, +) { + + /** The volumes to show, primary first, without duplicates. */ + fun volumes(): List = discoverVolumes() + + fun space(path: StoragePath): SpaceInfo? = probeSpace(path.value) + + /** [FileEntry] roots for [AllFilesSource], in display order. */ + fun rootEntries(): List = volumes().map { volume -> + FileEntry( + path = volume.root, + isDirectory = true, + sizeBytes = 0L, + lastModifiedMillis = 0L, + ) + } + + /** Free bytes summed across every volume. */ + fun totalFreeBytes(): Long = volumes().sumOf { space(it.root)?.freeBytes ?: 0L } + + /** Total bytes summed across every volume. */ + fun totalBytes(): Long = volumes().sumOf { space(it.root)?.totalBytes ?: 0L } + + companion object { + fun forContext(context: Context): VolumeRepository = VolumeRepository( + discoverVolumes = { discover(context) }, + probeSpace = { path -> stat(path) }, + ) + + private fun discover(context: Context): List { + val found = LinkedHashMap() + + // The primary shared storage is always browsable and is what the user means + // by "internal storage"; it is added first so it heads the list. + val primary = Environment.getExternalStorageDirectory() + if (primary != null) { + found[primary.absolutePath] = VolumeInfo( + root = StoragePath.of(primary.absolutePath), + label = null, + isRemovable = false, + isPrimary = true, + ) + } + + // SD cards and USB OTG drives come from StorageManager. + val manager = context.getSystemService(Context.STORAGE_SERVICE) as? StorageManager + manager?.storageVolumes?.forEach { volume -> + val directory = directoryOf(volume) ?: return@forEach + found.putIfAbsent( + directory.absolutePath, + VolumeInfo( + root = StoragePath.of(directory.absolutePath), + label = volume.getDescription(context), + isRemovable = volume.isRemovable, + isPrimary = volume.isPrimary, + ), + ) + } + + return found.values.toList() + } + + /** + * `StorageVolume.getDirectory()` only exists from Android 11; below that the + * conventional `/storage/` path is used instead. The primary volume has no + * uuid there and is already covered by the `Environment` call above. + */ + private fun directoryOf(volume: StorageVolume): File? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + volume.directory + } else { + volume.uuid?.let { File("/storage/$it") } + } + + private fun stat(path: String): SpaceInfo? = runCatching { + val statFs = StatFs(path) + SpaceInfo(totalBytes = statFs.totalBytes, freeBytes = statFs.availableBytes) + }.getOrNull() + } +} diff --git a/app/src/main/res/raw/keep.xml b/app/src/main/res/raw/keep.xml new file mode 100644 index 0000000..d6a7e02 --- /dev/null +++ b/app/src/main/res/raw/keep.xml @@ -0,0 +1,8 @@ + + + diff --git a/app/src/test/java/de/softwareapp_hb/filemanager/data/AllFilesSourceTest.kt b/app/src/test/java/de/softwareapp_hb/filemanager/data/AllFilesSourceTest.kt new file mode 100644 index 0000000..4797417 --- /dev/null +++ b/app/src/test/java/de/softwareapp_hb/filemanager/data/AllFilesSourceTest.kt @@ -0,0 +1,121 @@ +package de.softwareapp_hb.filemanager.data + +import de.softwareapp_hb.filemanager.core.FileEntry +import de.softwareapp_hb.filemanager.core.StoragePath +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +class AllFilesSourceTest { + + @get:Rule + val temp = TemporaryFolder() + + private fun source(vararg roots: File) = AllFilesSource( + roots = roots.map { FileEntry(StoragePath.of(it.absolutePath), isDirectory = true, sizeBytes = 0, lastModifiedMillis = 0) }, + ) + + private fun path(file: File) = StoragePath.of(file.absolutePath) + + @Test + fun `lists the children of a directory`() = runBlocking { + val dir = temp.newFolder("docs") + File(dir, "a.txt").writeText("hello") + File(dir, "nested").mkdirs() + + val entries = source(dir).list(path(dir)).associateBy { it.name } + + assertEquals(setOf("a.txt", "nested"), entries.keys) + assertFalse(entries.getValue("a.txt").isDirectory) + assertEquals(5L, entries.getValue("a.txt").sizeBytes) + assertTrue(entries.getValue("nested").isDirectory) + } + + @Test + fun `listing a missing directory yields nothing`() = runBlocking { + val missing = StoragePath.of(temp.root.absolutePath).child("nope") + assertTrue(source(temp.root).list(missing).isEmpty()) + } + + @Test + fun `stat and exists agree`() = runBlocking { + val file = temp.newFile("a.txt") + val missing = File(temp.root, "missing") + val src = source(temp.root) + + assertNotNull(src.stat(path(file))) + assertTrue(src.exists(path(file))) + assertFalse(src.exists(path(missing))) + assertNull(src.stat(path(missing))) + } + + @Test + fun `creates missing directories and empty files`() = runBlocking { + val src = source(temp.root) + val nested = StoragePath.of(temp.root.absolutePath).child("a").child("b") + assertTrue(src.createDirectory(nested)) + assertTrue(File(nested.value).isDirectory) + + val file = nested.child("note.txt") + assertTrue(src.createFile(file)) + assertTrue(File(file.value).isFile) + } + + @Test + fun `delete removes a tree recursively`() = runBlocking { + val dir = temp.newFolder("tree") + File(dir, "x/y").mkdirs() + File(dir, "x/y/deep.txt").writeText("d") + + assertTrue(source(temp.root).delete(path(dir))) + assertFalse(dir.exists()) + } + + @Test + fun `delete reports false for something that is not there`() = runBlocking { + assertFalse(source(temp.root).delete(path(File(temp.root, "ghost")))) + } + + @Test + fun `rename moves within the source and refuses to overwrite`() = runBlocking { + val from = temp.newFile("old.txt") + val to = File(temp.root, "new.txt") + val src = source(temp.root) + + assertTrue(src.rename(path(from), path(to))) + assertFalse(from.exists()) + assertTrue(to.exists()) + + assertFalse(src.rename(path(to), path(to))) + } + + @Test + fun `writing then reading round-trips, and append works`() = runBlocking { + val file = File(temp.root, "data.bin") + val src = source(temp.root) + + src.openWrite(path(file)).use { it.write("payload".toByteArray()) } + assertEquals("payload", src.openRead(path(file)).use { it.readBytes().decodeToString() }) + + src.openWrite(path(file), append = true).use { it.write("!".toByteArray()) } + assertEquals("payload!", src.openRead(path(file)).use { it.readBytes().decodeToString() }) + } + + @Test + fun `free space comes from the injected probe`() = runBlocking { + val src = AllFilesSource(roots = emptyList(), freeSpace = { 1234L }) + assertEquals(1234L, src.freeSpaceBytes(StoragePath.of("/"))) + } + + @Test + fun `reports its backend name`() { + assertEquals("all-files", source(temp.root).backendName) + } +} diff --git a/app/src/test/java/de/softwareapp_hb/filemanager/data/SafDocumentIdsTest.kt b/app/src/test/java/de/softwareapp_hb/filemanager/data/SafDocumentIdsTest.kt new file mode 100644 index 0000000..9d0a5e3 --- /dev/null +++ b/app/src/test/java/de/softwareapp_hb/filemanager/data/SafDocumentIdsTest.kt @@ -0,0 +1,51 @@ +package de.softwareapp_hb.filemanager.data + +import de.softwareapp_hb.filemanager.core.StoragePath +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class SafDocumentIdsTest { + + private val tree = "primary:Download" + + @Test + fun `the root maps onto the tree document id`() { + assertEquals(tree, SafDocumentIds.documentId(tree, StoragePath.Root)) + assertEquals(StoragePath.Root, SafDocumentIds.path(tree, tree)) + } + + @Test + fun `children append their relative path`() { + val path = StoragePath.of("/notes/todo.txt") + assertEquals("primary:Download/notes/todo.txt", SafDocumentIds.documentId(tree, path)) + assertEquals(path, SafDocumentIds.path(tree, "primary:Download/notes/todo.txt")) + } + + @Test + fun `every path round-trips`() { + listOf("/a", "/a/b", "/a/b/c.txt", "/a b/c d.txt").forEach { raw -> + val path = StoragePath.of(raw) + assertEquals(path, SafDocumentIds.path(tree, SafDocumentIds.documentId(tree, path))) + } + } + + @Test + fun `documents outside the tree are rejected`() { + assertNull(SafDocumentIds.path(tree, "primary:Documents/x.txt")) + assertNull(SafDocumentIds.path(tree, "primary:DownloadsExtra/x.txt")) + } + + @Test + fun `labels are derived from the tree id`() { + assertEquals("Download", SafDocumentIds.label("primary:Download")) + assertEquals("Download", SafDocumentIds.label("1A2B-3C4D:Download")) + assertEquals("bare", SafDocumentIds.label("bare")) + } + + @Test + fun `trees are not confused with similarly prefixed ones`() { + assertTrue(SafDocumentIds.path("primary:Download", "primary:Download2") == null) + } +} diff --git a/app/src/test/java/de/softwareapp_hb/filemanager/data/SafSourceTest.kt b/app/src/test/java/de/softwareapp_hb/filemanager/data/SafSourceTest.kt new file mode 100644 index 0000000..70537bd --- /dev/null +++ b/app/src/test/java/de/softwareapp_hb/filemanager/data/SafSourceTest.kt @@ -0,0 +1,90 @@ +package de.softwareapp_hb.filemanager.data + +import android.net.Uri +import de.softwareapp_hb.filemanager.core.StoragePath +import de.softwareapp_hb.filemanager.data.testutil.FakeDocumentsProvider +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class SafSourceTest { + + private lateinit var provider: FakeDocumentsProvider + + @Before + fun setUp() { + provider = Robolectric.buildContentProvider(FakeDocumentsProvider::class.java) + .create(FakeDocumentsProvider.AUTHORITY) + .get() + + provider.add(null, doc("primary:Download", "Download", isDirectory = true)) + provider.add("primary:Download", doc("primary:Download/notes", "notes", isDirectory = true)) + provider.add("primary:Download", doc("primary:Download/a.txt", "a.txt", size = 12, modified = 1_700_000_000_000)) + } + + private fun doc( + id: String, + name: String, + isDirectory: Boolean = false, + size: Long = 0, + modified: Long = 0, + ) = FakeDocumentsProvider.Doc(id, name, isDirectory, size, modified) + + private fun source() = SafSource(RuntimeEnvironment.getApplication().contentResolver, treeUri()) + + @Test + fun `lists the children of the tree root`() = runBlocking { + val entries = source().list(StoragePath.Root).associateBy { it.name } + + assertEquals(setOf("notes", "a.txt"), entries.keys) + assertTrue(entries.getValue("notes").isDirectory) + assertFalse(entries.getValue("a.txt").isDirectory) + assertEquals(12L, entries.getValue("a.txt").sizeBytes) + assertEquals(StoragePath.of("/a.txt"), entries.getValue("a.txt").path) + assertEquals(1_700_000_000_000L, entries.getValue("a.txt").lastModifiedMillis) + } + + @Test + fun `stats a single document`() = runBlocking { + val entry = source().stat(StoragePath.of("/notes")) + assertNotNull(entry) + assertTrue(entry!!.isDirectory) + } + + @Test + fun `a document the provider does not know does not exist`() = runBlocking { + val src = source() + assertFalse(src.exists(StoragePath.of("/missing.txt"))) + assertNull(src.stat(StoragePath.of("/missing.txt"))) + } + + @Test + fun `exposes a label and exactly one root`() { + val src = source() + assertEquals("Download", src.treeLabel) + assertEquals("saf", src.backendName) + assertEquals(listOf(StoragePath.Root), src.roots.map { it.path }) + } + + @Test + fun `reports no free space, since the framework cannot`() = runBlocking { + assertNull(source().freeSpaceBytes(StoragePath.Root)) + } + + companion object { + private fun treeUri() = + Uri.parse("content://${FakeDocumentsProvider.AUTHORITY}/tree/primary%3ADownload") + } +} diff --git a/app/src/test/java/de/softwareapp_hb/filemanager/data/StorageAccessPolicyTest.kt b/app/src/test/java/de/softwareapp_hb/filemanager/data/StorageAccessPolicyTest.kt new file mode 100644 index 0000000..56da4c2 --- /dev/null +++ b/app/src/test/java/de/softwareapp_hb/filemanager/data/StorageAccessPolicyTest.kt @@ -0,0 +1,58 @@ +package de.softwareapp_hb.filemanager.data + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class StorageAccessPolicyTest { + + @Test + fun `android 11 and up key off the all-files special permission`() { + assertEquals(StorageAccessMode.ALL_FILES, StorageAccessPolicy.mode(30, true, false)) + assertEquals(StorageAccessMode.ALL_FILES, StorageAccessPolicy.mode(36, true, false)) + assertEquals(StorageAccessMode.SAF_FALLBACK, StorageAccessPolicy.mode(30, false, true)) + assertEquals(StorageAccessMode.SAF_FALLBACK, StorageAccessPolicy.mode(36, false, true)) + } + + @Test + fun `android 10 and below key off the legacy write grant`() { + assertEquals(StorageAccessMode.ALL_FILES, StorageAccessPolicy.mode(29, false, true)) + assertEquals(StorageAccessMode.SAF_FALLBACK, StorageAccessPolicy.mode(29, true, false)) + assertEquals(StorageAccessMode.ALL_FILES, StorageAccessPolicy.mode(26, false, true)) + } + + @Test + fun `runtime permissions shrink as the platform moves on`() { + assertTrue(StorageAccessPolicy.missingPermissions(33) { false }.isEmpty()) + assertTrue(StorageAccessPolicy.missingPermissions(36) { false }.isEmpty()) + + assertEquals( + listOf(StorageAccessPolicy.READ_EXTERNAL_STORAGE), + StorageAccessPolicy.missingPermissions(30) { false }, + ) + assertEquals( + listOf( + StorageAccessPolicy.READ_EXTERNAL_STORAGE, + StorageAccessPolicy.WRITE_EXTERNAL_STORAGE, + ), + StorageAccessPolicy.missingPermissions(29) { false }, + ) + } + + @Test + fun `already granted permissions are not requested again`() { + assertTrue(StorageAccessPolicy.missingPermissions(29) { true }.isEmpty()) + assertEquals( + listOf(StorageAccessPolicy.WRITE_EXTERNAL_STORAGE), + StorageAccessPolicy.missingPermissions(29) { it == StorageAccessPolicy.READ_EXTERNAL_STORAGE }, + ) + } + + @Test + fun `the permission constants match the platform strings`() { + assertEquals("android.permission.READ_EXTERNAL_STORAGE", StorageAccessPolicy.READ_EXTERNAL_STORAGE) + assertEquals("android.permission.WRITE_EXTERNAL_STORAGE", StorageAccessPolicy.WRITE_EXTERNAL_STORAGE) + assertFalse(StorageAccessPolicy.API_R < 30) + } +} diff --git a/app/src/test/java/de/softwareapp_hb/filemanager/data/VolumeRepositoryTest.kt b/app/src/test/java/de/softwareapp_hb/filemanager/data/VolumeRepositoryTest.kt new file mode 100644 index 0000000..f718413 --- /dev/null +++ b/app/src/test/java/de/softwareapp_hb/filemanager/data/VolumeRepositoryTest.kt @@ -0,0 +1,74 @@ +package de.softwareapp_hb.filemanager.data + +import de.softwareapp_hb.filemanager.core.StoragePath +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class VolumeRepositoryTest { + + private val internalRoot = StoragePath.of("/storage/emulated/0") + private val sdRoot = StoragePath.of("/storage/1A2B-3C4D") + + private fun repository() = VolumeRepository( + discoverVolumes = { + listOf( + VolumeInfo(internalRoot, label = null, isRemovable = false, isPrimary = true), + VolumeInfo(sdRoot, label = "SD card", isRemovable = true, isPrimary = false), + ) + }, + probeSpace = { path -> + when (path) { + internalRoot.value -> SpaceInfo(totalBytes = 100_000L, freeBytes = 25_000L) + sdRoot.value -> SpaceInfo(totalBytes = 50_000L, freeBytes = 10_000L) + else -> null + } + }, + ) + + @Test + fun `volumes are listed primary first`() { + val volumes = repository().volumes() + assertEquals(2, volumes.size) + assertTrue(volumes[0].isPrimary) + assertEquals(sdRoot, volumes[1].root) + assertEquals("SD card", volumes[1].label) + assertNull(volumes[0].label) + } + + @Test + fun `roots are built as directories in order`() { + val roots = repository().rootEntries() + assertEquals(listOf(internalRoot, sdRoot), roots.map { it.path }) + assertTrue(roots.all { it.isDirectory }) + } + + @Test + fun `space is summed across volumes`() { + assertEquals(35_000L, repository().totalFreeBytes()) + assertEquals(150_000L, repository().totalBytes()) + } + + @Test + fun `an unknown volume reports no space`() { + assertNull(repository().space(StoragePath.of("/nope"))) + } + + @Test + fun `a volume without a space reading contributes nothing`() { + val repo = VolumeRepository( + discoverVolumes = { listOf(VolumeInfo(sdRoot, "SD card", true, false)) }, + probeSpace = { null }, + ) + assertNull(repo.space(sdRoot)) + assertEquals(0L, repo.totalFreeBytes()) + assertEquals(0L, repo.totalBytes()) + } + + @Test + fun `used bytes never go negative`() { + assertEquals(0L, SpaceInfo(totalBytes = 10L, freeBytes = 20L).usedBytes) + assertEquals(75_000L, SpaceInfo(totalBytes = 100_000L, freeBytes = 25_000L).usedBytes) + } +} diff --git a/app/src/test/java/de/softwareapp_hb/filemanager/data/testutil/FakeDocumentsProvider.kt b/app/src/test/java/de/softwareapp_hb/filemanager/data/testutil/FakeDocumentsProvider.kt new file mode 100644 index 0000000..1d7ffcc --- /dev/null +++ b/app/src/test/java/de/softwareapp_hb/filemanager/data/testutil/FakeDocumentsProvider.kt @@ -0,0 +1,89 @@ +package de.softwareapp_hb.filemanager.data.testutil + +import android.content.ContentProvider +import android.content.ContentValues +import android.database.Cursor +import android.database.MatrixCursor +import android.net.Uri +import android.provider.DocumentsContract +import de.softwareapp_hb.filemanager.data.SafSource + +/** + * A stand-in DocumentsProvider for [SafSource] tests. + * + * It answers the two query shapes the Storage Access Framework uses — a *children* + * query for a directory and a *document* query for a single item — from an in-memory + * map of documents, so the SAF backend can be exercised without a device or a real + * provider. + */ +class FakeDocumentsProvider : ContentProvider() { + + data class Doc( + val id: String, + val name: String, + val isDirectory: Boolean, + val size: Long, + val modified: Long, + ) + + private val documents = LinkedHashMap() + private val children = LinkedHashMap>() + + /** Register [doc] as a child of [parentId] (`null` for a tree root). */ + fun add(parentId: String?, doc: Doc) { + documents[doc.id] = doc + parentId?.let { children.getOrPut(it) { mutableListOf() } += doc.id } + } + + override fun onCreate(): Boolean = true + + override fun query( + uri: Uri, + projection: Array?, + selection: String?, + selectionArgs: Array?, + sortOrder: String?, + ): Cursor { + val documentId = DocumentsContract.getDocumentId(uri) + val columns = projection ?: SafSource.PROJECTION + val cursor = MatrixCursor(columns) + + if (uri.pathSegments.lastOrNull() == "children") { + children[documentId].orEmpty().forEach { id -> + documents[id]?.let { cursor.addRow(row(it, columns)) } + } + } else { + documents[documentId]?.let { cursor.addRow(row(it, columns)) } + } + return cursor + } + + private fun row(doc: Doc, columns: Array): Array = columns.map { column -> + when (column) { + DocumentsContract.Document.COLUMN_DOCUMENT_ID -> doc.id + DocumentsContract.Document.COLUMN_DISPLAY_NAME -> doc.name + DocumentsContract.Document.COLUMN_MIME_TYPE -> + if (doc.isDirectory) DocumentsContract.Document.MIME_TYPE_DIR else "application/octet-stream" + DocumentsContract.Document.COLUMN_SIZE -> if (doc.isDirectory) null else doc.size + DocumentsContract.Document.COLUMN_LAST_MODIFIED -> doc.modified + else -> null + } + }.toTypedArray() + + override fun getType(uri: Uri): String? = null + + override fun insert(uri: Uri, values: ContentValues?): Uri? = null + + override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int = 0 + + override fun update( + uri: Uri, + values: ContentValues?, + selection: String?, + selectionArgs: Array?, + ): Int = 0 + + companion object { + const val AUTHORITY = "com.softwareapp_hb.test.documents" + } +} diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index b95fc9d..1920a74 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -11,7 +11,7 @@ Legend: ✅ done · 🚧 in progress · ⏳ not started · 💡 candidate (propo |---|---|---|---| | M0 | Toolchain & scaffold | ✅ | Gradle 9.3.1 + AGP 9.1.0 wrapper, version catalog, app module, Compose theme, debug APK builds, installs and runs on the `phone` AVD: package `de.softwareapp_hb.filemanager.debug`, `versionCode 1`, `minSdk 26`/`targetSdk 36`, **no `INTERNET` permission**, `lintDebug` clean (one expected `UnusedResources` for the not-yet-used notification icon) | | M1 | Pure-Kotlin core + JVM tests | ✅ | `core/` is **Android-free**: `StoragePath` (normalised absolute paths that cannot escape the root), `FileEntry` + `FileKind` classification, `MimeTypes`, `NaturalOrder` + `SortSpec` (directories first; `file2` before `file10`), `FileSize`, `NameMatcher` (substring + `*`/`?` glob), `NameAllocator` ("name (1)") and `TransferPlanner` (conflict policies, move-into-self). **44 JVM tests** across 10 classes, all green | -| M2 | Storage layer | ⏳ | `StorageSource` interface with `AllFilesSource` (`MANAGE_EXTERNAL_STORAGE`) and `SafSource` (persisted tree URIs); `VolumeRepository` (`StorageManager`/`StorageVolume`, `StatFs`); permission + all-files-access state machine | +| M2 | Storage layer | ✅ | `StorageSource` with two backends — `AllFilesSource` (`java.io.File` under all-files access) and `SafSource` (`DocumentsContract` over a persisted tree URI), the latter split so the tree↔path mapping (`SafDocumentIds`) is exhaustively testable; `SafGrant` for the picker and its persistence; `VolumeRepository` (`StorageManager`/`StorageVolume` + `StatFs`); `StorageAccess` + `StorageAccessPolicy` for the all-files/SAF decision. **76 JVM tests** total (32 added here) | | M3 | Operations engine + service | ⏳ | create/rename/copy/move/delete, recursive copy with progress + cancellation, conflict resolution, trash via MediaStore, foreground service + progress notification | | M4 | Compose UI | ⏳ | browser list/grid, breadcrumb + up navigation, sort/filter sheet, multi-select action bar, details sheet, permission prompts, theme | | M5 | Analysis, archives, viewers | ⏳ | disk-usage analysis, large-file finder, ZIP/TAR/GZ via Commons Compress, image viewer, text editor |