android, desktop: storage breakdown in developer options (#7603)

* android, desktop: storage breakdown in developer options

Port of the iOS Storage screen (#5529): lists each top-level entry of the app's data, config and temp folders with its recursive size, so it is possible to see what takes space.

* android, desktop: simplify storage view

Use DefaultProgressView, drop the bold total and redundant absoluteFile, and do not follow top-level symlinks (matches iOS; avoids counting the Android lib link).

* android, desktop: storage view: do not crash on names the JVM cannot convert to Path

Under a non-UTF-8 locale on Linux, File.toPath() throws InvalidPathException for a non-ASCII top-level name (e.g. a received file decrypted into tmpDir); log it and show 0 bytes. Also correct plan wording.

* android, desktop: storage view: fold path conversion into treeSize, use produceState

Also correct plan wording (du per-directory overhead, unlistable roots, Windows junctions).

* android, desktop: storage view: open as card screen like other settings screens

* android, desktop: storage view: list top-level entries as Path to keep their exact names

File.listFiles + toPath loses names that are not valid in the JVM file-name encoding (non-ASCII under a C locale, invalid UTF-8), so those entries showed 0 bytes. Files.newDirectoryStream keeps the raw bytes.

* android, desktop: storage view: normalize root paths before dropping nested roots

A root spelled with .. (e.g. XDG_DATA_HOME=/x/data/../tmp) compared as nested in another root and its size was shown nowhere.

* android, desktop: document storage view in spec and product docs

* android, desktop: storage view: single catch when listing a root

* android, desktop: do not log file paths in storage view

The walk's IOException messages contain the full path, which in tmpDir can be a received file's original name.

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
This commit is contained in:
Narasimha-sc
2026-10-10 21:55:08 +01:00
committed by GitHub
co-authored by Evgeny Poberezkin
parent d72f68e6dc
commit afcaf22100
7 changed files with 159 additions and 0 deletions
@@ -60,6 +60,7 @@ fun DeveloperView(withAuth: (title: String, desc: String, block: () -> Unit) ->
}
SettingsPreferenceItem(painterResource(MR.images.ic_report), stringResource(MR.strings.show_internal_errors), appPreferences.showInternalErrors)
SettingsPreferenceItem(painterResource(MR.images.ic_avg_pace), stringResource(MR.strings.show_slow_api_calls), appPreferences.showSlowApiCalls)
SettingsActionItem(painterResource(MR.images.ic_folder_open), stringResource(MR.strings.storage), { ModalManager.start.showModal(cardScreen = true) { StorageView() } })
}
}
SectionDividerSpaced()
@@ -0,0 +1,100 @@
package chat.simplex.common.views.usersettings
import SectionBottomSpacer
import SectionDividerSpaced
import SectionItemViewSpaceBetween
import SectionView
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.DEFAULT_PADDING
import chat.simplex.common.views.helpers.*
import chat.simplex.res.*
import dev.icerock.moko.resources.compose.stringResource
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.io.IOException
import java.nio.file.*
import java.nio.file.attribute.BasicFileAttributes
private class EntryUsage(val name: String, val bytes: Long)
private class RootUsage(val dir: File, val entries: List<EntryUsage>) {
val bytes: Long = entries.sumOf { it.bytes }
}
@Composable
fun StorageView() {
val usage by produceState<List<RootUsage>?>(initialValue = null) {
value = withContext(Dispatchers.IO) { storageRoots().map(::rootUsage) }
}
ColumnWithScrollBar {
AppBarTitle(stringResource(MR.strings.storage))
val roots = usage
if (roots == null) {
DefaultProgressView(null)
} else {
roots.forEachIndexed { i, root ->
if (i > 0) SectionDividerSpaced()
SectionView(root.dir.path) {
UsageRow(stringResource(MR.strings.storage_total), root.bytes)
root.entries.forEach { UsageRow(it.name, it.bytes) }
}
}
}
SectionBottomSpacer()
}
}
@Composable
private fun UsageRow(name: String, bytes: Long) {
SectionItemViewSpaceBetween {
Text(name, Modifier.weight(1f))
Text(formatBytes(bytes), Modifier.padding(start = DEFAULT_PADDING), color = MaterialTheme.colors.secondary)
}
}
private fun storageRoots(): List<File> {
val dirs = listOf(dataDir, preferencesDir, tmpDir).map { it.normalize() }.distinct()
return dirs.filter { dir -> dirs.none { it != dir && dir.startsWith(it) } }
}
private fun rootUsage(root: File): RootUsage =
RootUsage(
root,
rootEntries(root)
.map { EntryUsage(it.fileName.toString(), treeSize(it)) }
.sortedByDescending { it.bytes }
)
private fun rootEntries(root: File): List<Path> =
try {
Files.newDirectoryStream(root.toPath()).use { it.toList() }
} catch (e: Exception) {
Log.e(TAG, "StorageView rootEntries: ${e.javaClass.simpleName}")
emptyList()
}
private fun treeSize(path: Path): Long {
var bytes = 0L
Files.walkFileTree(path, object : SimpleFileVisitor<Path>() {
override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult {
bytes += attrs.size()
return FileVisitResult.CONTINUE
}
override fun visitFileFailed(file: Path, exc: IOException): FileVisitResult {
Log.e(TAG, "StorageView visitFileFailed: ${exc.javaClass.simpleName}")
return FileVisitResult.CONTINUE
}
override fun postVisitDirectory(dir: Path, exc: IOException?): FileVisitResult {
if (exc != null) Log.e(TAG, "StorageView postVisitDirectory: ${exc.javaClass.simpleName}")
return FileVisitResult.CONTINUE
}
})
return bytes
}
@@ -1160,6 +1160,8 @@
<string name="deprecated_options_section">Deprecated options</string>
<string name="show_internal_errors">Show internal errors</string>
<string name="show_slow_api_calls">Show slow API calls</string>
<string name="storage">Storage</string>
<string name="storage_total">Total</string>
<string name="shutdown_alert_question">Shutdown?</string>
<string name="shutdown_alert_desc">Notifications will stop working until you re-launch the app</string>
<string name="prefs_error_saving_settings">Error saving settings</string>
+1
View File
@@ -277,6 +277,7 @@ SettingsView
-> CallSettings .............. (call preferences)
-> VersionInfoView ........... (about/version)
-> DeveloperView ............. (developer options)
-> StorageView ............. (storage breakdown)
-> HelpView .................. (help & support)
UserPicker
@@ -139,6 +139,7 @@ Platform-specific section (expect/actual composable):
|---|---|
| App updates (Desktop) | App update checker and installer |
| Developer tools | Toggle developer mode |
| Storage (developer options) | Opens `StorageView`: size of each top-level entry in the app's data, config and temp folders |
| Chat console | Opens `ChatConsoleView` terminal |
| Terminal always visible (Desktop) | Keep terminal window open |
| Install terminal app | Link to CLI app on GitHub |
+1
View File
@@ -220,6 +220,7 @@ Path prefix: `common/src/commonMain/kotlin/chat/simplex/common/`
| `views/usersettings/SetDeliveryReceiptsView.kt` | PC2 | Low | Delivery receipts toggle |
| `views/usersettings/RTCServers.kt` | PC17, PC25 | Medium | WebRTC ICE server configuration |
| `views/usersettings/DeveloperView.kt` | — | Low | Developer/debug settings |
| `views/usersettings/StorageView.kt` | — | Low | Storage breakdown (developer options) |
| `views/usersettings/HelpView.kt` | — | Low | Help and support links |
| `views/usersettings/MarkdownHelpView.kt` | PC4 | Low | Markdown formatting guide |
| `views/usersettings/VersionInfoView.kt` | — | Low | Version display |
@@ -0,0 +1,53 @@
# Android, desktop: storage breakdown in developer options
## Problem
When the Android or desktop app takes a lot of space, there is no way to see from inside the app what is using it. The Database screen shows only the count and size of received files (`directoryFileCountAndSize(appFilesDir.absolutePath)`, `Utils.kt`), and that helper is not recursive: it adds up `File.length()` of the direct children only.
The databases, their `-wal`/`.bak` copies, wallpapers, core temp files, migration leftovers, remote-host data and preferences are all invisible. On one desktop profile, for example, the `.bak` copies left by database migrations were 5.6 MB of the 13 MB of database files.
## Cause
iOS has had this screen since #5529 (`apps/ios/Shared/Views/UserSettings/StorageView.swift`, under Developer options), but it was never ported to Kotlin.
## Fix
A Storage item in Developer options (shown only when "Show developer options" is on) opens `StorageView`. The screen lists each top-level entry of the app's storage folders with its recursive size, largest first, and a total per folder.
Folders measured: `dataDir`, `preferencesDir` and `tmpDir`. Any folder that is the same as, or inside, another one is dropped, so nothing is counted twice:
| Platform | Folders shown |
|---|---|
| Android | `dataDir` only. `shared_prefs` and `app_temp` are inside it, and so are the databases, `files/`, `cache/` (exports) and `app_temp/remote_hosts`. |
| Linux, macOS | `$XDG_DATA_HOME/simplex`, `$XDG_CONFIG_HOME/simplex`, `java.io.tmpdir/simplex` |
| Windows | `%AppData%\SimpleX` (config and data are the same folder), `java.io.tmpdir\simplex` (usually `%TEMP%`) |
Like iOS, it shows whatever entries actually exist rather than named categories. Named categories would need to be kept in sync with the path helpers and would hide anything unexpected, and unexpected entries (such as `.bak` files) are what this screen is for.
It improves on iOS in four ways:
- It measures on `Dispatchers.IO`; iOS measures on the main thread in `.onAppear`.
- It sorts rows by size; iOS iterates a dictionary, so the order is random.
- It shows a total per folder.
- A file or folder inside the walk that cannot be read is logged and skipped, and a storage folder that cannot be listed shows as empty. On iOS, a failure to read one entry's attributes ends the whole walk.
The walk uses `Files.walkFileTree` (API 26 = minSdk) without `FOLLOW_LINKS`, as iOS's `FileManager.enumerator` does not follow links either: a symlink is counted as the link itself, so it cannot loop, escape to `/` (Windows directory junctions are not treated as links by the JDK, but the app never creates them), or (on Android) count the APK's native libraries through a `lib` link that the system may create in the data folder.
Sizes are file lengths (`BasicFileAttributes.size()`). iOS uses allocated size, which has no portable equivalent on the JVM, and length is what the existing Database screen reports.
## Verification
- A standalone harness copying the walk logic, run against:
- a symlink loop and a link to `/`: not followed
- an unreadable folder: logged, the walk continues
- a nested folder and a duplicate folder: dropped
- a missing folder: shown as empty
- On a real 187 MB desktop profile, the walk took 0.8 s and matched `du -sb` minus the 4 KB that `du` also counts for each directory.
- A desktop AppImage run on a separate test profile showed all three folders, with sizes matching `ls`.
Top-level entries are listed with `Files.newDirectoryStream` rather than `File.listFiles`, so each name stays a `Path` built from the raw bytes. Going through `String` and back with `File.toPath()` loses names that are not valid in the JVM's file-name encoding (any non-ASCII name under a C locale on Linux, e.g. a received file decrypted into `tmpDir`), which then either throws `InvalidPathException` or points to a file that does not exist. A root that cannot be listed (missing or unreadable) is logged and shown as empty.
## Not included
- The desktop Postgres build keeps its database on the Postgres server, so it is not in the total.
- Leaving the screen does not cancel a walk in progress. The result is simply discarded.