From afcaf22100675761ca5a732a61c15eca09f227a3 Mon Sep 17 00:00:00 2001 From: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:55:08 +0000 Subject: [PATCH] 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 --- .../views/usersettings/DeveloperView.kt | 1 + .../common/views/usersettings/StorageView.kt | 100 ++++++++++++++++++ .../moko-resources/base/strings.xml | 2 + apps/multiplatform/product/README.md | 1 + apps/multiplatform/product/views/settings.md | 1 + apps/multiplatform/spec/impact.md | 1 + ...2026-09-27-android-desktop-storage-view.md | 53 ++++++++++ 7 files changed, 159 insertions(+) create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/StorageView.kt create mode 100644 plans/2026-09-27-android-desktop-storage-view.md diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/DeveloperView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/DeveloperView.kt index cfdf003bc6..fce6c69033 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/DeveloperView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/DeveloperView.kt @@ -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() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/StorageView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/StorageView.kt new file mode 100644 index 0000000000..a3e0c3206b --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/StorageView.kt @@ -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) { + val bytes: Long = entries.sumOf { it.bytes } +} + +@Composable +fun StorageView() { + val usage by produceState?>(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 { + 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 = + 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() { + 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 +} diff --git a/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml index 71831e1c89..3a1720d883 100644 --- a/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml @@ -1160,6 +1160,8 @@ Deprecated options Show internal errors Show slow API calls + Storage + Total Shutdown? Notifications will stop working until you re-launch the app Error saving settings diff --git a/apps/multiplatform/product/README.md b/apps/multiplatform/product/README.md index 173def8ae7..5dc606a50e 100644 --- a/apps/multiplatform/product/README.md +++ b/apps/multiplatform/product/README.md @@ -277,6 +277,7 @@ SettingsView -> CallSettings .............. (call preferences) -> VersionInfoView ........... (about/version) -> DeveloperView ............. (developer options) + -> StorageView ............. (storage breakdown) -> HelpView .................. (help & support) UserPicker diff --git a/apps/multiplatform/product/views/settings.md b/apps/multiplatform/product/views/settings.md index e668bf2d04..563fa09d5b 100644 --- a/apps/multiplatform/product/views/settings.md +++ b/apps/multiplatform/product/views/settings.md @@ -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 | diff --git a/apps/multiplatform/spec/impact.md b/apps/multiplatform/spec/impact.md index ec302e30b5..7e0fb181d8 100644 --- a/apps/multiplatform/spec/impact.md +++ b/apps/multiplatform/spec/impact.md @@ -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 | diff --git a/plans/2026-09-27-android-desktop-storage-view.md b/plans/2026-09-27-android-desktop-storage-view.md new file mode 100644 index 0000000000..fbeb1be6f8 --- /dev/null +++ b/plans/2026-09-27-android-desktop-storage-view.md @@ -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.