M2: storage layer (all-files + SAF backends, volumes, access policy) + 32 JVM tests
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+89
@@ -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
@@ -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 |
|
||||||
|
|||||||
Reference in New Issue
Block a user