Skip to content

General: UI Rewrite and Polishing#2381

Draft
d4rken wants to merge 355 commits into
mainfrom
compose-rewrite
Draft

General: UI Rewrite and Polishing#2381
d4rken wants to merge 355 commits into
mainfrom
compose-rewrite

Conversation

@d4rken
Copy link
Copy Markdown
Member

@d4rken d4rken commented Apr 14, 2026

What changed

The entire UI layer is being rewritten and polished from scratch. This is a long-running effort — the branch will be merged to main once all screens are converted and the app runs fully on the new UI stack.

Why?

  • Higher UI fidelity and smoother animations
  • Faster feature development — new screens and UI tweaks take a fraction of the time
  • Easier to polish and iterate on details
  • Consistency with the sibling apps (butler, capod, bluemusic, octi)

Current progress:

  • 12 screens fully converted (Onboarding, Dashboard, Settings, and more)
  • All dashboard cards reimplemented with pixel-parity to the current UI
  • Core navigation and infrastructure in place
  • ~53 screens still to convert

This PR will be updated as work progresses. See COMPOSE_REWRITE_PLAN.md in the branch for the full status and remaining work.

@d4rken d4rken added enhancement New feature, request, improvement or optimization General UI/UX User Interface/Experience Chore labels Apr 17, 2026
d4rken added 26 commits April 26, 2026 10:32
Replaces the three Fragments (AppCleanerListFragment, AppJunkDetailsFragment, AppJunkFragment) with Compose Host/Screen pairs and folds AppJunkViewModel into AppJunkDetailsViewModel — selection state moves to the screen and a single MutableStateFlow tracks per-junk collapsed categories. AppJunkRoute is retired (verified zero call sites). InstallIdNavType / NullableInstallIdNavType stay in MainNavGraph for AppControl + Analyzer.

Both VMs rebase on ViewModel4 + SingleEventFlow + safeStateIn. AppCleanerListViewModel uses synchronous taskSubmitter.submit() for snappier snackbar timing; the folded Details VM uses the taskSubmitter.state listener with handledResults dedupe. Pro gate runs at both requestDelete() and confirmDelete() entry points; an empty-junk filter drops AppJunks that go matchless after a per-path appCleaner.exclude(installId, paths) so the pager doesn't show ghost zero-junk pages.

DeleteSpec sealed type + AppCleanerTaskFactory.buildAppCleanerTask helper map a UI intent to the right AppCleanerProcessingTask. Category / SingleFile / SelectedFiles tasks force includeInaccessible=false (the param defaults to true on the task type, which would otherwise silently clear the inaccessible cache during a file-only delete). Category specs omit targetContents — backend resolves matches via the category's filter to avoid the targetContents lookup colliding via single { tc.matches(it.path) } at AppCleaner.kt:226.

Material3 AlertDialog renders the 'Show details' neutral action as an inline TextButton (Material3 has no neutral slot). The List screen's row body click triggers single-delete; the trailing details IconButton navigates to AppJunkDetailsScreen and is disabled in selection mode; the row's app icon long-press opens the system app settings. Both List and Details emit Event.ExclusionsCreated(count) and surface a snackbar with a 'View' action that navigates to ExclusionsListRoute.

Tests: AppJunkElementBuilderTest covers the 4-element-type builder + per-category collapse + sort. AppCleanerTaskFactoryTest locks in the includeInaccessible=false invariant for non-Inaccessible specs and covers stale-snapshot / cross-category SelectedFiles reconstruction. coil-compose was promoted from implementation to api in app-common-coil so consumers can use AsyncImage directly without redeclaring the dep.
Date 2026-04-26. 50→53 screens. 50→52 VMs (AppJunkViewModel folded into AppJunkDetailsViewModel). ~12→~9 unconverted Fragments. AppCleaner moved to closed-batch list.
Migrates AppControlListFragment + AppActionDialog to Compose hosts, both

rebased on ViewModel4 with SingleEventFlow events. AppActionDialog becomes

a ModalBottomSheet route registered via AppControlNavigation; AppActionRoute

now resolves through entry<>{} instead of dialog<>(). The list keeps its

search field, sort dropdown, asc/desc toggle, full FilterSettings.Tag chip

set, and selection-mode TopAppBar (Delete/Exclude/SelectAll as icons; rest

in overflow). The action sheet renders a sealed AppActionItem list built by

the new pure buildAppActionItems helper, unit-tested across 13 cases

covering current-vs-other-user, existing-exclusion, enable/disable, archive/

restore, size/usage/export availability.

Codex-reviewed integrations: live-id revalidation per click/confirm via

firstOrNull (replaces backend's single{} which throws on stale ids); Pro

re-gating after SAF returns; AppExportTask uses savePath; single-app

Uninstall/Archive/Restore confirms re-instated (Archive/Restore via root/

ADB skip OS confirms otherwise); list scans on null data instead of

navUp() — auto-pop would dead-end Dashboard/shortcut/FAB entries; sheet

stays open after every action (parity with legacy, resolves snackbar race).

Resolves the dead-click FIXME at ExclusionListViewModel.openAppControl().

Closes the AppControl tool's Fragment surface.
AppActionRoute(installId: InstallId) crashed with MissingFieldException on

every action-sheet open. SavedStateHandle.toRoute<>() does not reliably

deserialise Nav3 routes whose serializable args are non-nullable, even with

a typeMap registered (project memory: feedback_nav3_route_binding).

Switch to the Picker pattern: entry<AppActionRoute> { route ->

AppActionSheetHost(installId = route.installId) }. The host now passes the

installId to the VM via setInstallId(), and the VM observes a

MutableStateFlow<InstallId?> in its state combine.
55 screens converted (+2). 54 VMs on ViewModel4 (+2). Unconverted Fragments

down to ~7 (Scheduler 2, Analyzer 5). AppControl tool moves to closed-batch

list with detailed rationale; ExclusionsList AppControl-FAB FIXME marked

resolved.
Replaces SchedulerManagerFragment + ScheduleItemDialog with Compose hosts. Both ViewModels rebased on ViewModel4.

Manager screen: Scaffold + LazyColumn renders schedule cards (ScheduleRow), an AlarmHintRow when any schedule is enabled, and a BatteryHintRow on API 31+ when optimization is not ignored. Top bar wires the back arrow to navUp, the help icon to WebpageTool, and a debug-only schedule trigger gated by Bugs.isDebug. The inline post-schedule commands editor became a Compose AlertDialog with a multiline OutlinedTextField.

Schedule item sheet: ModalBottomSheet route bound via the Picker entry-arg pattern (entry<ScheduleItemRoute> { route -> ScheduleItemSheetHost(scheduleId = route.scheduleId) }) — SavedStateHandle.toRoute<>() crashes for non-nullable serializable args under Nav3 (matches the AppActionRoute fix). Form is backed by a separate MutableStateFlow<FormDraft> initialized once from the live schedule lookup so external schedulerManager.state emissions don't overwrite the user's input. saveSchedule refuses to resurrect a deleted schedule (isCreate guard) and refuses to save into an externally-enabled schedule (live.isEnabled guard) — SchedulerManager only re-checks WorkManager on scheduledAt change, so a partial save would diverge displayed time from the actual delay. canSave normalizes blank labels to null so empty-string saves are blocked.

Time picker uses Compose Material3 TimePicker + rememberTimePickerState(is24Hour = true) inside an AlertDialog, replacing the FragmentManager-bound MaterialTimePicker. Sheet inner Column uses imePadding + navigationBarsPadding so the Save button stays clear of the soft keyboard.

Battery hint failures route through errorEvents.emit(throwable) so ErrorEventHandler shows the standard error dialog (parity with the legacy asErrorDialogBuilder; ActivityNotFoundException.message is nullable so a snackbar-only path was unsafe). updateCommandsAfterSchedule replaces the legacy getSchedule(id)!! NPE-bait with a firstOrNull lookup that no-ops when the schedule is missing or now enabled. Row ETA derivation wraps Schedule.calcExecutionEta in runCatching to defuse the infinite loop on repeatInterval.toDays() == 0.

Tests: SchedulerRoutesSerializationTest extended to cover SchedulerSettingsRoute and SchedulerManagerRoute data-objects. New ScheduleItemViewModelTest covers the two save-time guards (resurrect-deleted, externally-enabled) plus the create-new path and the canSave label invariant. All 36 :app-tool-scheduler tests pass; all 751 :app:testFossDebugUnitTest pass; :app:assembleFossDebug clean.

Smoke-tested on emulator sdmse-rewrite: list renders, edit sheet opens without MissingFieldException, label/time/days editing persists, Pro-gated toggle routes to Upgrade for non-Pro users, delete is one-tap with no confirm (legacy parity), FAB creates a new schedule with random id, IME Done on the label field opens the time picker.
55 → 57 screens converted, 54 → 56 VMs on ViewModel4, ~7 → ~5 unconverted Fragments. Scheduler moved to closed-batch with the ModalBottomSheet route pattern, Compose Material3 TimePicker, and the FormDraft + save-time-guard architecture documented.
…eens to Compose

Closes the entire app-tool-analyzer Fragment surface, completing the Compose rewrite for the priority-1 cleaning tools.

Five screens converted: DeviceStorage, StorageContent, Apps, AppDetails, Content. All 5 ViewModels rebased on ViewModel4. The four data-class routes drop their from(handle)+typeMap companions and switch to the Picker-style bindRoute pattern (per the route-binding feedback memory). Three screens with required upstream data (StorageContent/AppDetails/Content) use sealed Loading/Ready/NotFound state instead of nullable defaulted State(). Init-time submit work (StorageScanTask, AppDeepScanTask) moves to a separate routeFlow.filterNotNull().take(1) flow so SharingStarted.WhileSubscribed restarts don't re-fire it. Trend chart wraps the legacy SpaceHistoryChartView via AndroidView. Selection mode adds a Select All toolbar action mirroring legacy installListSelection. Compose snackbar text uses context.getString/getQuantityString instead of @composable resource APIs. Progress ratios are clamped 0f..1f to satisfy Material3's strict LinearProgressIndicator. Media category navigation switches single() to singleOrNull() with a no-op fallback. AnalyzerNavigation registers the 5 entries; MainNavGraph drops the 5 fragment registrations and the four NavType vals (no remaining consumers). ExclusionListViewModel.openStoragePicker() FIXME closes since DeviceStorageRoute is now a Compose entry. SpaceHistoryChartView.isCompact gets a public setter so AndroidView can opt-in.

Closes #N/A
Priority-1 batch closed: 5 Analyzer screens (DeviceStorage / StorageContent / Apps / AppDetails / Content) on Compose; the entire app-tool-analyzer Fragment surface is retired. Screen count 57 -> 62, ViewModel count 56 -> 61, unconverted Fragment count ~5 -> 0. The last priority-1 FIXME (ExclusionListViewModel storage-picker chip) closes since DeviceStorageRoute is now a Compose entry.
First batch of post-rewrite cleanup. Deletes 24 files (1,534 lines) of legacy infrastructure with zero callers after the Compose conversion.

Removed Fragment chain (Fragment2/3, DialogFragment2/3, BottomSheetDialogFragment2, PreferenceFragment2/3), orphan uix helpers (ToolbarHost, Service2, ViewModelLazyKeyed, DetailsPagerAdapter3, FragmentStatePagerAdapter4 + ext), nav helpers (LegacyNavigationBridge, FragmentExtensions, NavDestinationExtensions), MainNavGraph marker, LogViewerAdapter, and the lists/selection/ helpers.

Trimmed RecyclerViewExtensions.kt to setupDefaults — its only live consumer is RecorderActivity.

Kept ViewModel3, Activity2, NavCommand, NavControllerExtensions, and the rest of common.lists.* because RecorderActivity is still XML-based and consumes them. Subsequent batches will sweep layouts/adapters, then gradle deps.
Second batch of post-rewrite cleanup. Removes 1,249 lines across 36 files of XML resources and Kotlin source whose consumers were retired in the Compose conversion.

Layouts (8): main_activity, debug_logview_fragment, debug_logviewer_row, data_areas_fragment, data_areas_list_item, view_swipetool, view_preview, view_new_badge. Menus (4): menu_main, menu_data_areas, menu_setup, menu_exclusion_editor_pkg.

Legacy custom views with their paired XMLs (6 sources + 7 layouts): ProgressBarView, BreadCrumbBar (+ crumb item), QualityInputDialog, BadgedCheckboxPreference (replaced by Compose SettingsBadgedSwitchItem), legacy common.ui.AgeInputDialog and common.ui.SizeInputDialog (replaced by Compose compose.settings.dialogs versions).

ids.xml files (9, all entirely dead nav-action IDs from the legacy Fragment NavGraph): appcleaner, systemcleaner, corpsefinder, deduplicator, scheduler, swiper, squeezer, app-common-stats, app-common-exclusion. Removed BreadCrumbBarStyle from app/styles.xml.

Fixed ErrorDialogSetup.kt to reference eu.darken.sdmse.common.R.id.nav_host (the live shared id) instead of eu.darken.sdmse.R.id.nav_host (which was inlined in the deleted main_activity.xml). Now matches AutomationNoConsentException and InaccessibleDeletionException.
…e-livedata, and CaveatPreference

Final batch of post-rewrite cleanup. Removes 188 lines across 20 files of gradle and source: build features, dead deps, and the last legacy Preference custom views.

Stripped 'viewBinding = true' from 13 modules whose ViewBinding consumers were retired in the Compose conversion: corpsefinder, stats, coil, systemcleaner, picker, deduplicator, analyzer, squeezer, appcontrol, scheduler, appcleaner, exclusion, swiper. Kept in app (RecorderActivity + LogFileAdapter), app-common-ui (MascotView), app-common-automation (AutomationControlView).

Stripped 'androidx.recyclerview:recyclerview-selection:1.2.0' from 11 build.gradle.kts files — entire library was unused after installListSelection deletion. Removed 'androidx.compose.runtime:runtime-livedata' from buildSrc addCompose() — its only consumer was LegacyNavigationBridge, deleted earlier in this cleanup sequence.

Deleted CaveatPreferenceView, CaveatPreferenceGroup, and their two layout XMLs — both Preference subclasses were orphan after the preference XMLs converted to Compose.

Verified: :app:assembleFossDebug + :app:testFossDebugUnitTest (751/751) + :app:lintVitalFossRelease all clean.
Updates the rewrite plan to reflect the 3 cleanup commits (source / resource / gradle deps purge — 80 files, ~3,000 deletions). Marks the Final-cleanup #3 checklist mostly closed, with two carve-outs: (1) Fragment navigation deps still live in 14 modules and (2) the lists/ + ViewBinding chain stays alive for the RecorderActivity holdout.

Adds two new sections: #4 'RecorderActivity to Compose' (next priority — converting it unblocks dropping Activity2, ViewModel3, NavCommand, the lists/ chain, and viewBinding in app/), and #5 documenting the three legacy Navigation.findNavController(view, R.id.nav_host) callbacks (ErrorDialogSetup, AutomationNoConsentException, InaccessibleDeletionException) that are likely runtime-broken under the Compose-only MainActivity and need migrating before the navigation-fragment-ktx deps can be dropped.
Replace the XML-based RecorderActivity (Activity2 + ViewBinding + LogFileAdapter) with a ComponentActivity that uses setContent {} and a Compose UI hierarchy. RecorderViewModel rebases from ViewModel3 (LiveData) to ViewModel4 (SingleEventFlow + safeStateIn), exposes themeState built from GeneralSettings, and routes filesystem work through dispatcherProvider.IO.

Theming.kt is extended to also skip RecorderActivity from the XML-era theming pipeline (the Compose SdmSeTheme handles it). The Activity wraps share-intent launches with try/catch (ActivityNotFoundException, SecurityException) and routes failures back through vm.onShareLaunchFailed → errorEvents.

RecorderScreen.kt introduces 7 composables (hero / sensitive-info / session-path / failed / log-files-header / log-file-row / action-bar). The action bar uses three fixed-weight slots — share button alpha-hidden during zip with a CircularProgressIndicator overlay — so layout never reflows during state changes.
…rom app

After RecorderActivity converted to Compose, the entire legacy Activity/VM/RecyclerView infrastructure is unused. Migrate MainActivity from Activity2 to ComponentActivity (only the inherited verbose-logging hooks were used; MainActivity has its own TAG and onResume override). Rebase the test ViewModel in ViewModel2ScopeTest on ViewModel4 and read errorEvents via .first() instead of .value. Reduce DashboardItem to a plain interface with stableId since DifferItem and its payloadProvider machinery are gone.

Delete: Activity2.kt, ViewModel3.kt, NavCommand.kt, NavDirectionsExtensions.kt (NavEventSource), the entire app-common-ui/.../lists/ directory (17 files: BaseAdapter, BindableVH, DataAdapter, ListItem, MutableDataAdapter, RecyclerViewExtensions, ViewHolderBasedDivider, AsyncDiffer chain, ModularAdapter, modular/mods/*), LogFileAdapter, debug_recorder_activity.xml, debug_recorder_logfile_item.xml. Drop viewBinding = true from app/build.gradle.kts.
Update COMPOSE_REWRITE_PLAN.md after the RecorderActivity Compose conversion (commits 4e51209 + 2fcfb5e). Final-cleanup batch is now fully closed (5 commits, ~110 files, ~4,200 deletions). The XML/Adapters/ViewBinding sub-bullet is no longer partial — the entire app-common-ui/.../lists/ package, both debug_recorder_*.xml layouts, LogFileAdapter, and viewBinding=true on the app module are gone. The Activity2/ViewModel3/NavCommand/NavEventSource files are deleted. MainActivity is rebased on ComponentActivity.

Section #4 is now the 3 legacy Navigation.findNavController callbacks (renumbered from #5) — that's the last cleanup blocker before navigation-fragment-ktx/navigation-ui-ktx can be dropped from the 14 modules that still pull them in.
Three error-dialog callbacks called Navigation.findNavController(view, R.id.nav_host).safeNavigate(SomeRoute) — broken at runtime under the Compose-only MainActivity since no view in the tree carries the nav_host tag. Extend LocalizedError with declarative fixActionRoute and infoActionRoute fields and dispatch them through the NavigationController already available to ComposeErrorDialog via LocalNavigationController.current.

Migrated callsites: ErrorDialogSetup.kt (IncompleteSetupException → SetupRoute, WriteException → PathExclusionEditorRoute), AutomationNoConsentException, InaccessibleDeletionException. Existing intent-based fixAction/infoAction callbacks (gplay billing, automation web URLs, security center settings) stay unchanged.

Bug fix in ComposeErrorDialog: infoAction tap now dismisses the dialog (the legacy MaterialAlertDialogBuilder did this implicitly via the neutral button). Removed dead helpers: NavControllerExtensions.kt (safeNavigate), SetupModuleSetup.kt (showFixSetupHint/installShowSetupHint never invoked), SnackbarExtensions.kt (enableBigText only consumed by the dead SetupModuleSetup), the var showSetupHint declaration in SetupModuleExtensions.kt, R.id.nav_host. SetupModule.Type.labelRes inlined into ErrorDialogSetup.kt (its only consumer).
…odules

After the error-dialog fix actions were rewired through the existing NavigationController (commit 423e567), no Kotlin source references androidx.navigation.NavController anywhere in the codebase. The Fragment-aware Navigation 2.x dependencies are now dead weight.

Removed from app, app-common-ui, app-common-stats, app-common-picker, app-common-coil, app-common-automation, app-common-exclusion, and 9 app-tool-* modules. androidx.navigation:navigation-testing stays as androidTest in app/. dependencyInsight on fossDebugRuntimeClasspath confirms neither -fragment-ktx nor -ui-ktx is pulled in transitively.
Update COMPOSE_REWRITE_PLAN.md after the error-dialog migration + nav-fragment-ktx purge. §4 is now fully closed (4 callsites rewired through declarative LocalizedError routes; dead helpers gone; ComposeErrorDialog infoAction-dismiss bug fixed). The Fragment navigation deps sub-bullet under §3 is also marked done.

Renumbered the remaining cleanup target as §5: drop transitive fragment-ktx from addAndroidUI() — the three Fragment-era helper files (LiveDataExtensions, ViewBindingExtensions, ActivityExtensions) have no remaining callsites and can be deleted alongside the dep.
After the error-dialog migration, no main-source Kotlin code references androidx.fragment.app.Fragment. Drop fragment-ktx from addAndroidUI() and delete the three remaining Fragment-era helpers.

Deleted: LiveDataExtensions.kt (observe2(Fragment) overloads), ViewBindingExtensions.kt (Fragment.viewBinding delegate), ActivityExtensions.kt (viewParent/view/isContentViewSet/showFragment/todoToast). Also dropped the dead LoadingOverlayViewStyle from styles.xml and the now-empty app/.../res/values/ids.xml (last entry fragment_frame was only consumed by the deleted ActivityExtensions; loading_overlay was only consumed by LoadingOverlayViewStyle).

androidTest sources (HiltExtensions, EmptyFragmentActivity) still import Fragment / FragmentActivity / FragmentFactory. Those classes remain available transitively via androidx.appcompat:appcompat:1.7.1 (already pulled in by addAndroidCore()), so no androidTest dep adjustment was needed.
fragment-ktx is gone; the three Fragment-era helpers were truly dead. Bonus cleanup: dropped LoadingOverlayViewStyle and the now-empty ids.xml. Section §5 closed in a single commit.

Documented §6: a pre-existing androidTest compilation failure in MainActivityTest.kt (carries over from the ViewModel4 migration — mocks LiveData where the VM exposes Flow, references a non-existent onGo() method). Unrelated to §5; queued for a separate small batch.
Three of the four androidTest files were dead code from the legacy XML-era. MainActivityTest.kt mocked MainViewModel.state as LiveData where the VM has been Flow since the ViewModel4 migration, and verified a non-existent vm.onGo() method — broken at compile time and meaningless post-rewrite. ExampleFragmentTest.kt was fully commented out (referenced the long-gone MainFragment + MainFragmentVM).

Delete: MainActivityTest.kt, ExampleFragmentTest.kt, HiltExtensions.kt (launchFragmentInHiltContainer<T>() — only consumed by the commented-out test), EmptyFragmentActivity.kt (Hilt host for the fragment-launching helper). After this, no androidTest source imports androidx.fragment.app.Fragment. ExampleInstrumentedTest and BaseUITest stay (the latter is a 1-line scaffold for future Compose UI tests).
All 6 documented cleanup sections of the Compose rewrite are now closed. The branch has no outstanding cleanup blockers; future work falls outside the rewrite plan's original scope (e.g. converting MascotView and AutomationControlView from XML+ViewBinding to Compose, which would unblock dropping viewBinding from app-common-ui and app-common-automation).
Replaces the imperative AutomationControlView (custom ConstraintLayout +
ViewBinding) and the legacy MascotView (FrameLayout wrapper around
LottieAnimationView) with a ComposeView hosting a new AutomationOverlay
composable. AutomationService keeps the same WindowManager addView /
removeView lifecycle but the surface is now state-driven via a single
MutableStateFlow<AutomationOverlayState> instead of imperative setProgress /
setTitle / setCancelListener calls.

Implementation notes:

- ComposeView in a WindowManager overlay needs LifecycleOwner +
  ViewModelStoreOwner + SavedStateRegistryOwner. New OverlayLifecycleOwner
  helper drives explicit ON_CREATE/START/RESUME at addView and
  PAUSE/STOP/DESTROY at removeView. Composition strategy is
  DisposeOnDetachedFromWindow so we don't depend on the lifecycle owner
  destroying composition.
- updateProgress(...) now also writes overlayState.progress, replacing the
  always-on progress.onEach { setProgress(pd) } collector at line 144.
- changeOptions(...) writes title/subtitle to overlayState instead of
  controlView.setTitle.
- Theme is snapshotted (themeMode + themeStyle) once at addView and passed
  to SdmSeTheme — matches legacy ContextThemeWrapper-at-addView behavior.
- addView is wrapped so the lifecycle owner is destroyed if either
  setContent or addView throws (BadTokenException or anything else).
- removeView path is try/finally so controlView and overlayLifecycle are
  always nulled and onDestroy() fires even if removeView throws.
- AutomationOverlay uses the existing Compose SdmMascot (no Lottie
  pause/resume needed — early-return on progress == null removes the mascot
  from composition entirely). Zero-max guard mirrors ProgressOverlay.kt:
  Counter/Percent fall back to indeterminate when current==0L || max==0L.
  Size keeps the indeterminate spinner but renders displayValue (parity
  with legacy progress_text behavior).
- Layout robustness: card capped at widthIn(max=600.dp), secondary text
  uses maxLines only (no fixed heightIn cap), mascot capped at
  heightIn(max=240.dp).

Cleanup:

- viewBinding = true dropped from app-common-automation and app-common-ui
  (both were only used by the deleted custom views). No modules ship
  viewBinding = true now.
- Compose plugin + addCompose() added to app-common-automation.
- MascotView declare-styleable removed from attrs.xml; ProgressOverlayView
  styleable kept.
- vector_mascot_nobroom.xml is intentionally kept; launch_screen.xml still
  references it.
- Easter-egg click counter (wiggle/rotate animations on every 5th/12th tap)
  was dropped — debug-only, not surfaced to users.
Records that AutomationControlView and MascotView were the final
viewBinding holdouts and were converted in commit 85fa450. Updates the
historical 'viewBinding stripped from 14 modules' note to point at the
follow-up that closes the remaining two, and adds a dedicated bullet
covering OverlayLifecycleOwner, the state-flow plumbing, and the Compose
overlay layout decisions.
Drops 257 lines of dead code: the legacy ProgressOverlayView custom
ConstraintLayout, its two layout XMLs (view_progress_overlay.xml,
view_progress_overlay_large.xml), and the now-empty attrs.xml in
app-common-ui (the MascotView styleable was already removed in commit
85fa450; ProgressOverlayView was the only remaining entry).

The Compose ProgressOverlay.kt has replaced it everywhere — no Kotlin or
XML callers remain (verified by grep). Also drops the stale 'Compose
counterpart to ProgressOverlayView' doc comment now that the legacy
class is gone.
d4rken and others added 30 commits May 16, 2026 14:13
Mirrors the CorpseFinder test coverage improvement (commit d9901e6). Adds 78 new tests bringing the app-tool-swiper module from 63 to 141 unit tests. Covers SwiperSessionsViewModel, SwiperStatusViewModel, SwiperSwipeViewModel (including the stale-override guard branch), and a small SwiperSessionsScreen UI smoke test.

Includes a private SwiperPreviewData helper for synthetic SwipeItem/PathLookup builders shared between tests. Uses the TestScope.backgroundScope pattern in the harness to keep safeStateIn's WhileSubscribed upstream collection alive without blocking runTest completion.
Captures four learnings from the Swiper test work:

- safeStateIn + WhileSubscribed harness pattern using TestScope.backgroundScope (without this, every render-state assertion sees initialValue)

- DataStoreValue extension stubbing (value() and value(T) are extensions, not members — stub flow / verify update())

- ExclusionManager.save Set capture (the single-Exclusion overload is an extension)

- NavigationController.consumeResults hot-flow stubbing for picker results
…tivity inputs

Copy-paste bug at AppControl.performScan: hasInfoActive was computed from `task.loadInfoSize && curState.canInfoSize` instead of `task.loadInfoActive && curState.canInfoActive`.

Surface-level impact: after a toggle / uninstall / archive / restore operation, the post-action snapshot rebuild uses `snapshot.hasInfoActive` to decide whether to re-fetch isActive for the affected rows. With the bug:

- Sizing on + activity off → updated rows had isActive re-queried that the rest of the list didn't have.

- Sizing off + activity on → updated rows lost isActive while the rest of the list kept it → inconsistent column population.

Added AppControlTest (9 tests) covering performScan flag computation:

- hasInfoActive=true only when loadInfoActive AND canInfoActive (regression-shaped: also verifies hasInfoActive is independent of loadInfoSize)

- hasInfoSize / hasInfoScreenTime / hasIncludedMultiUser follow their own respective AND formulas

- AppScan.allApps receives the AND of task flag and capability gate, not the raw task flag

- Result.itemCount reflects the AppInfo set size

Uses the same SharedResource.createKeepAlive pattern that CorpseFinderTest established to keep keepResourceHoldersAlive(appScan) from failing on MockK relaxed mocks.
…nd hasInfoActive fix

Expands AppControl unit + Compose UI test coverage from ~30 to 144 tests, following the patterns established by the recent CorpseFinder testing work.

Surfaced and fixed one pre-existing bug: AppControl.performScan was computing Data.hasInfoActive from the size inputs (loadInfoSize && canInfoSize) instead of the activity inputs (loadInfoActive && canInfoActive). The wrong value then propagated into every post-action snapshot rebuild (toggle / uninstall / archive / restore), causing rows refreshed after an action to either lose or gain isActive info inconsistently with the rest of the list.
…ests

Mirrors the CorpseFinder ViewModel test pattern (d9901e6) for the two

settings ViewModels: state composition from DataStoreValue mocks, individual

setter write-through with captured update lambdas, picker-result wiring, and

navigation events for both search-locations and arbiter-config entry points.

ArbiterConfigViewModelTest pins the canonical default criterium order, covers

Mode.entries for every non-PreferredPath criterium type, asserts onModeSelected

matches by class (not by index, which would mis-mutate adjacent criteria), and

verifies the picker-result handler REPLACES PreferredPath.keepPreferPaths

without touching sibling criteria.

DeduplicatorSettingsViewModelTest pins the picker channel name

('picker.result.scan.location.paths') so consumer/producer drift can't silently

swallow results, and asserts onSearchLocationsClick / onArbiterConfigClick

emit the correct NavEvent.GoTo destinations.
Adds 34 unit tests covering state composition (sort-by-size, deleteTargetIds

across favorite/non-favorite-with-keeper/no-keeper branches), navUp on data

drain, deletion flows for clusters/single-dupe/multi-dupe (pro gating and

task submission), exclusion flows, layout-mode toggle, and task-result event

dispatch (init-time filtering, single-duplicate suppression).

Two pre-existing bugs fixed:

1. navUp during loading. The init flow used 'map { it.data }.drop(1).filter

   { !it.hasData }', so when performScan reset internalData to null at the

   start of a refresh, the user got bounced back to the parent screen mid

   scan. Switched to 'mapNotNull { it.data }.drop(1)...' to match the

   CorpseFinder fix in d9901e6 and the DeduplicatorDetailsVM pattern

   already in this module.

2. excludeClusters() snackbar count mismatch. The VM emitted

   'sanitized.sumOf { it.count }', which is the requested-files count, not

   the actually-saved exclusion count. When ExclusionManager.save() coalesces

   duplicate paths the user saw an inflated count. Now reads

   'undo.exclusionIds.size' (same fix shape as the CorpseFinder list VM).
41 unit tests covering the paged-details VM:

- bindRoute idempotency (single-route and updatePage-then-rebind cases)

- State target resolution: route identifier, updatePage swap, lastPosition

  fallback when the requested target is removed

- Directory view toggle and per-cluster collapsed-dir set with stale-cluster

  pruning

- Preview navigation: cluster (asserts paths), group with position clamping

  (over-range and negative), empty group no-op, single duplicate

- openDuplicate via ViewIntentTool: emits OpenDuplicate(intent) when the

  intent resolves, suppresses the event when the tool returns null

- deleteCluster/deleteGroup/deleteDuplicates: unconfirmed emits ConfirmDeletion

  with the correct DeleteTarget variant; confirmed-without-pro routes to

  UpgradeRoute(); confirmed-with-pro submits the right TargetMode

- deleteDuplicates filters stale ids AND ids from other clusters (the

  currentClusterOrNull scope is asserted)

- excludeCluster/excludeDuplicates with stale-id no-op paths and the saved-

  exclusion-count contract for the snackbar

- onUndoExclude sets currentTarget and calls undoExclude (re-emit forced via

  progressFlow since the deduplicator.state input has distinctUntilChangedBy)

- autoNavUpOnEmpty drain vs loading: empty → navUp, null → no navUp

- Task result dispatch: filters pre-init tasks, suppresses single-duplicate

  deletes, dedups by task id, forwards multi-target results
19 JVM-runnable Compose UI tests using BaseComposeRobolectricTest:

- DeduplicatorListScreenTest (6 tests): loading, empty, populated GRID/LINEAR

  rendering, layout-toggle action visibility, callback wiring on tap. The

  populated LINEAR test asserts an actual row body renders by checking for the

  duplicate file name — not just the absence of the empty placeholder.

- DeduplicatorDetailsScreenTest (4 tests): loading, empty, single-cluster

  flat-view, single-cluster directory-view. Both populated cases assert

  ClusterContent composed via the 'Occupied storage' summary label rather than

  the deferred LazyColumn row items. Stays on single-item state to avoid

  HorizontalPager / ScrollableTabRow interactions that Robolectric handles

  poorly. Provides a mocked GuidedTourController via CompositionLocalProvider

  because the screen reads LocalGuidedTourController.current and the local has

  no default.

- DeduplicatorSettingsScreenTest (9 tests): top bar, above-the-fold rows,

  Search-locations / Arbiter-config tap callbacks, allowDeleteAll switch, the

  Detection-method category header, and per-sleuth toggle wiring for all

  three sleuths (Checksum, PHash, Media) plus the Skip-uncommon switch.

  Catches regressions that swap sleuth callbacks across rows.
Fix #1 (state.value reads from business logic): SwiperStatusViewModel.navigateToItem and SwiperSwipeViewModel.setDecision / skip / advanceOrNavigate now read items + session directly from the source flows via a new snapshotForAction helper. Previously these methods read state.value, which is governed by safeStateIn's WhileSubscribed — they only worked because the Compose screen happened to be collecting.

Fix #2/#3 (stale-override clearing in combine): Drop the in-combine mutation of currentIndexOverride and the eager 'clear override when session.currentIndex == 0' branch. The branch fired on every fresh-session bindRoute with a non-default startIndex, not just on post-delete back-navigation. coerceIn alone handles the post-delete out-of-bounds case.

Fix #4 (dead updateState before delete): Drop sessionDao.updateState(COMPLETED) in Swiper.performDelete — the only consumers of state filter COMPLETED out, and the row is deleted on the next line.

Fix #5 (finalize Room flow read): Add a comment explaining why swiper.getSession(sid).first() reliably observes the post-delete state, since the pattern looks racey but isn't (Room's flow.first() runs a fresh query).

Tests updated: drop the test that documented the eager-clearing bug as desired behavior; merge two coercion tests into one; expand the override-applied test to cover both fresh and mid-session cases. 140 tests pass.
…s, document ViewModel render-state testing patterns
… bugs

Brings app-tool-systemcleaner from 1 narrow VM test to 21 test files (305 module tests total) covering engine orchestration + all 5 ViewModels + all 5 Screens + custom-filter subsystem, mirroring the CorpseFinder coverage pattern from d9901e6.

New engine coverage (35 tests): SystemCleanerTestFixtures shared harness, SystemCleanerExtensionsTest (hasData), FilterContentTest (size/totalSize/totalCount), SystemCleanerScanTest (scan/scheduler/oneclick dispatch + FilterSource onlyEnabled gating), SystemCleanerProcessingTest (targetFilters/targetContent scoping, NoSuchElementException for stale ids, swallowed PathException, ancestor-path matching), SystemCleanerExclusionTest (exclude/undoExclude, exclusionIds from save() result, stale-handle path), FilterSourceTest.

New ViewModel coverage (78 tests): FilterContentDetailsViewModelTest, SystemCleanerSettingsViewModelTest (23 toggles via parameterized test), CustomFilterListViewModelTest, CustomFilterEditorViewModelTest (save/cancel/remove state machine, phantom-filter guard, criteria mutators). SystemCleanerListViewModelTest extended from 5 → 16 tests.

New Compose UI coverage (22 tests): SystemCleanerListScreenTest, FilterContentDetailsScreenTest, SystemCleanerSettingsScreenTest, CustomFilterListScreenTest. CustomFilterEditorScreenTest extended 4 → 7.

New custom-filter unit coverage (24 tests): CustomFilterTest (config→sieve mapping), EditorOptionsCreatorTest (DIRECTORY/FILE branches, mixed-area label), CustomFilterExtensionsTest (toggleCustomFilter three-way logic), CustomFilterRepoTest (persistence with temp dir, save/remove/import/export round-trips).

9 BUG-FIXME tests lock in current (buggy) behavior — each commented with the marker so the PR description can cross-reference. These are documented for follow-up, not fixed in this PR per scope agreement: BUG-FIXME-1 (dead ScanTask fields pkgIdFilter/isWatcherTask), BUG-FIXME-2 (ProcessingTask cross-filter smear, missing init { require }), BUG-FIXME-3 (exclude() identifier param logged but not used to scope the prune — applies to ALL filters), BUG-FIXME-4 (details VM emits zero-count ExclusionsCreated event vs list VM which suppresses), BUG-FIXME-5 (editor VM save() bypasses canSave gating — UI is the only safety net), BUG-FIXME-6 (CustomFilterRepo decode crash on corrupt JSON kills entire configs flow), BUG-FIXME-7 (CustomFilterRepo listFiles()!! NPE when mkdirs silently fails), BUG-FIXME-8 (CustomFilterConfig.isUnderdefined ignores pathRegexes — regex-only filters unsaveable), BUG-FIXME-9 (SystemCleanerListViewModel navUp fires on null transition during scan-restart; FilterContentDetailsViewModel has the mapNotNull fix already).

Crawler integration tests (filter-set first-match race, area orchestration, exclusion second pass) and live-search in CustomFilterEditorViewModel are explicitly out of scope — they require real APathGateway/FileForensics mocking that exceeds the parity goal. Zero changes outside app-tool-systemcleaner/src/test/.
… 9 pre-existing bugs

SystemCleaner: Add unit and Compose UI tests, document 9 pre-existing bugs
Brings in 23 commits from main (1.7.2/1.7.3-rc cycle): storage rescue, Huawei TAF inventory fix, ShellOps streaming, root binder fixes, Honor/OneUI/Samsung AppCleaner specs, Squeezer video timestamp preservation, Media3 1.10, Crowdin translation refresh, Actions-driven release flow.

Conflict resolutions:

- App.kt: kept both new @Inject fields (taskResultNotifier + storageRescue)

- RootSetupModule.isComplete: kept compose-rewrite's when-block (equivalent logic to main's expression — both fix the same dashboard card visibility bug)

- SetupFragment.kt: accepted delete; Compose SetupScreen already refreshes setup on resume via LifecycleEventEffect(ON_RESUME), which covers the ShowOurDetailsPage refresh case main was fixing

- 9 translation XML files (fr/et/be/nl/uk/az): took main's Crowdin updates and dropped orphan keys no longer present in the English source after compose-rewrite cleanup commits
The debug-log card was force-inserted at index 1 when a recording started, jumping it to the top and reflowing the adaptive grid on multi-column layouts. Append it at its normal position instead so the active-recording card occupies the same slot as the idle start card.
The dashboard bottom-bar one-click/delete path submitted DeduplicatorDeleteTask and DeduplicatorOneClickTask without checking the Pro upgrade, unlike AppCleaner which gates the equivalent actions. Pro-gating for cleaning tools is enforced only in the UI layer (the task processors don't check), so a non-Pro user with Deduplicator one-click enabled could delete duplicates for free.

Gate both DELETE and ONECLICK on upgradeRepo.isPro(), and route non-Pro users to the upgrade screen from ONECLICK when Deduplicator is the only actionable tool (mirroring AppCleaner). DELETE gets no upgrade-nav branch because Deduplicator data isn't part of the DELETE trigger condition, making that case unreachable there.
…t boundary

Defense-in-depth so a UI mistake can't run a Pro-only task for free. The UI already gates Pro features before submitting; this refuses Pro-only tasks at the backend SDMTool.submit() boundary too, throwing a typed UpgradeRequiredException (HasLocalizedError) with a localized title + description. The app-level error dialog customizer attaches the UpgradeRoute fix action, so core code stays free of UI-route coupling.

Adds UpgradeRepo.isProSettled() for backend gates: it is deliberately generous/fail-open. It fast-paths to allow when already Pro (active purchase or grace period), otherwise triggers refresh() and waits briefly for billing to settle (rescuing the GPlay cold-start billing-connect race for real customers), and allows on any exception. Only a settled no-purchase reading denies. Existing UI isPro() calls are unchanged.

The gate runs before acquiring toolLock so the (possibly waiting) check never holds the lock. Premium-ness is a private per-tool exhaustive 'val Task.requiresPro' (scan is free; delete/processing/one-click/scheduler are gated), so new task types force a compile-time decision. Adds unit tests for both tools.
Navigation/route-binding: NavigationController queues cold-start shortcuts until the back stack is ready; Upgrade/Setup/Swiper/CorpseFinder/Preview routes use the bindRoute pattern instead of SavedStateHandle.toRoute (fixes MissingFieldException crashes); Upgrade forced-flag honored.

Lifecycle/state: Settings collects via LaunchedEffect (no leaked vmScope collector); GPLAY Upgrade uses SharingStarted.Lazily to stop offer reloads on resume; nullable isPro/setupDone avoids a sponsor-card flash.

Legacy parity restored: Picker selected-paths bottom sheet + Coil folder-preview rows + home-folder area-root icon; Deduplicator open-in-external-app action and cluster-level exclude; Deduplicator details file rows stack the delete marker above a right-aligned file size.

List/details correctness: eager selection-clear and single-item Show-details guard across CorpseFinder/SystemCleaner/AppCleaner/AppControl; AffectedPaths composite path:action key; Analyzer exitOnNotFound auto-navUp; AppCleaner Category targetContents kept null to avoid the single() crash.

Coil PreviewScreen: per-page zoom lock, immersive system bars, broken-image error state. SdmFastScroller RTL offset.

Dead code removed (DelayedState, unused ViewModel state/params, route companions, PickerResult bundle helpers). Added @preview2 coverage with bespoke mock data for Analyzer/Squeezer/Picker/CorpseFinder.

Cosmetic/localization: ProgressOverlay fade, ThemePicker scroll, DashboardCardConfig state source, SupportScreen WIKI/DISCORD links, new localized strings.
The main-action FAB applied combinedClickable to the Surface's outer modifier, which sits outside the Surface's shape clip. The click indication (hover highlight / ripple) was drawn as a square, ignoring the 16.dp rounded corners.

Move combinedClickable onto the inner content Box (now fillMaxSize so the full 56.dp stays tappable), which renders inside the Surface's shape clip so the indication is clipped to the rounded corners. shadowElevation stays on the Surface; the enabled!=WORKING guard moves with the click handler.
The dashboard list is a combine of ~22 source flows and shows only a loading spinner until every source emits at least once. Several sources re-run async work (Room, DataStore, a filesystem scan) on every ViewModel recreation because they're shared with stopTimeout=0/replayExpiration=0, so a single slow or blocked source could wedge the whole dashboard on the spinner after a warm Activity recreation (e.g. background->foreground under memory pressure).

Give every combine input a guaranteed immediate first value via onStart fallbacks (the pattern already used on the tool-state sources) and emit upgradeInfo null-first so title/upgrade-gated cards don't gate on it. The dashboard now renders immediately and fills cards in as data arrives. Each source is tagged with a privacy-safe VERBOSE diagnostic (type/size only) to pinpoint a laggard on reproduction.

Adds DashboardViewModelResilienceTest covering that listState still emits when any single source never emits.
…ashboard

The read-only session scan (DebugLogSessionManager.sessions) acquired the same fsMutex that zipSessionAsync/zipSession hold for the entire duration of a log zip. A long-running zip therefore blocked the scan indefinitely, and because the dashboard's listState combine waits for the sessions flow, this could wedge the whole dashboard on its loading spinner (observed after background->foreground under memory pressure, where orphaned recordings trigger an auto-zip on relaunch).

Run the scan lock-free (a reader must never be blocked by a writer) and keep fsMutex only on the zip/delete writers. scanSessions now skips the stale-temp cleanup for sessions currently being zipped, so the lock-free scan can't delete a .zip.tmp mid-write; the manual zipSession path registers in zippingIds for the same protection.

Adds a real-concurrency DeadlockRegression test (a long zip holds the lock while the scan must still complete) plus scanSessions temp-skip tests.
…ing spinner

Resilient dashboard listState (no single stalled card source can wedge the screen) plus the root-cause fix: DebugLogSessionManager's read-only scan no longer shares fsMutex with the long log-zip, which could block it indefinitely.
Floating summary card above the bottom bar that cradles the main-action FAB via a cutout mirrored from the bar's notch (shared, concentric geometry in DashboardChromeGeometry). It surfaces what the one-tap action will free after a scan (FREEABLE) and what was freed after a delete/one-click (FREED), action-truthfully: only tools whose one-click toggle is on, that have data, with AppCleaner additionally gated on Pro, and Deduplicator contributing redundantSize/clusters.

Interactions: per-tool chips open that tool's findings list; the card is dismissed via the X button or a downward swipe (BottomBar-owned drag so it composes with the existing slide/fade exit); a compact summary chip in the bar restores a user-dismissed hero.

Chrome polish: the bar now draws flush to the screen edge (fills the nav inset), the FAB cutout is dropped while the dashboard is loading (no empty notch), and the cradle notch is a uniform offset of the FAB. SdmInfoChip gains an optional onClick (clickable on the inner row; the Material3 Surface(onClick) overload dropped the chip inside the hero's alpha/offset graphicsLayer on-device).

Tests: hero-summary construction, chrome shape selection, and hero-card UI (chip navigation, restore, swipe-to-dismiss).
Brings the hero card feature up to date with compose-rewrite's dashboard loading-stuck fix, listState hardening, Pro-gating at the submit boundary, and FAB ripple clipping.

Conflict resolutions: the Deduplicator main-action keeps the Pro gate and upgrade-nav while wrapping submit in the freed-result accumulator; the main-action FAB keeps the inner-Box clickable (ripple clipped to the rounded shape) plus long-press; the grid's bottom content padding reuses the chromeBottom max (equivalent to the inlined nav-inset floor).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Chore enhancement New feature, request, improvement or optimization General UI/UX User Interface/Experience

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant