Skip to the content.

26.10.13 14:51

FastMediaSorter v2: Architecture & Flow

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

Module Structure

Data Flow

UI → ViewModel → UseCase → Repository → DataSource

Three-Layer Structure

Database Schema Contract (MANDATORY, S2306)

A Room migration and the entity it upgrades toward are two halves of one statement, and Room checks that they agree exactly once - on the user’s device, during the first launch after an update. When they disagree it does not degrade: DatabaseModule catches the failed open, copies the database to files/db-backups/<timestamp>, records a user-visible notice and recreates it empty (S0731). There is no fallbackToDestructiveMigration; this recovery path is the only way the database is ever dropped, and it is a last resort, not a licence to ship an unverified migration.

The watch module carries the same contract and a deliberately different recovery (S2356). WearAppModule also forces the open and also declares no fallbackToDestructiveMigration, but on failure it recreates the store and rebuilds the note index from the recordings on disk rather than copying the old file aside: the watch database is an index over app-private files, not the only copy of the data, and a backup written to app-private storage is unreachable on a watch - there is no file manager and the media index does not scan private directories (ADR-1). A recovered note is reset to LOCAL_ONLY, so nothing is delivered twice, and WearDatabaseResetNotice explains the rebuild once in the note list. Read the two paths as one policy with two shapes: back up what is irreplaceable, rebuild what is derivable.

Every schema change therefore carries all four of these, and none substitutes for another:

  1. The migration’s SQL names the column exactly as the entity declares it - screenIndex is not screen_index - with the same nullability, and it writes any @ColumnInfo(defaultValue = ..) the entity declares. A NOT NULL column added without a DEFAULT is refused by SQLite outright.
  2. The migration is listed in DatabaseModule.addMigrations(). An unregistered migration is not a no-op: the hop throws and the recovery path deletes the database.
  3. An instrumented test AppDatabaseMigration<N>To<M>Test exists, and AppDatabaseMigrationChainTest still names the current version so the whole chain stays covered.
  4. The migrations were executed, not only compiled - .\a.ps1 fam, and /spec-prerelease step 1.4 before a release. A green .\a.ps1 fa proves the tests parse and nothing more.

Points 1-3 are gated in every closure by assert-migration-schema-conformance.ps1 and assert-migration-test-pairing.ps1. They compare text; only point 4 runs SQL. The rule exists because on 2026-09-01 a migration satisfying none of them shipped, and the first execution of Room’s comparison anywhere was on the owner’s phone, which lost its resources, credentials, favourites and desktop (S2251).

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

The runtime call direction is strictly one-way: UI → ViewModel → UseCase → Repository → DataSource. 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

One Visual Form Per Element Role (MANDATORY, S2193)

A UI element’s visual form is declared once per module, by the role that owns it - a named reusable component, or a named style/taxonomy entry - never redeclared per screen. This is the general statement behind several rules already in force separately: the Trigger Row patterns and the Button Taxonomy below own every toggle, checkbox and button role in app_v2; S2006 ADR-2 (“a command button is declared once per module”) owns the wear player’s controls; S2133 ADR-2/ADR-4 own the wear dialog toggle and its mechanical gate. A hand-rolled duplicate of an already-owned role is technical debt, migrated opportunistically when its screen is next touched - never a forced rework campaign, the same policy Pattern A below already states for its own hand-built rows.

Caption and Value Proximity (MANDATORY, S2328)

A value is read together with the caption that names it, so the two must stay adjacent at every screen width. The gap between them is set by the text, never by the display.

Reference layouts: view_settings_selection_row.xml, view_settings_toggle_row.xml, view_settings_dropdown_row.xml.

A control at the row’s end - a switch, a chevron, an icon button - is not a value, and this section does not apply to it; that is the Trigger Row pattern below and it is unaffected.

Mechanical gate: the caption-value-split rule in scripts/quality/lib/source-matchers.ps1 (in post-change.ps1 via the neuroslop umbrella, and .\a.ps1 fg). It is structural rather than lexical, because the reference row and the defect carry the same attributes and differ only in where they sit. Its baseline also carries a known ambiguity class - two co-equal data columns, such as a player’s position/duration pair - which the rule cannot distinguish from a caption and its value; if a new row is genuinely that shape, justify it in review instead of raising the baseline.

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:

Reusable component

The canonical implementation is com.sza.fastmediasorter.ui.common.widget.FormCheckboxRow (compound view) backed by view_form_checkbox_row.xml. The subtitle is optional: setSubtitle(null) / an empty app:fcr_subtitle hides the subtitle view without breaking row layout, so this component is the single canonical form for every checkbox row - with or without a subtitle, including a short fixed-choice grid (e.g. a media-type filter row) laid out with layout_weight/layout_columnWeight. A raw MaterialCheckBox outside this component (S2193 inventory: 17 file entries, mostly a duplicated media-type filter set with no shared style) is technical debt, migrated opportunistically per the same policy as Pattern A - not a required rework whenever the debt is merely noticed.

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)

The taxonomy below owns the button role and nothing else. Every other phone UI role - the dialog and bottom-sheet construction seam, the list and grid row, the toolbar and action bar, the floating panel, and the design tokens all four inherit - is docs/ui/PHONE_UI_COMPONENT_PATTERNS.md, which extends this section and never contradicts it (S3231). A conflict found between the two is a finding against that catalogue.

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.
Destructive (standalone) Widget.FastMediaSorter.Button.Destructive A non-dialog destructive action that sits next to a neutral action and must not read lighter/secondary than it (e.g. “Delete all” beside “Install all” in a screen footer). Standard Filled sizing - NOT the oversized dialog-pair DialogDestructive below, which would visually dominate a standalone surface (S2179).

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).

Default focus of a destructive dialog (S4051, owner rule): the dialog opens with keyboard and D-pad focus on Cancel, so Enter or D-pad centre closes it instead of deleting. Builder dialogs get it at run time: the destructive overlay sets ?attr/dialogDestructive, and every showBoundTo* path hands the negative button the default focus (util/DestructiveDialogFocus.kt). A custom layout with a DialogDestructive button declares android:focusedByDefault="true" on its cancel - or on the list of a list dialog - and never on the destructive button; scripts/quality/assert-dialog-cancel-style.ps1 refuses either miss.

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:

Launcher Desktop Surface Colours (S2539)

The launcher desktop (desktop shortcut cells, status strip, tray indicators, and embedded gadgets) uses dedicated theme attributes rather than generic ?attr/colorOnSurface and ?attr/colorOnSurfaceVariant so that labels and captions follow the active palette accent overlay (ThemeOverlay.FastMediaSorter.DarkGreen, DarkBlue, DarkRed, LightGreen, LightBlue, LightRed, as well as base day/night themes) while maintaining contrast over custom wallpapers:

Both attributes are paired with OutlinedTextView / OutlinedTextClock stroke outline (@color/black / #CC000000) so that tinted text remains legible across arbitrary light/dark wallpapers. Non-desktop launcher surfaces (modal dialogs, sheets, and drawer fragments with opaque card backgrounds) continue to use standard Material3 ?attr/colorOnSurface tokens.

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:

External I/O contract

Every operation that leaves the process - a network round trip, a file write, a cloud request - obeys one contract, and the contract is a property of the seam rather than of any one protocol. The seam is the transfer strategy layer under data/transfer/: the FileOperationStrategy interface, its per-protocol adapters (LocalOperationStrategy, SafOperationStrategy, SmbOperationStrategy, SftpOperationStrategy, FtpOperationStrategy, CloudOperationStrategy), the cross-protocol pairs under data/transfer/strategies/, and the byte-level FileAccess adapters under data/transfer/access/. SMB, FTP, SFTP, cloud and local are five instances of one rule; an adapter family added later joins the same rule rather than negotiating its own.

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 - and still does in the split debug build S1972 added, because externalNativeBuild.cmake.abiFilters stays arm64-only regardless of packaging: 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 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. A feature cell carries the same kind of condition (S2382): it is seeded only when the feature is launchable - compiled into the build and switched on - rather than merely compiled in, so a capability the user has turned off is absent from the desktop it never asked for. Nothing is lost by the omission: the reactive shortcut sync places the cell the moment the feature becomes launchable, which is also why the earlier rule of seeding a disabled feature’s cell as a route to its own setting no longer buys anything. 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. One cell per profile carries the signature (S2385). The profile’s widget group opens with the single cell that says what device this desktop belongs to - the live map on a head unit, YouTube on a tablet - ahead of everything else that group earns, because the group is budgeted and a signature seeded at the tail is the first thing a compact screen cuts. The mapping is exhaustive over the profile enum, so a new profile names its signature or an explicit none rather than inheriting a silent default, and nine of the eleven name none because their group is already unlike any other. A signature that is an application joins the seed’s package-uniqueness contract as a third owner ahead of both app sections, so it is never the same icon twice on one desktop. Composition has two axes (S2309). The profile decides intent - which groups have anything in them at all - and the device’s screen class decides capacity: the section order, how many items each section seeds, and how many screens the desktop fills, which is composed rather than fixed at two. The class is a discrete pair - size by smallest width in dp, shape by the long-to-short ratio - derived by LauncherScreenClassifier from the running device and independent of the current orientation, so rotating a phone cannot change what its desktop contains. The rules live in LauncherStarterLayoutRules and take the screen class alone: a layout stated per profile was measured wrong, because a 20:9 phone and a 4:3 tablet can carry one profile and want different first screens. Several groups may seed one section key - the profile gadgets beside the utility widgets, the core resource aggregates beside the user tail beside the media windows - and when they land on one screen they are emitted under a single header, because a section is addressed by its key alone and two headers sharing one would fold together and collide in the packing pass; the screen cut is pushed past any such neighbouring pair, since two halves of one key on two screens would fold into each other. A per-section budget applies only where a shorter list is still a list (S2321). The content aggregates - recent, the four media types, the camera and “All files” - are a closed set that cannot grow without an edit to LauncherStarterSets, and each entry is the desktop’s only way into a whole content type, so they seed under their own unbounded CORE_RESOURCES group while the open user tail keeps the budget; the launcher’s own actions are exempt for the same reason. A budget reaching either group removes a capability rather than trimming a list - which is exactly what a compact screen did to “All documents”, the camera and “All files” until they were split out. A content type switched on after the seed reaches the desktop by the same reactive route (S2564). The aggregates seed from the virtual resources that exist at seed time, and the resource table self-heals - provisioning is idempotent and re-reads the settings - but the seed’s already-seeded early exit sits above that call, so a seeded desktop re-reads neither. The resource-tile sync mirrors the shortcut one for the other half of the desktop: it keeps its own baseline of virtual paths, adopts silently on its first pass, places only what became present since, and writes the baseline as a union, so a tile the user deleted never returns. What decides placement is the resource table and never the media-type flag - a tile needs the id of a record that exists, and the composite condition that creates an aggregate lives in provisioning, where a second copy would be the hand-listing the shortcut sync already removed. Every gadget key declares whether the seed uses it (S2672). LauncherGadgetSeedPolicy in src/main is the one home of the gadget key literals that table emits, and it answers for the whole registry rather than only for the keys it seeds: each key is Seeded or carries the reason it is not - a widget instance only its configuration screen can mint, a param only a person can choose, an owner ruling, or an owner decision not yet taken. The distinction matters because the registry lives in src/launcherEnabled and the seed cannot import it, so before this the two lists had no common home and twenty keys had drifted out of every profile with no recorded decision. LauncherStarterSetsParityTest reads the registry’s keys by reflection, so a new gadget fails the build until it declares a decision, and it proves each Seeded key by running itemsFor across every profile rather than trusting the label.

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 a RecyclerView’s machinery costs the model more than it returns. What that never meant is that re-inflating the cells is free: LauncherCellViewBinder hands the torn-down render’s roots to the next one as blanks, and on a 36-cell desktop that took the four-rotation gfxinfo p99 from 150 ms to 101 ms (S2686, Galaxy S21+). The pool is not a step back toward diffing - no list is compared, no subtree is updated in place, and the tree is still rebuilt whole; a pooled root is re-bound in full, listeners included, which is what keeps it clear of the stale-bind problem that killed the RecyclerView renderer. It is also refused across an edit-mode boundary, because edit decorations are added as children and never removed. 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. A bridged widget that keeps per-instance state reaches the desktop as well: its cell carries a launcher-minted instance token in the cell param instead of an appWidgetId, its own configuration screen is what the add flow opens, and the widget chain skips the calls that would hand that token to AppWidgetManager.

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. The recents strip reads the launcher journal, which holds one row per command with its latest launch (a relaunch updates that row), trimmed only past MAX_RECENT_PROGRAMS distinct programs; launch counts live in a separate never-trimmed table and decide the order. Both travel in the app’s own backup file as an optional recents section that a restore merges without deleting local rows.

Task placement. A target opened from a launcher surface lands in three possible tasks, and which one it is decides two separate behaviours, so neither may be changed alone. LauncherHomeActivity holds a task affinity of its own (${applicationId}.launcherhome, type=home); the app’s ordinary screens share the application default affinity; a foreign package has its own. Android refuses enterPictureInPictureMode() to anything inside a home-type task, which is why the launcher never starts a target into its own task - starting from the launcher Activity’s context, with no flags, is exactly the mistake that does (S2026). And FLAG_ACTIVITY_NEW_TASK joins an existing task whose affinity matches rather than always making a new one, so that flag alone drops an internal target on top of the app’s existing task and Back then unwinds into MainActivity or BrowseActivity (S2215). The rule that satisfies both: the shared executor keeps the start, adds FLAG_ACTIVITY_NEW_TASK for every target, and adds FLAG_ACTIVITY_MULTIPLE_TASK only when the target’s component is in our own package. A foreign target is deliberately left alone - its task is already separate by affinity, and forcing a duplicate would stop that app resuming where the user left it.

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.

The shortcut sync follows the toggle in both directions (S2664). SyncEnabledToolShortcutsUseCase compares the launchable sub-program set against its own stored baseline: a route that became launchable gains a cell, a route that stopped being launchable loses every cell carrying it in both stored orientations, and the baseline is then replaced by the launchable set rather than unioned with it. Replacement is what makes the removal half work at all - a union baseline only ever grows, so the “no longer launchable” difference would be empty on every pass after the first. Placement is section-addressed: a new cell enters the app_functions section through addCellInSection, which grows that section rather than refusing when it is full, and falls back to the first free slot only on a desktop that carries no such section, which is one seeded before the section existed. The cost is deliberate: a shortcut the user deleted by hand returns while the feature is still switched on, because presence is a function of the toggle alone.

One table names the sub-programs. SubProgramCatalog is the source for the shortcut sync above, the starter desktop’s App-functions section, the quick-access panel seed (SeedDefaultAppLaunchPanelUseCase), the internal-route picker and the widget catalogue’s pairing (HomeWidgetCatalog). The medium-screen App-functions budget holds every registry shortcut, while compact screens retain their proportional list limit. The table itself is described once, under “Sub-Program Registry” below. The gadget registry is the neighbour that is deliberately not in it: it carries a seed decision per key, enforced by the starter-set parity test (S2672).

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), S1930 (per-instance widgets on the desktop), S2026 and S2215 (task placement), S2309 (starter layout composed per screen class), S2382 (the first seed places launchable features only). Launcher classes are indexed in the class catalog under the launcher sector.

Sub-Program Registry

FMS ships about two dozen small programs of its own - the calculator, the stopwatch, the mini-game, the network monitor, the recorder, the lights, seven camera routes, the watch companion. SubProgramCatalog (core/panel/) is the one list of them, and every surface that offers a sub-program derives its composition from that list instead of holding one.

What the registry owns, and only that. A SubProgramEntry states which route key is a sub-program at all, its order in the one sequence every surface shows, the surfaces it is eligible for, the widgetKey it pairs with, and the disable that switches it off. It stores no name, no icon, no intent and no availability: the label, the icon and the launch intent come from InternalRouteCatalog.byKey(routeKey), and whether the program exists in this build and is switched on comes from ResolvePanelRouteAvailabilityUseCase. Copying either into the entry would recreate the second copy the registry exists to remove - which is exactly what the programs menu did until S1736 phase 04 took its label and icon table away.

Route keys are the vocabulary. They are already persisted on user devices inside launcher cell targets and quick-access tile targets (ADR-1), so a widget key or a menu id maps onto a route key rather than the other way round.

The surfaces. SubProgramSurface names five: PROGRAMS_MENU (the main-window dropdown and the programs panel that replays its population), QUICK_ACCESS_PANEL, WIDGET (the in-app picker that pins one of our home-screen widgets), LAUNCHER_SHORTCUT (a desktop cell added when the program is switched on) and OS_APP_SHORTCUT (the dynamic shortcuts under a long press on the app icon, moved by S1925 and read today by ResolveLauncherCommandLabelUseCase). Launcher informer tiles - the clock, the weather - are excluded permanently by ADR-3: they are not programs, nothing launches them, and half the entry’s fields would be inapplicable to half its rows.

Adding one. A new sub-program is one entry here and no surface edit. A new surface is a new SubProgramSurface constant plus a case in SubProgramCatalogCompletenessTest, never a new table - that suite is what turns “present on one surface, silently absent from another” from a device report into a build failure (ADR-4).

Two recorded exceptions, both in the programs menu. VR Cinema has no flat intent - its menu item runs a resource picker bound to MainActivity - so it stays outside the registry rather than gaining an invented entry point. Menu item ids stay hand-assigned in MainProgramsMenuCoordinator: seven manager classes and the programs panel dispatch taps on them, so an id derived from the registry’s order would move under every insertion.

Related specs: S1736 (this registry), S1925 (the OS-shortcut surface, which reads it), S2673 / S2889 (the programs menu’s move onto it).

Icon Looks (S3433)

The cross-project icon contract (ICON-RENDER 0.10, section 10) gives one glyph three looks - mono, colour, decorated - and says which surface takes which: an app shortcut and an in-app launch grid take the decorated look, a row icon that states a type takes the colour look, everything else stays mono. The product implements it with four roles, none of which owns a colour:

The rule that holds it together: a look is derived, never drawn. A coloured or painted copy of a glyph is what the product had before - the gold note, the blue network folder - and what the contract forbids. The measured glyph style and the size tiers are enforced by scripts/quality/assert-icon-style.ps1; docs/icons/README.md “Looks and the style gates” is the working reference.

Scheduled Operations Program (S3365)

The scheduled file operations are a sub-program in the registry’s fullest sense: one SubProgramEntry on the scheduled_tasks route key puts them on all five surfaces, and the entry’s disable is the very enableScheduledOperations setting that used to be the settings card’s master toggle - availability, the widget’s gate and “enable all” already read it, so no setting moved house in the data layer.

The screen, not a settings tab. ScheduledOperationsActivity (ui/scheduledops/) owns the management surface: the master toggle row, the operations list, the create/edit dialog (ScheduledOperationDialog, relocated from ui/dialog/ with the ViewModel and adapter), the group run controls (run all, pause/resume), the notification and battery-optimization permission flows, and the run history. The settings Operations tab keeps only a master switch with a help icon (rowEnableScheduledOps, the same setting as the screen’s switch), a short description and one button (btnOpenScheduledOpsScreen) that opens the screen; the embedded card that used to hold all of this is gone. The screen reconciles its switch with the list only on an observed empty <-> non-empty transition, so a switch turned off in settings is not turned back on when the screen opens.

The engine did not move. Planning, execution, the WorkManager self-rescheduling, boot-time rescheduling and backup integration are exactly as before (ADR-3) - only the interface layer was relocated. The run history is a structured read over the persisted scheduled_operations_log.txt (ADR-4: no Room change), parsed by ScheduledLogEntryParser into timestamp, operation, sides and an OK/ERROR result; the file’s yyyy-MM-dd HH:mm | OP | src → dst | message shape is written by ExecuteScheduledOperationUseCase and is Locale.US by S2598.

Persisted keys are healed centrally. The scheduled_tasks route key rides inside launcher cells and quick-access tiles saved on user devices; AppLaunchPanelRouteIntents.scheduledTasks now targets the screen, so those saved targets land there without migration. Widget status/row PendingIntents open the screen directly, and stale EXTRA_OPEN_SCHEDULED intents held by pre-update widgets are redirected from SettingsActivity.onCreate to it.

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.

5. Prevent-Sleep Hold (MANDATORY, S3285)

FLAG_KEEP_SCREEN_ON follows the global preventSleep setting on every screen, and the decision is stated once - KeepScreenAwakePolicy.shouldKeepScreenAwake(preventSleep, level) in core/ui/, which is preventSleep && level != PowerPolicyLevel.SAVING (S2536: the hold stands down in the power-saving level).

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.

List Refine State Persistence (MANDATORY, S2199)

A list that offers a filter or a sort remembers the choice. This is a project rule, not a per-screen decision: it went unwritten until S2199, and three screens skipped it independently as a result.

Housing differs by module, because wear does not depend on app_v2 and the two can share a pattern but never a class:

A new store reaches its screen through the full layer chain, however short the operation looks. On the phone that is MainListSessionStore (data) behind MainListSessionRepository (domain), read and written by ReadMainListSessionUseCase / SaveMainListSessionUseCase, which the screen’s MainListSessionManager holds. The ViewModel imports neither the store nor the repository. This is not a preference: the ui-imports-data and viewmodel-imports-repository dimensions of scripts/quality/assert-source-gates.ps1 (S2103) refuse both imports in a changed file, and their baselines only ever fall. The older shortcuts that predate those gates - StreamsSessionStore injected straight into StreamsViewModel, WearPreferencesRepository into the watch ViewModels - are baselined history, not a pattern to copy. S2199 ADR-6 originally proposed copying them and was refuted by the gate at closure time; the record is in dev/REFUTED_APPROACHES.md.

The arrow is also read backwards (S2751). The dimensions above all catch an upper layer reaching down past its neighbour; three more catch a lower layer reaching up into the screens, which no check could see until this ticket and which therefore appeared in silence - six such imports had accumulated in two watch use cases. wear-domain-imports-ui watches wear/.../domain/, wear-data-imports-ui watches wear/.../data/, and domain-imports-ui watches app_v2/.../domain/; all three live in scripts/quality/assert-source-gates.ps1 beside the S2103 set. Each module-and-layer pair carries its own baseline so a new edge in one cannot be paid for by a cleanup in another, a level falls when an edge is unpicked, and none of them is raised without a boundary decision. The cure is the same in every case: answer with a domain value and let the upper layer map it to a route or a glyph - a table that returns domain records belongs under domain/, and a navigation address or a drawable never does.

Wear List Residency (S2205)

A watch content list is read in full and narrowed in memory. There is no page at the query level, and that is a decision rather than an omission - S2205 was raised because a spec described these lists as paged and nothing in the tree ever was.

Watch module only. The phone’s lists answer to different constraints and this section says nothing about them.

Settings Model Shape (MANDATORY, S2300)

AppSettings is one data class with every default supplied, and that shape has a hard ceiling: a JVM method descriptor carries at most 255 slots including this, and Kotlin’s synthetic default-argument constructor spends one slot per parameter (two per non-nullable Long), one bitmask int per 32 parameters, and one marker. copy$default spends the same plus the receiver.

Wear Settings Persistence (S2050)

A wear-related field on the phone belongs to exactly one of two stores, decided by who reads it:

These are not two names for one setting - they answer different questions (“what did we last tell the watch” vs. “is the companion feature on”) - so merging them is out of scope; see PLAN/S2050_wear-settings-two-stores-on-phone.md §9 for the rejected alternative.

Wear Data Layer Bridge (S2591)

The phone and watch compile as separate modules, so their companion transport is a protocol rather than a shared Kotlin dependency. PhoneWearListenerService in the phone GMS source set owns traffic arriving on the phone; WatchWearListenerService owns the corresponding watch traffic. The listener declarations in the two manifests supply the route-prefix filters that cause Google Play services to start them. A new route outside an existing prefix needs its matching manifest filter before either listener can receive it.

Choose the transport by delivery semantics, not by payload size alone:

The normal structured payload is WearEventEnvelope: an event type, schema version, send time, and encoded data. It lets a receiver reject an unsupported event or payload shape without treating it as a different route. The compatible network-sources request, push, and acknowledgement channel predates this envelope and deliberately keeps its old payload format, but its routes are still part of the common contract.

WearDataLayerPaths is hand-mirrored in the phone and watch modules because they cannot share an artifact. Every common /fms/ route has the same name and value in both copies, even when only one listener consumes one direction. scripts/quality/assert-wear-wire-vocabulary-parity.ps1 compares that route map together with the wire vocabularies; its regression suite proves a one-sided route change fails. Add a route to both catalogues, the relevant listener, and this transport rule in the same change. Do not add a local literal as a shortcut around the parity gate.

Wear Shared UI Components (S2004)

The watch module has exactly one component for each of these two jobs. Both live in wear/ui/common/, and a screen that needs either asks for it rather than writing its own - S2004 was raised because seven screens had each written their own empty state and three had none at all.

Wear List Top-Edge Placement (S2466)

Every list-like surface in the watch module uses WearListColumn and rememberWearListState() from wear.ui.common. Direct calls to ScalingLazyColumn or rememberScalingLazyListState in wear/src/main are banned by the wear-list-start gate (scripts/quality/lib/source-matchers.ps1).