M2: storage layer (all-files + SAF backends, volumes, access policy) + 32 JVM tests

This commit is contained in:
2026-10-14 15:17:51 +02:00
parent 6d4c2c3f87
commit 0863b37d70
18 changed files with 1174 additions and 5 deletions
+11
View File
@@ -12,6 +12,17 @@ upload needs a **higher `versionCode`** than every previous upload.
### Added ### 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 - **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 every other layer builds on: `StoragePath` (normalised absolute paths that can never
escape the root), `FileEntry` with extension-based `FileKind` classification and a escape the root), `FileEntry` with extension-based `FileKind` classification and a
+5 -4
View File
@@ -12,10 +12,11 @@ no advertising or tracking SDKs.
## Status ## Status
**M1 — pure-Kotlin core: done.** The Android-free `core/` engine (paths, classification, **M2 — storage layer: done.** Two backends behind one interface — `AllFilesSource`
sorting, formatting, search, name allocation, transfer planning) is implemented and (`java.io.File` under all-files access) and the `SafSource` fallback — plus volume/space
covered by 44 JVM unit tests. See [docs/ROADMAP.md](docs/ROADMAP.md) for the full discovery and the access-mode state machine. Covered by 76 JVM unit tests (32 added
milestone plan. here), including a Robolectric run of the SAF backend. See
[docs/ROADMAP.md](docs/ROADMAP.md) for the full milestone plan.
## Tech stack ## Tech stack
@@ -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<FileEntry>,
private val freeSpace: (String) -> Long? = { null },
) : StorageSource {
override val backendName: String = BACKEND_NAME
override suspend fun list(directory: StoragePath): List<FileEntry> = 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(),
)
@@ -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 }
}
@@ -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<Uri> =
resolver.persistedUriPermissions
.filter { it.isReadPermission }
.map { it.uri }
}
@@ -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<FileEntry> = listOf(
FileEntry(
path = StoragePath.Root,
isDirectory = true,
sizeBytes = 0L,
lastModifiedMillis = 0L,
),
)
override suspend fun list(directory: StoragePath): List<FileEntry> = withContext(Dispatchers.IO) {
val parentId = SafDocumentIds.documentId(treeDocumentId, directory)
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, parentId)
val entries = ArrayList<FileEntry>()
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,
)
}
}
@@ -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<String> =
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
},
)
}
}
}
@@ -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<String> {
val missing = ArrayList<String>()
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
}
}
@@ -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<FileEntry>
/** Children of [directory], in whatever order the backend returns them (unsorted). */
suspend fun list(directory: StoragePath): List<FileEntry>
/** 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?
}
@@ -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<VolumeInfo>,
private val probeSpace: (String) -> SpaceInfo?,
) {
/** The volumes to show, primary first, without duplicates. */
fun volumes(): List<VolumeInfo> = discoverVolumes()
fun space(path: StoragePath): SpaceInfo? = probeSpace(path.value)
/** [FileEntry] roots for [AllFilesSource], in display order. */
fun rootEntries(): List<FileEntry> = 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<VolumeInfo> {
val found = LinkedHashMap<String, VolumeInfo>()
// 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/<uuid>` 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()
}
}
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Keeps resources that are referenced only from code Android's resource shrinker cannot
trace. `ic_notification` is the foreground service's small icon, which is passed to
`startForeground` as an id rather than referenced from a layout.
-->
<resources xmlns:tools="http://schemas.android.com/tools"
tools:keep="@drawable/ic_notification" />
@@ -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)
}
}
@@ -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)
}
}
@@ -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")
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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<String, Doc>()
private val children = LinkedHashMap<String, MutableList<String>>()
/** 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<out String>?,
selection: String?,
selectionArgs: Array<out String>?,
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<out String>): Array<Any?> = 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<out String>?): Int = 0
override fun update(
uri: Uri,
values: ContentValues?,
selection: String?,
selectionArgs: Array<out String>?,
): Int = 0
companion object {
const val AUTHORITY = "com.softwareapp_hb.test.documents"
}
}
+1 -1
View File
@@ -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) | | 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 | | 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 | | 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 | | 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 | | M5 | Analysis, archives, viewers | ⏳ | disk-usage analysis, large-file finder, ZIP/TAR/GZ via Commons Compress, image viewer, text editor |