# Architecture FreeSize is a Cargo workspace with six crates: ``` crates/core freesize-core scanner, tree model, stats, treemap and bubble layout, snapshots, scan files crates/cli freesize-cli command-line front end and the Hub agent crates/app freesize-app Slint UI for desktop and Android crates/proto freesize-proto keys, signatures, pairing links and messages shared by devices and the Hub crates/agent freesize-agent the device side of the Hub crates/hub freesize-hub FreeSize Hub: web server, dashboard, database ``` All the scanning logic lives in `core`, which has no UI or platform UI dependencies, so it can be tested on its own. The CLI and the app are thin layers on top of it. The Hub (see [Hub](#hub)) adds a server that collects what devices report. ## Core ### Tree model (`tree.rs`) A scan of a large drive can produce tens of millions of entries, so the tree is stored compactly: - Every entry is a `Node` in one `Vec`, referred to by a `u32` index. - All names live in one shared byte buffer. Each node stores an offset and a length into it. - **The children of a directory are always contiguous**, so a node needs only `first_child` and `child_count`, not sibling pointers. - **Children always have a higher index than their parent.** That makes the totals one reverse pass over the array (`Tree::aggregate`): by the time a directory is reached, everything below it has already been added in. A node is 56 bytes plus its name, roughly 70 bytes per entry in practice. A `PathBuf` per entry would need several times that. Names are stored as `OsStr::as_encoded_bytes`, so paths round-trip exactly, including non-UTF-8 names on Linux and unpaired surrogates on Windows. Display uses a lossy conversion. Each directory stores subtree totals: logical size, allocated size, file count, folder count and newest modification time. `mark_deleted` subtracts a node's totals from its ancestors, so the UI can update after a delete without rescanning. ### Scanner (`scan.rs`) ``` jobs (dir id + path) builder ───────────────────────▶ N worker threads (owns ◀─────────────────────── read_dir + metadata Tree) listings (one per dir) ``` - **Workers** pull a directory from a channel, list it and read every entry's metadata. They send back one `Listing` with all entry names in a single buffer. - The **builder** (the calling thread) owns the `Tree`. It appends each listing as one contiguous block of children, then sends the subdirectories back as new jobs. A pending-job counter tells it when the scan is done. - Only the builder touches the tree, so there is no locking on the hot path. - A panic in a worker is turned into an error listing. Without that, the builder would wait forever for a directory that never comes back. - Progress is a set of atomic counters (`ScanProgress`) that the UI polls. Cancelling sets a flag; workers then return empty listings, so the scan finishes quickly with a partial tree. - The default thread count is 2× the number of cores, clamped to 4–32, because scanning mostly waits on I/O. Rules shared by every platform: - **Symlinks and junctions** are recorded but never followed. - **Mount points**: directories on another filesystem are flagged and skipped unless `one_filesystem` is off. - **Hard links**: a file with more than one link is counted only the first time its `(device, inode)` is seen. - **Errors**: unreadable entries are flagged and counted; they never abort the scan. Only an unreadable root fails it. `Tree::unreadable` lists the folders that couldn't be listed, which is what a scan with more rights would add. ### Rights (`access.rs`) `rights()` says whose eyes a scan sees with: the user's, an elevated administrator's (Windows, from the token's elevation) or root's (Unix, `geteuid`). Reports carry its label so the Hub can say a Windows agent sees only what its user sees. `enable_backup_privilege()` turns on `SeBackupPrivilege` for the process; administrators hold it, and with it on, listing a folder succeeds whatever its permissions say. `beyond_reach` names what no right reaches on Windows (System Volume Information, the reserved storage). ### Platform specifics (`platform.rs`, `space.rs`) | | Allocated size | Extras | |---|---|---| | Unix (Linux, Android, macOS) | `st_blocks × 512` | `st_dev` for mount points, `st_nlink`/`st_ino` for hard links | | macOS | as Unix | `SF_DATALESS` flag marks iCloud files that aren't downloaded | | Windows | Size rounded up to the volume's cluster size; `GetCompressedFileSizeW` for compressed and sparse files | `RECALL_ON_DATA_ACCESS`, `RECALL_ON_OPEN` and `OFFLINE` attributes mark cloud placeholders (OneDrive), which count as 0 bytes on disk | On Windows the metadata comes free with `FindNextFileW`, so the scan costs about one system call per directory rather than one per file. It never opens a file, so OneDrive placeholders are never downloaded. `disk_space` wraps `statvfs` and `GetDiskFreeSpaceExW` to show free and total space on the start screen. ### Stats and treemap - `largest_files` keeps a size-`n` min-heap while walking a subtree; `largest_files_where` does the same for files passing a test (one type, one year, one size band). - `extension_stats` groups by lower-cased extension. Dotfiles like `.bashrc` count as having no extension. - `age_bins` sums files by the year they were last changed (this year and nine before it, then "before" and "no date"), and `size_bins` by size band. Each bin keeps its range, so tapping it filters the files list. - `treemap::squarify` is the squarified treemap algorithm (Bruls, Huizing & van Wijk). It keeps each rectangle's area proportional to its size while keeping the rectangles close to square. - `treemap::layout` nests it a few levels deep. Each directory gets a header strip for its label, tiny rectangles are dropped, and the total is capped (1500 by default) to keep rendering cheap. Items come out parent-first, so drawing them in order puts children on top. ### Kinds and bubbles (`kinds.rs`, `bubbles.rs`) - `kinds` gives every node a `Kind` for its icon: files by extension, and a folder the kind that makes up at least half of its bytes (else a plain folder). One reverse pass sums bytes per kind into each folder's parent; it runs on the scan thread right after scanning. - `bubbles::pack` is front-chain circle packing (Wang et al. 2006, as in d3-hierarchy): each circle goes next to the pair on the outer chain closest to the centre, and is moved along the chain if it would overlap. `fit` scales a packed group's bounding box into the view. `layout` draws a folder's biggest children (40 at most, each at least 0.4 % of the folder), folds the rest into one "more" bubble, and packs the contents of big folder bubbles inside them, fitted to an approximate smallest enclosing circle (Bădoiu–Clarkson iterations). ### Reclaimable space (`reclaim.rs`) - `rebuildable` walks folders and matches a table of rules: a folder name, optionally a parent name (`Caches` under `Library`) and sibling files that must exist (`target` next to `Cargo.toml`). A folder holding `pyvenv.cfg` is a virtual environment. Matched folders aren't searched further. - `old_files` is the largest files modified before a cut-off date. - `duplicate_candidates` collects files of at least a minimum size, skipping hard-link duplicates, cloud placeholders and unreadable entries, and groups them by size. It owns plain paths so it can move to a worker thread. `DuplicateCandidates::find` then hashes 64 KB from each end of every file with the same size (xxh3-128), and only files that still match get a full hash. Progress and cancellation go through `DuplicateProgress` atomics. ### System and app caches (`reclaim.rs`) `system_spots` matches the tree against `SpotRule`s: an absolute path in which `*` stands for one name (`.../Firefox/Profiles/*/cache2`), what it is, how to remove it, and a `Safety`: safe, check first, or use the system's own tool. `default_spot_rules` builds the list for the platform it runs on from environment variables (`SystemDrive`, `windir`, `LOCALAPPDATA`, the home folder), so the rules match only where the scan covers those places. Matching ignores case on Windows and macOS. ### Junk cleaner (`junk.rs`) The rules behind Clean junk, `freesize junk`, the Hub's Junk tab and the phone's cards: a static list per `Platform` (Windows, macOS, Linux, Android, Unraid) of `Rule`s, each with an id, a `Category`, a title and blurb, whether it `refills`, a `Safety` (safe, check first, use the tool), the app it belongs to and the process names that mean it's running, path patterns (templates over `~`, `$LOCAL`, `$ROAMING`, `$WINDIR`, `$DRIVE`, `$PROGRAMDATA`, `$PF86`, `$VAR`, `$STORAGE`, `$SHARES` and `$ROOT`, with `*` for one name or part of one), what to `Take` (the folder's contents, the folder itself, files by extension and age, or empty folders), names to keep, a minimum age, and a `fixture`: a made-up folder it must find and one it must not, which one test runs for every rule under a temporary root. `Env` holds the places for one machine (`system()` from the environment, `android(storage)`, `under(root)` for tests), and `find` expands the patterns, sizes what matches (`Item`: a path, its bytes and files, and whether it's the folder itself or its entries), skips what an earlier rule took so generic rules at the end of a list never double count, and marks a finding `running` when one of its processes is (`running_programs`: `/proc` on Linux, `ps` on macOS, `tasklist` on Windows). `Finding::ticked` is safe and not running (ticked at first); `Finding::can_tick` is not running and not "use the tool" (the person may tick it, "check first" ones then go to the quarantine); `delete` removes an item and reports what stayed in use. Nothing here decides to run: callers list, the person ticks. `find_all` adds the leftovers of removed programs (below) to `find`'s findings, dropping any a rule already covers; `find_here` is that for this machine. ### Leftovers of removed programs (`leftovers.rs`) The one junk rule (`program-leftovers`, Apps, check first) that isn't a pattern: the folders in AppData, `~/Library` and the XDG folders that belonged to a program that's gone. What's installed comes from the platform (`installed`): on Linux the `.desktop` files in the XDG data dirs, Flatpak's and snapd's app folders, dpkg's status file, pacman's database and `rpm -qa`; on macOS every app bundle Spotlight lists (`mdfind`) plus the Applications folders, with the bundle id from each `Info.plist`; on Windows the three Uninstall registry keys through `reg query` (DisplayName, Publisher, InstallLocation; system components and patches skipped). The parsers are plain functions with tests. A folder is matched to a program by `key` (lower case, letters and digits, without versions, architectures or brackets), by the program's id under the id-named places (`~/.var/app`, `~/snap`, `~/Library/Containers`, `Caches`, `Preferences`, `Saved Application State`), by its install location, and under a vendor folder (`Google\Chrome`) by vendor and name or by the publisher. Every look records the installed programs and the matched folders in `Record` (`programs.json` in the data folder, the one the app and `freesize agent` share); a recorded folder whose program isn't installed any more is a leftover, named after the program and the date it was last seen. Id-named folders with no program of that id are leftovers without a record, but only where the id list was read completely (`Installed::complete`), never for Apple's own ids, and never when an installed bundle shares the vendor (an app's helpers). Fewer than ten installed programs means the list wasn't read and nothing is offered; nothing changed for 14 days (`AGE_DAYS`) is required of every leftover, and a list of names shared between programs (`NEVER`) is never offered. Android and Unraid have none of this; the phone's own leftovers rule (`Android/media`) is in the app. ### Snapshots (`snapshot.rs`) A `Snapshot` is the folder sizes of one scan: paths relative to the scanned folder, logical and allocated size, for the biggest folders (10,000 in the app). Keeping folders rather than files keeps a snapshot of a whole drive to about 1 MB of JSON, however many files it holds. `compare` lines two snapshots up by path and reports folders that grew, shrank, appeared or went away by at least a minimum. A folder is skipped when one of its children accounts for 80% of its change, so the list names where the change happened rather than every folder above it. `find` maps a snapshot path back to a node. ### Folder review (`review.rs`) Sorts a folder's files for a review: `group_of` by extension through `Kind` (photos, documents, videos, the rest) and `important` for the ones worth a look, by extension and name (password vaults, keys and certificates, wallets, tax and accounting files, email archives, documents and spreadsheets, and camera photos by name, such as `IMG_1234` and `PXL_…`, or a `DCIM` folder). `copy_candidates` pairs each file of the folder with the files of its size elsewhere in the tree, owning the paths so the reading can run on another thread, and `CopyCandidates::find` confirms copies by a hash of both ends, then the whole file (`reclaim.rs`'s hashes), keeping one other copy per file. ### Scan files (`ncdu.rs`) `export` writes a tree in ncdu's JSON export format (version 1.2): a directory is an array whose first element describes it, a file an object, sizes as `asize`/`dsize` with a directory's own size separate from its contents. Read errors, other file systems, hard links and non-regular files map to ncdu's `read_error`, `excluded`, `hlnkc` and `notreg`. `import` reads files from FreeSize, ncdu 1.x and ncdu 2.x's JSON export and builds the tree the way the scanner does (breadth first, so children stay contiguous), then aggregates the totals. ### The rename (`renamed.rs`) DiskTree became FreeSize in 0.8.6. `adopt_dir` takes a data folder named for FreeSize and, when only the folder of the old name beside it exists (`DiskTree`, `disktree`, `disktree-hub`), renames that into place, so the desktop app, the agent, a Hub outside Docker and the Android app history keep their files. `old_env` lists `DISKTREE_…` variables to set under their `FREESIZE_…` names where those are unset; the Hub and the command line apply it before any thread starts. The names inside the protocol (signing headers and labels), the quarantine folder `.disktree-quarantine` (its manifests record full paths), the metrics and the webhook header keep the old name. ## App ### UI (`ui/app.slint`) The whole interface is one Slint file. Rust feeds it plain structs (`TreeRow`, `MapRect`, `FileRow`, `TypeRow`, `HomeRow`, `CleanRow`, `SheetAction`) and receives callbacks. - **Wide layout** (≥ 760 px): folder tree on the left; the map, largest files, file types, Clean up and Changes in tabs on the right. - **Compact layout** (phones, narrow windows): six tabs, larger touch rows, no percent column. - `compact` is set from a `changed width` handler rather than bound to `width`. A binding would make the window's size depend on itself through the layout. - The treemap is a list of absolutely positioned rectangles computed in Rust. Its size is reported back through `map-resized` whenever the pane changes. - Outside a scan the window shows one place at a time (`home::Section`: Home, Clean, Apps on phones, Quarantine and Hub on desktops, Settings), picked from the `Sidebar` (left, desktops) or the `BottomBar` (phones and windows below 760 px). The controller rebuilds the same `HomeRow` list for the current place and passes the places as `nav-items`, with the counts shown on them (Hub requests waiting, items in the quarantine). A scan takes the whole window with its own tabs. Settings are `HomeRow`s of kind 9, a drop-down, answered through `home-choice`. - The home screen and the Clean up tab are scrolling layouts, not list views. Slint measures wrapped text as a single line when it computes a layout's preferred height, so their scroll height comes from the position of an empty marker after the last row instead. - The storage chart is three filled `Path`s whose SVG commands Rust builds (`growth::chart`), drawn over each other so the areas stack. - Kind icons are SVGs in `ui/icons`, drawn tinted with `colorize`; the `Icons` global maps a kind number to one. App icons arrive from Rust as images and are drawn as they are. - `MapView` holds the treemap or `BubblePane` plus the Treemap/Bubbles switch. Bubbles are rounded rectangles whose size animates from zero when a new layout arrives, so zooming in grows the new bubbles; labels are a second layer that doesn't take taps. `Breadcrumb` shows the path to the shown folder, each step tappable. ### Controller (`controller.rs`) `Controller` holds the state: the current tree, size mode, selection, the focused folder (what the treemap and lists show) and any background job. It is shared as `Rc>`. UI callbacks borrow it through `Controller::run`; if it's already borrowed (a callback fired while Rust was updating the UI), the job is retried on the next event loop turn instead of panicking. Long-running work (scanning, deleting, measuring apps, the duplicate and similar photo searches, selection jobs) runs on a plain thread and writes its result into an `Arc>>`. A 100 ms Slint timer polls those results and the scan progress, and stops itself when nothing is pending. That keeps all UI updates on the UI thread with no `Send` requirements on UI state. Interaction rules: - Selecting a folder (in the tree, treemap or lists) makes it the focus: the treemap zooms into it and the lists cover it. - Selecting a file keeps the current focus if the file is inside it. - Selecting from the treemap or lists expands the tree down to the item and scrolls to it. The scan thread does more than scan: it works out every node's kind, takes a snapshot and records it (`scans.rs`, per scanned folder in `scans/.json` in the data folder, returning the earlier ones: the last 12 in full, older ones trimmed to their 300 biggest folders with `Snapshot::floor` saying how small a left-out folder was, thinned to a year like the Hub's history), and, when the scan covers a whole drive, asks the system about hidden space. A path ending in `.json` is read as a scan file instead (`imported`), which turns off Open, Delete, Select and the Clean up tab's tick boxes. The Clean up tab is a list of `filecleanup::Section`s: each has a card on the overview (`CleanSection`), a page of rows, what its "select all" ticks (`picks`; for duplicates every copy but `filecleanup::keeper`'s, for similar photos all but the largest), the same per group (`groups`, the group rows carry their index) and, for duplicates, the types to list one of. The controller keeps the sections, shows the open page's rows and `mark_cleanup` ticks rows from the selection (the code calls it the basket; an item is ticked when it or a folder above it is in it), counts them on the cards and warns on a set whose every copy is ticked; it runs on every change to the selection, so ticking from the tab, the selection bar or the review dialog stays in step. Similar photos are handed to Slint as `PhotoSet`s and laid out as tiles that wrap to the width. The report flattens the sections back into headings and rows (`filecleanup::flatten`). A scan that missed folders gets a card at the top of the Clean up tab (`filecleanup::unreadable_section`) naming them, with the way to a fuller scan that `elevate.rs` works out per platform. Windows: `ShellExecuteW` with `runas` starts `freesize-gui --administrator PATH`, a second window that turns on the backup privilege, scans PATH and says "administrator" in its title; the original window stays, since an elevated program doesn't see the user's mapped drives. macOS: first Full Disk Access, checked by opening TCC's own database and granted in System Settings (`Privacy_AllFiles`, opened for the user); then `osascript … with administrator privileges`. Linux: `pkexec`. Both run `freesize-gui --export FILE PATH` as root, with no window, into `root-scans/` in the data folder (older files removed first); the controller polls the child on its timer and opens the file as a saved scan, so nothing from a root's scan can be opened or deleted. The window itself never runs as root. Android gets no button: apps' private data is measured through Usage access instead. ### Scan view extras - `review.rs` makes a folder review's page of the Clean up tab: a `filecleanup::Section` with the key `review`, whose type choices are the tabs (`filter_index`, with no "Every type"), cached until the tab, the results, the previews or the counts of files change. The controller runs the previews (`similar::preview`, the first 400 photos, handed over as they're made), Compare and Look inside on threads it polls on its timer; Photos is drawn as tiles like similar photos. The words are kept in `review.json` in the data folder. The tree's rows have a context menu on desktops (not on phones, where a long press would fight the list's scrolling), and the selection bar a Review button. - `changes.rs` turns `snapshot::compare` into the Changes tab's rows. The bubble view looks up each folder in the compared snapshot and adds the difference to its label when it's at least 5% (and 10 MB). - `hidden.rs` explains used space that no file accounts for, only for a scan of a whole drive (`is_drive_root`: a different device than the parent on Unix, `C:\` on Windows). It asks the system's tools and stays quiet when one is missing: `zfs list` for the snapshots of the dataset mounted there, `btrfs subvolume list -s` and `btrfs qgroup show` (the snapshots' exclusive bytes, with quotas on) for btrfs, `vssadmin list shadowstorage` for shadow copies, `tmutil listlocalsnapshots` for Time Machine, `diskutil apfs listSnapshots` for other APFS snapshots and `diskutil info` for purgeable space. `filecleanup.rs` shows the gap only when it's at least 1 GB and 2% of the used space. - `moves.rs` moves the selection's items: the target keeps the path below the scanned folder, `rename` within a drive, else copy, compare sizes and only then remove the original. An existing target stops that item. The `(from, to)` pairs go to `last-move.json` so Put back can reverse them. - `report.rs` builds the HTML report (self-contained, light and dark) and the CSV; `actions::save_export` asks where on desktops (`rfd`) and writes to `Download/FreeSize` on Android. - `similar.rs` finds photos that look alike. A photo's fingerprint is two 64-bit difference hashes of a 9×9 grid of brightness averages: whether each cell is darker than its right neighbour, and than the one below, after turning the grid by the EXIF orientation. For a JPEG it first reads 160 KB and looks for the EXIF preview (APP1 → TIFF → IFD1's `JPEGInterchangeFormat`), decoding the whole picture only without one. Fingerprints within 12 bits of each other match, and pictures with almost no contrast get none. Grouping is greedy in time order: a photo joins the group whose first photo is closest, which keeps a slow drift through a series from chaining everything together. Previews (112 px) are made on the worker for the first 400 photos in groups. Four threads at most, and decoding is capped at 256 MB, so 50 MP photos without previews don't exhaust a phone's memory. ### Tree rows (`rows.rs`) `TreeRows` is a custom Slint `Model` over the flattened visible rows. Expanding or collapsing a folder splices a contiguous block of rows and reports it as one range change, so the list view never rebuilds. A folder shows its 500 largest children, and the rest are folded into one "… N smaller items" row. ### Android (`android.rs`) `android_main` is the NativeActivity entry point. Storage access goes through JNI, using the same `jni` 0.22 crate as Slint's Android backend: - Android 11+: `Environment.isExternalStorageManager()` checks access. If it's missing, the app opens `Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION`, falling back to the general screen on devices that lack the per-app one. - Android 10 and older: the classic `READ_EXTERNAL_STORAGE` runtime permission. - While access is missing, the controller re-checks every second. When access is granted, it hides the banner and starts any scan the user asked for. **Scanned pairing links** (0.15.1): the app's activity is a `NativeActivity` in `singleTask` mode, so a link scanned while it's running arrives through `onNewIntent`, which `NativeActivity` drops (`getIntent` still returns the launch intent). The `freesize://pair` and `disktree://pair` filter is therefore on `PairLink`, a Java activity that shows nothing (`Theme.NoDisplay`): it writes the link to `pair-link` in the files folder, opens the app through its launch intent and finishes. The controller takes the file (`hub::take_pair_link`, which removes it) at start and on every Hub tick (500 ms) and opens the connect sheet with the link; at start it still reads the launch intent's link too. **The background job** (`background.rs`, 0.14): `HubJob`, one Java class in `crates/app/android/java`, is a `JobService` Android runs about every 15 minutes (periodic, needs a network, persisted across reboots), scheduled by the app through JNI once it's paired (`android::schedule_hub_job`, through the context's class loader, since `FindClass` from a native thread only sees the system's classes) and cancelled on disconnect. The job calls `nativeRun` in the same `libfreesize_app.so`, which remembers the job's context (`android::init_background`) so every `Context` call in `android.rs` works without the activity, and runs one round: nothing while the app's own Hub worker is running in the process (`hub::worker_active`); otherwise the reports kept in `hub-pending/` go out (the app saves there what it couldn't send, oldest dropped past 20), then a fresh report (info, disks, quarantine, the Nextcloud folder; the apps and junk every six hours, by a marker file), then one poll. Commands for the phone are marked shown and come back as notices; what a grant would run isn't run, it's announced instead; approvals for other devices become notices too; withdrawn requests take their notices down. Java shows each notice (channel "hub", tagged by the request's id so a repeat replaces it, opening the app on tap) when Android 13+'s `POST_NOTIFICATIONS` was granted, which the app asks for after pairing. The logic stays in Rust; Java only schedules, calls and shows. Storage volumes come from `/storage`: `emulated/0` is internal storage, and the `XXXX-XXXX` entries are SD cards and USB drives. Errors and panics go to logcat under the tag `FreeSize`, since stderr goes nowhere on Android. If Slint can't start (it is already set up in this process), `android_main` logs the error and returns instead of crashing. ### Icons of apps and games (`owners.rs`, `winicon.rs`) `Owners` answers "which app does this folder belong to?" for the folders that get an icon, and caches the image per path: - Android: `Android/{data,media,obb}/`, with icons the app report carries. `android::app_icons` draws each app's icon (adaptive icons included) into a 72 px bitmap through `PackageManager.getApplicationIcon` and a `Canvas`, once per package and version. - Steam: a folder in `steamapps/common` is matched to its `appmanifest_.acf` by `installdir`; the icon is the hashed `.jpg` in `appcache/librarycache//` (or the older `_icon.jpg`) of the library's Steam folder or a usual Steam install. - Linux: a folder directly in `~/.config`, `~/.cache`, `~/.local/share`, `~/.var/app`, `~/snap`, `/opt`, `/usr/share`, `/usr/lib`, `/var/lib` (or a dot folder in the home folder) is matched by name to `.desktop` entries (file id, the last part of a reverse-DNS id, `StartupWMClass`, `Exec`, `Icon`). Icons come from the hicolor theme or `pixmaps`. - Windows: a folder directly in Program Files, AppData or ProgramData gets the icon of the `.exe` inside named most like it (else the biggest, two levels deep, skipping uninstallers and updaters). `winicon` extracts it at 64 px with `SHDefExtractIconW`, `GetIconInfo` and `GetDIBits`, using the icon mask when there's no alpha channel. - macOS: `.app` bundles, and folders in `~/Library` (Application Support, Caches, Containers, …) matched to installed apps by bundle id or name; the icon is decoded from the bundle's `.icns` (`plist`, `icns` crates). ### App and system storage (`apps.rs`, `home.rs`) A file scan can't account for most of a phone's used space. Other apps' private data (`/data/data/`, `Android/data`, `Android/obb`) and installed code can't be read by any app. Android does report their sizes through `StorageStatsManager`, which needs the user to grant Usage access (`PACKAGE_USAGE_STATS`, checked through `AppOpsManager`). `android::collect_app_report` runs on a worker thread and makes these calls: | Call | Result | |---|---| | `PackageManager.getInstalledApplications` | every app; `QUERY_ALL_PACKAGES` lets Android 11+ list them all | | `queryStatsForPackage` | each app's code, data and cache bytes | | `queryExternalStatsForUser` | shared storage total minus app-owned folders, i.e. the user's own files | | `getTotalBytes` | the advertised capacity | Each app gets its own JNI local frame, and an app that disappears mid-way is skipped. For each app it also asks: | Call | Result | |---|---| | `UsageStatsManager.queryUsageStats` | when the app was last on screen (two years back) and its screen time over about a month | | `getPackageInfo` | install date and version | | `getInstallSourceInfo` | the installer: Play Store, another store, a downloaded file | | `getLaunchIntentForPackage` | whether it has an icon | | `ApplicationInfo.isArchived` (Android 15+) | archived apps, listed with `MATCH_ARCHIVED_PACKAGES` | Usage history is read once per interval type (yearly, monthly, weekly, daily) and merged. A single two-year query looks simpler, but when none of Android's records start that far back, Android answers it from the daily records alone, which cover about a week; every app used before that then looked never opened. Once per report it collects the apps that work without being opened: the default home screen and browser (`resolveActivity`), SMS and phone apps, keyboards, accessibility services and notification listeners (`Settings.Secure`), and device admins. Android 13+ treats Usage access as a *restricted setting*: for an app installed from a downloaded APK the switch stays greyed out until the user picks *Allow restricted settings* from the ⋮ menu of the app's info page. Apps can't detect that state, so once the user has been sent to the Usage access screen, the home screen adds a card explaining it, with a button that opens FreeSize's own app info page. `AppReport::breakdown` divides the data partition (`statvfs`) the way Android Settings does: - Apps: code plus data. Preinstalled apps that were never updated count only their data, because their code lives on the system partition. - Your files: the shared storage figure above. - System & other: what's left of the used space. - Free. - Separately, the advertised capacity minus the partition size is reported as Android's own share outside user storage. `insights.rs` turns these facts into advice, as pure functions with tests: where an app came from and whether a store can reinstall it, which apps are safe to suggest, the groups (not opened in 3 months, never opened, not from a store), and tips for well-known apps. `history.rs` keeps a record of every app seen, so removed apps can be found again. `apps::collect` loads it, records the new report and saves it. It is JSON in `Documents/FreeSize/` (survives uninstalling FreeSize) and in the app's private storage (works without All files access); both copies are read and merged. `cleanup.rs` finds space that can go without touching any app, under the shared storage root: old `.apk` files in Download (except FreeSize's own `Download/FreeSize`), `DCIM/.thumbnails`, and `Android/media/` folders of packages that aren't installed (only when the installed list looks complete), plus the core junk cleaner's Android rules (old downloads, screen recordings, empty folders) as cards of their own, under the same ids the Hub uses. Android's trash comes from MediaStore's Files collection queried with `QUERY_ARG_MATCH_TRASHED`. The same collection's `owner_package_name` column says which app saved each file; `media_by_owner` adds those up per app and picks the folder holding most of them for the Browse button. Deleting runs on a worker thread after the shared confirmation dialog, into the quarantine like every delete in the app. ### Quarantine and updates (`quarantine.rs`, `updater.rs`) Every delete in the app (the selection, junk, a Hub request) goes through `quarantine.rs`, a thin wrapper over the agent's `Quarantine` with the app's own `Places`: the quarantine for the disk FreeSize's data folder is on is `/quarantine`, which the person owns, and other disks (and Android's shared storage) get `.disktree-quarantine` at their top as on servers. Batches made here are `a