Skip to the content.

FastMediaSorter v2: OPS & Guidelines

BUILD COMMANDS (PowerShell)

# PRIMARY DEBUG (standard flavor, auto-versions)
.\dev\build-with-version.ps1

# PER-FLAVOR SCRIPTS
.\scripts\builders\build-standard-debug.ps1
.\scripts\builders\build-standard-release.ps1
.\scripts\builders\build-lite-debug.ps1
.\scripts\builders\build-lite-release.ps1
.\scripts\builders\build-photos-debug.ps1
.\scripts\builders\build-photos-release.ps1
.\scripts\builders\build-legacy-debug.ps1
.\scripts\builders\build-legacy-release.ps1

# VR
.\scripts\builders\build-vr-debug.ps1                   # alias: .\a.ps1 vrd
.\scripts\builders\build-vr-release.ps1                 # alias: .\a.ps1 vr
.\scripts\builders\build-vr-aab.ps1                     # AAB for Meta Horizon Store
.\scripts\builders\install-vr-debug-to-device.ps1       # install, NO launch | alias: .\a.ps1 ivrd
.\scripts\builders\install-vr-release-to-device.ps1     # install, NO launch | alias: .\a.ps1 ivr
.\scripts\builders\build-vr-device.ps1                  # build+install+launch - smoke only, bypasses HorizonOS shell

# RELEASE AAB (standard, for Google Play)
.\scripts\builders\build-aab-release.ps1                # alias: .\a.ps1 r

# WEAR OS
.\gradlew.bat :wear:assembleDebug

# DIRECT GRADLE (any flavor×buildType combination)
.\gradlew.bat assembleStandardDebug
.\gradlew.bat assembleStandardRelease
.\gradlew.bat assembleLiteDebug
.\gradlew.bat assemblePhotosDebug
.\gradlew.bat assembleLegacyDebug
.\gradlew.bat assembleVrDebug
.\gradlew.bat assembleVrRelease
.\gradlew.bat assembleVrUnlicensedRelease
.\gradlew.bat bundleVrRelease                            # AAB for Meta Horizon Store
.\gradlew.bat assembleStandardStaging                    # staging = minified but debuggable

a.ps1 SHORTCUTS

Alias Action
.\a.ps1 r Build standard AAB release
.\a.ps1 vr Build VR release APK
.\a.ps1 vrd Build VR debug APK
.\a.ps1 ivr Install VR release to device (no launch)
.\a.ps1 ivrd Install VR debug to device (no launch)
.\a.ps1 d Build debug (standard)
.\a.ps1 db Build debug, skip zip
.\a.ps1 dc Clean + debug build
.\a.ps1 cls Clean Gradle caches
.\a.ps1 ss Show unresolved specs (sca-specs)

TEST & VERIFY

# UNIT TESTS
.\gradlew.bat testStandardDebugUnitTest

# LINT
.\gradlew.bat lintStandardDebug

KAPT stall recovery (targeted validation only)

Symptom: :app_v2:kaptGenerateStubsStandardDebugKotlin or :app_v2:kaptStandardDebugKotlin hangs with no output for several minutes while running a targeted validation command such as :app_v2:compileStandardDebugKotlin or :app_v2:testStandardDebugUnitTest. The build does not fail, so build-debug.PS1’s failure-driven auto-retry does not engage.

Fallback path - abort the stalled invocation, then:

# 1. Clean only volatile kapt/kotlin/executionHistory dirs and retry once with --no-daemon.
pwsh -File scripts/utils/recover-kapt-stall.ps1 -Task ":app_v2:testStandardDebugUnitTest"

# 2. Or recover and retry manually (omit -Task to skip the auto-retry).
pwsh -File scripts/utils/recover-kapt-stall.ps1
.\gradlew.bat :app_v2:testStandardDebugUnitTest --no-daemon

# 3. Last resort if the targeted retry stalls again - full wipe (forces a cold rebuild).
.\scripts\builders\clean-gradle-caches.ps1

recover-kapt-stall.ps1 is the targeted scalpel: it stops daemons, removes app_v2/build/tmp/kapt3, app_v2/build/generated/source/kapt*, app_v2/build/kotlin, app_v2/build/tmp/kotlin-classes, and .gradle/<ver>/executionHistory. clean-gradle-caches.ps1 nukes everything (.gradle/, build/, app_v2/build/) and is the cold-start option.

STRING RESOURCE TOOLING

# SINGLE-LOCALE UPDATE
pwsh -File scripts/utils/set-android-string.ps1 -Module app_v2 -Locale en -Key "cloud_check_failed" -Value "Could not check the cloud connection. Try again."

# EN/RU/UK UPDATE IN ONE CALL
pwsh -File scripts/utils/set-android-strings.ps1 -Module app_v2 -Key "cloud_check_failed" -EnValue "Could not check the cloud connection. Try again." -RuValue "Не удалось проверить подключение к облаку. Попробуйте ещё раз." -UkValue "Не вдалося перевірити підключення до хмари. Спробуйте ще раз."

# OPTIONAL SAFETY GUARDS
pwsh -File scripts/utils/set-android-strings.ps1 -Module app_v2 -Key "cloud_check_failed" -EnValue "Could not check the cloud connection. Try again." -RuValue "Не удалось проверить подключение к облаку. Попробуйте ещё раз." -UkValue "Не вдалося перевірити підключення до хмари. Спробуйте ще раз." -ExpectedOldEnValue "Could not check the cloud connection." -ExpectedOldRuValue "Не удалось проверить подключение к облаку." -ExpectedOldUkValue "Не вдалося перевірити підключення до хмари."

# LOCALE PARITY CHECK
pwsh -File scripts/check_strings_localized.ps1 -Module app_v2 -KeyPrefix "cloud_check_failed"

Use the string updater scripts for targeted <string> edits. Manual XML editing is still appropriate for structural resource changes such as plurals, string-array, comments, regrouping, or bulk rewrites.

BUILD TYPES

Type minify shrink debuggable appId suffix notes
debug - - .debug Custom keystore via debug.keystore.properties; LOG_NETWORK_THUMBNAILS=true; dedicated Dropbox key
staging - - .staging initWith(release) - release proguard, shrink disabled; matchingFallbacks=["release"]
release - - debugSymbolLevel=FULL; keystore via keystore.properties

FEATURE FLAGS (BuildConfig)

Core feature matrix

Flavor VIDEO AUDIO IMAGES CLOUD DOCS ANIM VR
standard [+] [+] [+] [+] [+] [+] [-]
lite [+] [+] [+] [-] [-] [-] [-]
photos [-] [-] [+] [+] [-] [+] [-]
legacy [+] [+] [+] [+] [+] [+] [-]
vr [+] [+] [+] [+] [+] [+] [+]
noLegal [+] [+] [+] [+] [+] [+] [+]

Extended per-flavor flags

Flag std lite photos legacy vr noL
SUPPORT_MIC_RECORDING [+] [-] [-] [+] [+] [+]
ENABLE_EPUB [+] [-] [-] [+] [+] [+]
ENABLE_TRANSLATION [+] [-] [-] [+] [+] [+]
ENABLE_PERSISTENT_AUDIO_PLAYBACK [+] [-] [-] [+] [+] [+]
SUPPORTS_DEFAULT_PLAYER [+] [-] [+] [+] [+] [+]
SUPPORT_WEAR_COMPANION [+] [-] [-] [+] [-] [+]
ENABLE_DTS_DECODER [+] [-] [-] [+] [+] [+]
SUPPORT_CAST [+] [+] [+] [+] [-] [+]
SUPPORT_VR_PLAYER - - - - [+] [+]
VR_UI_COMPOSITION_LAYER_ENABLED - - - - [+] [+]
IS_NO_LEGAL_FLAVOR - - - - - [+]

noL = noLegal. Cast is disabled in vr (Horizon OS lacks the Google Play Services Cast module); noLegal keeps it because it also targets phones/tablets. SUPPORT_WEAR_COMPANION = true in noLegal is harmless on Quest (no paired watch exists) and meaningful on phones/tablets — runtime decides. VR feature surface in noLegal is gated at runtime by XrDetectionFacade — VR controls show disabled on devices without an OpenXR runtime. S0250 (2026-05-19) archived the former vrUnlicensed flavor; noLegal now covers both phone-sideload and Quest-sideload through one APK.

Build-type flags (all flavors)

Flag debug staging release
LOG_SMB_IO [-] [-] [-]
LOG_NETWORK_THUMBNAILS [+] [-] [-]
LOG_LINK_DOWNLOAD [+] [-] [-]
ENABLE_LEAKCANARY [-] - -
ENABLE_SCHEDULED_OPERATIONS [+] [+] [+]
ENABLE_BACKGROUND_AUDIO [+] [+] [+]

ENABLE_LEAKCANARY is debug-only (debugImplementation); field absent in staging/release.

DATABASE

Room schema version: 6. Library: room-runtime:2.7.0. Migrations: AppDatabase.kt. Rule: Increment schema version on every schema change.

NDK & ABI

NDK r27c (27.2.12479018) - first NDK release with 16 KB page-size aligned libc++_shared.so (Google Play requirement since 2025-11-01 for apps targeting Android 15+).

ABI strategy is flavor-local, not buildType-local (AGP merges buildType+flavor abiFilters as UNION, not intersection - a buildType-level list would leak non-VR ABIs into VR AABs):

QUEST DEBUGGING (VR flavor)

Do NOT launch the VR build via adb shell am start, Android Studio Run, or MQDH Launch App. These entry points bypass the HorizonOS VR shell, so the panel activity is stacked inside the same Android task as VrPlayerActivity. Because the panel activity carries com.oculus.intent.category.2D, the compositor keeps rendering the task root as the foreground window and the XR session stops at VISIBLE instead of reaching FOCUSED - no true immersive VR.

Why FOCUSED requires the hybrid-app task split

HorizonOS follows Meta’s Hybrid App Model: an app declares two distinct Activities - a panel Activity with com.oculus.intent.category.2D (our MainActivity) and an immersive Activity with com.oculus.intent.category.VR (our VrPlayerActivity) - and switches between them via an explicit task swap.

Two co-requisites make the VR category safe:

  1. Separate tasks. VrPlayerActivity declares android:taskAffinity="${applicationId}.vr" in app_v2/src/vr/AndroidManifest.xml. MainActivity and the rest of the panel Activities stay on the default affinity. The compositor never sees a 2D window inside the VR task.
  2. Runtime handoff via VrTaskTransition. app_v2/src/main/java/com/sza/fastmediasorter/ui/player/entry/VrTaskTransition.kt implements the swap:
    • enterImmersive(source, vrIntent): ACTION_MAIN + FLAG_ACTIVITY_NEW_TASK on the intent, then source.finishAndRemoveTask() tears down the panel task.
    • exitImmersiveToPanel(source): builds a PendingIntent targeting MainActivity with FLAG_IMMUTABLE, attaches it as extra_launch_in_home_pending_intent on a CATEGORY_HOME intent, and calls finishAndRemoveTask() on the VR activity. HorizonOS fires the PendingIntent and the user lands on a fresh panel.

All non-VR PlayerActivity.createIntent(...) call sites in the VR flavor are wrapped with VrTaskTransition.shouldEnterImmersiveTask(intent) so that explicit standard-player intents (BrowseEventHandler.createStandardPlayerIntent for MONO/audio) stay on the panel-launch path and preserve their ActivityResultLauncher contract.

Correct workflow

1. Build + install only (no launch)

.\scripts\builders\build-vr-debug.ps1                    # build debug APK   | .\a.ps1 vrd
.\scripts\builders\build-vr-release.ps1                  # build release APK | .\a.ps1 vr
.\scripts\builders\install-vr-debug-to-device.ps1        # install debug, NO launch   | .\a.ps1 ivrd
.\scripts\builders\install-vr-release-to-device.ps1      # install release, NO launch | .\a.ps1 ivr

build-vr-device.ps1 DOES auto-launch via ADB - use it only for fast smoke checks where you don’t care about FOCUSED state.

2. Launch from the headset

Menu → Library → Unknown SourcesFastMediaSorter (VR debug) → tap. HorizonOS launches MainActivity as a 2D panel; tapping a VR-target file inside the library triggers the task swap described above, and VrPlayerActivity starts in its dedicated VR task.

3. Attach debugger (optional)

Android Studio → Run → Attach Debugger to Android Process → select com.sza.fastmediasorter.vr.debug. Breakpoints, variable inspection, evaluate expression - all work against the shell-launched process.

4. Live logcat (optional, run before the tap on headset)

adb logcat -s VrRuntimeClient OpenXR OpenXrNative VrPlayerActivity OpenXrSessionManager VrTaskTransition

Verifying FOCUSED is reached

After step 2, look for this line in logcat:

OpenXR  PostSessionStateChange: XR_SESSION_STATE_VISIBLE -> XR_SESSION_STATE_FOCUSED

Expected full sequence for a successful immersive entry:

XR_SESSION_STATE_IDLE -> XR_SESSION_STATE_READY
XR_SESSION_STATE_READY -> XR_SESSION_STATE_SYNCHRONIZED
XR_SESSION_STATE_SYNCHRONIZED -> XR_SESSION_STATE_VISIBLE
XR_SESSION_STATE_VISIBLE -> XR_SESSION_STATE_FOCUSED

If you only see ... -> XR_SESSION_STATE_VISIBLE and a later VrRuntimeClient: Client has lost focus., the panel task was not destroyed - either you launched via ADB/Studio/MQDH, or a panel Activity was recreated inside the VR task. Dump activities with:

adb shell dumpsys activity activities

The healthy state after immersive entry is exactly one task with affinity ...vr containing VrPlayerActivity, and no panel task at all.

Historical note

Earlier revisions of this app attempted to add com.oculus.intent.category.VR to VrPlayerActivity without splitting the task affinity. That produced an immediate black screen because HorizonOS disabled passthrough before the XR session was ready. The task split is the decisive co-requisite that makes the category safe. An even earlier theory - that FOCUSED requires forwarding a com.oculus.vrshell.launch_id extra - was disproved by intent dumps (the key was never present) and has been removed from the codebase; do not re-introduce it.

Release Signing Fingerprint (GitHub Store)

Spec S0214 - github-store-publication. Once the project ships its first release through GitHub Store, every subsequent release must be signed with the same key. If the SHA-256 fingerprint of the new APK does not match the fingerprint GitHub Store recorded on first install, every user with the app installed loses auto-update silently: the store flags the new release as untrusted and falls back to manual install. To prevent that:

What the pin protects

The pinned fingerprint is the contract between this repo and every device that installed FastMediaSorter via GitHub Store. Auto-update through the store’s Shizuku / Sui / Dhizuku silent-install paths depends on the fingerprint staying constant. Any deviation breaks updates en masse.

Where the pin lives

scripts/release/expected-signing-fingerprint.txt - single uppercase colon-separated SHA-256 line (32 bytes). Comments above explain capture time, source APK, and keystore alias.

How the publisher uses it

scripts/release/publish-github-release.ps1 extracts the SHA-256 fingerprint from each staged APK via apksigner verify --print-certs between the staging and release-create steps. A mismatch is a hard abort with expected: … / actual: … in the error message - the publisher exits non-zero before any GitHub-side mutation. The check runs regardless of -DryRun.

Rotation procedure (only when legitimately required)

Legitimate rotation reasons: keystore lost, mandated key change, compromise. Aesthetic re-keying is not legitimate - never rotate just to “freshen up” the signing config.

User-facing consequence is non-negotiable: every existing GitHub Store user must reinstall the app from scratch. Auto-update through the store will stop working until they do. Plan a rotation around a release where that cost is acceptable.

Steps:

  1. Produce a new keystore (out-of-band; document the new alias in local.properties and any signing config that lives outside the repo).
  2. Build a release APK with the new keystore (a.ps1 r / a.ps1 vr).
  3. Capture the new SHA-256 via apksigner verify --print-certs <new-apk>, format as uppercase colon-separated 32-byte form.
  4. Update scripts/release/expected-signing-fingerprint.txt with the new fingerprint and refresh the comment header (capture date, source APK, keystore alias).
  5. Add an explicit ## Note: signing-key rotation subsection to docs/WHATS_NEW.md for the release that rotates the key, with a one-line “users must reinstall via direct download” instruction.
  6. Run the publisher: pwsh -File scripts/release/publish-github-release.ps1 from the release worktree on main. The Assert-ExpectedFingerprint gate will now pass against the new pin.
  7. Append an ADR-style entry inside this section recording: rotation date, reason, old fingerprint, new fingerprint, release tag that contained the rotation.

ADR log

(no rotations have happened yet - first entry will land here.)