diff --git a/.gitattributes b/.gitattributes
index 4ca25977a..6fd1589a1 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1,3 +1,5 @@
*.txz filter=lfs diff=lfs merge=lfs -text
+app/src/main/assets/imagefs.tzst filter=lfs diff=lfs merge=lfs -text
+app/src/main/assets/imagefs.part*.tzst filter=lfs diff=lfs merge=lfs -text
reference/tooling/jadx.zip filter=lfs diff=lfs merge=lfs -text
reference/tooling/jadx/lib/jadx-1.5.3-all.jar filter=lfs diff=lfs merge=lfs -text
diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml
index 9342e3918..c32e0daa6 100644
--- a/.github/workflows/pr-ci.yml
+++ b/.github/workflows/pr-ci.yml
@@ -38,6 +38,10 @@ jobs:
artifactName: apk-pubg
gradleTask: assemblePubgDebug
apkPath: app/build/outputs/apk/pubg/debug/pubg.apk
+ - name: Antutu
+ artifactName: apk-antutu
+ gradleTask: assembleAntutuDebug
+ apkPath: app/build/outputs/apk/antutu/debug/antutu.apk
steps:
- name: Clone repository
@@ -63,22 +67,37 @@ jobs:
path: |
app/.cxx
app/build/intermediates/cxx
- key: ndk-${{ matrix.flavor.name }}-${{ hashFiles('app/src/main/cpp/**', 'app/build.gradle', 'app/src/main/cpp/CMakeLists.txt') }}
+ key: ndk-${{ hashFiles('app/src/main/cpp/**', 'app/build.gradle', 'app/src/main/cpp/CMakeLists.txt') }}
restore-keys: |
- ndk-${{ matrix.flavor.name }}-
ndk-
+ # Cargo target dir + registry sit outside the NDK cache paths; a cold Rust build dominates job time.
+ - name: Cache Rust Steam client
+ uses: actions/cache@v5
+ with:
+ path: |
+ ~/.cargo/registry
+ ~/.cargo/git
+ app/src/main/cpp/wn-steam-client/rust/target
+ key: cargo-${{ hashFiles('app/src/main/cpp/wn-steam-client/rust/Cargo.lock') }}
+ restore-keys: cargo-
+
- name: Set up JDK 17
uses: actions/setup-java@v5
with:
java-version: '17'
distribution: 'temurin'
+ - name: Install Rust Android target
+ run: rustup target add aarch64-linux-android
+
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v6
with:
validate-wrappers: true
cache-cleanup: 'on-success'
+ # PRs restore the Gradle cache read-only; only main pushes write it.
+ cache-read-only: ${{ github.event_name == 'pull_request' }}
- name: Decode keystore and build
timeout-minutes: 15
@@ -141,6 +160,8 @@ jobs:
path: artifacts/${{ matrix.flavor.artifactName }}/*.apk
retention-days: 7
if-no-files-found: error
+ # APK contents are already compressed; deflating ~560MB again wastes CPU.
+ compression-level: 0
cleanup-caches:
needs: build
@@ -155,7 +176,8 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
- gh cache list --repo "$GITHUB_REPOSITORY" --json id,key,createdAt --limit 100 |
+ # Dedupe only main-scope caches so a newer PR-scoped twin can't get main's copy deleted.
+ gh cache list --repo "$GITHUB_REPOSITORY" --ref refs/heads/main --json id,key,createdAt --limit 100 |
jq -r '
group_by(.key | sub("-[a-f0-9]{20,}$"; ""))
| .[]
diff --git a/.github/workflows/pr-release-publish.yml b/.github/workflows/pr-release-publish.yml
index 068e21d1e..2825147c0 100644
--- a/.github/workflows/pr-release-publish.yml
+++ b/.github/workflows/pr-release-publish.yml
@@ -8,7 +8,8 @@ on:
- completed
concurrency:
- group: pr-release-${{ github.event.workflow_run.id }}
+ # Serialize publishes of the same PR branch; per-run-id groups let them race each other.
+ group: pr-release-${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }}
cancel-in-progress: false
jobs:
@@ -84,9 +85,11 @@ jobs:
echo "standard=WinNative-Debug-Standard-${label}.apk"
echo "ludashi=WinNative-Debug-Ludashi-${label}.apk"
echo "pubg=WinNative-Debug-Pubg-${label}.apk"
+ echo "antutu=WinNative-Debug-Antutu-${label}.apk"
echo "release_standard=WinNative-Debug-Standard-${release_label}.apk"
echo "release_ludashi=WinNative-Debug-Ludashi-${release_label}.apk"
echo "release_pubg=WinNative-Debug-Pubg-${release_label}.apk"
+ echo "release_antutu=WinNative-Debug-Antutu-${release_label}.apk"
} >> "$GITHUB_OUTPUT"
- name: Download PR build artifacts
@@ -95,9 +98,15 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
mkdir -p artifacts
- gh run download "$RUN_ID" --repo "$SOURCE_REPO" --name apk-standard --dir artifacts/standard
- gh run download "$RUN_ID" --repo "$SOURCE_REPO" --name apk-ludashi --dir artifacts/ludashi
- gh run download "$RUN_ID" --repo "$SOURCE_REPO" --name apk-pubg --dir artifacts/pubg
+ # Download all flavors in parallel; the sequential form left the runner's network idle per transfer.
+ pids=()
+ for flavor in standard ludashi pubg antutu; do
+ gh run download "$RUN_ID" --repo "$SOURCE_REPO" --name "apk-$flavor" --dir "artifacts/$flavor" &
+ pids+=($!)
+ done
+ for pid in "${pids[@]}"; do
+ wait "$pid"
+ done
- name: Build release notes from PR metadata
if: ${{ steps.scope.outputs.allowed == 'true' }}
@@ -127,6 +136,7 @@ jobs:
{
printf '# PR #%s\n\n' "$PR_NUMBER"
printf 'Title: %s\n\n' "$safe_title"
+ printf 'Author: @%s\n\n' "$pr_author"
printf 'Source PR: %s\n' "$pr_url"
printf 'Source branch: %s\n' "$head_ref"
printf 'Source commit: `%s`\n' "$HEAD_SHA"
@@ -141,6 +151,7 @@ jobs:
printf -- '%s\n' "- ${{ steps.apk.outputs.release_standard }}"
printf -- '%s\n' "- ${{ steps.apk.outputs.release_ludashi }}"
printf -- '%s\n' "- ${{ steps.apk.outputs.release_pubg }}"
+ printf -- '%s\n' "- ${{ steps.apk.outputs.release_antutu }}"
printf '\nBuilt from source commit `%s`.\n' "$short_sha"
} > release-notes.md
@@ -158,9 +169,20 @@ jobs:
discord_marker=''
discord_posted=false
- if gh release view "$tag" --repo "$TARGET_REPO" >/dev/null 2>&1; then
+ # A transient API error must not be read as "release doesn't exist" — that ends in
+ # a create over an existing tag (HTTP 422).
+ if view_out=$(gh api "repos/$TARGET_REPO/releases/tags/$tag" 2>&1); then
+ release_exists=true
+ elif printf '%s' "$view_out" | grep -qi 'Not Found'; then
+ release_exists=false
+ else
+ printf '%s\n' "$view_out"
+ exit 1
+ fi
+
+ if [ "$release_exists" = true ]; then
created=false
- existing_body=$(gh release view "$tag" --repo "$TARGET_REPO" --json body --jq '.body // ""')
+ existing_body=$(printf '%s' "$view_out" | jq -r '.body // ""')
if printf '%s' "$existing_body" | grep -Fq "$discord_marker"; then
discord_posted=true
if ! grep -Fq "$discord_marker" release-notes.md; then
@@ -174,26 +196,38 @@ jobs:
--prerelease
else
created=true
- gh release create "$tag" \
+ if ! gh release create "$tag" \
--repo "$TARGET_REPO" \
--title "$title" \
--notes-file release-notes.md \
- --prerelease
+ --prerelease; then
+ # Another publish run created it between our check and the create — update instead.
+ created=false
+ gh release edit "$tag" \
+ --repo "$TARGET_REPO" \
+ --title "$title" \
+ --notes-file release-notes.md \
+ --prerelease
+ fi
fi
standard_path="artifacts/standard/${{ steps.apk.outputs.standard }}"
ludashi_path="artifacts/ludashi/${{ steps.apk.outputs.ludashi }}"
pubg_path="artifacts/pubg/${{ steps.apk.outputs.pubg }}"
+ antutu_path="artifacts/antutu/${{ steps.apk.outputs.antutu }}"
release_standard_path="release-assets/${{ steps.apk.outputs.release_standard }}"
release_ludashi_path="release-assets/${{ steps.apk.outputs.release_ludashi }}"
release_pubg_path="release-assets/${{ steps.apk.outputs.release_pubg }}"
+ release_antutu_path="release-assets/${{ steps.apk.outputs.release_antutu }}"
test -f "$standard_path"
test -f "$ludashi_path"
test -f "$pubg_path"
+ test -f "$antutu_path"
mkdir -p release-assets
cp "$standard_path" "$release_standard_path"
cp "$ludashi_path" "$release_ludashi_path"
cp "$pubg_path" "$release_pubg_path"
+ cp "$antutu_path" "$release_antutu_path"
release_json=$(gh api "repos/$TARGET_REPO/releases/tags/$tag")
existing_apk_assets=$(
@@ -231,6 +265,7 @@ jobs:
upload_asset "$release_standard_path"
upload_asset "$release_ludashi_path"
upload_asset "$release_pubg_path"
+ upload_asset "$release_antutu_path"
release_url=$(gh release view "$tag" --repo "$TARGET_REPO" --json url --jq '.url')
{
diff --git a/.github/workflows/tag-apk-artifacts.yml b/.github/workflows/tag-apk-artifacts.yml
index d08b727df..ba0955edb 100644
--- a/.github/workflows/tag-apk-artifacts.yml
+++ b/.github/workflows/tag-apk-artifacts.yml
@@ -31,6 +31,10 @@ jobs:
variant: Pubg
gradleTask: assemblePubgDebug
apkPath: app/build/outputs/apk/pubg/debug/pubg.apk
+ - name: Antutu
+ variant: Antutu
+ gradleTask: assembleAntutuDebug
+ apkPath: app/build/outputs/apk/antutu/debug/antutu.apk
steps:
- name: Clone repository
@@ -56,18 +60,30 @@ jobs:
path: |
app/.cxx
app/build/intermediates/cxx
- key: tag-ndk-${{ matrix.flavor.name }}-${{ hashFiles('app/src/main/cpp/**', 'app/build.gradle', 'app/src/main/cpp/CMakeLists.txt') }}
+ key: tag-ndk-${{ hashFiles('app/src/main/cpp/**', 'app/build.gradle', 'app/src/main/cpp/CMakeLists.txt') }}
restore-keys: |
- tag-ndk-${{ matrix.flavor.name }}-
tag-ndk-
ndk-
+ - name: Cache Rust Steam client
+ uses: actions/cache@v5
+ with:
+ path: |
+ ~/.cargo/registry
+ ~/.cargo/git
+ app/src/main/cpp/wn-steam-client/rust/target
+ key: cargo-${{ hashFiles('app/src/main/cpp/wn-steam-client/rust/Cargo.lock') }}
+ restore-keys: cargo-
+
- name: Set up JDK 17
uses: actions/setup-java@v5
with:
java-version: '17'
distribution: 'temurin'
+ - name: Install Rust Android target
+ run: rustup target add aarch64-linux-android
+
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v6
with:
@@ -121,3 +137,4 @@ jobs:
path: artifacts/apk/*.apk
retention-days: 30
if-no-files-found: error
+ compression-level: 0
diff --git a/.gitignore b/.gitignore
index 4a6805100..d3a4ae7a1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,16 +17,16 @@ app/src/main/assets/proton-9.0-x86_64.txz
*.log
**/*.log
-# Kotlin/Gradle artifacts
-.kotlin/
-.tmp/
-.gradle/
-.gradle-user/
-.gradle-user-fresh/
-build/
-app/build/
-app/.cxx/
-app/release/
+# Kotlin/Gradle artifacts
+.kotlin/
+.tmp/
+.gradle/
+.gradle-user/
+.gradle-user-fresh/
+build/
+app/build/
+app/.cxx/
+app/release/
# Python artifacts
__pycache__/
@@ -47,3 +47,18 @@ app/schemas/
*.jks
*.keystore
signing.properties
+
+# Local-only dev artifacts (never check in)
+.claude/
+References/
+*.hprof
+android_sysvshm/build64/
+
+# local build artifacts (FEX/Proton wcp)
+dist/
+
+/cores/*/
+!/cores/patches/
+
+/armsx2/emucore-src/
+/armsx2/emucore-out/
diff --git a/.gitmodules b/.gitmodules
index e1ec52737..b5cf369f6 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -1,3 +1,7 @@
[submodule "app/src/main/cpp/adrenotools"]
path = app/src/main/cpp/adrenotools
url = https://github.com/Pipetto-crypto/libadrenotools.git
+[submodule "app/src/main/cpp/vkbasalt"]
+ path = app/src/main/cpp/vkbasalt
+ url = https://github.com/WinNative-Emu/vkBasalt.git
+ branch = Test
diff --git a/EMULATOR_CREDITS.md b/EMULATOR_CREDITS.md
new file mode 100644
index 000000000..5327d1b5b
--- /dev/null
+++ b/EMULATOR_CREDITS.md
@@ -0,0 +1,74 @@
+# Emulator & Library Credits
+
+WinNative's retro-console features are built on open-source emulators and libraries.
+This project is distributed under the **GNU General Public License v3.0** (see [LICENSE](LICENSE)).
+In compliance with the GPL and the other licenses below, the corresponding source code
+for every GPL/copyleft component is available from the upstream projects linked here,
+and their copyright and license notices are preserved.
+
+## PlayStation 2
+
+PS2 games are recognized and imported into the library. PS2 emulation is built on
+**ARMSX2** (a GPL-3.0 fork of PCSX2) and is in active development.
+
+| Component | Role | License | Source |
+| --- | --- | --- | --- |
+| ARMSX2 | PS2 emulation + RetroAchievements | GPL-3.0 | https://github.com/ARMSX2/ARMSX2 |
+| PCSX2 | Upstream project ARMSX2 is derived from | GPL-3.0 | https://github.com/pcsx2/pcsx2 |
+
+## GameCube / Wii
+
+GameCube and Wii games are recognized and imported into the library. GC/Wii emulation is
+built on an embedded build of **Dolphin**, rendered on Vulkan and driven entirely by
+WinNative (Dolphin's own UI is stripped). Local and online multiplayer use Dolphin's own
+native NetPlay engine.
+
+| Component | Role | License | Source |
+| --- | --- | --- | --- |
+| Dolphin | GameCube/Wii emulation + NetPlay | GPL-2.0-or-later | https://github.com/dolphin-emu/dolphin |
+
+## Bundled libretro cores
+
+Each core is shipped as an unmodified `arm64-v8a` build and loaded through LibretroDroid.
+
+| System | Core | License | Source |
+| --- | --- | --- | --- |
+| Game Boy / Color | Gambatte | GPL-2.0 | https://github.com/libretro/gambatte-libretro |
+| Game Boy Advance | mGBA | MPL-2.0 | https://github.com/libretro/mgba |
+| Genesis / Master System / Game Gear | Genesis Plus GX | Genesis Plus GX License (non-commercial) | https://github.com/libretro/Genesis-Plus-GX |
+| NES | FCEUmm | GPL-2.0 | https://github.com/libretro/libretro-fceumm |
+| Nintendo 64 | ParaLLEl N64 | GPL-2.0 | https://github.com/libretro/parallel-n64 |
+| Nintendo 64 | Mupen64Plus-Next | GPL-2.0 | https://github.com/libretro/mupen64plus-libretro-nx |
+| PlayStation | Beetle PSX (mednafen_psx) | GPL-2.0 | https://github.com/libretro/beetle-psx-libretro |
+| SNES | Snes9x | Snes9x License (non-commercial) | https://github.com/libretro/snes9x |
+
+### Also evaluated for PlayStation
+
+| Component | License | Source |
+| --- | --- | --- |
+| SwanStation | GPL-3.0 | https://github.com/libretro/swanstation |
+
+## Frontend, achievements, and supporting libraries
+
+| Component | Role | License | Source |
+| --- | --- | --- | --- |
+| LibretroDroid | libretro frontend the retro backend is built on | GPL-3.0 | https://github.com/Swordfish90/LibretroDroid |
+| LibretroDroid (WinNative fork) | the build WinNative ships, carrying the RetroAchievements, netplay and SGSR changes | GPL-3.0 | https://github.com/WinNative-Emu/LibretroDroid/tree/winnative |
+| Oboe | Audio output | Apache-2.0 | https://github.com/google/oboe |
+| rcheevos | RetroAchievements client library | MIT | https://github.com/RetroAchievements/rcheevos |
+| Snapdragon Game Super Resolution (SGSR) | Upscaling shader | BSD-3-Clause | https://github.com/quic/snapdragon-gsr |
+| Winlator | Windows-on-Android base this project forks | GPL-3.0 | https://github.com/brunodev85/winlator |
+
+## Source availability
+
+WinNative is released under the GPL-3.0. As required by that license and by the GPL-2.0
+cores above, the complete corresponding source for every copyleft component is obtainable
+from the repositories linked in this document, and the bundled license texts are retained
+in the source tree of the repository that builds each component.
+
+The libretro frontend is no longer built from source in this repository. WinNative links
+against `libretrodroid.aar`, published from the `winnative` branch of
+https://github.com/WinNative-Emu/LibretroDroid, which is where its GPL-3.0 source, the
+rcheevos `LICENSE` and `SGSR_LICENSE` texts now live. The exact build WinNative ships is
+pinned by release tag and SHA-256 in `tools/libretrodroid.version`, so the corresponding
+source for any given APK is the commit that tag was built from.
diff --git a/README.md b/README.md
index 9b57a18df..2f15049e8 100644
--- a/README.md
+++ b/README.md
@@ -1,58 +1,98 @@
-
-
-
-
-
-
-
-
-
-## WinNative: A Community Built Windows Emulation App for Android
-
-**WinNative** is an advanced, high-performance Windows (x86_64) emulation environment for Android. It bridges the gap between desktop gaming and mobile by unifying the best technologies from **Winlator Bionic** and **Pluvia**.
-
-Designed for enthusiasts and power users, WinNative delivers the full Winlator experience while making it easy to connect your Steam, Epic, and GOG game libraries.
-
----
-
-### Installation
-
-1. **Download:** Get the latest APK from the [Releases](https://github.com/maxjivi05/WinNative/releases) section.
-2. **Variants:**
- - `Ludashi`: Best for Xiaomi/RedMagic (Performance Mode trigger).
- - `Vanilla`: Standard package name for side-loading with other forks.
-3. **Setup:** Launch the app, allow the ImageFS to install, and start adding your games manually or sync your library.
-
----
-
-### How to Build
-
-**Requirements:** Android Studio, JDK 17, NDK `27.3.13750724`, and CMake.
-
-1. **Clone the repository and update submodules** (Required):
- ```bash
- git clone https://github.com/MaxsTechReview/WinNative.git
- cd WinNative
- git submodule update --init --recursive
- ```
-2. **Build via Android Studio:** Open the `WinNative` directory, let Gradle sync, then select **Build > Build APK(s)**.
-3. **Build via CLI:** Run `.\gradlew.bat assembleDebug` (Windows).
-
----
-
-### Contributing
-
-We welcome community contributions! Feel free to open a pull request for bug fixes, driver updates, UI improvements, or anything else you'd like to add.
-
-Please match the existing code style and ensure any AI-assisted code is thoroughly reviewed and tested before submission.
-
----
-
-### Credits & Acknowledgments
-
-- **Original Winlator** by [brunodev85](https://github.com/brunodev85/winlator)
-- **Winlator Bionic** by [Pipetto-crypto](https://github.com/Pipetto-crypto/winlator)
-- **Pluvia** features by the [Pluvia](https://github.com/oxters168/Pluvia) / [GameNative](https://github.com/utkarshdalal/GameNative) community
-- **Mesa/Turnip** contributions by the [Mesa3D](https://www.mesa3d.org/) team
-- **Goldberg Steam Emulator** by [Mr. Goldberg](https://gitlab.com/Mr_Goldberg/goldberg_emulator), maintained by [Detanup01](https://github.com/Detanup01/gbe_fork)
+
+
+
+
+
+
+
+
+
+## WinNative: A Community Built Windows Emulation App for Android
+
+**WinNative** is an advanced, high-performance Windows (x86_64) emulation environment for Android. It bridges the gap between desktop gaming and mobile by unifying the best technologies from **Winlator Bionic** and **Pluvia**.
+
+Designed for enthusiasts and power users, WinNative delivers the full Winlator experience while making it easy to connect your Steam, Epic, and GOG game libraries.
+
+---
+
+### Installation
+
+1. **Download:** Get the latest APK from the [Releases](https://github.com/WinNative-Emu/WinNative/releases) section.
+2. **Variants:**
+ - `Ludashi`: Forces both Max GPU and CPU clocks on some devices. (Performance Mode trigger).
+ - `Vanilla`: Standard package name for side-loading with other forks.
+ - `Antutu`: Forces Max GPU clocks on most devices. (antutu benchmark spoof)
+ - `Pubg`: Standard pubg package name which allows some Game Booster advanced Features.
+3. **Setup:** Launch the app, allow the ImageFS to install, and start adding your games manually or sync your library.
+
+---
+
+### How to Build
+
+**Requirements:** Android Studio, JDK 17, and [Git LFS](https://git-lfs.com). The NDK
+(`27.3.13750724`) and CMake are only needed if you build native cores from source (see below).
+
+1. **Clone with submodules and pull LFS objects** (Required):
+ ```bash
+ git clone --recursive https://github.com/MaxsTechReview/WinNative.git
+ cd WinNative
+ git lfs pull # fetches imagefs
+ git submodule update --init --recursive
+ ```
+2. **Build via Android Studio:** Open the `WinNative` directory, let Gradle sync, then select **Build > Build APK(s)**.
+3. **Build via CLI:** Run `./gradlew assembleStandardDebug` (or `.\gradlew.bat` on Windows).
+
+The APK carries no retro console cores. Each core is built from its own fork under the
+[WinNative-Emu](https://github.com/WinNative-Emu) org, and
+[Retro-Consoles](https://github.com/WinNative-Emu/Retro-Consoles) packs every core plus the
+Dolphin and ARMSX2 runtime data into one `retro-consoles.tzst`. The app downloads and
+verifies it on demand from **Settings > Retro > Download console cores**, so a core update
+no longer needs an app release. To change a core, change its fork and re-run the
+Retro-Consoles bundle workflow.
+
+---
+
+### Retro Console Support
+
+WinNative can also run classic console games alongside your PC library. Retro games live in the same Library and launch just like PC games, but run on an embedded libretro backend instead of Wine.
+
+Supported systems (bundled cores):
+
+| System | Core | ROM extensions |
+| --- | --- | --- |
+| NES | FCEUmm | `.nes` `.unf` `.unif` |
+| SNES | Snes9x | `.smc` `.sfc` `.swc` `.fig` |
+| Game Boy / Color | Gambatte | `.gb` `.gbc` |
+| Game Boy Advance | mGBA | `.gba` |
+| Genesis / Mega Drive, Master System, Game Gear | Genesis Plus GX | `.gen` `.md` `.smd` `.sms` `.gg` |
+| Nintendo 64 | Mupen64Plus-Next | `.n64` `.z64` `.v64` |
+| PlayStation | Beetle PSX | `.cue` `.chd` `.pbp` `.m3u` `.iso` |
+| PlayStation 2 | ARMSX2 (PCSX2 fork) | `.iso` `.chd` `.cso` `.bin` |
+
+Cores ship **prebuilt** (committed via Git LFS) and are used by default; they are built from
+source with the opt-in flags above (see `cores/` for the libretro cores and
+`armsx2/build-emucore.sh` / `dolphin/build-emucore.sh` for the PS2 and GameCube/Wii cores).
+PlayStation 2 online play is supported through the emulated DEV9 network adapter (see the
+in-game **Online** tab).
+
+**How to use:** In the Library, tap **Add Custom Game** and select a ROM instead of an `.exe`. WinNative detects the console and adds the game to your Library. Tap **Play** to launch it with on-screen touch controls and physical gamepad support; the in-game menu (Back button or on-screen **MENU**) offers save/load state, reset, and fast-forward. PlayStation and PlayStation 2 BIOS files can be imported from **Settings → Retro**.
+
+### Contributing
+
+We welcome community contributions! Feel free to open a pull request for bug fixes, driver updates, UI improvements, or anything else you'd like to add.
+
+Please match the existing code style and ensure any AI-assisted code is thoroughly reviewed and tested before submission.
+
+---
+
+### Credits & Acknowledgments
+
+- **Original Winlator** by [brunodev85](https://github.com/brunodev85/winlator)
+- **Winlator Bionic** by [Pipetto-crypto](https://github.com/Pipetto-crypto/winlator)
+- **Pluvia** features by the [Pluvia](https://github.com/oxters168/Pluvia) / [GameNative](https://github.com/utkarshdalal/GameNative) community
+- **Mesa/Turnip** contributions by the [Mesa3D](https://www.mesa3d.org/) team
+- **Goldberg Steam Emulator** by [Mr. Goldberg](https://gitlab.com/Mr_Goldberg/goldberg_emulator), maintained by [Detanup01](https://github.com/Detanup01/gbe_fork)
+- **LibretroDroid** by [Filippo Scognamiglio](https://github.com/Swordfish90/LibretroDroid) (GPL-3.0) — the embedded libretro host for retro console support
+- **libretro / RetroArch** and the individual core authors, built from source: [FCEUmm](https://github.com/libretro/libretro-fceumm), [Snes9x](https://github.com/libretro/snes9x), [Gambatte](https://github.com/libretro/gambatte-libretro), [mGBA](https://github.com/libretro/mgba), [Genesis Plus GX](https://github.com/libretro/Genesis-Plus-GX), [Mupen64Plus-Next](https://github.com/libretro/mupen64plus-libretro-nx), [Beetle PSX](https://github.com/libretro/beetle-psx-libretro)
+- **ARMSX2** by the [ARMSX2](https://github.com/ARMSX2/ARMSX2) team (GPL-3.0) — the PlayStation 2 core, a fork of **[PCSX2](https://github.com/pcsx2/pcsx2)** (GPL-3.0), built from source into `libemucore`. PS2 online play uses PCSX2's DEV9 network adapter
diff --git a/android_sysvshm/android_sysvshm.c b/android_sysvshm/android_sysvshm.c
index 4c890c77e..0602dcb2a 100644
--- a/android_sysvshm/android_sysvshm.c
+++ b/android_sysvshm/android_sysvshm.c
@@ -40,7 +40,8 @@ static int find_shmemory_index(int shmid) {
static void sysvshm_connect() {
if (sysvshm_server_fd >= 0) return;
char* path = getenv("ANDROID_SYSVSHM_SERVER");
-
+ if (path == NULL) return;
+
int fd = socket(AF_UNIX, SOCK_STREAM, 0);
if (fd < 0) return;
@@ -183,7 +184,7 @@ int shmget(key_t key, size_t size, int flags) {
void* shmat(int shmid, const void* shmaddr, int shmflg) {
pthread_mutex_lock(&mutex);
- void* addr;
+ void* addr = (void *)-1;
int index = find_shmemory_index(shmid);
if (index != -1) {
if (shmemories[index].addr == NULL) {
diff --git a/app/build.gradle b/app/build.gradle
index 2144fd93b..14fecd478 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -8,9 +8,45 @@ plugins {
alias(libs.plugins.spotless)
}
+/*
+abstract class InstallVulkanValidationLayerTask extends DefaultTask {
+ @Input
+ abstract Property getLayerVersion()
+
+ @OutputFile
+ abstract RegularFileProperty getOutputFile()
+
+ @TaskAction
+ void install() {
+ def version = layerVersion.get()
+ def zipFile = new File(temporaryDir, "android-binaries-${version}.zip")
+ if (!zipFile.exists()) {
+ temporaryDir.mkdirs()
+ def url = new URL("https://github.com/KhronosGroup/Vulkan-ValidationLayers/releases/download/vulkan-sdk-${version}/android-binaries-${version}.zip")
+ url.withInputStream { input ->
+ zipFile.withOutputStream { output -> output << input }
+ }
+ }
+
+ def targetFile = outputFile.get().asFile
+ targetFile.parentFile.mkdirs()
+ new java.util.zip.ZipFile(zipFile).withCloseable { zip ->
+ def entry = zip.entries().find {
+ it.name.endsWith("/arm64-v8a/libVkLayer_khronos_validation.so")
+ }
+ if (entry == null) {
+ throw new GradleException("arm64-v8a Vulkan validation layer not found in ${zipFile.name}")
+ }
+ zip.getInputStream(entry).withCloseable { input ->
+ targetFile.withOutputStream { output -> output << input }
+ }
+ }
+ }
+}
+*/
+
task checkSubmodules {
- // Resolve the file path during the configuration phase so we don't
- // pass the Gradle Project object into the execution closure (which breaks the Configuration Cache).
+ // Keep the execution closure configuration-cache friendly.
def submodulePath = file("src/main/cpp/adrenotools").absolutePath
doFirst {
@@ -23,6 +59,67 @@ task checkSubmodules {
preBuild.dependsOn(checkSubmodules)
+abstract class FetchLibretroDroidTask extends DefaultTask {
+ @Input
+ abstract Property getTag()
+
+ @Input
+ abstract Property getSha256()
+
+ @OutputFile
+ abstract RegularFileProperty getOutputFile()
+
+ static String digestOf(File file) {
+ def digest = java.security.MessageDigest.getInstance("SHA-256")
+ file.withInputStream { input ->
+ byte[] buffer = new byte[1 << 16]
+ int read
+ while ((read = input.read(buffer)) >= 0) digest.update(buffer, 0, read)
+ }
+ digest.digest().collect { String.format("%02x", it) }.join()
+ }
+
+ @TaskAction
+ void fetch() {
+ def expected = sha256.get()
+ def target = outputFile.get().asFile
+ if (target.isFile() && digestOf(target) == expected) return
+
+ target.parentFile.mkdirs()
+ def source = "https://github.com/WinNative-Emu/LibretroDroid/releases/download/${tag.get()}/libretrodroid.aar"
+ def staging = new File(target.parentFile, target.name + ".part")
+ new URL(source).withInputStream { input ->
+ staging.withOutputStream { output -> output << input }
+ }
+
+ def actual = digestOf(staging)
+ if (actual != expected) {
+ staging.delete()
+ throw new GradleException("libretrodroid.aar from ${tag.get()} has checksum ${actual}, expected ${expected}")
+ }
+ target.delete()
+ staging.renameTo(target)
+ }
+}
+
+def libretroDroidPin = rootProject.file("tools/libretrodroid.version").readLines()
+ .collect { it.trim() }
+ .findAll { !it.isEmpty() }
+def libretroDroidAar = layout.buildDirectory.file("libretrodroid/libretrodroid.aar")
+
+def fetchLibretroDroid = tasks.register("fetchLibretroDroid", FetchLibretroDroidTask) {
+ tag.set(libretroDroidPin[0])
+ sha256.set(libretroDroidPin[1])
+ outputFile.set(libretroDroidAar)
+}
+
+/*
+def installVulkanValidationLayer = tasks.register("installVulkanValidationLayer", InstallVulkanValidationLayerTask) {
+ layerVersion.set("1.4.341.0")
+ outputFile.set(layout.projectDirectory.file("src/debug/jniLibs/arm64-v8a/libVkLayer_khronos_validation.so"))
+}
+*/
+
ksp {
arg("room.schemaLocation", "$projectDir/schemas")
}
@@ -31,6 +128,9 @@ def appVersionName = providers.gradleProperty("VERSION_NAME")
.orElse(providers.environmentVariable("VERSION_NAME"))
.getOrElse("Nightly")
+def coldClientVersionFile = rootProject.file("tools/gbe_fork.version")
+def coldClientVersion = coldClientVersionFile.exists() ? coldClientVersionFile.text.trim() : "unknown"
+
android {
namespace 'com.winlator.cmod'
compileSdk 35
@@ -39,13 +139,15 @@ android {
applicationId "com.winnative.cmod"
minSdk 26
targetSdk 28
- versionCode 21
+ versionCode 2
versionName appVersionName
externalNativeBuild {
cmake {
cppFlags ''
- arguments "-DCMAKE_SHARED_LINKER_FLAGS=-Wl,-z,max-page-size=16384", "-DCMAKE_EXE_LINKER_FLAGS=-Wl,-z,max-page-size=16384"
+ arguments "-DCMAKE_SHARED_LINKER_FLAGS=-Wl,-z,max-page-size=16384",
+ "-DCMAKE_EXE_LINKER_FLAGS=-Wl,-z,max-page-size=16384",
+ "-DANDROID_STL=c++_shared"
}
}
@@ -53,13 +155,14 @@ android {
abiFilters 'arm64-v8a'
}
- resConfigs "en", "da", "de", "es", "fr", "it", "ko", "pl", "pt-rBR", "ro", "uk", "zh-rCN", "zh-rTW"
+ resConfigs "en", "da", "de", "es", "b+es+419", "fi", "fr", "hi", "it", "ja", "ko", "no", "pl", "pt", "pt-rBR", "ro", "ru", "sv", "th", "tr", "uk", "zh-rCN", "zh-rTW"
+
+ buildConfigField("String", "COLD_CLIENT_VERSION", "\"${coldClientVersion}\"")
+ buildConfigField "String", "RESHADE_CATALOG_URL", "\"https://raw.githubusercontent.com/nicholasx417/WinNative-Components/main/Reshade.json\""
+ }
- buildConfigField("boolean", "GOLD", "false")
- buildConfigField("String", "POSTHOG_API_KEY", "\"\"")
- buildConfigField("String", "POSTHOG_HOST", "\"\"")
- buildConfigField("String", "SUPABASE_URL", "\"\"")
- buildConfigField("String", "SUPABASE_KEY", "\"\"")
+ androidResources {
+ noCompress += ['tzst', 'txz']
}
compileOptions {
@@ -92,11 +195,14 @@ android {
buildTypes {
debug {
minifyEnabled false
- // Use release signing if available, otherwise fall back to default debug signing
+ proguardFiles 'proguard-rules.pro'
+ // Prefer release signing when configured.
signingConfig signingConfigs.release.storeFile != null ? signingConfigs.release : signingConfigs.debug
}
release {
minifyEnabled true
+ proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'),
+ 'proguard-rules.pro'
signingConfig signingConfigs.release
}
}
@@ -116,6 +222,10 @@ android {
dimension "branding"
applicationId "com.tencent.ig"
}
+ antutu {
+ dimension "branding"
+ applicationId "com.antutu.ABenchMark"
+ }
}
ndkVersion '27.3.13750724'
@@ -132,15 +242,10 @@ android {
excludes += "META-INF/versions/9/OSGI-INF/MANIFEST.MF"
}
jniLibs {
+ // PulseAudio runs libpulseaudio.so directly from nativeLibraryDir and loads
+ // libpulse*/libltdl/libsndfile as its dependencies, so these must be packaged
+ // and extracted to disk (useLegacyPackaging) rather than excluded.
useLegacyPackaging = true
- excludes += [
- "lib/arm64-v8a/libltdl.so",
- "lib/arm64-v8a/libpulseaudio.so",
- "lib/arm64-v8a/libpulse.so",
- "lib/arm64-v8a/libpulsecommon-13.0.so",
- "lib/arm64-v8a/libpulsecore-13.0.so",
- "lib/arm64-v8a/libsndfile.so"
- ]
}
}
@@ -148,6 +253,7 @@ android {
compose true
viewBinding true
buildConfig true
+ prefab true // native Steam/runtime components link OpenSSL + curl via NDK Prefab AARs
}
sourceSets {
@@ -157,13 +263,24 @@ android {
'src/main/feature',
'src/main/sharedmemory',
'src/main/runtime',
- 'src/main/shared'
+ 'src/main/shared',
+ // SDL2 and love-android glue, vendored for the 3D engine.
+ // Their class names are a JNI contract with liblove.so and
+ // cannot be renamed or repackaged, so they are kept apart
+ // from WinNative's own sources with their upstream licence
+ // headers intact. See EMULATOR_CREDITS.md.
+ 'src/main/engine'
]
}
}
applicationVariants.configureEach { variant ->
if (variant.buildType.name == "debug") {
+/*
+ tasks.named("pre${variant.name.capitalize()}Build").configure {
+ dependsOn installVulkanValidationLayer
+ }
+*/
variant.outputs.configureEach { output ->
output.outputFileName = "${variant.flavorName}.apk"
}
@@ -176,8 +293,8 @@ dependencies {
implementation libs.okhttp
implementation libs.okhttpDnsOverHttps
- implementation libs.okhttpLoggingInterceptor
implementation libs.serializationJson
+ implementation 'org.yaml:snakeyaml:2.2'
implementation libs.jwtDecode
implementation libs.securityCrypto
@@ -197,7 +314,6 @@ dependencies {
implementation libs.composeMaterialIconsExtended
implementation libs.activityCompose
implementation libs.navigationCompose
- implementation libs.lifecycleRuntimeCompose
implementation libs.lifecycleViewmodelCompose
implementation libs.composeUiTextGoogleFonts
implementation libs.coilCompose
@@ -207,9 +323,7 @@ dependencies {
implementation libs.zxingCore
implementation libs.commonsIo
implementation libs.commonsLang3
- implementation libs.protobufJava
implementation("com.github.luben:zstd-jni:${libs.versions.zstdJni.get()}@aar")
- implementation libs.bouncyCastle
implementation libs.flexbox
implementation libs.appcompat
@@ -224,13 +338,18 @@ dependencies {
implementation libs.recyclerview
implementation libs.coreKtx
- implementation libs.javaSteam
- implementation libs.javaSteamDepotDownloader
+
+ implementation 'com.android.ndk.thirdparty:openssl:1.1.1q-beta-1'
+ implementation 'com.android.ndk.thirdparty:curl:7.85.0-beta-1'
implementation libs.playServicesGamesV2
- implementation libs.playServicesAuth
implementation libs.workRuntimeKtx
+
+ implementation files(libretroDroidAar) { builtBy fetchLibretroDroid }
+ implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.5.1'
+ implementation project(':armsx2')
+ implementation project(':dolphin')
}
spotless {
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
new file mode 100644
index 000000000..309cd5ea7
--- /dev/null
+++ b/app/proguard-rules.pro
@@ -0,0 +1,105 @@
+# Project ProGuard / R8 rules. Built APK is `minifyEnabled true`; without
+# these keeps, R8's release build strips classes that the runtime reaches
+# only via reflection — most notably BouncyCastle's java.security.Provider
+# Service registrations (the SPI map BouncyCastleProvider uses to expose
+# KeyStore.BKS, MessageDigest.*, etc.).
+#
+# Crash symptom 2026-05-19 (commit e1cb6c6 onward; not caused by hybrid work):
+# java.security.NoSuchAlgorithmException: BKS KeyStore not available
+# at sun.security.jca.GetInstance.getInstance:159
+# at java.security.Security.getImpl:628
+# at java.security.KeyStore.getInstance:901
+# at com.android.org.conscrypt.KeyManagerFactoryImpl.engineInit
+# at OkHttp Platform.systemDefaultTrustManager (obfuscated e8.j.n)
+#
+# PluviaApp.onCreate calls Security.addProvider(BouncyCastleProvider()),
+# which DOES expose "BKS" — but only if R8 didn't strip the Service
+# classes BC registers reflectively. The rules below keep them.
+# [[project_bks_keystore_crash]]
+
+-keep class org.bouncycastle.** { *; }
+-keep class org.bouncycastle.jce.provider.** { *; }
+-keep class org.bouncycastle.jcajce.provider.** { *; }
+-dontwarn org.bouncycastle.**
+
+# BouncyCastle reads service-loader entries from META-INF/services. R8's
+# default config copies META-INF/services for the kept classes; the
+# explicit `-keep` above ensures the impl classes survive.
+
+# JNI bridge classes — Rust `libwnsteam.so` and the wn-steam-bootstrap C++ lib
+# look these up by string in JNI_OnLoad / via FindClass. R8 renames break
+# the lookup → JNI_OnLoad returns JNI_ERR and the entire native side dies.
+# Crash 2026-05-19: "JNI_ERR returned from JNI_OnLoad in libwnsteam.so"
+# was triggered by R8 renaming WnConnectionObserver and the auth/library/
+# session callback observers the native JNI layer finds by name.
+-keep class com.winlator.cmod.feature.stores.steam.wnsteam.** {
+}
+-keepclassmembers class com.winlator.cmod.feature.stores.steam.wnsteam.** {
+}
+# JNI also calls back into PrefManager / SteamService observers + Steam
+# data classes (UserFileInfo, AppMetadata, etc.). Keep the feature
+# package broadly — the .so depends on the runtime layout, and shaving
+# bytes here is high-risk-low-reward.
+-keep class com.winlator.cmod.feature.stores.steam.** { *; }
+-keepclassmembers class com.winlator.cmod.feature.stores.steam.** { *; }
+
+# Native-method classes: `native` keyword on Kotlin/Java methods is a
+# universal R8 keep signal, but pinning the enclosing class avoids
+# subclass-rename surprises.
+-keepclasseswithmembernames class * {
+ native ;
+}
+
+# zstd-jni — the native side does GetFieldID("srcPos", "J") /
+# GetFieldID("dstPos", "J") on ZstdInputStreamNoFinalizer and
+# similar via JNI; R8 renames the Kotlin/Java fields and the lookup
+# crashes the process at sign-in (`libwnsteam.so` decompresses Steam
+# CM messages with zstd). Diagnosed 2026-05-19 from
+# `Abort message: java.lang.NoSuchFieldError: no "J" field "srcPos"
+# in class Lcom/github/luben/zstd/ZstdInputStreamNoFinalizer`.
+-keep class com.github.luben.zstd.** { *; }
+-keepclassmembers class com.github.luben.zstd.** { *; }
+-dontwarn com.github.luben.zstd.**
+
+# Conscrypt / OkHttp use reflection to discover security providers; keep
+# the Provider names they look up by string.
+-keep class java.security.Provider { *; }
+-keep class * extends java.security.Provider { *; }
+-keep class * extends java.security.KeyStoreSpi { *; }
+-keep class * extends java.security.MessageDigestSpi { *; }
+-keep class * extends javax.crypto.CipherSpi { *; }
+-keep class * extends javax.crypto.MacSpi { *; }
+-keep class * extends javax.crypto.KeyAgreementSpi { *; }
+-keep class * extends java.security.KeyFactorySpi { *; }
+-keep class * extends javax.crypto.SecretKeyFactorySpi { *; }
+-keep class * extends javax.crypto.KeyGeneratorSpi { *; }
+-keep class * extends java.security.AlgorithmParametersSpi { *; }
+-keep class * extends java.security.SignatureSpi { *; }
+-keep class com.winlator.cmod.shared.io.NativeContentIO {
+ *;
+}
+
+-keep class com.winlator.cmod.shared.util.OnExtractFileListener {
+ public java.io.File onExtractFile(java.io.File, long);
+}
+
+-keep class com.winlator.cmod.runtime.content.Downloader$DownloadListener {
+ public void onProgress(long, long);
+}
+
+-dontwarn androidx.window.extensions.WindowExtensions
+-dontwarn androidx.window.extensions.WindowExtensionsProvider
+-dontwarn androidx.window.extensions.area.ExtensionWindowAreaPresentation
+-dontwarn androidx.window.extensions.core.util.function.Consumer
+-dontwarn androidx.window.extensions.core.util.function.Function
+-dontwarn androidx.window.extensions.core.util.function.Predicate
+-dontwarn androidx.window.extensions.layout.DisplayFeature
+-dontwarn androidx.window.extensions.layout.FoldingFeature
+-dontwarn androidx.window.extensions.layout.WindowLayoutComponent
+-dontwarn androidx.window.extensions.layout.WindowLayoutInfo
+-dontwarn androidx.window.sidecar.SidecarDeviceState
+-dontwarn androidx.window.sidecar.SidecarDisplayFeature
+-dontwarn androidx.window.sidecar.SidecarInterface$SidecarCallback
+-dontwarn androidx.window.sidecar.SidecarInterface
+-dontwarn androidx.window.sidecar.SidecarProvider
+-dontwarn androidx.window.sidecar.SidecarWindowLayoutInfo
diff --git a/app/src/antutu/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/antutu/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 000000000..d372a4fca
--- /dev/null
+++ b/app/src/antutu/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/antutu/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/antutu/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 000000000..d372a4fca
--- /dev/null
+++ b/app/src/antutu/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/antutu/res/mipmap-hdpi/ic_launcher.png b/app/src/antutu/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 000000000..eeda513bc
Binary files /dev/null and b/app/src/antutu/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/app/src/antutu/res/mipmap-hdpi/ic_launcher_foreground.png b/app/src/antutu/res/mipmap-hdpi/ic_launcher_foreground.png
new file mode 100644
index 000000000..fd40adda6
Binary files /dev/null and b/app/src/antutu/res/mipmap-hdpi/ic_launcher_foreground.png differ
diff --git a/app/src/antutu/res/mipmap-hdpi/ic_launcher_round.png b/app/src/antutu/res/mipmap-hdpi/ic_launcher_round.png
new file mode 100644
index 000000000..91ecbc7c6
Binary files /dev/null and b/app/src/antutu/res/mipmap-hdpi/ic_launcher_round.png differ
diff --git a/app/src/antutu/res/mipmap-mdpi/ic_launcher.png b/app/src/antutu/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 000000000..1d28f9b4e
Binary files /dev/null and b/app/src/antutu/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/app/src/antutu/res/mipmap-mdpi/ic_launcher_foreground.png b/app/src/antutu/res/mipmap-mdpi/ic_launcher_foreground.png
new file mode 100644
index 000000000..28f3b653f
Binary files /dev/null and b/app/src/antutu/res/mipmap-mdpi/ic_launcher_foreground.png differ
diff --git a/app/src/antutu/res/mipmap-mdpi/ic_launcher_round.png b/app/src/antutu/res/mipmap-mdpi/ic_launcher_round.png
new file mode 100644
index 000000000..5309989cd
Binary files /dev/null and b/app/src/antutu/res/mipmap-mdpi/ic_launcher_round.png differ
diff --git a/app/src/antutu/res/mipmap-xhdpi/ic_launcher.png b/app/src/antutu/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 000000000..c36adff62
Binary files /dev/null and b/app/src/antutu/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/app/src/antutu/res/mipmap-xhdpi/ic_launcher_foreground.png b/app/src/antutu/res/mipmap-xhdpi/ic_launcher_foreground.png
new file mode 100644
index 000000000..15834a0d4
Binary files /dev/null and b/app/src/antutu/res/mipmap-xhdpi/ic_launcher_foreground.png differ
diff --git a/app/src/antutu/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/antutu/res/mipmap-xhdpi/ic_launcher_round.png
new file mode 100644
index 000000000..514cd6047
Binary files /dev/null and b/app/src/antutu/res/mipmap-xhdpi/ic_launcher_round.png differ
diff --git a/app/src/antutu/res/mipmap-xxhdpi/ic_launcher.png b/app/src/antutu/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 000000000..6d4571181
Binary files /dev/null and b/app/src/antutu/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/app/src/antutu/res/mipmap-xxhdpi/ic_launcher_foreground.png b/app/src/antutu/res/mipmap-xxhdpi/ic_launcher_foreground.png
new file mode 100644
index 000000000..d19894c47
Binary files /dev/null and b/app/src/antutu/res/mipmap-xxhdpi/ic_launcher_foreground.png differ
diff --git a/app/src/antutu/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/antutu/res/mipmap-xxhdpi/ic_launcher_round.png
new file mode 100644
index 000000000..e4642022c
Binary files /dev/null and b/app/src/antutu/res/mipmap-xxhdpi/ic_launcher_round.png differ
diff --git a/app/src/antutu/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/antutu/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 000000000..aec9083dc
Binary files /dev/null and b/app/src/antutu/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/app/src/antutu/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/app/src/antutu/res/mipmap-xxxhdpi/ic_launcher_foreground.png
new file mode 100644
index 000000000..f059f3ddb
Binary files /dev/null and b/app/src/antutu/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ
diff --git a/app/src/antutu/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/antutu/res/mipmap-xxxhdpi/ic_launcher_round.png
new file mode 100644
index 000000000..9e6038c70
Binary files /dev/null and b/app/src/antutu/res/mipmap-xxxhdpi/ic_launcher_round.png differ
diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml
new file mode 100644
index 000000000..f47226353
--- /dev/null
+++ b/app/src/debug/AndroidManifest.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
diff --git a/app/src/debug/jniLibs/arm64-v8a/libVkLayer_khronos_validation.so b/app/src/debug/jniLibs/arm64-v8a/libVkLayer_khronos_validation.so
new file mode 100644
index 000000000..60983e850
Binary files /dev/null and b/app/src/debug/jniLibs/arm64-v8a/libVkLayer_khronos_validation.so differ
diff --git a/app/src/ludashi/res/mipmap-hdpi/ic_launcher.png b/app/src/ludashi/res/mipmap-hdpi/ic_launcher.png
index eeda513bc..b9dff1158 100644
Binary files a/app/src/ludashi/res/mipmap-hdpi/ic_launcher.png and b/app/src/ludashi/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/app/src/ludashi/res/mipmap-hdpi/ic_launcher_foreground.png b/app/src/ludashi/res/mipmap-hdpi/ic_launcher_foreground.png
index fd40adda6..cc7dfe0b2 100644
Binary files a/app/src/ludashi/res/mipmap-hdpi/ic_launcher_foreground.png and b/app/src/ludashi/res/mipmap-hdpi/ic_launcher_foreground.png differ
diff --git a/app/src/ludashi/res/mipmap-hdpi/ic_launcher_round.png b/app/src/ludashi/res/mipmap-hdpi/ic_launcher_round.png
index 91ecbc7c6..dfaa43758 100644
Binary files a/app/src/ludashi/res/mipmap-hdpi/ic_launcher_round.png and b/app/src/ludashi/res/mipmap-hdpi/ic_launcher_round.png differ
diff --git a/app/src/ludashi/res/mipmap-mdpi/ic_launcher.png b/app/src/ludashi/res/mipmap-mdpi/ic_launcher.png
index 1d28f9b4e..992f8fbed 100644
Binary files a/app/src/ludashi/res/mipmap-mdpi/ic_launcher.png and b/app/src/ludashi/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/app/src/ludashi/res/mipmap-mdpi/ic_launcher_foreground.png b/app/src/ludashi/res/mipmap-mdpi/ic_launcher_foreground.png
index 28f3b653f..c2a7fc0fc 100644
Binary files a/app/src/ludashi/res/mipmap-mdpi/ic_launcher_foreground.png and b/app/src/ludashi/res/mipmap-mdpi/ic_launcher_foreground.png differ
diff --git a/app/src/ludashi/res/mipmap-mdpi/ic_launcher_round.png b/app/src/ludashi/res/mipmap-mdpi/ic_launcher_round.png
index 5309989cd..d95315bf6 100644
Binary files a/app/src/ludashi/res/mipmap-mdpi/ic_launcher_round.png and b/app/src/ludashi/res/mipmap-mdpi/ic_launcher_round.png differ
diff --git a/app/src/ludashi/res/mipmap-xhdpi/ic_launcher.png b/app/src/ludashi/res/mipmap-xhdpi/ic_launcher.png
index c36adff62..f75bc0375 100644
Binary files a/app/src/ludashi/res/mipmap-xhdpi/ic_launcher.png and b/app/src/ludashi/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/app/src/ludashi/res/mipmap-xhdpi/ic_launcher_foreground.png b/app/src/ludashi/res/mipmap-xhdpi/ic_launcher_foreground.png
index 15834a0d4..2bcc3709e 100644
Binary files a/app/src/ludashi/res/mipmap-xhdpi/ic_launcher_foreground.png and b/app/src/ludashi/res/mipmap-xhdpi/ic_launcher_foreground.png differ
diff --git a/app/src/ludashi/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/ludashi/res/mipmap-xhdpi/ic_launcher_round.png
index 514cd6047..84f357dd5 100644
Binary files a/app/src/ludashi/res/mipmap-xhdpi/ic_launcher_round.png and b/app/src/ludashi/res/mipmap-xhdpi/ic_launcher_round.png differ
diff --git a/app/src/ludashi/res/mipmap-xxhdpi/ic_launcher.png b/app/src/ludashi/res/mipmap-xxhdpi/ic_launcher.png
index 6d4571181..7ce21a60b 100644
Binary files a/app/src/ludashi/res/mipmap-xxhdpi/ic_launcher.png and b/app/src/ludashi/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/app/src/ludashi/res/mipmap-xxhdpi/ic_launcher_foreground.png b/app/src/ludashi/res/mipmap-xxhdpi/ic_launcher_foreground.png
index d19894c47..0bf39ea0f 100644
Binary files a/app/src/ludashi/res/mipmap-xxhdpi/ic_launcher_foreground.png and b/app/src/ludashi/res/mipmap-xxhdpi/ic_launcher_foreground.png differ
diff --git a/app/src/ludashi/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/ludashi/res/mipmap-xxhdpi/ic_launcher_round.png
index e4642022c..745f05e2e 100644
Binary files a/app/src/ludashi/res/mipmap-xxhdpi/ic_launcher_round.png and b/app/src/ludashi/res/mipmap-xxhdpi/ic_launcher_round.png differ
diff --git a/app/src/ludashi/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/ludashi/res/mipmap-xxxhdpi/ic_launcher.png
index aec9083dc..1dd339ca2 100644
Binary files a/app/src/ludashi/res/mipmap-xxxhdpi/ic_launcher.png and b/app/src/ludashi/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/app/src/ludashi/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/app/src/ludashi/res/mipmap-xxxhdpi/ic_launcher_foreground.png
index f059f3ddb..9a6c604b5 100644
Binary files a/app/src/ludashi/res/mipmap-xxxhdpi/ic_launcher_foreground.png and b/app/src/ludashi/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ
diff --git a/app/src/ludashi/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/ludashi/res/mipmap-xxxhdpi/ic_launcher_round.png
index 9e6038c70..3ca95afb8 100644
Binary files a/app/src/ludashi/res/mipmap-xxxhdpi/ic_launcher_round.png and b/app/src/ludashi/res/mipmap-xxxhdpi/ic_launcher_round.png differ
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 7e2ec759f..7d06e5609 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -7,10 +7,22 @@
+
+
+
+
+
+
+
+
@@ -19,8 +31,11 @@
+
+
+
@@ -88,7 +103,23 @@
android:supportsPictureInPicture="true"
android:screenOrientation="sensorLandscape"
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|smallestScreenSize|density|navigation"
- android:exported="true" />
+ android:exported="true">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -111,6 +174,18 @@
android:exported="false"
android:foregroundServiceType="dataSync" />
+
+
+
+
+
+
+
+
+
+
+ android:value="false" />
+
+
+
+
+
+
+
+
diff --git a/app/src/main/app/PluviaApp.kt b/app/src/main/app/PluviaApp.kt
index 9ca57b4e8..7db378cbe 100644
--- a/app/src/main/app/PluviaApp.kt
+++ b/app/src/main/app/PluviaApp.kt
@@ -8,6 +8,7 @@ import com.winlator.cmod.app.update.UpdateChecker
import com.winlator.cmod.feature.stores.gog.service.GOGAuthManager
import com.winlator.cmod.feature.stores.gog.service.GOGConstants
import com.winlator.cmod.feature.stores.steam.events.EventDispatcher
+import com.winlator.cmod.feature.stores.steam.service.SteamService
import com.winlator.cmod.feature.stores.steam.utils.PrefManager
import com.winlator.cmod.runtime.display.XServerDisplayActivity
import com.winlator.cmod.shared.android.RefreshRateUtils
@@ -17,39 +18,59 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
-import org.bouncycastle.jce.provider.BouncyCastleProvider
-import java.security.Security
+import java.io.File
@HiltAndroidApp
class PluviaApp : Application() {
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
+ private fun isPs2Process(): Boolean {
+ val name =
+ if (android.os.Build.VERSION.SDK_INT >= 28) {
+ Application.getProcessName()
+ } else {
+ runCatching {
+ val pid = android.os.Process.myPid()
+ val am = getSystemService(ACTIVITY_SERVICE) as android.app.ActivityManager
+ am.runningAppProcesses?.firstOrNull { it.pid == pid }?.processName
+ }.getOrNull()
+ }
+ return name?.endsWith(":ps2") == true || name?.endsWith(":gc") == true
+ }
+
override fun onCreate() {
super.onCreate()
instance = this
- registerRefreshRateLifecycleCallbacks()
+ Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
+ Log.e("PluviaApp", "CRASH in thread ${thread.name}", throwable)
+ }
+
+ com.winlator.cmod.feature.retro.Ps2GameOverlay.install()
+ com.winlator.cmod.feature.retro.DolphinGameOverlay.install()
+ if (isPs2Process()) return
- // Replace Android's limited BouncyCastle provider with the full one
- // so that JavaSteam can use SHA-1 (and other algorithms) via the "BC" provider.
- Security.removeProvider("BC")
- Security.addProvider(BouncyCastleProvider())
+ // Cached probe for devices whose native stack still needs system libjpeg preloaded.
+ preloadSystemLibraries()
+
+ registerRefreshRateLifecycleCallbacks()
- // Register application context so secure Steam prefs can initialize lazily.
PrefManager.install(this)
GOGConstants.init(this)
- // Initialize process-wide reactive network state
com.winlator.cmod.app.service.NetworkMonitor
.init(this)
scheduleColdStartWarmups()
-
- Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
- Log.e("PluviaApp", "CRASH in thread ${thread.name}", throwable)
- }
}
companion object {
+ private const val STARTUP_PROBES_PREFS = "startup_probes"
+ private const val KEY_SYSTEM_JPEG_PRELOAD_STATE = "system_jpeg_preload_state"
+ private const val KEY_SYSTEM_JPEG_PRELOAD_VERSION = "system_jpeg_preload_version"
+ private const val SYSTEM_JPEG_PRELOAD_UNKNOWN = 0
+ private const val SYSTEM_JPEG_PRELOAD_SUCCESS = 1
+ private const val SYSTEM_JPEG_PRELOAD_UNSUPPORTED = 2
+
lateinit var instance: PluviaApp
private set
@@ -59,6 +80,76 @@ class PluviaApp : Application() {
@JvmField
val events = EventDispatcher()
+
+ // Visible activity count; mutated only on the main thread.
+ @Volatile
+ private var startedActivityCount = 0
+
+ // Live game windows, including backgrounded sessions.
+ @Volatile
+ private var gameActivityCount = 0
+
+ fun isGameSessionActive(): Boolean = gameActivityCount > 0
+ }
+
+ private fun preloadSystemLibraries() {
+ val prefs = getSharedPreferences(STARTUP_PROBES_PREFS, MODE_PRIVATE)
+ val currentVersion = currentVersionCode()
+ val state =
+ if (prefs.getLong(KEY_SYSTEM_JPEG_PRELOAD_VERSION, -1L) == currentVersion) {
+ prefs.getInt(KEY_SYSTEM_JPEG_PRELOAD_STATE, SYSTEM_JPEG_PRELOAD_UNKNOWN)
+ } else {
+ SYSTEM_JPEG_PRELOAD_UNKNOWN
+ }
+
+ if (state == SYSTEM_JPEG_PRELOAD_UNSUPPORTED) return
+
+ val is64 = android.os.Build.SUPPORTED_64_BIT_ABIS.isNotEmpty()
+ val candidates = if (is64) {
+ listOf("/system/lib64/libjpeg.so", "/system/lib/libjpeg.so")
+ } else {
+ listOf("/system/lib/libjpeg.so", "/system/lib64/libjpeg.so")
+ }
+ for (path in candidates) {
+ if (!File(path).exists()) continue
+ try {
+ System.load(path)
+ prefs.edit()
+ .putInt(KEY_SYSTEM_JPEG_PRELOAD_STATE, SYSTEM_JPEG_PRELOAD_SUCCESS)
+ .putLong(KEY_SYSTEM_JPEG_PRELOAD_VERSION, currentVersion)
+ .apply()
+ Log.i("PluviaApp", "Preloaded $path")
+ return
+ } catch (t: Throwable) {
+ if (isPermanentSystemLibraryPreloadFailure(t)) {
+ prefs.edit()
+ .putInt(KEY_SYSTEM_JPEG_PRELOAD_STATE, SYSTEM_JPEG_PRELOAD_UNSUPPORTED)
+ .putLong(KEY_SYSTEM_JPEG_PRELOAD_VERSION, currentVersion)
+ .apply()
+ Log.i("PluviaApp", "Skipping future system libjpeg preload attempts: ${t.message}")
+ return
+ }
+ Log.w("PluviaApp", "Preload $path failed: ${t.message}")
+ }
+ }
+ }
+
+ private fun isPermanentSystemLibraryPreloadFailure(error: Throwable): Boolean {
+ val message = error.message.orEmpty()
+ return message.contains("not accessible for the namespace", ignoreCase = true) ||
+ message.contains("is not accessible", ignoreCase = true)
+ }
+
+ private fun currentVersionCode(): Long {
+ return runCatching {
+ val info = packageManager.getPackageInfo(packageName, 0)
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.P) {
+ info.longVersionCode
+ } else {
+ @Suppress("DEPRECATION")
+ info.versionCode.toLong()
+ }
+ }.getOrDefault(0L)
}
private fun registerRefreshRateLifecycleCallbacks() {
@@ -68,6 +159,9 @@ class PluviaApp : Application() {
activity: Activity,
savedInstanceState: Bundle?,
) {
+ if (activity is XServerDisplayActivity) {
+ gameActivityCount++
+ }
if (shouldManageAppRefreshRate(activity)) {
RefreshRateUtils.onActivityCreated(activity)
}
@@ -80,7 +174,11 @@ class PluviaApp : Application() {
}
}
- override fun onActivityStarted(activity: Activity) {}
+ override fun onActivityStarted(activity: Activity) {
+ if (startedActivityCount++ == 0) {
+ SteamService.onAppForegrounded()
+ }
+ }
override fun onActivityPaused(activity: Activity) {
if (currentForegroundActivity === activity) {
@@ -88,7 +186,12 @@ class PluviaApp : Application() {
}
}
- override fun onActivityStopped(activity: Activity) {}
+ override fun onActivityStopped(activity: Activity) {
+ startedActivityCount = (startedActivityCount - 1).coerceAtLeast(0)
+ if (startedActivityCount == 0) {
+ SteamService.onAppBackgrounded()
+ }
+ }
override fun onActivitySaveInstanceState(
activity: Activity,
@@ -102,13 +205,20 @@ class PluviaApp : Application() {
if (currentForegroundActivity === activity) {
currentForegroundActivity = null
}
+ if (activity is XServerDisplayActivity) {
+ gameActivityCount = (gameActivityCount - 1).coerceAtLeast(0)
+ // If the last game window ends while backgrounded, let Steam sleep.
+ if (gameActivityCount == 0 && startedActivityCount == 0) {
+ SteamService.onAppBackgrounded()
+ }
+ }
}
},
)
}
private fun shouldManageAppRefreshRate(activity: Activity): Boolean {
- // Game windows own per-title refresh policy and should not inherit the global app override.
+ // Game windows own per-title refresh policy.
return activity !is XServerDisplayActivity
}
@@ -118,8 +228,7 @@ class PluviaApp : Application() {
withContext(Dispatchers.IO) {
GOGAuthManager.updateLoginStatus(this@PluviaApp)
- // Pre-warm encrypted preferences off the UI thread so launcher auth checks
- // are less likely to pay MasterKey/EncryptedSharedPreferences startup cost.
+ // Keep encrypted prefs setup off launcher auth checks.
val steamLogsEnabled =
runCatching {
PrefManager.init(this@PluviaApp)
@@ -137,10 +246,7 @@ class PluviaApp : Application() {
runCatching { PluviaDatabase.init(this@PluviaApp) }
.onFailure { Log.e("PluviaApp", "Database warmup failed", it) }
- // Initialize the cross-store DownloadCoordinator and auto-resume any
- // downloads that were running when the app was killed. PAUSED downloads
- // stay PAUSED; DOWNLOADING ones are demoted to QUEUED and dispatched as
- // store services start.
+ // Restore interrupted downloads after DB/coordinator startup.
runCatching {
val db = PluviaDatabase.getInstance(this@PluviaApp)
com.winlator.cmod.app.service.download.DownloadCoordinator.init(db)
@@ -148,8 +254,6 @@ class PluviaApp : Application() {
.attemptStartupRestoration()
}.onFailure { Log.e("PluviaApp", "DownloadCoordinator startup failed", it) }
- com.winlator.cmod.runtime.system.LogManager
- .rotateLogsOnAppStart(this@PluviaApp)
com.winlator.cmod.runtime.system.LogManager
.startAppLogging(this@PluviaApp)
diff --git a/app/src/main/app/config/SettingsConfig.kt b/app/src/main/app/config/SettingsConfig.kt
index ed9d2de21..528abe7fd 100644
--- a/app/src/main/app/config/SettingsConfig.kt
+++ b/app/src/main/app/config/SettingsConfig.kt
@@ -5,7 +5,13 @@ import androidx.preference.PreferenceManager
object SettingsConfig {
@JvmField
- val DEFAULT_WINE_DEBUG_CHANNELS: String = "warn,err,fixme"
+ val DEFAULT_WINE_DEBUG_CHANNELS: String = "module,loaddll,seh"
+
+ @JvmField
+ val DEFAULT_WINE_DEBUG_CLASSES: String = "err,warn,fixme"
+
+ @JvmField
+ val WINE_DEBUG_CLASSES: List = listOf("err", "warn", "fixme", "trace")
@JvmField
val DEFAULT_WINLATOR_PATH: String =
diff --git a/app/src/main/app/db/PluviaDatabase.kt b/app/src/main/app/db/PluviaDatabase.kt
index 888a1beb9..cb118149e 100644
--- a/app/src/main/app/db/PluviaDatabase.kt
+++ b/app/src/main/app/db/PluviaDatabase.kt
@@ -2,6 +2,8 @@ package com.winlator.cmod.app.db
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
+import androidx.room.migration.Migration
+import androidx.sqlite.db.SupportSQLiteDatabase
import com.winlator.cmod.feature.stores.steam.data.AppInfo
import com.winlator.cmod.feature.stores.steam.data.CachedLicense
import com.winlator.cmod.feature.stores.steam.data.ChangeNumbers
@@ -43,7 +45,7 @@ const val DATABASE_NAME = "pluvia_database"
DownloadingAppInfo::class,
DownloadRecord::class,
],
- version = 4,
+ version = 8,
exportSchema = false,
)
@TypeConverters(
@@ -89,7 +91,8 @@ abstract class PluviaDatabase : RoomDatabase() {
context.applicationContext,
PluviaDatabase::class.java,
DATABASE_NAME,
- ).fallbackToDestructiveMigration()
+ ).addMigrations(MIGRATION_6_7, MIGRATION_7_8)
+ .fallbackToDestructiveMigration(true)
.build()
.also { instance = it }
}
@@ -97,5 +100,19 @@ abstract class PluviaDatabase : RoomDatabase() {
fun getInstance(context: android.content.Context): PluviaDatabase = init(context)
fun getInstance(): PluviaDatabase = instance ?: throw IllegalStateException("PluviaDatabase not initialized")
+
+ private val MIGRATION_7_8 =
+ object : Migration(7, 8) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL("ALTER TABLE app_info ADD COLUMN install_path TEXT")
+ }
+ }
+
+ private val MIGRATION_6_7 =
+ object : Migration(6, 7) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL("ALTER TABLE gog_games ADD COLUMN hero_image_url TEXT NOT NULL DEFAULT ''")
+ }
+ }
}
}
diff --git a/app/src/main/app/db/download/DownloadRecord.kt b/app/src/main/app/db/download/DownloadRecord.kt
index bad13ed34..a9d287280 100644
--- a/app/src/main/app/db/download/DownloadRecord.kt
+++ b/app/src/main/app/db/download/DownloadRecord.kt
@@ -32,6 +32,9 @@ data class DownloadRecord(
val selectedDlcs: String = "",
@ColumnInfo("language")
val language: String = "",
+ /** One of: INSTALL, UPDATE, VERIFY. */
+ @ColumnInfo("task_type")
+ val taskType: String = TASK_INSTALL,
/** One of: QUEUED, DOWNLOADING, PAUSED, COMPLETE, CANCELLED, FAILED. */
@ColumnInfo("status")
val status: String = STATUS_QUEUED,
@@ -51,6 +54,10 @@ data class DownloadRecord(
const val STORE_EPIC = "EPIC"
const val STORE_GOG = "GOG"
+ const val TASK_INSTALL = "INSTALL"
+ const val TASK_UPDATE = "UPDATE"
+ const val TASK_VERIFY = "VERIFY"
+
const val STATUS_QUEUED = "QUEUED"
const val STATUS_DOWNLOADING = "DOWNLOADING"
const val STATUS_PAUSED = "PAUSED"
diff --git a/app/src/main/app/db/download/DownloadRecordDao.kt b/app/src/main/app/db/download/DownloadRecordDao.kt
index 21d2eb6d4..c0942d848 100644
--- a/app/src/main/app/db/download/DownloadRecordDao.kt
+++ b/app/src/main/app/db/download/DownloadRecordDao.kt
@@ -54,7 +54,11 @@ interface DownloadRecordDao {
): Int
@Query(
- "UPDATE download_records SET bytes_downloaded = :bytesDownloaded, bytes_total = :bytesTotal, updated_at = :now WHERE id = :id",
+ "UPDATE download_records SET " +
+ "bytes_downloaded = CASE " +
+ "WHEN :bytesTotal = bytes_total AND :bytesDownloaded < bytes_downloaded THEN bytes_downloaded " +
+ "ELSE :bytesDownloaded END, " +
+ "bytes_total = :bytesTotal, updated_at = :now WHERE id = :id",
)
suspend fun updateProgress(
id: Long,
diff --git a/app/src/main/app/service/DownloadService.kt b/app/src/main/app/service/DownloadService.kt
index 754f72058..c6c99e8d7 100644
--- a/app/src/main/app/service/DownloadService.kt
+++ b/app/src/main/app/service/DownloadService.kt
@@ -116,13 +116,36 @@ object DownloadService {
// (PAUSED or QUEUED) for which no store has yet created an in-memory DownloadInfo.
// Fabricate stub DownloadInfos for those so the Downloads tab shows them and the user
// can Resume / Cancel them.
- val knownIds = list.map { it.first }.toSet()
val coord = com.winlator.cmod.app.service.download.DownloadCoordinator
- coord.snapshotRecords().forEach { record ->
+ val records = coord.snapshotRecords()
+ val recordsByUiId = records.associateBy { "${it.store}_${it.storeGameId}" }
+ list.forEach { (id, info) ->
+ val record = recordsByUiId[id] ?: return@forEach
+ if (record.bytesTotal > 0L) {
+ info.setDisplayTotalExpectedBytes(record.bytesTotal)
+ if (info.getTotalExpectedBytes() <= 0L) {
+ info.setTotalExpectedBytes(record.bytesTotal)
+ info.initializeBytesDownloaded(record.bytesDownloaded)
+ }
+ }
+ }
+
+ val knownIds = list.map { it.first }.toSet()
+ records.forEach { record ->
val id = "${record.store}_${record.storeGameId}"
if (id in knownIds) return@forEach
val phase = mapRecordStatusToPhase(record.status)
val gameIdInt = record.storeGameId.toIntOrNull() ?: 0
+ // Preserve the original FAILED reason across restarts so the row
+ // doesn't collapse to a generic "Unknown error" on rehydrate.
+ val stubStatusMessage =
+ when (phase) {
+ com.winlator.cmod.feature.stores.steam.enums.DownloadPhase.PAUSED ->
+ appContext?.getString(R.string.downloads_queue_paused_resume_hint)
+ com.winlator.cmod.feature.stores.steam.enums.DownloadPhase.FAILED ->
+ record.errorMessage?.takeUnless { it.isBlank() }
+ else -> null
+ }
val stub =
com.winlator.cmod.feature.stores.steam.data.DownloadInfo(
jobCount = 1,
@@ -130,14 +153,10 @@ object DownloadService {
downloadingAppIds = java.util.concurrent.CopyOnWriteArrayList(),
).apply {
setActive(false)
- updateStatus(
- phase,
- appContext?.getString(R.string.downloads_queue_paused_resume_hint).takeIf {
- phase == com.winlator.cmod.feature.stores.steam.enums.DownloadPhase.PAUSED
- },
- )
+ updateStatus(phase, stubStatusMessage)
if (record.bytesTotal > 0L) {
setTotalExpectedBytes(record.bytesTotal)
+ setDisplayTotalExpectedBytes(record.bytesTotal)
initializeBytesDownloaded(record.bytesDownloaded)
}
}
diff --git a/app/src/main/app/service/download/DownloadCoordinator.kt b/app/src/main/app/service/download/DownloadCoordinator.kt
index 5900f694c..992fb27ed 100644
--- a/app/src/main/app/service/download/DownloadCoordinator.kt
+++ b/app/src/main/app/service/download/DownloadCoordinator.kt
@@ -21,46 +21,25 @@ import kotlinx.coroutines.withContext
import timber.log.Timber
/**
- * Single source of truth for downloads across Steam / Epic / GOG.
- *
- * Responsibilities:
- * * Enforces a single active download shared across all stores.
- * * Persists every download as a [DownloadRecord] so they survive app restarts.
- * * Auto-resumes downloads that were active when the app exited; leaves PAUSED ones paused.
- *
- * Flow of a download:
- * 1. The store-specific service receives a download request from the UI.
- * 2. It calls [requestSlot]. The coordinator persists or updates a DownloadRecord and tells
- * the caller whether to start now ([Decision.Start]) or wait ([Decision.Queue]).
- * 3. When a download finishes, the store calls [notifyFinished]; the coordinator updates the
- * record and dispatches the next queued download (if any) via the registered [Dispatcher].
- * 4. UI controls (pause / resume / cancel / clear) call into the coordinator, which mutates
- * the record and asks the dispatcher to perform the side effect (cancel the running job,
- * delete partial files, etc.).
+ * Coordinates persisted downloads across stores, allowing one active transfer at a time and
+ * dispatching queued work to each store service.
*/
object DownloadCoordinator {
private const val MAX_PARALLEL_DOWNLOADS = 1
- /**
- * A per-store hook the coordinator uses to start, pause, resume, or cancel an actual
- * download. Stores register their dispatcher at service startup.
- */
+ /** Per-store hook for actual download side effects. */
interface Dispatcher {
- /**
- * Start a download that the coordinator just dequeued. The store should look up the
- * pending request matching this record and launch the actual coroutine. Called from
- * the coordinator's IO scope.
- */
+ /** Start a download the coordinator just dequeued. */
fun startQueued(record: DownloadRecord)
- /** Pause an actively running download, persisting partial files. */
+ /** Pause a running download while keeping partial files. */
fun pauseRunning(record: DownloadRecord)
- /**
- * Cancel an actively running download and delete partial files. The coordinator has
- * already marked the record CANCELLED.
- */
+ /** Cancel a running download and delete partial files. */
fun cancelRunning(record: DownloadRecord)
+
+ /** True while the store has a live transfer for this record. */
+ fun isTransferActive(record: DownloadRecord): Boolean = true
}
private val mutex = Mutex()
@@ -79,6 +58,10 @@ object DownloadCoordinator {
private val recordChanges = MutableSharedFlow(extraBufferCapacity = 16)
val changes = recordChanges.asSharedFlow()
+ /** True only while a download is actively transferring. */
+ fun hasActiveDownload(): Boolean =
+ recordsState.value.any { it.status == DownloadRecord.STATUS_DOWNLOADING }
+
fun init(database: PluviaDatabase) {
if (dao != null) return
dao = database.downloadRecordDao()
@@ -86,8 +69,7 @@ object DownloadCoordinator {
fun registerDispatcher(store: String, dispatcher: Dispatcher) {
dispatchers[store] = dispatcher
- // A newly-registered dispatcher might have queued records waiting from a previous
- // process or from a moment ago when it wasn't available yet. Drain the queue.
+ // Pick up queued records that were waiting for this dispatcher.
if (dao != null) {
scope.launch { tick() }
}
@@ -104,12 +86,7 @@ object DownloadCoordinator {
data class Queue(val record: DownloadRecord) : Decision()
}
- /**
- * Persist a download request and decide whether to start it immediately or queue it. The
- * caller (a store service) should inspect the result; on Start it should launch the actual
- * download, on Queue it should create a UI entry showing QUEUED status and wait for the
- * coordinator to dispatch via [Dispatcher.startQueued].
- */
+ /** Persist a request and decide whether it starts now or waits in the queue. */
suspend fun requestSlot(
store: String,
storeGameId: String,
@@ -118,6 +95,7 @@ object DownloadCoordinator {
installPath: String = "",
selectedDlcs: String = "",
language: String = "",
+ taskType: String = DownloadRecord.TASK_INSTALL,
bytesTotal: Long = 0L,
): Decision {
val daoRef = dao ?: throw IllegalStateException("DownloadCoordinator not initialised")
@@ -126,11 +104,7 @@ object DownloadCoordinator {
val now = System.currentTimeMillis()
val existing = daoRef.findByStoreGame(store, storeGameId)
- // Re-entry guard: tick() promotes a QUEUED record to DOWNLOADING and then
- // dispatches it to the store, which calls back into requestSlot via the public
- // download API. The slot was already granted by tick(); without this short
- // circuit we'd count the record against itself, decide there are no free slots,
- // and rewrite it back to QUEUED — making Resume hang at "Queued" forever.
+ // tick() may dispatch into code that calls requestSlot again; keep the granted slot.
if (existing != null && existing.status == DownloadRecord.STATUS_DOWNLOADING) {
return@withLock Decision.Start(existing)
}
@@ -157,6 +131,7 @@ object DownloadCoordinator {
installPath = installPath,
selectedDlcs = selectedDlcs,
language = language,
+ taskType = taskType,
bytesTotal = bytesTotal,
status = status,
createdAt = now,
@@ -165,14 +140,7 @@ object DownloadCoordinator {
val id = daoRef.upsert(newRecord)
newRecord.copy(id = id)
} else {
- // We only reach here for re-enqueue cases (record was COMPLETE/CANCELLED/
- // FAILED/PAUSED/QUEUED — anything but DOWNLOADING, which short-circuits
- // above). For a re-enqueue the caller is fully respecifying the request,
- // so OVERWRITE the row with the new values. Previously we did
- // `selectedDlcs.ifEmpty { existing.selectedDlcs }` which was ambiguous —
- // an empty list (user wants base game only) was indistinguishable from
- // "caller didn't supply", so old DLC selections leaked through to a fresh
- // download.
+ // Re-enqueue replaces request fields so empty DLC selection means base game only.
val updated =
existing.copy(
title = title,
@@ -180,7 +148,9 @@ object DownloadCoordinator {
installPath = installPath,
selectedDlcs = selectedDlcs,
language = language,
+ taskType = taskType,
bytesTotal = if (bytesTotal > 0L) bytesTotal else existing.bytesTotal,
+ bytesDownloaded = 0L,
status = status,
errorMessage = null,
updatedAt = now,
@@ -194,19 +164,27 @@ object DownloadCoordinator {
}
}
- /** Update progress for a running download. Lightweight; runs without locking the queue. */
+ /** Update progress without locking the queue. */
fun updateProgress(store: String, storeGameId: String, bytesDownloaded: Long, bytesTotal: Long) {
val daoRef = dao ?: return
scope.launch {
val record = daoRef.findByStoreGame(store, storeGameId) ?: return@launch
- daoRef.updateProgress(record.id, bytesDownloaded, bytesTotal)
+ val safeTotal = bytesTotal.coerceAtLeast(0L)
+ val safeDownloaded = bytesDownloaded.coerceAtLeast(0L).let { next ->
+ if (safeTotal == record.bytesTotal && next < record.bytesDownloaded) {
+ record.bytesDownloaded
+ } else {
+ next
+ }
+ }.let { next ->
+ if (safeTotal > 0L) next.coerceAtMost(safeTotal) else next
+ }
+ daoRef.updateProgress(record.id, safeDownloaded, safeTotal)
+ refreshState(daoRef)
}
}
- /**
- * Notify the coordinator that a download has terminated (success / fail / cancel / pause).
- * The coordinator persists the new status and starts the next queued download.
- */
+ /** Persist a terminal status and start the next queued download. */
suspend fun notifyFinished(
store: String,
storeGameId: String,
@@ -219,11 +197,11 @@ object DownloadCoordinator {
daoRef.updateStatus(record.id, finalStatus, error)
refreshState(daoRef)
}
- // Drain the queue outside the lock to avoid re-entrancy with dispatcher callbacks.
+ // Drain after releasing the lock so dispatcher callbacks can re-enter.
tick()
}
- /** Pause a running download. Marks PAUSED and asks the dispatcher to cancel its job. */
+ /** Mark a download PAUSED and ask its dispatcher to stop work. */
suspend fun pause(store: String, storeGameId: String) {
val daoRef = dao ?: return
val record = daoRef.findByStoreGame(store, storeGameId) ?: return
@@ -247,7 +225,7 @@ object DownloadCoordinator {
running.forEach { pause(it.store, it.storeGameId) }
}
- /** Resume a paused / queued / failed download. */
+ /** Resume a paused, queued, or failed download. */
suspend fun resume(store: String, storeGameId: String) {
val daoRef = dao ?: return
val record = daoRef.findByStoreGame(store, storeGameId) ?: return
@@ -262,17 +240,43 @@ object DownloadCoordinator {
}
tick()
}
+ DownloadRecord.STATUS_DOWNLOADING -> {
+ // Requeue records wedged in DOWNLOADING with no live transfer so Retry works.
+ val live = dispatchers[store]?.isTransferActive(record) ?: false
+ if (!live) {
+ Timber.w("resume: requeuing wedged DOWNLOADING record ${record.store}/${record.storeGameId}")
+ mutex.withLock {
+ daoRef.updateStatus(record.id, DownloadRecord.STATUS_QUEUED)
+ refreshState(daoRef)
+ }
+ tick()
+ }
+ }
else -> Unit
}
}
+ /** Put a just-dispatched record back in the queue without ticking; a later tick() retries it. */
+ suspend fun requeue(store: String, storeGameId: String) {
+ val daoRef = dao ?: return
+ val record = daoRef.findByStoreGame(store, storeGameId) ?: return
+ if (record.status != DownloadRecord.STATUS_DOWNLOADING) return
+ mutex.withLock {
+ daoRef.updateStatus(record.id, DownloadRecord.STATUS_QUEUED)
+ refreshState(daoRef)
+ }
+ }
+
suspend fun resumeAll() {
val daoRef = dao ?: return
- val toResume = daoRef.findByStatus(DownloadRecord.STATUS_PAUSED)
+ // FAILED downloads keep enough state for Resume All to continue them.
+ val toResume =
+ daoRef.findByStatus(DownloadRecord.STATUS_PAUSED) +
+ daoRef.findByStatus(DownloadRecord.STATUS_FAILED)
toResume.forEach { resume(it.store, it.storeGameId) }
}
- /** Cancel a download and delete partial files via the dispatcher. */
+ /** Cancel a download and ask its dispatcher to delete partial files. */
suspend fun cancel(store: String, storeGameId: String) {
val daoRef = dao ?: return
val record = daoRef.findByStoreGame(store, storeGameId) ?: return
@@ -293,14 +297,13 @@ object DownloadCoordinator {
cancellable.forEach { cancel(it.store, it.storeGameId) }
}
- /** Remove finished records (COMPLETE / CANCELLED / FAILED) from the table. */
+ /** Remove finished records from the table. */
suspend fun clear() {
val daoRef = dao ?: return
mutex.withLock {
daoRef.deleteFinished()
refreshState(daoRef)
}
- // Notify the Downloads tab to refresh.
PluviaApp.events.emit(AndroidEvent.DownloadStatusChanged(0, false))
}
@@ -309,10 +312,7 @@ object DownloadCoordinator {
runBlocking { clear() }
}
- /**
- * Drain the queue: while there are free slots and queued records, dispatch the oldest one
- * to its store-specific dispatcher.
- */
+ /** Dispatch queued records while slots are available. */
suspend fun tick() {
val daoRef = dao ?: return
val toStart = mutableListOf()
@@ -323,6 +323,8 @@ object DownloadCoordinator {
for (record in queued) {
if (activeCount >= MAX_PARALLEL_DOWNLOADS) break
+ // Keep records QUEUED until their store dispatcher is ready.
+ if (dispatchers[record.store] == null) continue
val started = record.copy(status = DownloadRecord.STATUS_DOWNLOADING, updatedAt = now)
daoRef.update(started)
toStart.add(started)
@@ -331,8 +333,7 @@ object DownloadCoordinator {
refreshState(daoRef)
}
- // Dispatch outside the lock so dispatchers can synchronously call back into the
- // coordinator if needed.
+ // Dispatch outside the lock so callbacks can re-enter safely.
toStart.forEach { record ->
val dispatcher = dispatchers[record.store]
if (dispatcher != null) {
@@ -347,38 +348,27 @@ object DownloadCoordinator {
}
}
- /**
- * Called once on app startup. Records that were DOWNLOADING when the process died are
- * moved back to QUEUED (auto-resume); PAUSED records stay PAUSED until the user resumes
- * them. Then the queue is drained.
- *
- * Idempotent: subsequent calls within the same process are no-ops.
- */
+ /** Restore interrupted downloads once per process and drain the queue. */
suspend fun onAppStart() {
if (startupRestored) return
startupRestored = true
val daoRef = dao ?: return
mutex.withLock {
- // DOWNLOADING -> QUEUED (auto-resume on next launch).
+ // Auto-resume interrupted active downloads.
daoRef.replaceStatus(DownloadRecord.STATUS_DOWNLOADING, DownloadRecord.STATUS_QUEUED)
refreshState(daoRef)
}
tick()
}
- /** Triggers onAppStart from a non-coroutine caller. */
+ /** Trigger startup restoration from a non-coroutine caller. */
fun attemptStartupRestoration() {
if (startupRestored) return
scope.launch { onAppStart() }
}
- /**
- * Called by AppTerminationHelper when the app is exiting. Does NOT pause everything — it
- * leaves DOWNLOADING records in DOWNLOADING state so they auto-resume on next launch, and
- * leaves PAUSED records PAUSED.
- */
+ /** Exit hook; statuses are already persisted during each transition. */
fun onAppExit() {
- // Nothing to persist here — every status transition was already written to the DAO.
}
private suspend fun refreshState(daoRef: DownloadRecordDao) {
@@ -392,16 +382,13 @@ object DownloadCoordinator {
scope.launch { tick() }
}
- /** Initialize records flow on startup. Safe to call multiple times. */
+ /** Initialize records flow on startup. */
suspend fun loadInitial() {
val daoRef = dao ?: return
refreshState(daoRef)
}
- /**
- * Look up the persisted record for a given store+gameId. Useful for resume to recover the
- * original install path / dlcs / language without going through the in-memory params map.
- */
+ /** Look up a persisted record by store and game id. */
suspend fun findRecord(store: String, storeGameId: String): DownloadRecord? {
val daoRef = dao ?: return null
return daoRef.findByStoreGame(store, storeGameId)
diff --git a/app/src/main/app/shell/BoxBlurTransformation.kt b/app/src/main/app/shell/BoxBlurTransformation.kt
new file mode 100644
index 000000000..6149d8174
--- /dev/null
+++ b/app/src/main/app/shell/BoxBlurTransformation.kt
@@ -0,0 +1,94 @@
+package com.winlator.cmod.app.shell
+
+import android.graphics.Bitmap
+import coil.size.Size
+import coil.transform.Transformation
+
+/** Bakes a blur into the decoded bitmap (two separable box passes ≈ Gaussian). */
+class BoxBlurTransformation(
+ private val radius: Int,
+) : Transformation {
+ override val cacheKey = "boxBlur:$radius"
+
+ override suspend fun transform(
+ input: Bitmap,
+ size: Size,
+ ): Bitmap {
+ if (radius < 1) return input
+ val w = input.width
+ val h = input.height
+ val a = IntArray(w * h)
+ val b = IntArray(w * h)
+ input.getPixels(a, 0, w, 0, 0, w, h)
+ repeat(2) {
+ horizontalPass(a, b, w, h)
+ verticalPass(b, a, w, h)
+ }
+ val out = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
+ out.setPixels(a, 0, w, 0, 0, w, h)
+ return out
+ }
+
+ private fun horizontalPass(
+ src: IntArray,
+ dst: IntArray,
+ w: Int,
+ h: Int,
+ ) {
+ val div = 2 * radius + 1
+ for (y in 0 until h) {
+ val row = y * w
+ var sa = 0
+ var sr = 0
+ var sg = 0
+ var sb = 0
+ for (i in -radius..radius) {
+ val p = src[row + i.coerceIn(0, w - 1)]
+ sa += p ushr 24
+ sr += (p shr 16) and 0xFF
+ sg += (p shr 8) and 0xFF
+ sb += p and 0xFF
+ }
+ for (x in 0 until w) {
+ dst[row + x] = ((sa / div) shl 24) or ((sr / div) shl 16) or ((sg / div) shl 8) or (sb / div)
+ val add = src[row + (x + radius + 1).coerceAtMost(w - 1)]
+ val sub = src[row + (x - radius).coerceAtLeast(0)]
+ sa += (add ushr 24) - (sub ushr 24)
+ sr += ((add shr 16) and 0xFF) - ((sub shr 16) and 0xFF)
+ sg += ((add shr 8) and 0xFF) - ((sub shr 8) and 0xFF)
+ sb += (add and 0xFF) - (sub and 0xFF)
+ }
+ }
+ }
+
+ private fun verticalPass(
+ src: IntArray,
+ dst: IntArray,
+ w: Int,
+ h: Int,
+ ) {
+ val div = 2 * radius + 1
+ for (x in 0 until w) {
+ var sa = 0
+ var sr = 0
+ var sg = 0
+ var sb = 0
+ for (i in -radius..radius) {
+ val p = src[i.coerceIn(0, h - 1) * w + x]
+ sa += p ushr 24
+ sr += (p shr 16) and 0xFF
+ sg += (p shr 8) and 0xFF
+ sb += p and 0xFF
+ }
+ for (y in 0 until h) {
+ dst[y * w + x] = ((sa / div) shl 24) or ((sr / div) shl 16) or ((sg / div) shl 8) or (sb / div)
+ val add = src[(y + radius + 1).coerceAtMost(h - 1) * w + x]
+ val sub = src[(y - radius).coerceAtLeast(0) * w + x]
+ sa += (add ushr 24) - (sub ushr 24)
+ sr += ((add shr 16) and 0xFF) - ((sub shr 16) and 0xFF)
+ sg += ((add shr 8) and 0xFF) - ((sub shr 8) and 0xFF)
+ sb += (add and 0xFF) - (sub and 0xFF)
+ }
+ }
+ }
+}
diff --git a/app/src/main/app/shell/Eyeglasses2Icon.kt b/app/src/main/app/shell/Eyeglasses2Icon.kt
new file mode 100644
index 000000000..126f92610
--- /dev/null
+++ b/app/src/main/app/shell/Eyeglasses2Icon.kt
@@ -0,0 +1,32 @@
+package com.winlator.cmod.app.shell
+
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.graphics.vector.PathParser
+import androidx.compose.ui.unit.dp
+
+// "eyeglasses_2" glyph (absent from compose material-icons); its 960x960 viewBox is offset -960 in y, so the path sits in a group translated down 960.
+private const val EYEGLASSES_2_PATH =
+ "M218-320q-42 0-75.5-27T100-416L71-550l-44 3-7-80q78-7 133.5-10t99.5-3q65 0 105 6t72 21q14 7 " +
+ "26.5 10t23.5 3q11 0 21.5-3t24.5-9q33-15 76-21.5t114-6.5q46 0 102 3t122 9l-7 79-43-3-30 137q-9 " +
+ "42-42 68.5T743-320h-89q-42 0-74-25.5T538-411l-27-107h-61l-27 107q-11 41-43 66t-73 25h-89Zm-40-112q3 " +
+ "14 14 23t25 9h89q14 0 25-8.5t14-21.5l31-121q-27-5-61-6.5t-62-1.5q-23 0-49.5.5T154-556l24 124Zm437 " +
+ "2q3 13 14 21.5t25 8.5h89q14 0 25-9t14-23l26-125q-20-1-46-1.5t-46-.5q-30 0-66.5 1.5T584-551l31 121Z"
+
+val Eyeglasses2Icon: ImageVector by lazy {
+ ImageVector.Builder(
+ name = "Eyeglasses2",
+ defaultWidth = 24.dp,
+ defaultHeight = 24.dp,
+ viewportWidth = 960f,
+ viewportHeight = 960f,
+ ).apply {
+ addGroup(translationY = 960f)
+ addPath(
+ pathData = PathParser().parsePathString(EYEGLASSES_2_PATH).toNodes(),
+ fill = SolidColor(Color.Black),
+ )
+ clearGroup()
+ }.build()
+}
diff --git a/app/src/main/app/shell/LibraryGameLaunchScreen.kt b/app/src/main/app/shell/LibraryGameLaunchScreen.kt
new file mode 100644
index 000000000..1fd6cacb1
--- /dev/null
+++ b/app/src/main/app/shell/LibraryGameLaunchScreen.kt
@@ -0,0 +1,1309 @@
+package com.winlator.cmod.app.shell
+
+import android.os.Build
+import android.view.WindowManager
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.MutableTransitionState
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.animateFloat
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.spring
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.scaleIn
+import androidx.compose.animation.scaleOut
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.interaction.collectIsPressedAsState
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.WindowInsetsSides
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.navigationBars
+import androidx.compose.foundation.layout.only
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.layout.windowInsetsPadding
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.outlined.ArrowBack
+import androidx.compose.material.icons.automirrored.outlined.FactCheck
+import androidx.compose.material.icons.outlined.ArrowDropDown
+import androidx.compose.material.icons.outlined.Bolt
+import androidx.compose.material.icons.outlined.CloudSync
+import androidx.compose.material.icons.outlined.SaveAlt
+import androidx.compose.material.icons.outlined.Construction
+import androidx.compose.material.icons.outlined.Delete
+import androidx.compose.material.icons.outlined.DesktopWindows
+import androidx.compose.material.icons.outlined.EmojiEvents
+import androidx.compose.material.icons.outlined.History
+import androidx.compose.material.icons.outlined.Refresh
+import androidx.compose.material.icons.outlined.Home
+import androidx.compose.material.icons.outlined.PlayArrow
+import androidx.compose.material.icons.outlined.Save
+import androidx.compose.material.icons.outlined.Schedule
+import androidx.compose.material.icons.outlined.Settings
+import androidx.compose.material.icons.outlined.SportsEsports
+import androidx.compose.material.icons.outlined.Storage
+import androidx.compose.material.icons.outlined.Warning
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.blur
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.TransformOrigin
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.layout.onSizeChanged
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalView
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.DpOffset
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.DialogProperties
+import androidx.compose.ui.window.DialogWindowProvider
+import androidx.compose.ui.window.Popup
+import androidx.compose.ui.window.PopupProperties
+import androidx.core.view.WindowCompat
+import coil.compose.AsyncImage
+import coil.request.CachePolicy
+import coil.request.ImageRequest
+import com.winlator.cmod.R
+import androidx.compose.runtime.CompositionLocalProvider
+import com.winlator.cmod.shared.ui.focus.controllerFocusGlow
+import com.winlator.cmod.shared.ui.outlinedSwitchColors
+import com.winlator.cmod.shared.ui.nav.DialogPaneNav
+import com.winlator.cmod.shared.ui.nav.LocalPaneNav
+import com.winlator.cmod.shared.ui.nav.PaneNavRegistry
+import com.winlator.cmod.shared.ui.nav.paneNavItem
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+private val LaunchBlack = Color.Black
+private val LaunchCard = Color(0xFF12121B)
+private val LaunchAccent = Color(0xFF1A9FFF)
+private val LaunchAccentGlow = Color(0xFF58A6FF)
+private val LaunchTextPrimary = Color(0xFFF0F4FF)
+private val LaunchTextSecondary = Color(0xFF93A6BC)
+private val LaunchDanger = Color(0xFFFF6B6B)
+
+@Composable
+internal fun LibraryGameLaunchScreen(
+ appName: String,
+ subtitle: String,
+ sourceLabel: String,
+ heroImageUrl: Any?,
+ customHeroImageCacheKey: String?,
+ releaseDateEpochSeconds: Long,
+ totalPlaytimeMillis: Long,
+ playCount: Int,
+ lastPlayedMillis: Long,
+ installSizeText: String?,
+ isCustom: Boolean,
+ isRetro: Boolean = false,
+ showBootToDesktop: Boolean = !isRetro,
+ showSaveTransfer: Boolean = false,
+ hasPinnedShortcut: Boolean,
+ steamMenuEnabled: Boolean = false,
+ areSteamActionsEnabled: Boolean = true,
+ showVerifyFiles: Boolean = true,
+ showCheckForUpdate: Boolean = true,
+ showWorkshop: Boolean = true,
+ playEnabled: Boolean = true,
+ playDisabledLabel: String? = null,
+ /**
+ * An alternative engine this particular game can be played with, offered
+ * right above Play because it changes what Play does. Absent (and the row
+ * not drawn at all) for every game that has no such choice, which is all
+ * but a handful.
+ */
+ altEngineLabel: String? = null,
+ altEngineEnabled: Boolean = false,
+ onAltEngineChange: ((Boolean) -> Unit)? = null,
+ onBack: () -> Unit,
+ onPlay: () -> Unit,
+ onSettings: () -> Unit,
+ onBootToDesktop: () -> Unit,
+ onAchievements: (() -> Unit)? = null,
+ onCheats: (() -> Unit)? = null,
+ cheatsEnabled: Boolean = true,
+ onShortcut: () -> Unit,
+ onCloudSaves: () -> Unit,
+ onSaveTransfer: (() -> Unit)? = null,
+ onUninstall: () -> Unit,
+ onVerifyFiles: () -> Unit = {},
+ onCheckForUpdate: () -> Unit = {},
+ onWorkshop: () -> Unit = {},
+) {
+ val context = LocalContext.current
+ var uninstallMenuOpen by remember { mutableStateOf(false) }
+ val saveTransferVisible = showSaveTransfer && onSaveTransfer != null
+ val bootVisible = showBootToDesktop
+ val actionIconCount =
+ 1 +
+ (if (saveTransferVisible) 1 else 0) +
+ 1 +
+ (if (bootVisible) 1 else 0) +
+ 1 +
+ 1
+
+ LaunchScreenCutoutMode()
+
+ Box(Modifier.fillMaxSize()) {
+ val edgePadding = 22.dp
+ val bottomPadding = 20.dp
+ val actionIconSize = 46.dp
+ val actionIconSpacing = 8.dp
+ val actionWidth = actionIconSize * actionIconCount + actionIconSpacing * (actionIconCount - 1).coerceAtLeast(0)
+ val playHeight = 56.dp
+ val contentGap = 18.dp
+ val horizontalNavInsets = WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal)
+
+ if (heroImageUrl != null) {
+ val heroRequest =
+ remember(heroImageUrl, customHeroImageCacheKey, context) {
+ ImageRequest
+ .Builder(context)
+ .data(heroImageUrl)
+ .apply {
+ if (customHeroImageCacheKey != null) {
+ memoryCacheKey(customHeroImageCacheKey)
+ diskCacheKey(customHeroImageCacheKey)
+ }
+ }.crossfade(150)
+ .memoryCachePolicy(CachePolicy.ENABLED)
+ .diskCachePolicy(CachePolicy.ENABLED)
+ .build()
+ }
+ AsyncImage(
+ model = heroRequest,
+ contentDescription = "$appName artwork",
+ modifier = Modifier.fillMaxSize(),
+ contentScale = ContentScale.Crop,
+ alignment = Alignment.Center,
+ )
+ } else {
+ Box(
+ Modifier
+ .fillMaxSize()
+ .background(
+ Brush.radialGradient(
+ colors = listOf(LaunchAccent.copy(alpha = 0.34f), LaunchCard, LaunchBlack),
+ radius = 980f,
+ ),
+ ),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ Icons.Outlined.SportsEsports,
+ contentDescription = null,
+ tint = LaunchTextPrimary.copy(alpha = 0.18f),
+ modifier = Modifier.size(132.dp),
+ )
+ }
+ }
+
+ Box(
+ Modifier
+ .fillMaxSize()
+ .background(
+ Brush.horizontalGradient(
+ colorStops =
+ arrayOf(
+ 0.0f to LaunchBlack.copy(alpha = 0.9f),
+ 0.36f to LaunchBlack.copy(alpha = 0.58f),
+ 0.72f to LaunchBlack.copy(alpha = 0.18f),
+ 1.0f to LaunchBlack.copy(alpha = 0.62f),
+ ),
+ ),
+ ),
+ )
+ Box(
+ Modifier
+ .fillMaxSize()
+ .background(
+ Brush.verticalGradient(
+ colorStops =
+ arrayOf(
+ 0.0f to LaunchBlack.copy(alpha = 0.54f),
+ 0.36f to Color.Transparent,
+ 0.72f to LaunchBlack.copy(alpha = 0.32f),
+ 1.0f to LaunchBlack.copy(alpha = 0.94f),
+ ),
+ ),
+ ),
+ )
+
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .windowInsetsPadding(horizontalNavInsets)
+ .padding(start = edgePadding, top = 12.dp, end = edgePadding),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ IconButton(
+ onClick = onBack,
+ modifier =
+ Modifier
+ .size(44.dp)
+ .clip(CircleShape)
+ .background(LaunchBlack.copy(alpha = 0.5f))
+ .border(1.dp, Color.White.copy(alpha = 0.18f), CircleShape),
+ ) {
+ Icon(
+ Icons.AutoMirrored.Outlined.ArrowBack,
+ contentDescription = stringResource(R.string.common_ui_back),
+ tint = LaunchTextPrimary,
+ modifier = Modifier.size(24.dp),
+ )
+ }
+ Spacer(Modifier.weight(1f))
+ SourceTag(
+ sourceLabel = sourceLabel,
+ menuEnabled = steamMenuEnabled,
+ showVerifyFiles = showVerifyFiles,
+ showCheckForUpdate = showCheckForUpdate,
+ showWorkshop = showWorkshop,
+ showAchievements = onAchievements != null,
+ showCheats = onCheats != null,
+ cheatsEnabled = cheatsEnabled,
+ areSteamActionsEnabled = areSteamActionsEnabled,
+ onVerifyFiles = onVerifyFiles,
+ onCheckForUpdate = onCheckForUpdate,
+ onWorkshop = onWorkshop,
+ onAchievements = { onAchievements?.invoke() },
+ onCheats = { onCheats?.invoke() },
+ )
+ }
+
+ Column(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .windowInsetsPadding(WindowInsets.navigationBars)
+ .padding(start = edgePadding, top = 68.dp, end = edgePadding, bottom = bottomPadding),
+ verticalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Column(
+ modifier = Modifier.widthIn(max = 640.dp),
+ verticalArrangement = Arrangement.spacedBy(6.dp),
+ ) {
+ Text(
+ appName,
+ style = MaterialTheme.typography.headlineLarge,
+ color = LaunchTextPrimary,
+ fontWeight = FontWeight.Bold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ if (subtitle.isNotBlank()) {
+ Text(
+ subtitle,
+ style = MaterialTheme.typography.titleSmall,
+ color = LaunchTextPrimary.copy(alpha = 0.72f),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ if (releaseDateEpochSeconds > 0L) {
+ val releaseDateText = remember(releaseDateEpochSeconds) { formatReleaseDate(releaseDateEpochSeconds) }
+ Text(
+ releaseDateText,
+ style = MaterialTheme.typography.bodyMedium,
+ color = LaunchTextPrimary.copy(alpha = 0.6f),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(contentGap),
+ verticalAlignment = Alignment.Bottom,
+ ) {
+ FlowRow(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ modifier = Modifier.weight(1f),
+ ) {
+ if (totalPlaytimeMillis > 0L) {
+ val playtimeText = remember(totalPlaytimeMillis) { formatLibraryPlaytime(totalPlaytimeMillis) }
+ GameStatChip(
+ icon = Icons.Outlined.Schedule,
+ label = stringResource(R.string.library_games_playtime),
+ value = playtimeText,
+ )
+ }
+ if (playCount > 0) {
+ GameStatChip(
+ icon = Icons.Outlined.SportsEsports,
+ label = stringResource(R.string.library_games_plays),
+ value = playCount.toString(),
+ )
+ }
+ if (lastPlayedMillis > 0L) {
+ val lastPlayedText = remember(lastPlayedMillis) { formatLibraryLastPlayed(lastPlayedMillis) }
+ GameStatChip(
+ icon = Icons.Outlined.History,
+ label = stringResource(R.string.library_games_last_played),
+ value = lastPlayedText,
+ )
+ }
+ if (installSizeText != null) {
+ GameStatChip(
+ icon = Icons.Outlined.Storage,
+ label = stringResource(R.string.common_ui_size),
+ value = installSizeText,
+ )
+ }
+ }
+
+ Column(
+ modifier = Modifier.width(actionWidth),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ if (altEngineLabel != null && onAltEngineChange != null) {
+ LaunchAltEngineToggle(
+ label = altEngineLabel,
+ checked = altEngineEnabled,
+ width = actionWidth,
+ onCheckedChange = onAltEngineChange,
+ )
+ }
+
+ LaunchPlayButton(
+ height = playHeight,
+ enabled = playEnabled,
+ disabledLabel = playDisabledLabel,
+ onClick = onPlay,
+ )
+
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(actionIconSpacing),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ LaunchIconActionButton(
+ icon = Icons.Outlined.Settings,
+ contentDescription = stringResource(R.string.common_ui_settings),
+ size = actionIconSize,
+ onClick = onSettings,
+ )
+ if (saveTransferVisible) {
+ LaunchIconActionButton(
+ icon = Icons.Outlined.SaveAlt,
+ contentDescription = stringResource(R.string.retro_save_transfer_title),
+ size = actionIconSize,
+ onClick = { onSaveTransfer?.invoke() },
+ )
+ }
+ LaunchIconActionButton(
+ icon = Icons.Outlined.CloudSync,
+ contentDescription = stringResource(R.string.cloud_saves_title),
+ size = actionIconSize,
+ onClick = onCloudSaves,
+ )
+ if (bootVisible) {
+ LaunchIconActionButton(
+ icon = Icons.Outlined.DesktopWindows,
+ contentDescription = stringResource(R.string.hero_boot_to_desktop_title),
+ size = actionIconSize,
+ onClick = onBootToDesktop,
+ )
+ }
+ LaunchIconActionButton(
+ icon = Icons.Outlined.Home,
+ contentDescription =
+ stringResource(
+ if (hasPinnedShortcut) R.string.common_ui_remove else R.string.common_ui_shortcut,
+ ),
+ size = actionIconSize,
+ onClick = onShortcut,
+ )
+ Box {
+ LaunchIconActionButton(
+ icon = Icons.Outlined.Delete,
+ contentDescription =
+ stringResource(if (isCustom) R.string.common_ui_remove else R.string.common_ui_uninstall),
+ size = actionIconSize,
+ onClick = { uninstallMenuOpen = true },
+ tint = LaunchDanger,
+ )
+ LaunchUninstallMenu(
+ expanded = uninstallMenuOpen,
+ appName = appName,
+ isCustom = isCustom,
+ onDismissRequest = { uninstallMenuOpen = false },
+ onConfirm = {
+ uninstallMenuOpen = false
+ onUninstall()
+ },
+ )
+ }
+ }
+ }
+ }
+ }
+
+ if (uninstallMenuOpen) {
+ Box(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .background(LaunchBlack.copy(alpha = 0.46f))
+ .clickable(
+ interactionSource = remember { MutableInteractionSource() },
+ indication = null,
+ onClick = { uninstallMenuOpen = false },
+ ),
+ )
+ }
+ }
+}
+
+@Composable
+private fun LaunchScreenCutoutMode() {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) return
+
+ val view = LocalView.current
+ DisposableEffect(view) {
+ val window = (view.parent as? DialogWindowProvider)?.window
+ ?: return@DisposableEffect onDispose { }
+
+ val originalCutoutMode = window.attributes.layoutInDisplayCutoutMode
+ val originalWidth = window.attributes.width
+ val originalHeight = window.attributes.height
+ val originalNavigationBarColor = window.navigationBarColor
+ val originalNavBarContrastEnforced =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ window.isNavigationBarContrastEnforced
+ } else {
+ false
+ }
+
+ WindowCompat.setDecorFitsSystemWindows(window, false)
+ window.clearFlags(
+ WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION or
+ WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS,
+ )
+ // FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS is required for navigationBarColor; Dialog windows don't set it by default.
+ window.addFlags(
+ WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS or
+ WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS,
+ )
+ window.setLayout(
+ WindowManager.LayoutParams.MATCH_PARENT,
+ WindowManager.LayoutParams.MATCH_PARENT,
+ )
+ window.attributes = window.attributes.apply {
+ layoutInDisplayCutoutMode = launchScreenCutoutMode()
+ }
+ window.navigationBarColor = android.graphics.Color.TRANSPARENT
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ window.isNavigationBarContrastEnforced = false
+ }
+
+ onDispose {
+ window.attributes = window.attributes.apply {
+ layoutInDisplayCutoutMode = originalCutoutMode
+ }
+ window.navigationBarColor = originalNavigationBarColor
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ window.isNavigationBarContrastEnforced = originalNavBarContrastEnforced
+ }
+ window.clearFlags(
+ WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS or
+ WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS,
+ )
+ WindowCompat.setDecorFitsSystemWindows(window, true)
+ window.setLayout(originalWidth, originalHeight)
+ }
+ }
+}
+
+private fun launchScreenCutoutMode(): Int =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS
+ } else {
+ WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
+ }
+
+@Composable
+private fun LaunchUninstallMenu(
+ expanded: Boolean,
+ appName: String,
+ isCustom: Boolean,
+ onDismissRequest: () -> Unit,
+ onConfirm: () -> Unit,
+) {
+ val title = stringResource(if (isCustom) R.string.library_games_remove_game else R.string.library_games_uninstall_game)
+ val confirmLabel = stringResource(if (isCustom) R.string.common_ui_remove else R.string.common_ui_uninstall)
+ val message =
+ stringResource(
+ if (isCustom) R.string.library_games_remove_confirm else R.string.library_games_uninstall_confirm,
+ appName,
+ )
+
+ LaunchDangerConfirmDialog(
+ visible = expanded,
+ title = title,
+ message = message,
+ confirmLabel = confirmLabel,
+ onDismissRequest = onDismissRequest,
+ onConfirm = onConfirm,
+ icon = Icons.Outlined.Delete,
+ cancelColor = LaunchAccent,
+ )
+}
+
+@Composable
+internal fun LaunchDangerConfirmMenu(
+ expanded: Boolean,
+ title: String,
+ message: String,
+ confirmLabel: String,
+ onDismissRequest: () -> Unit,
+ onConfirm: () -> Unit,
+ icon: ImageVector = Icons.Outlined.Delete,
+) {
+ DropdownMenu(
+ expanded = expanded,
+ onDismissRequest = onDismissRequest,
+ offset = DpOffset(x = 0.dp, y = (-56).dp),
+ modifier = Modifier.width(286.dp),
+ shape = RoundedCornerShape(12.dp),
+ containerColor = LaunchCard,
+ border = BorderStroke(1.dp, Color.White.copy(alpha = 0.14f)),
+ tonalElevation = 0.dp,
+ shadowElevation = 14.dp,
+ ) {
+ Column(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 12.dp, vertical = 10.dp),
+ verticalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(9.dp),
+ ) {
+ Icon(
+ icon,
+ contentDescription = null,
+ tint = LaunchDanger,
+ modifier = Modifier.size(18.dp),
+ )
+ Text(
+ title,
+ color = LaunchTextPrimary,
+ fontSize = 13.sp,
+ fontWeight = FontWeight.Bold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ Text(
+ message,
+ color = LaunchTextSecondary,
+ fontSize = 12.sp,
+ lineHeight = 16.sp,
+ )
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ LaunchMenuTextAction(
+ label = stringResource(R.string.common_ui_cancel),
+ textColor = LaunchTextSecondary,
+ onClick = onDismissRequest,
+ )
+ LaunchMenuTextAction(
+ label = confirmLabel,
+ textColor = LaunchDanger,
+ onClick = onConfirm,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+internal fun LaunchDangerConfirmDialog(
+ visible: Boolean,
+ title: String,
+ message: String,
+ confirmLabel: String,
+ onDismissRequest: () -> Unit,
+ onConfirm: () -> Unit,
+ icon: ImageVector = Icons.Outlined.Warning,
+ titleTextAlign: TextAlign = TextAlign.Start,
+ messageTextAlign: TextAlign = TextAlign.Start,
+ accentColor: Color = LaunchDanger,
+ cancelColor: Color = LaunchTextSecondary,
+) {
+ if (!visible) return
+
+ val registry = remember { PaneNavRegistry() }
+ Dialog(
+ onDismissRequest = onDismissRequest,
+ properties = DialogProperties(usePlatformDefaultWidth = false),
+ ) {
+ CompositionLocalProvider(LocalPaneNav provides registry) {
+ DialogPaneNav(registry, onDismiss = onDismissRequest)
+ Box(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .background(LaunchBlack.copy(alpha = 0.46f))
+ .clickable(
+ interactionSource = remember { MutableInteractionSource() },
+ indication = null,
+ onClick = onDismissRequest,
+ ),
+ contentAlignment = Alignment.Center,
+ ) {
+ Surface(
+ modifier =
+ Modifier
+ .width(286.dp)
+ .clickable(
+ interactionSource = remember { MutableInteractionSource() },
+ indication = null,
+ onClick = { },
+ ),
+ shape = RoundedCornerShape(12.dp),
+ color = LaunchCard,
+ border = BorderStroke(1.dp, Color.White.copy(alpha = 0.14f)),
+ shadowElevation = 14.dp,
+ tonalElevation = 0.dp,
+ ) {
+ LaunchDangerConfirmContent(
+ title = title,
+ message = message,
+ confirmLabel = confirmLabel,
+ onDismissRequest = onDismissRequest,
+ onConfirm = onConfirm,
+ icon = icon,
+ titleTextAlign = titleTextAlign,
+ messageTextAlign = messageTextAlign,
+ accentColor = accentColor,
+ cancelColor = cancelColor,
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun LaunchDangerConfirmContent(
+ title: String,
+ message: String,
+ confirmLabel: String,
+ onDismissRequest: () -> Unit,
+ onConfirm: () -> Unit,
+ icon: ImageVector,
+ titleTextAlign: TextAlign,
+ messageTextAlign: TextAlign,
+ accentColor: Color,
+ cancelColor: Color,
+) {
+ Column(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 12.dp, vertical = 10.dp),
+ verticalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ if (titleTextAlign == TextAlign.Center) {
+ Box(
+ modifier = Modifier.fillMaxWidth(),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ icon,
+ contentDescription = null,
+ tint = accentColor,
+ modifier =
+ Modifier
+ .align(Alignment.CenterStart)
+ .size(18.dp),
+ )
+ Text(
+ title,
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 28.dp),
+ color = LaunchTextPrimary,
+ fontSize = 13.sp,
+ fontWeight = FontWeight.Bold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ textAlign = TextAlign.Center,
+ )
+ }
+ } else {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(9.dp),
+ ) {
+ Icon(
+ icon,
+ contentDescription = null,
+ tint = accentColor,
+ modifier = Modifier.size(18.dp),
+ )
+ Text(
+ title,
+ color = LaunchTextPrimary,
+ fontSize = 13.sp,
+ fontWeight = FontWeight.Bold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+ Text(
+ message,
+ modifier = Modifier.fillMaxWidth(),
+ color = LaunchTextSecondary,
+ fontSize = 12.sp,
+ lineHeight = 16.sp,
+ textAlign = messageTextAlign,
+ )
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ LaunchMenuTextAction(
+ label = stringResource(R.string.common_ui_cancel),
+ textColor = cancelColor,
+ onClick = onDismissRequest,
+ modifier = Modifier.paneNavItem(onActivate = onDismissRequest),
+ )
+ LaunchMenuTextAction(
+ label = confirmLabel,
+ textColor = accentColor,
+ onClick = onConfirm,
+ modifier = Modifier.paneNavItem(onActivate = onConfirm, isEntry = true),
+ )
+ }
+ }
+}
+
+@Composable
+private fun LaunchMenuTextAction(
+ label: String,
+ textColor: Color,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Box(
+ modifier =
+ modifier
+ .clip(RoundedCornerShape(8.dp))
+ .controllerFocusGlow(cornerRadius = 8.dp)
+ .clickable(onClick = onClick)
+ .padding(horizontal = 10.dp, vertical = 7.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ label,
+ color = textColor,
+ fontSize = 12.sp,
+ fontWeight = FontWeight.SemiBold,
+ maxLines = 1,
+ )
+ }
+}
+
+@Composable
+private fun SourceTag(
+ sourceLabel: String,
+ menuEnabled: Boolean = false,
+ showVerifyFiles: Boolean = true,
+ showCheckForUpdate: Boolean = true,
+ showWorkshop: Boolean = true,
+ showAchievements: Boolean = false,
+ showCheats: Boolean = false,
+ cheatsEnabled: Boolean = true,
+ areSteamActionsEnabled: Boolean = true,
+ onVerifyFiles: () -> Unit = {},
+ onCheckForUpdate: () -> Unit = {},
+ onWorkshop: () -> Unit = {},
+ onAchievements: () -> Unit = {},
+ onCheats: () -> Unit = {},
+) {
+ var menuOpen by remember { mutableStateOf(false) }
+ var anchorHeightPx by remember { mutableStateOf(0) }
+ val menuInteractive = menuEnabled || showAchievements || showCheats
+ Box {
+ Surface(
+ color = Color.White.copy(alpha = 0.1f),
+ shape = RoundedCornerShape(8.dp),
+ border = BorderStroke(1.dp, Color.White.copy(alpha = 0.14f)),
+ modifier =
+ Modifier
+ .onSizeChanged { anchorHeightPx = it.height }
+ .then(if (menuInteractive) Modifier.clickable { menuOpen = true } else Modifier),
+ ) {
+ Row(
+ modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Box(
+ Modifier
+ .size(8.dp)
+ .clip(CircleShape)
+ .background(LaunchAccent),
+ )
+ Text(
+ sourceLabel.uppercase(),
+ color = LaunchTextPrimary,
+ fontSize = 12.sp,
+ fontWeight = FontWeight.Bold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ if (menuInteractive) {
+ Icon(
+ Icons.Outlined.ArrowDropDown,
+ contentDescription = stringResource(R.string.store_game_steam_options),
+ tint = LaunchTextPrimary,
+ modifier = Modifier.size(18.dp),
+ )
+ }
+ }
+ }
+ if (menuInteractive) {
+ val gapPx = with(LocalDensity.current) { 6.dp.roundToPx() }
+ LaunchSourceActionPopup(
+ expanded = menuOpen,
+ onDismissRequest = { menuOpen = false },
+ offset = IntOffset(0, anchorHeightPx + gapPx),
+ ) {
+ if (menuEnabled && showVerifyFiles) {
+ LaunchSourceMenuItem(
+ icon = Icons.AutoMirrored.Outlined.FactCheck,
+ label = stringResource(R.string.store_game_verify_files),
+ enabled = areSteamActionsEnabled,
+ ) { menuOpen = false; onVerifyFiles() }
+ }
+ if (menuEnabled && showCheckForUpdate) {
+ LaunchSourceMenuItem(
+ icon = Icons.Outlined.Refresh,
+ label = stringResource(R.string.store_game_check_for_update),
+ enabled = areSteamActionsEnabled,
+ ) { menuOpen = false; onCheckForUpdate() }
+ }
+ if (menuEnabled && showWorkshop) {
+ LaunchSourceMenuItem(
+ icon = Icons.Outlined.Construction,
+ label = stringResource(R.string.store_game_workshop),
+ enabled = areSteamActionsEnabled,
+ ) { menuOpen = false; onWorkshop() }
+ }
+ if (showAchievements) {
+ LaunchSourceMenuItem(
+ icon = Icons.Outlined.EmojiEvents,
+ label = stringResource(R.string.steam_achievements_title),
+ ) { menuOpen = false; onAchievements() }
+ }
+ if (showCheats) {
+ LaunchSourceMenuItem(
+ icon = Icons.Outlined.Bolt,
+ label = stringResource(R.string.retro_cheats_title),
+ enabled = cheatsEnabled,
+ ) { menuOpen = false; onCheats() }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun LaunchSourceActionPopup(
+ expanded: Boolean,
+ onDismissRequest: () -> Unit,
+ offset: IntOffset,
+ content: @Composable () -> Unit,
+) {
+ val transitionState = remember { MutableTransitionState(false) }
+ transitionState.targetState = expanded
+ if (!transitionState.currentState && !transitionState.targetState) return
+
+ Popup(
+ alignment = Alignment.TopEnd,
+ offset = offset,
+ onDismissRequest = onDismissRequest,
+ properties = PopupProperties(focusable = true),
+ ) {
+ AnimatedVisibility(
+ visibleState = transitionState,
+ enter =
+ fadeIn(animationSpec = tween(durationMillis = 90)) +
+ scaleIn(
+ animationSpec =
+ spring(
+ dampingRatio = 0.78f,
+ stiffness = Spring.StiffnessMediumLow,
+ ),
+ initialScale = 0.88f,
+ transformOrigin = TransformOrigin(1f, 0f),
+ ),
+ exit =
+ fadeOut(animationSpec = tween(durationMillis = 80)) +
+ scaleOut(
+ animationSpec = tween(durationMillis = 110),
+ targetScale = 0.92f,
+ transformOrigin = TransformOrigin(1f, 0f),
+ ),
+ ) {
+ Surface(
+ color = LaunchBlack.copy(alpha = 0.78f),
+ shape = RoundedCornerShape(12.dp),
+ border = BorderStroke(1.dp, Color.White.copy(alpha = 0.22f)),
+ tonalElevation = 0.dp,
+ shadowElevation = 16.dp,
+ ) {
+ Column { content() }
+ }
+ }
+ }
+}
+
+@Composable
+private fun LaunchSourceMenuItem(
+ icon: ImageVector,
+ label: String,
+ enabled: Boolean = true,
+ onClick: () -> Unit,
+) {
+ val contentColor = if (enabled) Color.White else Color.White.copy(alpha = 0.45f)
+ Row(
+ modifier =
+ Modifier
+ .then(if (enabled) Modifier.clickable(onClick = onClick) else Modifier)
+ .padding(start = 14.dp, end = 14.dp, top = 10.dp, bottom = 10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ Icon(
+ icon,
+ contentDescription = null,
+ tint = contentColor,
+ modifier = Modifier.size(16.dp),
+ )
+ Text(
+ label,
+ color = contentColor,
+ fontSize = 12.sp,
+ fontWeight = FontWeight.SemiBold,
+ )
+ }
+}
+
+@Composable
+private fun GameStatChip(
+ icon: ImageVector,
+ label: String,
+ value: String,
+ modifier: Modifier = Modifier,
+) {
+ Surface(
+ modifier = modifier,
+ color = LaunchBlack.copy(alpha = 0.44f),
+ shape = RoundedCornerShape(12.dp),
+ border = BorderStroke(1.dp, Color.White.copy(alpha = 0.11f)),
+ ) {
+ Row(
+ modifier =
+ Modifier.padding(
+ horizontal = 10.dp,
+ vertical = 8.dp,
+ ),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(7.dp),
+ ) {
+ Icon(icon, contentDescription = null, modifier = Modifier.size(16.dp), tint = LaunchAccentGlow)
+ Column(verticalArrangement = Arrangement.spacedBy(1.dp)) {
+ Text(
+ label.uppercase(),
+ color = LaunchTextSecondary,
+ fontSize = 9.sp,
+ fontWeight = FontWeight.Bold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ Text(
+ value,
+ color = LaunchTextPrimary,
+ fontSize = 12.sp,
+ fontWeight = FontWeight.SemiBold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+ }
+}
+
+/**
+ * The alternative-engine switch above Play.
+ *
+ * Deliberately the same width as the Play button and immediately above it: it
+ * decides which engine Play will start, so it belongs in the reading path to
+ * that button rather than buried in a settings pane. The state is the game's
+ * own saved setting, so what it shows survives leaving the screen.
+ */
+@Composable
+private fun LaunchAltEngineToggle(
+ label: String,
+ checked: Boolean,
+ width: Dp,
+ onCheckedChange: (Boolean) -> Unit,
+) {
+ Row(
+ modifier = Modifier
+ .width(width)
+ .clip(RoundedCornerShape(14.dp))
+ .background(Color.White.copy(alpha = 0.06f))
+ .clickable { onCheckedChange(!checked) }
+ .padding(horizontal = 16.dp, vertical = 10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Text(
+ text = label,
+ color = Color.White.copy(alpha = 0.92f),
+ fontSize = 14.sp,
+ fontWeight = FontWeight.Medium,
+ )
+ Switch(
+ checked = checked,
+ onCheckedChange = onCheckedChange,
+ colors = outlinedSwitchColors(
+ accentColor = LaunchAccent,
+ textSecondaryColor = Color.White.copy(alpha = 0.55f),
+ ),
+ )
+ }
+}
+
+@Composable
+private fun LaunchPlayButton(
+ height: Dp,
+ enabled: Boolean = true,
+ disabledLabel: String? = null,
+ onClick: () -> Unit,
+) {
+ val interactionSource = remember { MutableInteractionSource() }
+ val isPressed by interactionSource.collectIsPressedAsState()
+ val scale by animateFloatAsState(
+ targetValue = if (enabled && isPressed) 0.96f else 1f,
+ animationSpec = spring(dampingRatio = 0.5f, stiffness = 600f),
+ label = "launchPlayScale",
+ )
+ val flare by animateFloatAsState(
+ targetValue = if (enabled && isPressed) 1f else 0f,
+ animationSpec = spring(dampingRatio = 0.6f, stiffness = 500f),
+ label = "launchPlayFlare",
+ )
+
+ val playShape = remember { RoundedCornerShape(14.dp) }
+ // Disabled: drop the clickable entirely so focus skips it and a stray controller A-press can't fire onClick.
+ val backgroundBrush =
+ if (enabled) {
+ Brush.horizontalGradient(
+ colors =
+ listOf(
+ Color(0xFF00B4D8).copy(alpha = 0.38f),
+ LaunchAccent.copy(alpha = 0.38f),
+ Color(0xFF7B2FF7).copy(alpha = 0.38f),
+ ),
+ )
+ } else {
+ Brush.horizontalGradient(
+ colors =
+ listOf(
+ Color(0xFF3A3F4A).copy(alpha = 0.35f),
+ Color(0xFF2D313A).copy(alpha = 0.35f),
+ Color(0xFF3A3F4A).copy(alpha = 0.35f),
+ ),
+ )
+ }
+ val glassSheenBrush =
+ if (enabled) {
+ Brush.verticalGradient(
+ 0.00f to Color.White.copy(alpha = 0.28f),
+ 0.35f to Color.White.copy(alpha = 0.06f),
+ 0.55f to Color.Transparent,
+ 1.00f to Color.Black.copy(alpha = 0.12f),
+ )
+ } else {
+ Brush.verticalGradient(
+ 0.0f to Color.White.copy(alpha = 0.10f),
+ 0.6f to Color.Transparent,
+ 1.0f to Color.Black.copy(alpha = 0.08f),
+ )
+ }
+ val glassRimBrush =
+ if (enabled) {
+ Brush.verticalGradient(
+ 0.0f to Color.White.copy(alpha = 0.55f + 0.35f * flare),
+ 0.5f to Color.White.copy(alpha = 0.08f + 0.18f * flare),
+ 1.0f to Color.White.copy(alpha = 0.22f + 0.22f * flare),
+ )
+ } else {
+ Brush.verticalGradient(
+ 0.0f to Color.White.copy(alpha = 0.16f),
+ 1.0f to Color.White.copy(alpha = 0.04f),
+ )
+ }
+ val foregroundAlpha = if (enabled) 1f else 0.75f
+
+ val baseModifier =
+ Modifier
+ .fillMaxWidth()
+ .height(height)
+ .graphicsLayer {
+ scaleX = scale
+ scaleY = scale
+ }.clip(playShape)
+ .background(backgroundBrush)
+ .background(glassSheenBrush)
+ .border(1.dp, glassRimBrush, playShape)
+ val finalModifier =
+ if (enabled) {
+ baseModifier
+ .controllerFocusGlow(cornerRadius = 14.dp)
+ .clickable(
+ interactionSource = interactionSource,
+ indication = null,
+ onClick = onClick,
+ )
+ } else {
+ baseModifier
+ }
+
+ val showStatus = !enabled && !disabledLabel.isNullOrBlank()
+ val icon = if (showStatus) Icons.Outlined.Refresh else Icons.Outlined.PlayArrow
+ val label = if (showStatus) disabledLabel!! else stringResource(R.string.library_games_play)
+
+ Box(
+ modifier = finalModifier,
+ contentAlignment = Alignment.Center,
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.Center,
+ ) {
+ Icon(
+ icon,
+ contentDescription = null,
+ modifier = Modifier.size(28.dp),
+ tint = Color.White.copy(alpha = foregroundAlpha),
+ )
+ Spacer(Modifier.width(8.dp))
+ Text(
+ label,
+ color = Color.White.copy(alpha = foregroundAlpha),
+ fontSize = 19.sp,
+ fontWeight = FontWeight.Bold,
+ maxLines = 1,
+ )
+ }
+ }
+}
+
+@Composable
+private fun LaunchIconActionButton(
+ icon: ImageVector,
+ contentDescription: String,
+ size: Dp,
+ onClick: () -> Unit,
+ tint: Color = Color.White,
+) {
+ Surface(
+ modifier =
+ Modifier
+ .size(size)
+ .clip(RoundedCornerShape(8.dp))
+ .clickable(onClick = onClick),
+ color = LaunchBlack.copy(alpha = 0.46f),
+ shape = RoundedCornerShape(8.dp),
+ border = BorderStroke(1.dp, tint.copy(alpha = 0.18f)),
+ ) {
+ Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Icon(
+ icon,
+ contentDescription = contentDescription,
+ modifier = Modifier.size(28.dp),
+ tint = tint,
+ )
+ }
+ }
+}
+
+private fun formatReleaseDate(releaseDateEpochSeconds: Long): String =
+ SimpleDateFormat("MMM d, yyyy", Locale.getDefault())
+ .format(Date(releaseDateEpochSeconds * 1000L))
+
+private fun formatLibraryPlaytime(playtimeMillis: Long): String {
+ val totalMinutes = (playtimeMillis / 60000L).coerceAtLeast(1L)
+ val hours = totalMinutes / 60L
+ val minutes = totalMinutes % 60L
+ return when {
+ hours > 0L && minutes > 0L -> "${hours}h ${minutes}m"
+ hours > 0L -> "${hours}h"
+ else -> "${minutes}m"
+ }
+}
+
+private fun formatLibraryLastPlayed(lastPlayedMillis: Long): String =
+ SimpleDateFormat("MMM d", Locale.getDefault()).format(Date(lastPlayedMillis))
diff --git a/app/src/main/app/shell/RetroConsoleRibbon.kt b/app/src/main/app/shell/RetroConsoleRibbon.kt
new file mode 100644
index 000000000..7b2d6c04f
--- /dev/null
+++ b/app/src/main/app/shell/RetroConsoleRibbon.kt
@@ -0,0 +1,72 @@
+package com.winlator.cmod.app.shell
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.width
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.layout
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+
+internal val retroLibrarySystemIds = mutableStateOf