M7: on-device validation - fix usage-screen latency and first-run copy
This commit is contained in:
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -35,6 +35,18 @@ class UsageAnalysisTest {
|
||||
assertEquals(0L, nodes[3].sizeBytes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rows are reported as they are measured`() = runBlocking {
|
||||
val seen = mutableListOf<String>()
|
||||
val nodes = UsageAnalysis(storage()).childrenBySize(root) { seen += it.name }
|
||||
|
||||
// The callback sees every child, in the order they were measured...
|
||||
assertEquals(4, seen.size)
|
||||
assertEquals(setOf("big", "small", "empty", "top.bin"), seen.toSet())
|
||||
// ...while the returned list is the sorted one.
|
||||
assertEquals(listOf("big", "top.bin", "small", "empty"), nodes.map { it.name })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the largest files are found anywhere below the folder`() = runBlocking {
|
||||
val files = UsageAnalysis(storage()).largestFiles(root, limit = 2)
|
||||
|
||||
Reference in New Issue
Block a user