M7: on-device validation - fix usage-screen latency and first-run copy

This commit is contained in:
2026-10-14 17:05:03 +02:00
parent e2cfb5d9ca
commit a79b7eb58e
8 changed files with 158 additions and 27 deletions
@@ -25,20 +25,34 @@ data class UsageNode(
*/
class UsageAnalysis(private val source: StorageSource) {
/** The children of [directory] with their recursive sizes, largest first. */
suspend fun childrenBySize(directory: StoragePath): List<UsageNode> =
source.list(directory)
.map { entry ->
val measured = measure(entry.path)
UsageNode(
path = entry.path,
name = entry.name,
isDirectory = entry.isDirectory,
sizeBytes = measured.first,
fileCount = measured.second,
)
}
.sortedWith(compareByDescending<UsageNode> { it.sizeBytes }.thenBy { it.name })
/**
* The children of [directory] with their recursive sizes, largest first.
*
* [onProgress] is called with each child as soon as it has been measured, so a screen
* can show rows appearing instead of staying blank while a large tree is walked —
* measuring the root of a phone's internal storage takes tens of seconds.
*/
suspend fun childrenBySize(
directory: StoragePath,
onProgress: suspend (UsageNode) -> Unit = {},
): List<UsageNode> {
val measured = ArrayList<UsageNode>()
for (child in source.list(directory)) {
// The parent's listing already told us whether each child is a folder, so no
// extra stat is needed: one listing per directory and nothing more.
val size = measure(child)
val node = UsageNode(
path = child.path,
name = child.name,
isDirectory = child.isDirectory,
sizeBytes = size.first,
fileCount = size.second,
)
onProgress(node)
measured += node
}
return measured.sortedWith(compareByDescending<UsageNode> { it.sizeBytes }.thenBy { it.name })
}
/** The largest files anywhere below [directory], biggest first. */
suspend fun largestFiles(directory: StoragePath, limit: Int = LARGEST_LIMIT): List<FileEntry> {
@@ -48,16 +62,18 @@ class UsageAnalysis(private val source: StorageSource) {
}
/** Total bytes and file count below [directory]. */
suspend fun total(directory: StoragePath): Pair<Long, Int> = measure(directory)
suspend fun total(directory: StoragePath): Pair<Long, Int> {
val entry = source.stat(directory) ?: return 0L to 0
return measure(entry)
}
private suspend fun measure(path: StoragePath): Pair<Long, Int> {
val entry = source.stat(path) ?: return 0L to 0
private suspend fun measure(entry: FileEntry): Pair<Long, Int> {
if (!entry.isDirectory) return entry.sizeBytes to 1
var size = 0L
var count = 0
for (child in source.list(path)) {
val measured = measure(child.path)
for (child in source.list(entry.path)) {
val measured = measure(child)
size += measured.first
count += measured.second
}
@@ -29,6 +29,7 @@ import de.softwareapp_hb.filemanager.domain.OperationKind
import de.softwareapp_hb.filemanager.domain.OperationNotifier
import de.softwareapp_hb.filemanager.domain.OperationResult
import de.softwareapp_hb.filemanager.domain.UsageAnalysis
import de.softwareapp_hb.filemanager.domain.UsageNode
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -281,9 +282,24 @@ class BrowserViewModel(
_state.update { it.copy(overlay = Overlay.Usage(path, emptyList(), loading = true)) }
viewModelScope.launch {
val nodes = runCatching { UsageAnalysis(source).childrenBySize(path) }
.getOrDefault(emptyList())
_state.update { it.copy(overlay = Overlay.Usage(path, nodes, loading = false)) }
// Rows are published as they are measured: walking a whole phone's internal
// storage takes tens of seconds, and a blank screen for that long reads as a
// hang. Sorting on every emission is cheap — there are only ever as many rows
// as the folder has children.
runCatching {
UsageAnalysis(source).childrenBySize(path) { node ->
_state.update { current ->
val overlay = current.overlay as? Overlay.Usage ?: return@update current
val nodes = (overlay.nodes + node)
.sortedWith(compareByDescending<UsageNode> { it.sizeBytes }.thenBy { it.name })
current.copy(overlay = overlay.copy(nodes = nodes))
}
}
}
_state.update { current ->
val overlay = current.overlay as? Overlay.Usage ?: return@update current
current.copy(overlay = overlay.copy(loading = false))
}
}
}
+1 -1
View File
@@ -27,7 +27,7 @@
<string name="browser_all_files_body">To browse every folder, grant this app “All files access” in Settings. Without it you can still browse a folder you pick yourself.</string>
<string name="browser_all_files_action">Grant full access</string>
<string name="browser_tree_title">Choose a folder</string>
<string name="browser_tree_body">Full access was declined, so pick a folder for the app to work in. The grant is remembered across restarts.</string>
<string name="browser_tree_body">Without full access you can still work inside one folder you choose here. The grant is remembered across restarts.</string>
<string name="browser_tree_action">Choose folder</string>
<string name="browser_tree_granted">Folder added</string>
<string name="browser_tree_rejected">The folder could not be remembered</string>