Architecture: OSOT (one source of truth). DualScreenManager (DSM) owns ALL
cross-activity state as StateFlows; both activities are consumers. There is no
messaging layer between the activities.
All paths below are under app/src/main/kotlin/com/nendo/argosy/. References are by
SYMBOL, not line number - offsets rot on the next edit above them. Grep the symbol.
NAMING WARNING (read first)
SecondaryHomeBroadcastHelper and every broadcast* method name in it (and a
few on DSM: broadcastForegroundState, broadcastUnifiedSaves,
broadcastSessionCleared, broadcastOpenOverlay) are HISTORICAL names from a
deleted broadcast-based layer. They are plain, same-process method calls into
DSM / CompanionHost. DualScreenBroadcasts.kt no longer exists; neither
activity registers a receiver or calls sendBroadcast for cross-activity
communication (verify: zero sendBroadcast/registerReceiver hits in
hardware/SecondaryHomeActivity.kt and hardware/SecondaryHomeBroadcastHelper.kt).
A rename is queued; until then do NOT let the names suggest a broadcast layer,
and do NOT add Android broadcasts between the activities.
DSM's own registerReceivers() / unregisterReceivers() (called from MainActivity) are
part of the same mess: they register a DisplayManager display listener, not a
BroadcastReceiver, and nothing about them concerns companion messaging.
OSOT Model (the spine)
DualScreenManager (DualScreenManager.kt)
held in DualScreenManagerHolder.instance
StateFlows (pull) CompanionHost (push)
dualScreenShowcase interface CompanionHost in DSM
dualGameDetailState implemented by SecondaryHomeActivity
dualViewMode / dualAppBarFocused
dualDrawerOpen / dualCollectionShowcase
dualSyncOverlay / dualSaveConflict (+focus indexes)
pendingOverlayEvent / isCompanionActive
isRolesSwapped / isDualScreenDevice
swapped* family
| |
v v
MainActivity + ArgosyApp SecondaryHomeActivity
(creates DSM, collects flows (collects flows in
in onCreate) initializeCompanion, receives
CompanionHost pushes)
MainActivity creates DSM (or rebinds to the existing Holder instance) in
onCreate and clears the Holder only when finishing.
Companion -> DSM direction is plain method calls, routed through
SecondaryHomeBroadcastHelper (dsm.onGameSelected, dsm.handleDirectAction,
dsm.handleInlineUpdate, ...).
DSM -> companion direction is companionHost?.onX(...) pushes.
LAW: never anchor shared state in an activity.
Rule: any state both displays can render, or that must survive a companion
respawn, lives as a DSM StateFlow.
Why: the showcase role renders entirely from DSM flows collected in
SecondaryHomeActivity.initializeCompanion; state parked in MainActivity never
reaches it. State parked in SecondaryHomeActivity dies on respawn - the OS recreates
the SECONDARY_HOME activity at will, and onResume reconnects to a possibly
NEW DSM (the stale-DSM reconnect).
Exception: purely local UI state (e.g. isScreenshotViewerOpen,
launchedExternalApp in SecondaryHomeActivity).
Boundary: the moment the other display renders it, or a respawn must restore
it, it moves to DSM (or SessionStateStore if it must survive process death).
Process and Lifecycle (verified, carried over)
ONE process, two activities. SecondaryHomeActivity has no android:process;
it is NOT a Hilt entry point and reaches shared singletons through dsm.*
internals (manual VM construction in initializeDependencies()).
Manifest: SecondaryHomeActivity is the SECONDARY_HOME activity, intent-filter
priority 1000 (MAIN + SECONDARY_HOME + DEFAULT), launchMode=singleTop,
taskAffinity="", excludeFromRecents (grep SecondaryHomeActivity in
AndroidManifest.xml). The OS pins it: finish() respawns it. To actually
remove it, disable the component: SecondaryHomeComponent.setEnabled(context, false) (util/SecondaryHomeComponent.kt; called from applyDualScreenEnabled).
FGS guard: CompanionGuardService, foregroundServiceType
specialUse|dataSync, subtype property companion_display_guard (grep
companion_display_guard in AndroidManifest.xml), keeps the display session alive.
onCreate gate: SessionStateStore.isDualScreenEnabled() finishes immediately
when off.
DSM acquisition on companion boot (onCreate): use the Holder instance if
present; if respawned without a running Argosy and not default home, disable the
component and finish; else launch MainActivity on the default display and poll
the Holder (100ms x 50) before initializing.
Stale-DSM reconnect: onResume compares dsm to the current Holder instance
and re-runs initializeCompanion() on mismatch. Any init-time wiring you add
must live inside that path or it will be lost across a MainActivity recreate.
Theme: SecondaryHomeTheme + live prefs collected through
dsm.preferencesRepository keyed on isInitialized. V2 theme locals are
available on the lower display; custom fonts flow through the fonts parameter.
File Map (what lives where)
DualScreenManager.kt - all shared state, modal state machine, game actions,
save operations, launch/display routing, companion lifecycle watchdogs.
DualScreenManagerHolder.kt - @Volatile var instance (the whole file is 6
lines). Set by MainActivity, read everywhere else.
hardware/SecondaryHomeActivity.kt (993 lines) - lifecycle, CompanionHost
implementation, key dispatch entry, DSM flow collection. Logic is extracted
to the three helpers below; do not grow the activity.
Dedup first, always: every dispatch path calls dsm.claimInput(event) before
handling (MainActivity.dispatchKeyEvent and dispatchGenericMotionEvent,
SecondaryHomeActivity.onKeyDown, plus the libretro activity). First claimant
wins; parallel deliveries of the same physical event are dropped
(InputDedupBuffer, reached via DualScreenManager.claimInput). New dispatch
paths MUST claim before handling.
Companion side (SecondaryHomeActivity.onKeyDown), in order:
dsm.claimInput - return if already claimed.
Sync-conflict then save-conflict handlers (these read DSM's
dualSyncOverlay/dualSaveConflict and consume everything while active).
Showcase branch: if isShowcaseRole, only showcase-modal events are handled
(via ShowcaseViewModel.handleModalGamepadEvent, and only while Argosy is
backgrounded); everything else falls through to super.
routeInput (SecondaryHomeInputHandler): conflicts again (forwarded keys enter
here directly), then HOME/GAME_DETAIL handlers when Argosy is foreground and no
game is active, else handleCompanionInput (the in-game/backgrounded app-bar
dashboard; returns UNHANDLED on external displays).
Primary side (MainActivity.dispatchKeyEvent), in order:
dsm.claimInput.
dsm.handleConflictInput - conflict overlays win on both sides.
Game on the other display and no overlay focused -> forward raw event to
dsm.emulatorKeyDispatcher (cross-display session input).
Not swapped + on Home + companion active + no overlay ->
companionHost?.onForwardKey(keyCode, swapAB, swapXY, swapStartSelect). The
companion re-maps and feeds the SAME routeInput. Sticks take the same path
via dispatchGenericMotionEvent.
Rule: the companion's SecondaryHomeInputHandler is the single gamepad brain
for lower-screen content, whichever activity physically received the key.
Exception: FILE_PICKER and SAVE_NAME modals (upper-owned, table below) and
the swapped role (upper is the interactive screen).
Why: forwarded and direct keys converge on routeInput, so state moves in
exactly one place.
Boundary: if you handle a key on the upper screen for lower-screen content,
you have created a second brain - move it.
Swap prefs reach the companion by TWO paths, and only one of them is live:
Boot: loadInitialState calls stateManager.loadInputSwapPreferences() once and
writes the activity fields. This reads the SessionStateStore mirror and IGNORES
prefs.controllerLayout entirely.
Live: initializeCompanion collects dsm.preferencesRepository.userPreferences
and calls applyInputSwapState(stateManager.inputSwapStateFrom(prefs)) on every
emission. inputSwapStateFrom is a DIFFERENT method from
loadInputSwapPreferences and is the ONLY place the companion honours the
Controller Layout override.
A swap-affecting preference wired only into the boot path silently never goes live -
add it to inputSwapStateFrom too. The resulting fields feed every
mapKeycodeToGamepadEvent call; icon swaps flow through CompositionLocals
LocalABIconsSwapped / LocalXYIconsSwapped / LocalSwapStartSelect.
ControllerDetector lives at core/input/ControllerDetector.kt (NOT ui/input).
Role Swap / Showcase Mode
isRolesSwapped mirrors DisplayRoleResolver output (override pref + display
type; external HDMI defaults swapped). swapRoles() toggles the override -
debounced 500ms, NO-OP while a session is active.
When swapped, the roles invert:
The PRIMARY activity becomes the interactive screen, driven by DSM's swapped
mirror state: swappedDualHomeViewModel (built by initSwappedViewModel,
triggered from MainActivity's onRoleSwapped callback),
swappedCurrentScreen, swappedGameDetailViewModel (created by
selectGameSwapped), swappedIsGameActive, swappedCompanionState,
swappedSessionTimer.
The COMPANION becomes a passive showcase: onRoleSwapped(isSwapped) sets
isShowcaseRole and setContent renders ShowcaseRoleContent from the
DSM-mirrored flows. Showcase touch on modals goes through ShowcaseViewModel
straight into DSM confirm/move methods.
HDMI unplug mid-swap: cleanupSwappedState resets the override to AUTO,
clears swapped VMs/timers, and ends the session if the emulator was on the
removed display.
Per-game display targets can force an effective swap at launch only:
resolveEmulatorDisplaySwapped maps HERO/LIBRARY/TOP/BOTTOM, and
preGameRolesSwapped restores the pre-launch state at session end.
This is why the OSOT law exists: the same feature must render on whichever
display currently holds the showcase role, and only DSM flows reach both.
ForwardingMode + Overlay Event Flow
ForwardingMode { NONE, OVERLAY, BACKGROUND } (DualHomeViewModel.kt). While
!= NONE the lower home swallows all input (guard at the top of routeInput's
home path in SecondaryHomeInputHandler).
OVERLAY (drawer / quick menu / quick settings on the upper screen):
Companion presses Menu/L3/R3 -> broadcasts.broadcastOpenOverlay(name)
with the name from overlayNameFor (names OVERLAY_MENU / QUICK_MENU /
QUICK_SETTINGS, matched in DSM's onOpenOverlayFromCompanion).
Helper sets startDrawerForwarding() (OVERLAY) then calls
dsm.onOpenOverlayFromCompanion.
ArgosyApp collects pendingOverlayEvent, opens the matching overlay, then
calls clearPendingOverlay().
Close: every overlay-close observer funnels into notifyOverlayClosed in
ArgosyApp -> isOverlayFocused = false + companionHost.onOverlayClosed() +
refocusSelf(); the companion's onOverlayClosed calls
stopDrawerForwarding().
BACKGROUND: an overlay closed while the upper is NOT on the Home route (user
navigated into Apps/Settings) -> companionHost.onBackgroundForward() -> lower
enters BACKGROUND forwarding: keys swallowed, tap on the lower screen refocuses
the upper. Returning to Home fires notifyOverlayClosed and clears it.
Safety nets: dual-screen topology changes reset overlay focus and modal state
(the ArgosyApp LaunchedEffect keyed on isRolesSwapped, companionActive,
swappedGameActive - grep dsmForTopology); reassertCompanionForwarding
clears a latched isOverlayFocused when input arrives on a stale link.
Modal System
Modals render on the upper screen inside DualGameDetailUpperScreen, state
lives in DSM's dualGameDetailState (DualGameDetailUpperState.modalType).
Opening always goes through a DSM open*Modal method which sets state and
calls refocusMain(). Those methods are NOT contiguous in DualScreenManager.kt
openModal, openEmulatorModal, openCollectionModal, openSaveNameModal,
openDiscModal, openSteamInstallModal, openSteamChooserForHome sit together,
while openCoreModal, openSavePathModal, openDisplayTargetModal,
openMemoryCardModal and openVariantModal are scattered several hundred lines
later. Grep fun open, do not scroll. The lower screen dims while a modal is
active (isDimmed = activeModal != ActiveModal.NONE, wired in
SecondaryHomeComposables.kt).
Input ownership per modal, normal (non-swapped) mode. "Companion-owned" =
handleModalInput (SecondaryHomeInputHandler) drives the companion VM and
mirrors focus to the upper via broadcastInlineUpdate(<field>) ->
dsm.handleInlineUpdate; confirm goes through broadcastModalConfirmResult ->
dsm.onModalConfirmResult.
live focus forwarding via broadcastInlineUpdate("emulator_focus")
CORE
Companion
core_focus
SAVE_PATH
Companion
save_path_focus
DISPLAY_TARGET
Companion
display_target_focus
MEMORY_CARD
Companion
memory_card_focus; opened via broadcastMemoryCardModalOpen -> dsm.openMemoryCardModal; upper mirror is moveDualMemoryCardFocus / confirmDualMemoryCardSelection
companion swallows ALL input incl. Back (the else fallthrough); text + confirm on the upper via updateDualSaveNameText / confirmDualSaveName on DSM
DISC_PICKER
Companion
disc_focus; Confirm closes modal + direct action PLAY_DISC
VARIANT_PICKER
Companion
variant_focus
STEAM_INSTALL
Companion
steam_install_focus; also opens from Home as a chooser (openSteamChooserForHome)
FILE_PICKER
UPPER
companion is Back-only dismiss; all focus/selection state is DSM filePicker* fields driven by the upper dualModalInputHandler and touch
COVER_PICKER
UPPER
search text needs the keyboard; state is DSM coverPicker* / coverCandidates, driven by handleDualCoverPickerInput (both handlers), the upper dualModalInputHandler and touch; opened via direct action CHANGE_COVER, X re-runs the search
REVIEW_EDITOR
UPPER
review body needs the keyboard; draft is DSM reviewEditor (ReviewEditorState, shared with the single-screen editor), driven by handleDualReviewEditorInput (both handlers), the upper dualModalInputHandler and touch; opened via direct action WRITE_REVIEW from the REVIEWS tab or the options row; Start/Select submit, Y prompts delete, B discards with a confirm when dirty; closes on the repository's reviewWriteEvents
Rule: new picker modals are companion-owned with live focus forwarding -
copy the EMULATOR branch, not FILE_PICKER.
Why: a modal with no companion branch is a dead modal when the companion has
window focus - keys land on the companion and vanish.
Boundary: every new ActiveModal value MUST get an explicit
handleModalInput branch, even if it is only Back-dismiss.
The upper dualModalInputHandler (ArgosyApp.kt, subscribed while
dualModalActive) mirrors every picker for the cases where the upper owns input
(overlay focus, swapped role); keep both sides in sync when adding a modal.
Result delivery: DSM confirm/dismiss methods push
companionHost.onModalResult(...); the companion applies to its VM and
refocusSelf(). Watchdog: if the companion pauses with a modal open, DSM
auto-dismisses it after 5s (onCompanionPaused); resyncCompanionState also
clears stale modals on companion resume.
ID-Based Carousel Restore
SessionStateStore.CarouselNavContext: restores the lower carousel by IDENTITY
(sectionKind + platformId + gameId) plus the full filter/sort context; legacy
index fields are fallback only. Persisted via
stateManager.persistCarouselPosition on selection moves and in onStop;
restored in stateManager.loadInitialState ->
dualHomeViewModel.restoreNavContext. The same load path also restores a
GAME_DETAIL screen (rebuilds the detail VM for the saved gameId) and clears any
persisted modal/screenshot-viewer state. Do not restore by index anywhere -
dynamic sections shift and the two screens end up on different games.
CompanionInGameState
CompanionInGameState (hardware/CompanionPanel.kt) is the in-game dashboard
state. Merge rule: async metadata loads MUST NOT clobber the live quick-action
flags - always apply withLiveQuickActionState(quickActionsAvailable, hasQuickSave) after building a fresh snapshot (used by
SecondaryHomeActivity.loadCompanionGameData and by DSM's own load). DSM's
_swappedCompanionState copy is canonical in BOTH companion modes;
updateCompanionHasQuickSave maintains it regardless of role.
Session Survival Rules
dsm.hasLiveSession() = in-memory PlaySessionTracker check, flips false the
moment teardown BEGINS. SessionStateStore.hasActiveSession() = persisted
flag, stays true until save sync completes. Pick deliberately: UI "is a game up
right now" -> hasLiveSession; "is it safe to relaunch/companion-launch" ->
hasActiveSession.
Rule: Argosy UI foregrounded = session over. onForegroundChanged ends the
companion's session view when Argosy comes foreground during a game.
Exception: showcase role checks !dsm.hasLiveSession() first - a
cross-display session survives the upper UI foregrounding.
Why: on a single pair of displays, foregrounding the launcher means the
game lost its screen; in swapped mode the game may still be running on the
other display.
Boundary: only cross-display sessions survive.
Companion resumed ON the emulator's display -> the game lost that display:
end the session (onResume, guarded by dsm.isLaunchingGame).
HDMI disconnect with the emulator on the removed display ends the session
(cleanupSwappedState).
swapRoles() refuses while hasActiveSession().
ensureCompanionLaunched refuses during an active session unless
allowDuringSession; a startup guard retries every 1.5s until the companion
is up.
Display Affinity
DisplayAffinityHelper.getActivityOptions(forEmulator: Boolean, rolesSwapped: Boolean = false, overrideDisplayId: Int? = null) - note the two
newer params: swapped launches route the emulator to the secondary display, and
overrideDisplayId bypasses resolution entirely. Emulator display resolution at
launch goes through resolveEmulatorDisplaySwapped (per-game/per-platform
EmulatorDisplayTarget); DSM records emulatorDisplayId at launch and it drives
cross-display input forwarding (isGameOnOtherDisplay in MainActivity) and the
session-survival rules above. Companion launch uses getCompanionLaunchOptions().
hasSecondaryDisplay is gated by THREE conditions, not one:
dualScreenEnabled && secondaryDisplayUsable && hasPhysicalSecondaryDisplay.
dualScreenEnabled is a plain var - set it from prefs before trusting it.
secondaryDisplayUsable is a FALLBACK LATCH, not a preference. It starts true,
is hydrated at MainActivity startup from
SessionStateStore.isSecondaryDisplayUsable(), and is set false by
DualScreenManager.fallbackToSingleScreen(persistent) once the companion has
been proven unable to initialize on the secondary display (OS builds that will
not run a home activity there). DualScreenManager.reprobeSecondaryDisplay()
clears it, and re-enabling dual screen in settings sets it true directly.
Consequence: on a device that latched false, every dual-screen entry point is
off even though the hardware and the preference both say yes. Check the latch
before diagnosing a "dual screen does nothing" report.
Focus Zones and Visuals (kept)
Home: DualHomeFocusZone { CAROUSEL, APP_BAR } and DualHomeViewMode { CAROUSEL, COLLECTIONS, COLLECTION_GAMES, LIBRARY_GRID }, both in
DualHomeViewModel.kt; one input handler per mode in SecondaryHomeInputHandler.
Detail tabs: SAVES (dual column, SaveFocusColumn { SLOTS, HISTORY } from
ui/common/savechannel/), STATES, MEDIA (grid), OPTIONS (list + inline
LEFT/RIGHT adjust for RATING/DIFFICULTY/STATUS which mirror via
broadcastInlineUpdate).
Lower dimming while a modal is up: SecondaryHomeComposables.kt passes
isDimmed into DualGameDetailLowerScreen.
Dual modality is non-negotiable: gamepad via SecondaryHomeInputHandler AND
touch via touchOnly/clickableNoFocus on the composables (grep
broadcastRefocusUpper in DualHomeLowerContent.kt for the dim-tap pattern).
Shared Surfaces, Not Second Copies (read before "DS does not have this")
The home surfaces are ONE feature drawn on two displays. When something the
phone-sized home has is missing on dual screen, the fix is to render the SAME
component there, never to build a companion-only variant and never to hide the
entry that leads to it.
The shared layer already exists, and it is where new grid behaviour goes:
ui/home/grid/CustomGridCoordinator.kt - every action the curated grid takes,
for both surfaces. Behaviour belongs here, not in a view model.
ui/home/grid/DualCustomGridInputRouter.kt - gamepad routing for the grid on a
companion display, shared by both DS handlers.
ui/home/grid/PageChooserEntrySource.kt - the rows the page chooser offers.
ui/components/ - CustomTileMenuModal, HomeTilePickerModal,
PageChooserModal, PageBackdrop, PageThemePlayer. All take state in and
hand callbacks out; none of them know which display they are on.
A missing DS feature is therefore almost always three small edits: render the
component in DualHomeLowerContent, route its input in
DualCustomGridInputRouter, and delegate the view-model calls in
DualHomeViewModel. Do not describe that as parity work to be scheduled.
Two rules that follow:
Hiding a menu entry on DS is not a fix. If an action opens something the
companion does not draw, build the consumption site - the setting is otherwise
a ghost, which the AGENTS.md settings-chain law already forbids.
A capability flag is only legitimate for something the surface genuinely
cannot host, and it must name the blocker. Today there is exactly one:
PageChooserEntrySource.canBrowseFiles, false on the companion because
FileBrowserScreen needs LocalInputDispatcher and a hiltViewModel(), and
SecondaryHomeActivity is deliberately not a Hilt entry point and provides no
Compose input dispatcher. Removing that flag means giving the companion those
two things, or pushing browsable rows from DSM the way FILE_PICKER does.
New Dual-Screen Feature Checklist
State: add a StateFlow on DSM (never an activity field). Companion
mutations go through a method on DSM, exposed to the companion via
SecondaryHomeBroadcastHelper.
Push: if the companion must react to a main-side event, add a
CompanionHost method (interface in DualScreenManager.kt), implement it
in SecondaryHomeActivity, call it from DSM next to its state update.
Consumers: collect on the upper (ArgosyApp/MainActivity) AND, if the
showcase role renders it, add a collector in initializeCompanion plus
ShowcaseRoleContent wiring.
SHA overrides: any new CompanionHost method needs its
SecondaryHomeActivity override to survive the stale-DSM reconnect path
(re-wired by initializeCompanion).
Input: companion branch in SecondaryHomeInputHandler (routed via
routeInput); upper branch in ArgosyApp's handler if the upper can own
input for it; both sides inherit dsm.claimInput dedup from their
activities.
Modal? Extend ActiveModal, add DualGameDetailUpperState fields, DSM
open/move/confirm methods + handleInlineUpdate field, a
handleModalInput branch, an ArgosyApp dualModalInputHandler branch,
onModalResult handling in SecondaryHomeActivity, the upper render
branch - and add the modal to the ownership table in this skill.
Dual modality: touch handlers on every interactive composable.
Resync: decide what resyncCompanionState / the pause watchdog should
do with your state when the companion bounces.
Prefs: read once at operation start, pass down.
DS parity: verify in BOTH roles (normal and swapped/showcase) - a
feature that only works in one role is incomplete.
Home-surface work: reuse the shared grid layer above. A component that
already takes state and callbacks gets rendered on DS, not reimplemented
and not gated off.
1---2name: dual-screen3description: Dual-screen development reference. Load this before implementing any dual-screen feature.4---56# Dual-Screen Development Reference78Architecture: OSOT (one source of truth). `DualScreenManager` (DSM) owns ALL9cross-activity state as StateFlows; both activities are consumers. There is no10messaging layer between the activities.1112All paths below are under `app/src/main/kotlin/com/nendo/argosy/`. References are by13SYMBOL, not line number - offsets rot on the next edit above them. Grep the symbol.1415## NAMING WARNING (read first)1617`SecondaryHomeBroadcastHelper` and every `broadcast*` method name in it (and a18few on DSM: `broadcastForegroundState`, `broadcastUnifiedSaves`,19`broadcastSessionCleared`, `broadcastOpenOverlay`) are HISTORICAL names from a20deleted broadcast-based layer. They are plain, same-process method calls into21DSM / `CompanionHost`. `DualScreenBroadcasts.kt` no longer exists; neither22activity registers a receiver or calls `sendBroadcast` for cross-activity23communication (verify: zero `sendBroadcast`/`registerReceiver` hits in24`hardware/SecondaryHomeActivity.kt` and `hardware/SecondaryHomeBroadcastHelper.kt`).25A rename is queued; until then do NOT let the names suggest a broadcast layer,26and do NOT add Android broadcasts between the activities.2728DSM's own `registerReceivers()` / `unregisterReceivers()` (called from MainActivity) are29part of the same mess: they register a DisplayManager display listener, not a30BroadcastReceiver, and nothing about them concerns companion messaging.3132## OSOT Model (the spine)3334```35 DualScreenManager (DualScreenManager.kt)36 held in DualScreenManagerHolder.instance37 StateFlows (pull) CompanionHost (push)38 dualScreenShowcase interface CompanionHost in DSM39 dualGameDetailState implemented by SecondaryHomeActivity40 dualViewMode / dualAppBarFocused41 dualDrawerOpen / dualCollectionShowcase42 dualSyncOverlay / dualSaveConflict (+focus indexes)43 pendingOverlayEvent / isCompanionActive44 isRolesSwapped / isDualScreenDevice45 swapped* family46 | |47 v v48 MainActivity + ArgosyApp SecondaryHomeActivity49 (creates DSM, collects flows (collects flows in50 in onCreate) initializeCompanion, receives51 CompanionHost pushes)52```5354- MainActivity creates DSM (or rebinds to the existing Holder instance) in55 `onCreate` and clears the Holder only when finishing.56- Companion -> DSM direction is plain method calls, routed through57 `SecondaryHomeBroadcastHelper` (`dsm.onGameSelected`, `dsm.handleDirectAction`,58 `dsm.handleInlineUpdate`, ...).59- DSM -> companion direction is `companionHost?.onX(...)` pushes.6061LAW: never anchor shared state in an activity.62- Rule: any state both displays can render, or that must survive a companion63 respawn, lives as a DSM StateFlow.64- Why: the showcase role renders entirely from DSM flows collected in65 `SecondaryHomeActivity.initializeCompanion`; state parked in MainActivity never66 reaches it. State parked in SecondaryHomeActivity dies on respawn - the OS recreates67 the SECONDARY_HOME activity at will, and `onResume` reconnects to a possibly68 NEW DSM (the stale-DSM reconnect).69- Exception: purely local UI state (e.g. `isScreenshotViewerOpen`,70 `launchedExternalApp` in SecondaryHomeActivity).71- Boundary: the moment the other display renders it, or a respawn must restore72 it, it moves to DSM (or SessionStateStore if it must survive process death).7374## Process and Lifecycle (verified, carried over)7576ONE process, two activities. SecondaryHomeActivity has no `android:process`;77it is NOT a Hilt entry point and reaches shared singletons through `dsm.*`78internals (manual VM construction in `initializeDependencies()`).7980- Manifest: SecondaryHomeActivity is the SECONDARY_HOME activity, intent-filter81 priority 1000 (MAIN + SECONDARY_HOME + DEFAULT), `launchMode=singleTop`,82 `taskAffinity=""`, `excludeFromRecents` (grep `SecondaryHomeActivity` in83 AndroidManifest.xml). The OS pins it: `finish()` respawns it. To actually84 remove it, disable the component: `SecondaryHomeComponent.setEnabled(context,85 false)` (util/SecondaryHomeComponent.kt; called from `applyDualScreenEnabled`).86- FGS guard: `CompanionGuardService`, foregroundServiceType87 `specialUse|dataSync`, subtype property `companion_display_guard` (grep88 `companion_display_guard` in AndroidManifest.xml), keeps the display session alive.89- onCreate gate: `SessionStateStore.isDualScreenEnabled()` finishes immediately90 when off.91- DSM acquisition on companion boot (`onCreate`): use the Holder instance if92 present; if respawned without a running Argosy and not default home, disable the93 component and finish; else launch MainActivity on the default display and poll94 the Holder (100ms x 50) before initializing.95- Stale-DSM reconnect: `onResume` compares `dsm` to the current Holder instance96 and re-runs `initializeCompanion()` on mismatch. Any init-time wiring you add97 must live inside that path or it will be lost across a MainActivity recreate.98- Theme: `SecondaryHomeTheme` + live prefs collected through99 `dsm.preferencesRepository` keyed on `isInitialized`. V2 theme locals are100 available on the lower display; custom fonts flow through the `fonts` parameter.101102## File Map (what lives where)103104- `DualScreenManager.kt` - all shared state, modal state machine, game actions,105 save operations, launch/display routing, companion lifecycle watchdogs.106- `DualScreenManagerHolder.kt` - `@Volatile var instance` (the whole file is 6107 lines). Set by MainActivity, read everywhere else.108- `hardware/SecondaryHomeActivity.kt` (993 lines) - lifecycle, CompanionHost109 implementation, key dispatch entry, DSM flow collection. Logic is extracted110 to the three helpers below; do not grow the activity.111- `hardware/SecondaryHomeInputHandler.kt` - ALL companion gamepad routing:112 `routeInput`, per-view-mode handlers, `handleModalInput`,113 `handleCompanionInput` (in-game dashboard), drawer input.114- `hardware/SecondaryHomeStateManager.kt` - boot-time state restore115 (`loadInitialState`, incl. CarouselNavContext + GAME_DETAIL restore),116 `loadInputSwapPreferences`, `inputSwapStateFrom`, `loadCompanionGameData`,117 `createGameDetailViewModel`, `persistCarouselPosition`.118- `hardware/SecondaryHomeBroadcastHelper.kt` - thin adapter, companion -> DSM119 method calls only (see NAMING WARNING).120- `hardware/SecondaryHomeComposables.kt` - `CompanionScreen` enum,121 `SecondaryHomeContent` (normal role), `ShowcaseRoleContent` (swapped role),122 lower dimming wiring.123- `hardware/CompanionPanel.kt` - `CompanionInGameState`,124 `withLiveQuickActionState`, `CompanionSessionTimer`.125- `ui/dualscreen/home/DualHomeViewModel.kt` - lower home state,126 `DualHomeFocusZone`, `DualHomeViewMode`, `ForwardingMode`, nav-context127 save/restore.128- `ui/dualscreen/gamedetail/DualGameDetailModels.kt` - `DualGameDetailTab`129 {SAVES, STATES, MEDIA, OPTIONS}, `ActiveModal`, `GameDetailOption`,130 `DualGameDetailUpperState`, save-entry JSON DTOs.131- `ui/dualscreen/ShowcaseViewModel.kt` - touch/modal input adapter for the132 showcase role, gated by `isControlActive`.133- `util/DisplayAffinityHelper.kt` - display enumeration, usability latch, launch134 options.135- `data/preferences/SessionStateStore.kt` - SharedPreferences persistence layer136 (session flags, swap prefs, `CarouselNavContext`, companion screen).137138## Input Flow139140Dedup first, always: every dispatch path calls `dsm.claimInput(event)` before141handling (`MainActivity.dispatchKeyEvent` and `dispatchGenericMotionEvent`,142`SecondaryHomeActivity.onKeyDown`, plus the libretro activity). First claimant143wins; parallel deliveries of the same physical event are dropped144(`InputDedupBuffer`, reached via `DualScreenManager.claimInput`). New dispatch145paths MUST claim before handling.146147Companion side (`SecondaryHomeActivity.onKeyDown`), in order:1481. `dsm.claimInput` - return if already claimed.1492. Sync-conflict then save-conflict handlers (these read DSM's150 `dualSyncOverlay`/`dualSaveConflict` and consume everything while active).1513. Showcase branch: if `isShowcaseRole`, only showcase-modal events are handled152 (via `ShowcaseViewModel.handleModalGamepadEvent`, and only while Argosy is153 backgrounded); everything else falls through to `super`.1544. `inputHandler.routeInput(event, true, isGameActive, currentScreen)`.155156`routeInput` (SecondaryHomeInputHandler): conflicts again (forwarded keys enter157here directly), then HOME/GAME_DETAIL handlers when Argosy is foreground and no158game is active, else `handleCompanionInput` (the in-game/backgrounded app-bar159dashboard; returns UNHANDLED on external displays).160161Primary side (`MainActivity.dispatchKeyEvent`), in order:1621. `dsm.claimInput`.1632. `dsm.handleConflictInput` - conflict overlays win on both sides.1643. Game on the other display and no overlay focused -> forward raw event to165 `dsm.emulatorKeyDispatcher` (cross-display session input).1664. Not swapped + on Home + companion active + no overlay ->167 `companionHost?.onForwardKey(keyCode, swapAB, swapXY, swapStartSelect)`. The168 companion re-maps and feeds the SAME `routeInput`. Sticks take the same path169 via `dispatchGenericMotionEvent`.1705. Stale-link reassert (`reassertCompanionForwarding`).1716. Local handling via GamepadInputHandler.172173Rule: the companion's `SecondaryHomeInputHandler` is the single gamepad brain174for lower-screen content, whichever activity physically received the key.175- Exception: FILE_PICKER and SAVE_NAME modals (upper-owned, table below) and176 the swapped role (upper is the interactive screen).177- Why: forwarded and direct keys converge on `routeInput`, so state moves in178 exactly one place.179- Boundary: if you handle a key on the upper screen for lower-screen content,180 you have created a second brain - move it.181182Swap prefs reach the companion by TWO paths, and only one of them is live:183- Boot: `loadInitialState` calls `stateManager.loadInputSwapPreferences()` once and184 writes the activity fields. This reads the `SessionStateStore` mirror and IGNORES185 `prefs.controllerLayout` entirely.186- Live: `initializeCompanion` collects `dsm.preferencesRepository.userPreferences`187 and calls `applyInputSwapState(stateManager.inputSwapStateFrom(prefs))` on every188 emission. `inputSwapStateFrom` is a DIFFERENT method from189 `loadInputSwapPreferences` and is the ONLY place the companion honours the190 Controller Layout override.191192A swap-affecting preference wired only into the boot path silently never goes live -193add it to `inputSwapStateFrom` too. The resulting fields feed every194`mapKeycodeToGamepadEvent` call; icon swaps flow through CompositionLocals195`LocalABIconsSwapped` / `LocalXYIconsSwapped` / `LocalSwapStartSelect`.196`ControllerDetector` lives at `core/input/ControllerDetector.kt` (NOT ui/input).197198## Role Swap / Showcase Mode199200`isRolesSwapped` mirrors DisplayRoleResolver output (override pref + display201type; external HDMI defaults swapped). `swapRoles()` toggles the override -202debounced 500ms, NO-OP while a session is active.203204When swapped, the roles invert:205- The PRIMARY activity becomes the interactive screen, driven by DSM's swapped206 mirror state: `swappedDualHomeViewModel` (built by `initSwappedViewModel`,207 triggered from MainActivity's `onRoleSwapped` callback),208 `swappedCurrentScreen`, `swappedGameDetailViewModel` (created by209 `selectGameSwapped`), `swappedIsGameActive`, `swappedCompanionState`,210 `swappedSessionTimer`.211- The COMPANION becomes a passive showcase: `onRoleSwapped(isSwapped)` sets212 `isShowcaseRole` and setContent renders `ShowcaseRoleContent` from the213 DSM-mirrored flows. Showcase touch on modals goes through `ShowcaseViewModel`214 straight into DSM confirm/move methods.215- HDMI unplug mid-swap: `cleanupSwappedState` resets the override to AUTO,216 clears swapped VMs/timers, and ends the session if the emulator was on the217 removed display.218- Per-game display targets can force an effective swap at launch only:219 `resolveEmulatorDisplaySwapped` maps HERO/LIBRARY/TOP/BOTTOM, and220 `preGameRolesSwapped` restores the pre-launch state at session end.221222This is why the OSOT law exists: the same feature must render on whichever223display currently holds the showcase role, and only DSM flows reach both.224225## ForwardingMode + Overlay Event Flow226227`ForwardingMode { NONE, OVERLAY, BACKGROUND }` (DualHomeViewModel.kt). While228!= NONE the lower home swallows all input (guard at the top of `routeInput`'s229home path in SecondaryHomeInputHandler).230231OVERLAY (drawer / quick menu / quick settings on the upper screen):2321. Companion presses Menu/L3/R3 -> `broadcasts.broadcastOpenOverlay(name)`233 with the name from `overlayNameFor` (names OVERLAY_MENU / QUICK_MENU /234 QUICK_SETTINGS, matched in DSM's `onOpenOverlayFromCompanion`).2352. Helper sets `startDrawerForwarding()` (OVERLAY) then calls236 `dsm.onOpenOverlayFromCompanion`.2373. DSM sets `isOverlayFocused = true`, publishes `pendingOverlayEvent`, calls238 `refocusMain()`.2394. ArgosyApp collects `pendingOverlayEvent`, opens the matching overlay, then240 calls `clearPendingOverlay()`.2415. Close: every overlay-close observer funnels into `notifyOverlayClosed` in242 ArgosyApp -> `isOverlayFocused = false` + `companionHost.onOverlayClosed()` +243 `refocusSelf()`; the companion's `onOverlayClosed` calls244 `stopDrawerForwarding()`.245246BACKGROUND: an overlay closed while the upper is NOT on the Home route (user247navigated into Apps/Settings) -> `companionHost.onBackgroundForward()` -> lower248enters BACKGROUND forwarding: keys swallowed, tap on the lower screen refocuses249the upper. Returning to Home fires `notifyOverlayClosed` and clears it.250251Safety nets: dual-screen topology changes reset overlay focus and modal state252(the ArgosyApp `LaunchedEffect` keyed on `isRolesSwapped`, `companionActive`,253`swappedGameActive` - grep `dsmForTopology`); `reassertCompanionForwarding`254clears a latched `isOverlayFocused` when input arrives on a stale link.255256## Modal System257258Modals render on the upper screen inside `DualGameDetailUpperScreen`, state259lives in DSM's `dualGameDetailState` (`DualGameDetailUpperState.modalType`).260Opening always goes through a DSM `open*Modal` method which sets state and261calls `refocusMain()`. Those methods are NOT contiguous in DualScreenManager.kt262- `openModal`, `openEmulatorModal`, `openCollectionModal`, `openSaveNameModal`,263`openDiscModal`, `openSteamInstallModal`, `openSteamChooserForHome` sit together,264while `openCoreModal`, `openSavePathModal`, `openDisplayTargetModal`,265`openMemoryCardModal` and `openVariantModal` are scattered several hundred lines266later. Grep `fun open`, do not scroll. The lower screen dims while a modal is267active (`isDimmed = activeModal != ActiveModal.NONE`, wired in268SecondaryHomeComposables.kt).269270`ActiveModal` - 17 values (DualGameDetailModels.kt):271NONE, RATING, DIFFICULTY, STATUS, EMULATOR, CORE, SAVE_PATH, DISPLAY_TARGET,272MEMORY_CARD, COLLECTION, SAVE_NAME, DISC_PICKER, VARIANT_PICKER, STEAM_INSTALL,273FILE_PICKER, COVER_PICKER, REVIEW_EDITOR.274275Input ownership per modal, normal (non-swapped) mode. "Companion-owned" =276`handleModalInput` (SecondaryHomeInputHandler) drives the companion VM and277mirrors focus to the upper via `broadcastInlineUpdate(<field>)` ->278`dsm.handleInlineUpdate`; confirm goes through `broadcastModalConfirmResult` ->279`dsm.onModalConfirmResult`.280281| Modal | Owner | Mechanics (branch in `handleModalInput`) |282|---|---|---|283| NONE | - | no-op |284| RATING | Companion | Left/Right adjust, mirror `modal_rating`; Confirm/Back |285| DIFFICULTY | Companion | same branch as RATING |286| STATUS | Companion | Up/Down, mirror `modal_status` |287| EMULATOR | Companion | live focus forwarding via `broadcastInlineUpdate("emulator_focus")` |288| CORE | Companion | `core_focus` |289| SAVE_PATH | Companion | `save_path_focus` |290| DISPLAY_TARGET | Companion | `display_target_focus` |291| MEMORY_CARD | Companion | `memory_card_focus`; opened via `broadcastMemoryCardModalOpen` -> `dsm.openMemoryCardModal`; upper mirror is `moveDualMemoryCardFocus` / `confirmDualMemoryCardSelection` |292| COLLECTION | Companion | `collection_focus`; Confirm -> `collection_toggle` / `collection_create` |293| SAVE_NAME | UPPER | companion swallows ALL input incl. Back (the else fallthrough); text + confirm on the upper via `updateDualSaveNameText` / `confirmDualSaveName` on DSM |294| DISC_PICKER | Companion | `disc_focus`; Confirm closes modal + direct action PLAY_DISC |295| VARIANT_PICKER | Companion | `variant_focus` |296| STEAM_INSTALL | Companion | `steam_install_focus`; also opens from Home as a chooser (`openSteamChooserForHome`) |297| FILE_PICKER | UPPER | companion is Back-only dismiss; all focus/selection state is DSM `filePicker*` fields driven by the upper `dualModalInputHandler` and touch |298| COVER_PICKER | UPPER | search text needs the keyboard; state is DSM `coverPicker*` / `coverCandidates`, driven by `handleDualCoverPickerInput` (both handlers), the upper `dualModalInputHandler` and touch; opened via direct action CHANGE_COVER, X re-runs the search |299| REVIEW_EDITOR | UPPER | review body needs the keyboard; draft is DSM `reviewEditor` (`ReviewEditorState`, shared with the single-screen editor), driven by `handleDualReviewEditorInput` (both handlers), the upper `dualModalInputHandler` and touch; opened via direct action WRITE_REVIEW from the REVIEWS tab or the options row; Start/Select submit, Y prompts delete, B discards with a confirm when dirty; closes on the repository's `reviewWriteEvents` |300301Rule: new picker modals are companion-owned with live focus forwarding -302copy the EMULATOR branch, not FILE_PICKER.303- Exception: modals needing upper-only capabilities (text entry: SAVE_NAME;304 complex multi-select rows: FILE_PICKER) are upper-owned; companion swallows305 input (Back-only dismiss at most).306- Why: a modal with no companion branch is a dead modal when the companion has307 window focus - keys land on the companion and vanish.308- Boundary: every new `ActiveModal` value MUST get an explicit309 `handleModalInput` branch, even if it is only Back-dismiss.310311The upper `dualModalInputHandler` (ArgosyApp.kt, subscribed while312`dualModalActive`) mirrors every picker for the cases where the upper owns input313(overlay focus, swapped role); keep both sides in sync when adding a modal.314315Result delivery: DSM confirm/dismiss methods push316`companionHost.onModalResult(...)`; the companion applies to its VM and317`refocusSelf()`. Watchdog: if the companion pauses with a modal open, DSM318auto-dismisses it after 5s (`onCompanionPaused`); `resyncCompanionState` also319clears stale modals on companion resume.320321## ID-Based Carousel Restore322323`SessionStateStore.CarouselNavContext`: restores the lower carousel by IDENTITY324(sectionKind + platformId + gameId) plus the full filter/sort context; legacy325index fields are fallback only. Persisted via326`stateManager.persistCarouselPosition` on selection moves and in `onStop`;327restored in `stateManager.loadInitialState` ->328`dualHomeViewModel.restoreNavContext`. The same load path also restores a329GAME_DETAIL screen (rebuilds the detail VM for the saved gameId) and clears any330persisted modal/screenshot-viewer state. Do not restore by index anywhere -331dynamic sections shift and the two screens end up on different games.332333## CompanionInGameState334335`CompanionInGameState` (hardware/CompanionPanel.kt) is the in-game dashboard336state. Merge rule: async metadata loads MUST NOT clobber the live quick-action337flags - always apply `withLiveQuickActionState(quickActionsAvailable,338hasQuickSave)` after building a fresh snapshot (used by339`SecondaryHomeActivity.loadCompanionGameData` and by DSM's own load). DSM's340`_swappedCompanionState` copy is canonical in BOTH companion modes;341`updateCompanionHasQuickSave` maintains it regardless of role.342343## Session Survival Rules344345- `dsm.hasLiveSession()` = in-memory PlaySessionTracker check, flips false the346 moment teardown BEGINS. `SessionStateStore.hasActiveSession()` = persisted347 flag, stays true until save sync completes. Pick deliberately: UI "is a game up348 right now" -> hasLiveSession; "is it safe to relaunch/companion-launch" ->349 hasActiveSession.350- Rule: Argosy UI foregrounded = session over. `onForegroundChanged` ends the351 companion's session view when Argosy comes foreground during a game.352 - Exception: showcase role checks `!dsm.hasLiveSession()` first - a353 cross-display session survives the upper UI foregrounding.354 - Why: on a single pair of displays, foregrounding the launcher means the355 game lost its screen; in swapped mode the game may still be running on the356 other display.357 - Boundary: only cross-display sessions survive.358- Companion resumed ON the emulator's display -> the game lost that display:359 end the session (`onResume`, guarded by `dsm.isLaunchingGame`).360- HDMI disconnect with the emulator on the removed display ends the session361 (`cleanupSwappedState`).362- `swapRoles()` refuses while `hasActiveSession()`.363- `ensureCompanionLaunched` refuses during an active session unless364 `allowDuringSession`; a startup guard retries every 1.5s until the companion365 is up.366367## Display Affinity368369`DisplayAffinityHelper.getActivityOptions(forEmulator: Boolean,370rolesSwapped: Boolean = false, overrideDisplayId: Int? = null)` - note the two371newer params: swapped launches route the emulator to the secondary display, and372`overrideDisplayId` bypasses resolution entirely. Emulator display resolution at373launch goes through `resolveEmulatorDisplaySwapped` (per-game/per-platform374`EmulatorDisplayTarget`); DSM records `emulatorDisplayId` at launch and it drives375cross-display input forwarding (`isGameOnOtherDisplay` in MainActivity) and the376session-survival rules above. Companion launch uses `getCompanionLaunchOptions()`.377378`hasSecondaryDisplay` is gated by THREE conditions, not one:379`dualScreenEnabled && secondaryDisplayUsable && hasPhysicalSecondaryDisplay`.380381- `dualScreenEnabled` is a plain var - set it from prefs before trusting it.382- `secondaryDisplayUsable` is a FALLBACK LATCH, not a preference. It starts true,383 is hydrated at MainActivity startup from384 `SessionStateStore.isSecondaryDisplayUsable()`, and is set false by385 `DualScreenManager.fallbackToSingleScreen(persistent)` once the companion has386 been proven unable to initialize on the secondary display (OS builds that will387 not run a home activity there). `DualScreenManager.reprobeSecondaryDisplay()`388 clears it, and re-enabling dual screen in settings sets it true directly.389- Consequence: on a device that latched false, every dual-screen entry point is390 off even though the hardware and the preference both say yes. Check the latch391 before diagnosing a "dual screen does nothing" report.392393## Focus Zones and Visuals (kept)394395- Home: `DualHomeFocusZone { CAROUSEL, APP_BAR }` and `DualHomeViewMode396 { CAROUSEL, COLLECTIONS, COLLECTION_GAMES, LIBRARY_GRID }`, both in397 DualHomeViewModel.kt; one input handler per mode in SecondaryHomeInputHandler.398- Detail tabs: SAVES (dual column, `SaveFocusColumn { SLOTS, HISTORY }` from399 `ui/common/savechannel/`), STATES, MEDIA (grid), OPTIONS (list + inline400 LEFT/RIGHT adjust for RATING/DIFFICULTY/STATUS which mirror via401 `broadcastInlineUpdate`).402- Lower dimming while a modal is up: SecondaryHomeComposables.kt passes403 `isDimmed` into `DualGameDetailLowerScreen`.404- Dual modality is non-negotiable: gamepad via SecondaryHomeInputHandler AND405 touch via `touchOnly`/`clickableNoFocus` on the composables (grep406 `broadcastRefocusUpper` in DualHomeLowerContent.kt for the dim-tap pattern).407408## Shared Surfaces, Not Second Copies (read before "DS does not have this")409410The home surfaces are ONE feature drawn on two displays. When something the411phone-sized home has is missing on dual screen, the fix is to render the SAME412component there, never to build a companion-only variant and never to hide the413entry that leads to it.414415The shared layer already exists, and it is where new grid behaviour goes:416417- `ui/home/grid/CustomGridCoordinator.kt` - every action the curated grid takes,418 for both surfaces. Behaviour belongs here, not in a view model.419- `ui/home/grid/DualCustomGridInputRouter.kt` - gamepad routing for the grid on a420 companion display, shared by both DS handlers.421- `ui/home/grid/PageChooserEntrySource.kt` - the rows the page chooser offers.422- `ui/components/` - `CustomTileMenuModal`, `HomeTilePickerModal`,423 `PageChooserModal`, `PageBackdrop`, `PageThemePlayer`. All take state in and424 hand callbacks out; none of them know which display they are on.425426A missing DS feature is therefore almost always three small edits: render the427component in `DualHomeLowerContent`, route its input in428`DualCustomGridInputRouter`, and delegate the view-model calls in429`DualHomeViewModel`. Do not describe that as parity work to be scheduled.430431Two rules that follow:432433- Hiding a menu entry on DS is not a fix. If an action opens something the434 companion does not draw, build the consumption site - the setting is otherwise435 a ghost, which the AGENTS.md settings-chain law already forbids.436- A capability flag is only legitimate for something the surface genuinely437 cannot host, and it must name the blocker. Today there is exactly one:438 `PageChooserEntrySource.canBrowseFiles`, false on the companion because439 `FileBrowserScreen` needs `LocalInputDispatcher` and a `hiltViewModel()`, and440 `SecondaryHomeActivity` is deliberately not a Hilt entry point and provides no441 Compose input dispatcher. Removing that flag means giving the companion those442 two things, or pushing browsable rows from DSM the way FILE_PICKER does.443444## New Dual-Screen Feature Checklist4454461. [ ] State: add a StateFlow on DSM (never an activity field). Companion447 mutations go through a method on DSM, exposed to the companion via448 SecondaryHomeBroadcastHelper.4492. [ ] Push: if the companion must react to a main-side event, add a450 `CompanionHost` method (interface in DualScreenManager.kt), implement it451 in SecondaryHomeActivity, call it from DSM next to its state update.4523. [ ] Consumers: collect on the upper (ArgosyApp/MainActivity) AND, if the453 showcase role renders it, add a collector in `initializeCompanion` plus454 ShowcaseRoleContent wiring.4554. [ ] SHA overrides: any new CompanionHost method needs its456 SecondaryHomeActivity override to survive the stale-DSM reconnect path457 (re-wired by `initializeCompanion`).4585. [ ] Input: companion branch in SecondaryHomeInputHandler (routed via459 `routeInput`); upper branch in ArgosyApp's handler if the upper can own460 input for it; both sides inherit `dsm.claimInput` dedup from their461 activities.4626. [ ] Modal? Extend `ActiveModal`, add `DualGameDetailUpperState` fields, DSM463 open/move/confirm methods + `handleInlineUpdate` field, a464 `handleModalInput` branch, an ArgosyApp `dualModalInputHandler` branch,465 `onModalResult` handling in SecondaryHomeActivity, the upper render466 branch - and add the modal to the ownership table in this skill.4677. [ ] Dual modality: touch handlers on every interactive composable.4688. [ ] Resync: decide what `resyncCompanionState` / the pause watchdog should469 do with your state when the companion bounces.4709. [ ] Prefs: read once at operation start, pass down.47110. [ ] DS parity: verify in BOTH roles (normal and swapped/showcase) - a472 feature that only works in one role is incomplete.47311. [ ] Home-surface work: reuse the shared grid layer above. A component that474 already takes state and callbacks gets rendered on DS, not reimplemented475 and not gated off.
Run npx skillmds@latest add rommapp/dual-screen in your terminal (requires Node.js), paste this page's agent-chat prompt into Claude, Cursor, or any MCP-connected agent, or download the SKILL.md file and copy it into your agent's skills directory.
Dual-screen development reference. Load this before implementing any dual-screen feature. It is listed under Coding & Dev Tools on SkillMD.
This skill has not completed SkillMD's automated safety review yet. SkillMD never runs a skill's scripts for you; review the SKILL.md before installing.
This skill is tagged as working with Claude Code, Claude.ai, OpenAI Codex. SKILL.md is an open format, so most agents that read a skills directory can load it too.
Yes. Installing skills from SkillMD is free, and the skill stays under its author's original license.
rommapp (@rommapp) published this skill. Their other Agent Skills are listed on their SkillMD profile.