Skip to the content.

FastMediaSorter v2: Architecture & Flow

Framework: Android Native (Kotlin 1.9+, Java 17). Pattern: Clean Architecture + MVVM + Hilt DI.

Module Structure

Data Flow

UIViewModelUseCaseRepositoryDataSource

Three-Layer Structure

Dependency Rule (accepted convention, read before “fixing”)

The runtime call direction is strictly one-way: UIViewModelUseCaseRepositoryDataSource. A lower layer never calls back up, and UI holds no business logic. This part is enforced.

Compile-time dependencies are not textbook Clean Architecture. The domain layer is deliberately allowed to import concrete data.* classes: Room entities and DAOs (data.local.db.*), scanners and constants (data.local.LocalMediaScanner, VIRTUAL_PATH_*), protocol clients (data.network/data.remote/data.cloud), shared enums and DTOs (data.model.*, e.g. DeviceProfileType), and even concrete repositories (data.repository.*). Roughly a third of domain/*.kt files import at least one data.* type, spread across a dozen-plus data.* subpackages. Some repository interfaces in domain/repository/ also expose data.model types in their signatures.

This is a long-standing, consistent project convention - not an accident, and not a violation to refactor on sight:

Implication for new code: importing a concrete data.* type from a use case is acceptable and matches precedent. Add a domain-owned abstraction only when it earns a real seam (testing, DI, or flavor isolation via src/<flavor>/) - never solely to satisfy layer purity.

Rule 3 is mechanically enforced (S1329)

An Activity may not declare an @Inject field of a repository, use case, data source, DAO or database type. The count is held down by scripts/quality/assert-activity-logic-not-growing.ps1 against a committed baseline, so a new violation fails the gate rather than joining the lint baseline unnoticed. The remaining debt is 32 violations, all inside PlayerActivity and PhotoVideoStandaloneActivity, which carry a shared image-edit cluster and are being cleared by a follow-up ticket. Two fixes are sanctioned: move the dependency into the host’s ViewModel and expose behaviour rather than the injected type, or - when the host only forwards the object into a manager it builds by hand - put it in an @Inject constructor factory that builds that manager, as app_v2/src/main/java/com/sza/fastmediasorter/widget/PhotoCaptureLaunchManagerFactory.kt does. A screen that merely reads settings needs neither: BaseActivity.appSettings is the inherited stream, and reaching for SettingsRepository in a subclass is the mistake it exists to prevent.

Key Patterns

UI Patterns - Trigger Row (MANDATORY)

Every toggle/switch or checkbox control that carries a description must follow one of the two canonical row patterns below. Mixing the patterns or using ad-hoc sizes is prohibited.

Pattern A - Switch/Toggle row (settings fragments)

Canonical row layout is title + helper inline on the top line, with the subtitle directly under the title. Prefer the reusable SettingsToggleRow compound view (see “Reusable component” below) over hand-built LinearLayouts - the raw XML below is included for reference and one-off exceptions only.

<LinearLayout
    android:orientation="horizontal"
    android:gravity="center_vertical"
    android:minHeight="@dimen/button_height">

    <!-- 1. Trigger control (leftmost) - canonical on/off class is Material3 MaterialSwitch -->
    <com.google.android.material.materialswitch.MaterialSwitch
        android:layout_marginEnd="@dimen/settings_switch_margin_end" />

    <!-- 2. Text group (fills remaining width) -->
    <LinearLayout
        android:layout_width="0dp"
        android:layout_weight="1"
        android:orientation="vertical">

        <!-- 2a. Title line: title + helper inline (helper sits next to the title) -->
        <LinearLayout
            android:orientation="horizontal"
            android:gravity="center_vertical">

            <!-- Main label: always toggler_title_text_size (14sp) -->
            <TextView
                android:layout_width="wrap_content"
                android:textSize="@dimen/toggler_title_text_size" />

            <!-- Help icon button: inline next to the title (NOT rightmost) -->
            <ImageButton
                android:layout_width="@dimen/settings_help_icon_size"
                android:layout_height="@dimen/settings_help_icon_size"
                android:layout_marginStart="@dimen/settings_help_icon_margin"
                android:src="@drawable/ic_help_outline_24" />
        </LinearLayout>

        <!-- 2b. Subtitle: always toggler_desc_text_size (12sp) = title − 2sp -->
        <TextView
            android:textSize="@dimen/toggler_desc_text_size"
            android:textColor="@color/text_color_secondary" />
    </LinearLayout>

    <!-- 3. Optional trailing action slot (rare; e.g. an extra action button
         that belongs to the row). Empty/hidden by default. -->
</LinearLayout>

Rules:

Reusable component

The canonical implementation is com.sza.fastmediasorter.ui.common.widget.SettingsToggleRow (compound view) backed by view_settings_toggle_row.xml. It embeds the canonical Material3 MaterialSwitch, so wrapping a control in this component is the single recommended form for every new on/off toggle - in settings fragments, forms, AND dialogs. New switch rows MUST use this component instead of hand-rolled MaterialSwitch + TextView + ImageButton triplets. The component encapsulates title, subtitle, helper visibility, tooltip wiring, and the optional trailing action slot. Hand-built rows are technical debt and must be migrated when adjacent code is touched.

Any on/off switch that must stay outside SettingsToggleRow (e.g. a dense list-item row where the full toggle row would break the layout) MUST be a com.google.android.material.materialswitch.MaterialSwitch; it inherits the project materialSwitchStyle (themes.xml) so it matches the switch rendered inside the component.

Selection/value row (SettingsSelectionRow)

Pattern B - Checkbox row (add-resource, cloud folder pickers)

<LinearLayout android:orientation="vertical">

    <!-- 1. Trigger control -->
    <com.google.android.material.checkbox.MaterialCheckBox />
    <!-- MaterialCheckBox default text = 16sp (Material3 bodyLarge) -->

    <!-- 2. Help text: always text_size_small (14sp) = checkbox − 2sp -->
    <TextView
        android:layout_marginStart="@dimen/checkbox_subtitle_margin_start"
        android:textSize="@dimen/text_size_small"
        android:textColor="@color/text_color_secondary" />
</LinearLayout>

Rules:

Dimen reference

Dimen key Value Role
toggler_title_text_size 14sp Switch row main label
toggler_desc_text_size 12sp Switch row help text (title − 2sp)
text_size_small 14sp Checkbox row help text (checkbox − 2sp)
settings_switch_margin_end - Gap between switch and text group
settings_help_icon_size - Help icon button size
settings_help_icon_margin - Gap between text group and help icon
checkbox_subtitle_margin_start - Help text indent under checkbox

Button Taxonomy (MANDATORY)

One named Material3 style per semantic role, defined in values/themes.xml. The same role must look identical everywhere - do NOT introduce a plain <Button>, a raw Widget.MaterialComponents.*/Widget.Material3.* reference, or a one-off per-screen style for a role already covered below. Pick by the button’s role, not by how it should look.

Role Style When to use
Primary / confirm Widget.FastMediaSorter.Button.Filled The single main affirmative action of a screen or dialog (Save, OK, Grant, primary CTA). At most one per surface.
Secondary emphasis Widget.FastMediaSorter.Button.Tonal A secondary action that still needs weight next to the primary (alternative confirm, “Use anyway”).
Secondary Widget.FastMediaSorter.Button.Outlined Neutral secondary action paired with a Filled primary (Back, Choose, Browse).
Low-emphasis / cancel Widget.FastMediaSorter.Button.Text Link-like / inline dismiss (“Not now”, “Skip”) OUTSIDE a dialog action pair; anything that previously used ?android:attr/borderlessButtonStyle. For a dialog/bottom-sheet confirm-cancel pair use the S0538/S0684 DialogCancel slot below (soft-pink tonal), not this style.
Icon-only Widget.FastMediaSorter.Button.Icon Toolbar / inline icon actions that want a Material ripple and 48dp target.

Dialog action pair (S0538/S0684) - special-purpose, NOT the general role taxonomy. Use these (and only these) for the confirm/cancel pair of any non-system dialog, action-pair bottom sheet, or custom dialog layout. The pair is deliberately asymmetric so a blind finger tap (e.g. while driving) cannot miss or confuse the actions: the confirm/destructive slot is large (min dialog_action_button_min_height, ~56dp) and wide (dialog_confirm_button_min_width), while the cancel is intentionally shorter (dialog_cancel_button_min_height, 48dp) and narrower so the affirmative action dominates. A dialog_action_button_gap sits between them. Colour key: green = confirm, soft-pink tonal = cancel, saturated red = destructive confirm only. The “at most one Filled per surface” rule does not apply to this pair.

Slot Style Look
Confirm (OK / Save / Apply) Widget.FastMediaSorter.Button.DialogConfirm Green filled (@color/confirm_button_bg), wide (dialog_confirm_button_min_width) so it is the dominant “under-finger” action
Cancel Widget.FastMediaSorter.Button.DialogCancel Soft-pink tonal fill (@color/cancel_button_bg/cancel_button_on), deliberately SMALLER than the green confirm - shorter (dialog_cancel_button_min_height, 48dp touch floor) and narrower (content-sized vs the wide confirm) - so confirm dominates and cancel reads as the lighter escape. Saturated red is reserved for DialogDestructive only (S0684).
Destructive confirm (delete / remove / clear) Widget.FastMediaSorter.Button.DialogDestructive Red filled (@color/delete_button)

Seam: MaterialAlertDialogBuilder dialogs inherit this pair automatically via materialAlertDialogTheme on the app theme (positive -> DialogConfirm, negative/neutral -> DialogCancel) - no per-call edit. A destructive builder dialog opts into the red variant with the per-dialog overload MaterialAlertDialogBuilder(context, R.style.ThemeOverlay_FastMediaSorter_MaterialAlertDialog_Destructive). Custom inflated layouts apply the named style directly on each MaterialButton. OS/system dialogs are exempt (we do not own their chrome).

Rules:

UI Toolkit Boundary (MANDATORY)

app_v2 is View: XML layouts and ViewBinding. wear is Compose end to end and owns no XML layout at all. A new screen in app_v2 is built in View, and a new setContent { .. } under app_v2/src/main is refused by the compose-island dimension of scripts/quality/assert-source-gates.ps1 (CLAUDE.md Rule 32, S1694).

The boundary is drawn by module rather than by screen because that is where the technical necessity already sits: on the watch Compose has no reasonable alternative, and in the phone app 404169 lines of View have no reason to move. Converting app_v2 to Compose was proposed and rejected by the owner on 2026-08-15 - it is a rewrite with no user-visible result and a large regression surface.

Six islands exist and are allowed to: the Wear companion settings screen, the beam animation dialog, and the four widget-configuration screens (resource shortcut, photo frame, camera quick capture, network monitor). They are removed opportunistically - when another ticket reaches one for its own reasons - never as a campaign. Each removal lowers the baseline in scripts/quality/compose-island-baseline.txt by one. The baseline is a ceiling that only descends; raising it is a boundary decision, not a build fix.

Why a gate and not only this paragraph: the fifth-to-sixth island appeared five days after an audit had counted five, without anyone deciding to grow the set. A rule addressed to someone who is already writing an island (the theming section below) is read at the right moment; a rule addressed to someone still choosing a toolkit is not.

Removing Compose from app_v2 entirely

Only possible once the last island is gone, and it has a precondition that is not discharged by removing the island:

Compose Island Theming (MANDATORY)

Every ComposeView.setContent { .. } in app_v2 wraps its content in FastMediaSorterComposeTheme (ui/common/compose/). The app is View-based and its colours live in a View theme - Theme.FastMediaSorter.App plus whichever ThemeOverlay.FastMediaSorter.* accent the user picked (S0569). Compose reads none of that: an unthemed setContent, and equally a bare MaterialTheme { .. } with no colorScheme argument, falls back to the Material3 baseline palette and renders in stock purple no matter which accent is active. The island then looks foreign next to the Views around it, and an AndroidView hosted inside it - which does inherit the View theme - disagrees with its own container.

The wrapper resolves the M3 colour attributes off the host Context at composition time and hands them to MaterialTheme as a ColorScheme, so an island follows the accent overlay and the day/night variant without a second source of colour. Light-vs-dark is decided from the resolved surface luminance rather than the system night mode, because the accent overlays set brightness independently of it.

Rules:

Dialog Result Delivery (MANDATORY)

A DialogFragment never holds its result callback in a field. FragmentManager rebuilds a restored dialog through the no-argument constructor, so any handler the caller assigned after construction is null on the rebuilt instance - the user confirms, nothing happens, and nothing is logged. The recreation does not need a rotation to happen: a theme change, a language change, a font-size change, “don’t keep activities” and process death all trigger it, and most hosts here declare configChanges for orientation, so rotation is in fact the one trigger that does NOT reproduce it.

The result travels as a FragmentResult instead. The dialog declares a RESULT_KEY, one payload key per returned value, and a private ARG_REQUEST_KEY; newInstance takes requestKey: String = RESULT_KEY and stores it in arguments; onCreate reads it back out of requireArguments(), so a restored instance recovers it. The confirm path calls setFragmentResult(requestKey, bundleOf(..)). The host registers setFragmentResultListener in its own onCreate/onViewCreated - never at the moment the dialog is opened, because a recreated host must have the listener back before the restored dialog resumes. SearchableLanguagePickerDialog is the reference implementation (S1214).

Payloads carry Bundle primitives. Where a value is a domain object, put its fields in the bundle and rebuild the object in the host rather than making a domain model Parcelable. Where one picker serves many rows, the row id rides in the arguments and comes back in the result bundle, so a single host listener serves them all.

One accepted limitation: when the opening host is a plain AlertDialog rather than a DialogFragment, the host itself does not survive recreation, so a pick made after recreation is delivered the next time that picker is opened rather than immediately. Making such a host a DialogFragment is a separate change per surface.

Dialog Lifecycle Binding (MANDATORY)

A dialog raised from a helper, manager or any other non-DialogFragment holder is shown with AlertDialog.Builder.showBoundTo(fragment) (util/LifecycleDialogExt.kt), never with a bare .show(). The extension registers a lifecycle observer that dismisses the dialog on ON_DESTROY, so the window cannot outlive the host. A site that needs the dialog before showing it calls create() and then the same showBoundTo(owner) on the created AlertDialog.

The rule has a ratchet gate behind it: scripts/quality/assert-untracked-dialogs.ps1 counts builder chains ending in a bare .show() across every shipped source set and fails when the count grows. It runs inside post-change.ps1 through the source-gate runner, so a new untracked dialog fails closure rather than waiting to be noticed in review (S1456).

A bare .show() discards the returned AlertDialog, which leaves nothing able to close it: a dialog still on screen during a configuration change keeps the destroyed Fragment and Activity alive. The predecessor fix (S1197) tracked the dialog by hand - a field in the helper, a dismiss method, a call from the host onDestroy - and that shape needs three coordinated edits per dialog, which is why it was never applied beyond the one helper it was written for while 34 untracked dialogs accumulated in the settings helpers alone (S1447).

Exempt: a DialogFragment, whose FragmentManager already dismisses it, and OS/system dialogs we do not own.

Landscape Layouts under configChanges (MANDATORY)

An Activity that lists orientation in android:configChanges does not recreate on rotation, and an Activity that does not recreate never re-inflates its layout. Its layout-land/ (or layout-w600dp/) variant therefore applies only when the screen is opened while already in that configuration - a file that looks live, is referenced by nothing, and drifts silently. S1549 found sixteen screens in this state.

Owning a landscape layout and absorbing the rotation is the pair that must never coexist. Three resolutions count as fixed, and only the applied result matters, not the means (ADR-2a):

Two traps worth knowing before touching this. values-w600dp outranks values-land, and it matches a tablet or an unfolded foldable held in portrait: a landscape-only override under a layout-w600dp variant re-applies portrait metrics over a wide-layout tree. A partial re-inflate has no seam - BaseActivity assigns its ViewBinding once and never reassigns it, so swapping a subtree leaves every binding field and every helper built from it pointing at discarded views, silently. And re-pointing every reference is still not enough: a view whose state was set once, imperatively, at load time - and is never re-derived from any observable state - comes back at its XML default and nothing restores it. S1943 lost stream video to exactly this, PlayerView being declared gone and revealed only by the one-shot call in the media loader, so the surviving ExoPlayer decoded into a hidden surface for good. When you inventory what a re-inflate must carry, list one-shot view state beside the reference holders; a reference audit alone will not find it.

Gate: scripts/quality/assert-orientation-layout-pairing.ps1, wired into .\a.ps1 fg and post-change.ps1. Exceptions live in scripts/quality/orientation-layout-pairing-exceptions.txt, and every entry carries a mandatory # reason naming both what the screen would lose on recreation and where its re-apply lives - a list without reasons is indistinguishable from a list of forgotten defects.

Standalone Player Toolbar Order (MANDATORY)

The four standalone hosts (PhotoVideoStandaloneActivity, TextStandaloneActivity, DocumentStandaloneActivity, AudioStandaloneActivity) share ONE top-toolbar button order so a file feels the same whichever host opened it (S0920). Each host declares its own activity_standalone_*.xml, so there is no single shared layout to enforce this - a new host or an edit must follow the order by hand.

Canonical order: Back -> [paging: Prev, Next, Random, Slideshow] -> Delete -> Favorite -> Share -> Info -> Rename -> [type-specific actions] -> Overflow.

Rules:

Directory Operations Subsystem

Create, rename, delete, copy and move a whole folder, for every resource type. Architectural boundaries:

Internet Streams Subsystem

Dedicated screen for internet audio/video/RTSP sources. Architectural boundaries:

Cast (Chromecast) Path

Casting is a flavor-scoped seam: CastController lives in src/main, its Google Cast implementation in src/castEnabled, and the vr flavor mounts src/castDisabled instead. Local files reach the receiver through LocalCastProxyServer, which used to serve bytes unchanged.

Desktop Companion Config (.fmscfg) Subsystem

Imports an SFTP share published by the Windows desktop companion (a separate Go/Wails app in its own repository) as ready-made resources, so the user never types host/port/credentials by hand. Not to be confused with the Wear OS companion (wear/) - unrelated subsystem, same word.

Immersive VR / OpenXR Subsystem

Immersive VR is a flavor-scoped subsystem: code lives in app_v2/src/vr/ (packages core/xr, ui/xr) plus a native OpenXR layer under app_v2/src/vr/cpp/. It compiles only in the vr and noLegal flavors; standard/lite/photos/legacy never see it.

Entry and gating. XrEnvironmentDetectorImpl / XrDetectionFacadeImpl detect a headset; VrMediaSectionContractImpl gates the VR entry points, so a phone build reports the section unavailable and falls back gracefully. XrEntryGatewayImpl + StartVrPlaybackUseCaseImpl route a media item into an immersive host. Two hosts exist: DiagnosticXrActivity (diagnostic playlist) and ImmersiveBrowseActivity (immersive browse grid).

Native runtime. NativeDiagnosticXrRuntime loads libfms_diagnostic_xr.so (built by app_v2/src/vr/cpp/CMakeLists.txt) and forwards every session call over JNI to diagnostic_xr_runtime.cpp. The native side is single-instance. The noLegal flavor ships only the arm64-v8a slice: on x86_64 emulators / non-arm64 devices the library is intentionally absent - isNativeAvailable flips to false and every call short-circuits to a clean “loader unavailable” outcome. This is an expected device-capability mismatch, not an error (no UnsatisfiedLinkError storm in logcat).

Render thread + EGL/GL confinement. DiagnosticXrRenderThread owns the whole pipeline (init -> attach Surface -> start session -> upload texture -> frame loop -> shutdown) and blocks inside the native frame loop for its whole life - it has no Handler, and nothing else is posted to it. All GL/OpenXR objects are created and torn down on this one thread, satisfying both EGL and OpenXR thread-confinement rules. The suspend modifier on the runtime’s setup methods is an API artefact - they execute synchronously on the render thread; hopping to a coroutine dispatcher would create EGL on the wrong thread and leave the render thread without a current GL context (a featureless black composition layer).

Two texture channels. The main scene is rendered per frame in native code. The 2D HUD is a separate channel: HudCanvasRenderer paints a Canvas bitmap (a 1024-wide RGBA panel - status line, AUDIO/SUBS cycle rows, transport buttons + sliders) that is uploaded to a HUD quad via queueHud only on state change, never per frame. SubtitleCueRenderer feeds subtitle cues into the same HUD channel. HUD interaction is controller-ray UV hit-testing against the quad, not view-level touch.

Re-entry. The XrInstance is reused across immersive entry/exit. On re-entry xrCreateSession runs before Meta Horizon OS re-registers the volumetric window, so the render thread awaits window focus before startSession; otherwise the runtime defers readiness and never fires the native ready callback.

Related specs: S0249 (render thread), S0290 / S0964 (HUD quad), S0156 (native library-availability ADR), S0986 (immersive subtitles). VR classes are indexed in the class catalog under ui/xr and core/xr.

Launcher Mode

Launcher Mode turns the app into an Android home screen: a cell desktop, a bottom taskbar with a status tray, and placeable gadgets. It is the most restricted subsystem here - more than VR, not less - because it needs two independent conditions, a flavor that compiles it and a role only the user can grant.

Entry and gating. LauncherHomeActivity carries the HOME intent filter and ships android:enabled="false". LauncherRoleManager owns the role protocol: it flips that component with PackageManager.setComponentEnabledSetting, then asks for the role through RoleManager.createRequestRoleIntent on API 29+, or sends the user to Settings.ACTION_HOME_SETTINGS below it. Android never hands the HOME role over programmatically - enabling the component only makes the app a candidate, and the user chooses. Anything that reasons about “is the launcher active” must ask the role manager, not a build flag. One secondary entry point ships enabled regardless: LauncherPinRequestActivity, the CONFIRM_PIN_SHORTCUT target other apps use to pin a shortcut into our desktop.

Flavor seam. SUPPORT_LAUNCHER is true in standard and noLegal only. Those two flavors mount src/launcherEnabled (the entire ui/launcher/** tree, its res, and an explicitly injected manifest); the rest mount src/launcherDisabled, which holds nothing but a no-op LauncherModeContract implementation and its Hilt module. The domain and data layers stay in src/main and therefore compile into every flavor, self-hiding at runtime through LauncherModeContract.isAvailableInBuild - the same shape as Desktop Companion Config above. Per Rule 14 there is no BuildConfig.SUPPORT_LAUNCHER branch in src/main; the single production read of that flag is the permission registry, which uses it to gate rationale rows.

Desktop model. Cells live in one Room table, with kind and orientation stored as enum names and the command encoded into a single prefixed TEXT column, so a new command variant never forces a migration. Portrait and landscape are two fully independent layouts, not one layout re-flowed: every repository call is scoped to a LauncherOrientation, and the resolved column count is stored per orientation too. A cell is an anchor plus a span, so gaps between cells are meaningful and a gadget claims a rectangle.

First-run starter set. An empty desktop is seeded once from LauncherStarterSets, the single profile table for what a detected device profile receives. Third-party app cells are conditional on the matching package being installed, so a first seed never leaves a dead app icon; existing desktops are not rewritten when the profile changes. The set is packed per section, not across the whole grid: a section header raises a packing floor to its own row, and nothing seeded after it may anchor above that row. The floor is a correctness rule before it is an aesthetic one, because section membership is positional - a cell that backfilled the gap a shorter group left behind would belong to the section above it and collapse with it. Content leads the set and the launcher’s own actions close it, so the first screen of a phone carries the media resources rather than five service shortcuts; the actions stay reachable from the Start menu, which is what makes that order safe.

Grid. The desktop is a hand-written ViewGroup, deliberately not a RecyclerView (ADR-9): the persisted model is a canvas with 2D positions, spans and meaningful gaps, which no stock LayoutManager expresses - and a desktop is dozens of cells, not a feed, so recycling buys nothing while costing the model. Column count resolves from available width and a user density factor within a fixed range; height is the scroll axis. All footprint arithmetic funnels through one geometry helper precisely so layout, hit-testing and the free-slot sweep cannot disagree. Drag-to-move uses a container-level OnDragListener with startDragAndDrop rather than ItemTouchHelper, which is RecyclerView-only for the same ADR-9 reason.

Gadgets. A gadget is an interactive block the user places on the desktop, and it is always our own view - never a third-party AppWidget (ADR-5): hosting foreign widgets means foreign layout outside our control and breaks the D-pad contract, so instead the pre-existing home-screen widget catalog is bridged into gadgets rather than duplicated. The registry is an open extension point fed by qualified Hilt list multibindings; treat the set of gadgets as growing, and read the current membership from the registry rather than from any document. Gadget lifecycle is enforced in one place: the view starts its work in onActive under repeatOnLifecycle(STARTED) and cancels on detach, because the grid is not a RecyclerView and there is no onViewRecycled to lean on.

Taskbar and command funnel. The taskbar is bottom-anchored in both orientations, hosting the Start button, the recents and pinned strips, and the status tray. Each tray indicator subscribes to its source only while that indicator is switched on and the launcher owns the status area, and going false cancels the collector rather than merely hiding the view; an indicator whose state cannot be read is absent rather than drawn as “off”. Every tap on every surface - desktop cell, either taskbar strip, Start menu row, gadget-issued command - funnels through a single guarded execution path on the launcher’s ViewModel, so there is exactly one launch guard and one failure message, and a gadget never builds a parallel one.

Surface colours. Every foreground on the taskbar and the Start panel comes from a launcher-scoped theme attribute - launcherTaskbarStartText and launcherTaskbarAllAppsText for the two taskbar buttons, which sit on colorSurfaceVariant, and launcherStartRowGroup1..launcherStartRowGroup4 for the four semantic row groups, which sit on colorSurface. Three rules hold together. Each attribute has a value in every theme set: the base day and night themes define all six, and the six ThemeOverlay.FastMediaSorter.* colour themes inherit them, overriding one only where their own surfaces would fall short. Each M3 role the app actually paints with is defined by the app in both sets - colorSurfaceVariant, colorTertiary and colorError used to resolve from the library baseline, which is a colour nobody chose and a library upgrade can move. And the result is measured, not eyeballed: scripts/quality/assert-launcher-contrast.ps1 (in the .\a.ps1 fg batch) resolves each attribute and its background out of the resource files for all eight themes and fails below 7:1, the owner’s threshold, above WCAG’s 4.5:1 for ordinary text. The attributes are launcher-scoped rather than plain M3 roles because those roles paint dozens of other surfaces, where the lightness this threshold demands would be an unrelated change; the check is a script because the previous pass over these same colours was signed off by looking at it and shipped the Start label at 4.22:1. A new colour theme, or a new Start-panel row, runs the gate rather than matching a value by eye.

Related specs: S0404 (the founding ADR set, archived), S1103 (cell actions), S1170 (widget-to-gadget bridge), S1415 (tray composition), S1461 (this section), S1587 (per-section seeding floor and content-first order), S1895 (surface colours and the contrast gate). Launcher classes are indexed in the class catalog under the launcher sector.

Performance & Resource Optimization

To maintain fast startup times (cold start), low memory consumption, and efficient CPU usage, the following patterns must be strictly enforced:

1. Lazy Dependency Injection (dagger.Lazy)

Heavy singletons, network managers, and protocol clients (e.g., SmbClient, SftpClient, DropboxClient) must NOT be eagerly injected into global scopes like Application or entry points like PlayerActivity.

2. Layout Optimization via ViewStub

Do not use android:visibility="gone" for complex, format-specific, or optional layout elements (e.g., search overlays, specific player controls, game modules) in main activity XML layouts.

3. On-Demand Media Lifecycle Management

Media players (ExoPlayer, MediaPlayer) and image loading caches (Glide) must only allocate system resources (decoders, native memory) when active playback is running.

4. Dynamic OS Component Gating

Optional background elements like widget receivers (AppWidgetProvider) should not consume system resources when disabled by user settings.

Collapsible Section Groups (MANDATORY)

New screens with collapsible/expandable sections MUST use the unified pattern (S0535) - do not build a bespoke header or persistence mechanism.