diff --git a/.gitattributes b/.gitattributes index 6fd1589a1..0b6224cf0 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,3 +3,5 @@ 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 + +app/src/main/jniLibs/arm64-v8a/*_libretro_android.so filter=lfs diff=lfs merge=lfs -text diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index c32e0daa6..e13440f96 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -91,10 +91,15 @@ jobs: - name: Install Rust Android target run: rustup target add aarch64-linux-android + - name: Validate Gradle wrappers + uses: gradle/actions/wrapper-validation@v6 + with: + allow-checksums: e2b82129ab64751fd40437007bd2f7f2afb3c6e41a9198e628650b22d5824a14 + - name: Set up Gradle uses: gradle/actions/setup-gradle@v6 with: - validate-wrappers: true + validate-wrappers: false cache-cleanup: 'on-success' # PRs restore the Gradle cache read-only; only main pushes write it. cache-read-only: ${{ github.event_name == 'pull_request' }} diff --git a/.github/workflows/tag-apk-artifacts.yml b/.github/workflows/tag-apk-artifacts.yml index ba0955edb..5b3aae746 100644 --- a/.github/workflows/tag-apk-artifacts.yml +++ b/.github/workflows/tag-apk-artifacts.yml @@ -84,10 +84,15 @@ jobs: - name: Install Rust Android target run: rustup target add aarch64-linux-android + - name: Validate Gradle wrappers + uses: gradle/actions/wrapper-validation@v6 + with: + allow-checksums: e2b82129ab64751fd40437007bd2f7f2afb3c6e41a9198e628650b22d5824a14 + - name: Set up Gradle uses: gradle/actions/setup-gradle@v6 with: - validate-wrappers: true + validate-wrappers: false cache-cleanup: 'on-success' - name: Decode keystore and build debug APK diff --git a/.gitignore b/.gitignore index ecf01c18a..d3a4ae7a1 100644 --- a/.gitignore +++ b/.gitignore @@ -53,6 +53,12 @@ signing.properties References/ *.hprof android_sysvshm/build64/ - -# local build artifacts (FEX/Proton wcp) -dist/ + +# local build artifacts (FEX/Proton wcp) +dist/ + +/cores/*/ +!/cores/patches/ + +/armsx2/emucore-src/ +/armsx2/emucore-out/ diff --git a/.gitmodules b/.gitmodules index b5cf369f6..9a403e6ed 100644 --- a/.gitmodules +++ b/.gitmodules @@ -5,3 +5,9 @@ path = app/src/main/cpp/vkbasalt url = https://github.com/WinNative-Emu/vkBasalt.git branch = Test +[submodule "libretrodroid/src/main/cpp/oboe"] + path = libretrodroid/src/main/cpp/oboe + url = https://github.com/google/oboe +[submodule "libretrodroid/src/main/cpp/libretro/libretro-common"] + path = libretrodroid/src/main/cpp/libretro/libretro-common + url = https://github.com/libretro/libretro-common diff --git a/EMULATOR_CREDITS.md b/EMULATOR_CREDITS.md new file mode 100644 index 000000000..588c0b91e --- /dev/null +++ b/EMULATOR_CREDITS.md @@ -0,0 +1,67 @@ +# 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 | +| 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 upstream repositories linked in this document, and the bundled license texts are +retained in the source tree (for example `libretrodroid/src/main/cpp/rcheevos/LICENSE`, +`libretrodroid/src/main/cpp/SGSR_LICENSE`, and the top-level `LICENSE`). diff --git a/README.md b/README.md index 5a008d88c..2f15049e8 100644 --- a/README.md +++ b/README.md @@ -1,60 +1,98 @@ -

- WinNative -

-

- - Discord - -

- -## 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, 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 +

+

+ + Discord + +

+ +## 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/app/build.gradle b/app/build.gradle index 62637d031..5971e9d69 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -209,7 +209,13 @@ 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' ] } } @@ -285,6 +291,10 @@ dependencies { implementation libs.playServicesGamesV2 implementation libs.workRuntimeKtx + + implementation project(':libretrodroid') + implementation project(':armsx2') + implementation project(':dolphin') } spotless { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index dcb6bc9e1..7d06e5609 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -127,6 +127,38 @@ android:screenOrientation="sensorLandscape" android:exported="false" /> + + + + + + + + + diff --git a/app/src/main/app/PluviaApp.kt b/app/src/main/app/PluviaApp.kt index b52d883b9..7db378cbe 100644 --- a/app/src/main/app/PluviaApp.kt +++ b/app/src/main/app/PluviaApp.kt @@ -24,10 +24,32 @@ import java.io.File 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 + 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 + // Cached probe for devices whose native stack still needs system libjpeg preloaded. preloadSystemLibraries() @@ -39,10 +61,6 @@ class PluviaApp : Application() { com.winlator.cmod.app.service.NetworkMonitor .init(this) scheduleColdStartWarmups() - - Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> - Log.e("PluviaApp", "CRASH in thread ${thread.name}", throwable) - } } companion object { diff --git a/app/src/main/app/shell/LibraryGameLaunchScreen.kt b/app/src/main/app/shell/LibraryGameLaunchScreen.kt index 66f62fb8f..1fd6cacb1 100644 --- a/app/src/main/app/shell/LibraryGameLaunchScreen.kt +++ b/app/src/main/app/shell/LibraryGameLaunchScreen.kt @@ -43,7 +43,9 @@ 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 @@ -63,6 +65,7 @@ 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 @@ -105,6 +108,7 @@ 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 @@ -134,6 +138,9 @@ internal fun LibraryGameLaunchScreen( lastPlayedMillis: Long, installSizeText: String?, isCustom: Boolean, + isRetro: Boolean = false, + showBootToDesktop: Boolean = !isRetro, + showSaveTransfer: Boolean = false, hasPinnedShortcut: Boolean, steamMenuEnabled: Boolean = false, areSteamActionsEnabled: Boolean = true, @@ -142,13 +149,25 @@ internal fun LibraryGameLaunchScreen( 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 = {}, @@ -156,6 +175,15 @@ internal fun LibraryGameLaunchScreen( ) { 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() @@ -164,9 +192,7 @@ internal fun LibraryGameLaunchScreen( val bottomPadding = 20.dp val actionIconSize = 46.dp val actionIconSpacing = 8.dp - // Action icons: Settings, Boot, CloudSync, Shortcut, Delete. - val actionIconCount = 5 - val actionWidth = actionIconSize * actionIconCount + actionIconSpacing * (actionIconCount - 1) + val actionWidth = actionIconSize * actionIconCount + actionIconSpacing * (actionIconCount - 1).coerceAtLeast(0) val playHeight = 56.dp val contentGap = 18.dp val horizontalNavInsets = WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal) @@ -278,11 +304,14 @@ internal fun LibraryGameLaunchScreen( 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() }, ) } @@ -374,6 +403,15 @@ internal fun LibraryGameLaunchScreen( 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, @@ -391,18 +429,28 @@ internal fun LibraryGameLaunchScreen( size = actionIconSize, onClick = onSettings, ) - LaunchIconActionButton( - icon = Icons.Outlined.DesktopWindows, - contentDescription = stringResource(R.string.hero_boot_to_desktop_title), - size = actionIconSize, - onClick = onBootToDesktop, - ) + 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 = @@ -821,14 +869,18 @@ private fun SourceTag( 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), @@ -837,7 +889,7 @@ private fun SourceTag( modifier = Modifier .onSizeChanged { anchorHeightPx = it.height } - .then(if (menuEnabled) Modifier.clickable { menuOpen = true } else Modifier), + .then(if (menuInteractive) Modifier.clickable { menuOpen = true } else Modifier), ) { Row( modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), @@ -858,7 +910,7 @@ private fun SourceTag( maxLines = 1, overflow = TextOverflow.Ellipsis, ) - if (menuEnabled) { + if (menuInteractive) { Icon( Icons.Outlined.ArrowDropDown, contentDescription = stringResource(R.string.store_game_steam_options), @@ -868,28 +920,28 @@ private fun SourceTag( } } } - if (menuEnabled) { + if (menuInteractive) { val gapPx = with(LocalDensity.current) { 6.dp.roundToPx() } LaunchSourceActionPopup( expanded = menuOpen, onDismissRequest = { menuOpen = false }, offset = IntOffset(0, anchorHeightPx + gapPx), ) { - if (showVerifyFiles) { + if (menuEnabled && showVerifyFiles) { LaunchSourceMenuItem( icon = Icons.AutoMirrored.Outlined.FactCheck, label = stringResource(R.string.store_game_verify_files), enabled = areSteamActionsEnabled, ) { menuOpen = false; onVerifyFiles() } } - if (showCheckForUpdate) { + if (menuEnabled && showCheckForUpdate) { LaunchSourceMenuItem( icon = Icons.Outlined.Refresh, label = stringResource(R.string.store_game_check_for_update), enabled = areSteamActionsEnabled, ) { menuOpen = false; onCheckForUpdate() } } - if (showWorkshop) { + if (menuEnabled && showWorkshop) { LaunchSourceMenuItem( icon = Icons.Outlined.Construction, label = stringResource(R.string.store_game_workshop), @@ -902,6 +954,13 @@ private fun SourceTag( 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() } + } } } } @@ -1034,6 +1093,48 @@ private fun GameStatChip( } } +/** + * 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, 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>(emptyMap()) + +internal fun libraryBadgeLabel( + appId: Int, + isCustom: Boolean, +): String? { + val systemId = retroLibrarySystemIds.value[appId] + if (systemId != null) { + return com.winlator.cmod.feature.retro.RetroSystems + .fromId(systemId) + ?.badgeLabel + ?: systemId + } + return if (isCustom) "PC" else null +} + +@Composable +internal fun RetroConsoleRibbon( + label: String, + modifier: Modifier = Modifier, +) { + Box( + modifier = + modifier + .fillMaxHeight() + .width(14.dp) + .background(Color(0xD9090C10)), + contentAlignment = Alignment.Center, + ) { + Text( + text = label, + color = Color(0xFFE6EDF3), + fontSize = 8.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 1.2.sp, + maxLines = 1, + softWrap = false, + modifier = Modifier.verticalRibbonText(), + ) + } +} + +private fun Modifier.verticalRibbonText(): Modifier = + this.layout { measurable, _ -> + val placeable = measurable.measure(androidx.compose.ui.unit.Constraints()) + + layout(placeable.height, placeable.width) { + placeable.placeWithLayer( + x = -(placeable.width - placeable.height) / 2, + y = -(placeable.height - placeable.width) / 2, + ) { + rotationZ = -90f + } + } + } diff --git a/app/src/main/app/shell/UnifiedActivity.kt b/app/src/main/app/shell/UnifiedActivity.kt index ab661f60d..d0c4076af 100644 --- a/app/src/main/app/shell/UnifiedActivity.kt +++ b/app/src/main/app/shell/UnifiedActivity.kt @@ -430,6 +430,7 @@ class UnifiedActivity : private var dpadHeld = false private var joystickActive = false + @Volatile private var retroCloudUploadBusy = false internal val settingsNavBridge = SettingsNavBridge() internal val downloadsNavBridge = DownloadsNavBridge() @@ -628,6 +629,7 @@ class UnifiedActivity : } UpdateChecker.startBackgroundLoop(this) + processPendingRetroCloudBackup() } override fun onWindowFocusChanged(hasFocus: Boolean) { @@ -911,6 +913,74 @@ class UnifiedActivity : handleSettingsIntent(intent) } + internal fun retryPendingRetroCloudBackup() = processPendingRetroCloudBackup() + + private fun processPendingRetroCloudBackup() { + val prefs = androidx.preference.PreferenceManager.getDefaultSharedPreferences(this) + val hasLegacy = prefs.getString("retro_pending_backup_id", null) != null + val hasDolphin = com.winlator.cmod.feature.retro.DolphinCloudSync.peekPending(this) != null + if (!hasLegacy && !hasDolphin) return + if (!com.winlator.cmod.feature.sync.google.GameSaveBackupManager.isDriveConnected(this)) return + runCatching { + com.winlator.cmod.feature.sync.google.PlayGamesBootstrap.ensureInitialized(this) + com.google.android.gms.games.PlayGames + .getGamesSignInClient(this) + .signIn() + .addOnCompleteListener { runPendingRetroUploads() } + }.onFailure { runPendingRetroUploads() } + } + + private fun runPendingRetroUploads() { + if (retroCloudUploadBusy) return + val prefs = androidx.preference.PreferenceManager.getDefaultSharedPreferences(this) + val pendingId = prefs.getString("retro_pending_backup_id", null) + val pendingName = prefs.getString("retro_pending_backup_name", null) + val dolphinPending = com.winlator.cmod.feature.retro.DolphinCloudSync.peekPending(this) + if ((pendingId == null || pendingName == null) && dolphinPending == null) return + retroCloudUploadBusy = true + lifecycleScope.launch(Dispatchers.IO) { + try { + dolphinPending?.let { p -> + if (uploadRetroCloudBackup(p.cloudId, p.gameName)) { + com.winlator.cmod.feature.retro.DolphinCloudSync.clearPending(this@UnifiedActivity) + if (p.fingerprint.isNotEmpty()) { + prefs.edit().putString("retro_cloud_fp_${p.cloudId}", p.fingerprint).apply() + } + } + } + if (pendingId != null && pendingName != null && + uploadRetroCloudBackup(pendingId, pendingName) + ) { + prefs.edit().remove("retro_pending_backup_id").remove("retro_pending_backup_name").apply() + } + } finally { + retroCloudUploadBusy = false + } + } + } + + private suspend fun uploadRetroCloudBackup(cloudId: String, gameName: String): Boolean { + val result = + runCatching { + GameSaveBackupManager.backupSaveToGoogle( + this@UnifiedActivity, + GameSaveBackupManager.GameSource.CUSTOM, + cloudId, + gameName, + GameSaveBackupManager.BackupOrigin.AUTO, + com.winlator.cmod.feature.sync.google.GoogleAuthMode.RESUME, + customSaveDir = com.winlator.cmod.feature.retro.RetroSaveStates.gameDir(this, gameName), + ) + }.getOrNull() + android.util.Log.i("WnDolphin", "upload id=$cloudId success=${result?.success} msg=${result?.message}") + if (result?.success == true) { + androidx.preference.PreferenceManager.getDefaultSharedPreferences(this) + .edit().putLong("retro_cloud_mark_$cloudId", System.currentTimeMillis()).apply() + return true + } + return false + } + override fun onCreate(savedInstanceState: Bundle?) { instance = this super.onCreate(savedInstanceState) @@ -931,6 +1001,7 @@ class UnifiedActivity : com.winlator.cmod.runtime.display.GlassesManager.init(this) bootstrapStartupState() maybeAutoSignInGoogleOnLaunch() + processPendingRetroCloudBackup() // Surface store-session events as toasts. lifecycleScope.launch { @@ -1191,6 +1262,7 @@ class UnifiedActivity : internal data class HomeShortcutUiState( val shortcut: Shortcut? = null, val isPinned: Boolean = false, + val loaded: Boolean = false, ) internal data class ArtworkCacheId( diff --git a/app/src/main/app/shell/UnifiedActivityDrawer.kt b/app/src/main/app/shell/UnifiedActivityDrawer.kt index 3f749527a..0fd85a29f 100644 --- a/app/src/main/app/shell/UnifiedActivityDrawer.kt +++ b/app/src/main/app/shell/UnifiedActivityDrawer.kt @@ -776,6 +776,7 @@ internal fun UnifiedActivity.AddCustomGameDialog(onDismiss: () -> Unit) { var selectedExePath by remember { mutableStateOf(null) } var gameName by remember { mutableStateOf("") } var gameFolder by remember { mutableStateOf(null) } + var retroSystem by remember { mutableStateOf(null) } var isAdding by remember { mutableStateOf(false) } var nameEditing by remember { mutableStateOf(false) } val nameFocus = remember { FocusRequester() } @@ -787,26 +788,46 @@ internal fun UnifiedActivity.AddCustomGameDialog(onDismiss: () -> Unit) { } } val registry = remember { PaneNavRegistry() } - val addEnabled = selectedExePath != null && gameName.isNotBlank() && gameFolder != null && !isAdding + val addEnabled = + selectedExePath != null && gameName.isNotBlank() && !isAdding && + (retroSystem != null || gameFolder != null) val doAdd: () -> Unit = { isAdding = true + val chosenRetro = retroSystem scope.launch(Dispatchers.IO) { - addCustomGame(context, gameName.trim(), selectedExePath!!, gameFolder!!) + val added = + if (chosenRetro != null) { + com.winlator.cmod.feature.retro.RetroShortcuts + .create(context, gameName.trim(), selectedExePath!!, chosenRetro) + } else { + addCustomGame(context, gameName.trim(), selectedExePath!!, gameFolder!!) + true + } withContext(Dispatchers.Main) { isAdding = false - com.winlator.cmod.shared.ui.toast.WinToast.show( - context, - "$gameName added!", - android.widget.Toast.LENGTH_SHORT, - ) - onDismiss() + if (added) { + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + "$gameName added!", + android.widget.Toast.LENGTH_SHORT, + ) + onDismiss() + } else { + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + "Could not add game", + android.widget.Toast.LENGTH_SHORT, + ) + } } } } fun selectExecutable(path: String) { + val detectedRetro = com.winlator.cmod.feature.retro.RetroSystems.detectForFile(path) val file = java.io.File(path) - if (!file.isFile || file.extension.lowercase() !in DirectoryPickerDialog.ExecutableExtensions) { + val launchable = file.extension.lowercase() in DirectoryPickerDialog.ExecutableExtensions + if (!file.isFile || (!launchable && detectedRetro == null)) { com.winlator.cmod.shared.ui.toast.WinToast.show( context, R.string.common_ui_select_valid_exe_file, @@ -816,7 +837,13 @@ internal fun UnifiedActivity.AddCustomGameDialog(onDismiss: () -> Unit) { } selectedExePath = path - gameFolder = LibraryShortcutUtils.detectCustomGameFolder(path) + retroSystem = detectedRetro + gameFolder = + if (detectedRetro != null) { + java.io.File(path).parent + } else { + LibraryShortcutUtils.detectCustomGameFolder(path) + } // Auto-generate a game name from the EXE name (without extension) if (gameName.isBlank()) { gameName = @@ -886,7 +913,8 @@ internal fun UnifiedActivity.AddCustomGameDialog(onDismiss: () -> Unit) { android.os.Environment.DIRECTORY_DOWNLOADS, ).absolutePath, title = getString(R.string.common_ui_select_exe), - allowedExtensions = DirectoryPickerDialog.ExecutableExtensions, + allowedExtensions = DirectoryPickerDialog.ExecutableExtensions + + com.winlator.cmod.feature.retro.RetroSystems.allExtensions, dimAmount = 0.5f, preserveBackdropBlur = true, extraRoots = driveRoots(includeInternal = true), @@ -899,7 +927,7 @@ internal fun UnifiedActivity.AddCustomGameDialog(onDismiss: () -> Unit) { Icon(Icons.Outlined.FolderOpen, contentDescription = null, tint = Accent, modifier = Modifier.size(16.dp)) Spacer(Modifier.width(8.dp)) Text( - selectedExePath ?: "Select Executable", + selectedExePath ?: "Select Executable or Console ROM", color = if (selectedExePath == null) TextSecondary else TextPrimary, maxLines = if (selectedExePath == null) 1 else Int.MAX_VALUE, overflow = if (selectedExePath == null) TextOverflow.Ellipsis else TextOverflow.Visible, @@ -947,6 +975,72 @@ internal fun UnifiedActivity.AddCustomGameDialog(onDismiss: () -> Unit) { Spacer(Modifier.height(8.dp)) + if (retroSystem != null) { + val activeRetroSystem = retroSystem + var consoleMenuOpen by remember { mutableStateOf(false) } + Box { + Row( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .background(Color.White.copy(alpha = 0.05f)) + .paneNavItem( + cornerRadius = 10.dp, + tapToSelect = true, + onActivate = { consoleMenuOpen = true }, + ).clickable { consoleMenuOpen = true } + .padding(horizontal = 10.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + Icons.Outlined.SportsEsports, + contentDescription = null, + tint = StatusOnline.copy(alpha = 0.7f), + modifier = Modifier.size(14.dp), + ) + Spacer(Modifier.width(6.dp)) + Column(Modifier.weight(1f)) { + Text("Console", color = TextSecondary, fontSize = 9.sp) + Text( + activeRetroSystem?.displayName ?: "", + color = TextPrimary, + fontSize = 10.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Icon( + Icons.Outlined.Edit, + contentDescription = stringResource(R.string.common_ui_change), + tint = Accent, + modifier = Modifier.size(14.dp), + ) + } + DropdownMenu( + expanded = consoleMenuOpen, + onDismissRequest = { consoleMenuOpen = false }, + containerColor = Color(0xFF1C232E), + ) { + com.winlator.cmod.feature.retro.RetroSystems.ALL.forEach { candidate -> + DropdownMenuItem( + text = { + Text( + candidate.displayName, + color = + if (candidate.id == activeRetroSystem?.id) Accent else TextPrimary, + fontSize = 12.sp, + ) + }, + onClick = { + retroSystem = candidate + consoleMenuOpen = false + }, + ) + } + } + } + } else { // Game folder — single compact row Row( modifier = @@ -1006,6 +1100,7 @@ internal fun UnifiedActivity.AddCustomGameDialog(onDismiss: () -> Unit) { ) } } + } } } diff --git a/app/src/main/app/shell/UnifiedActivityGameDialogs.kt b/app/src/main/app/shell/UnifiedActivityGameDialogs.kt index 8d19bc398..57bc7e5d4 100644 --- a/app/src/main/app/shell/UnifiedActivityGameDialogs.kt +++ b/app/src/main/app/shell/UnifiedActivityGameDialogs.kt @@ -899,6 +899,7 @@ internal fun UnifiedActivity.GameSettingsDialog( HomeShortcutUiState( shortcut = shortcut, isPinned = shortcut?.let { LibraryShortcutUtils.hasPinnedHomeShortcut(context, it) } == true, + loaded = true, ) } } @@ -1265,6 +1266,7 @@ internal fun UnifiedActivity.GameSettingsDialog( gameId = gameIdStr, gameName = app.name, shortcut = shortcut, + retroSaveDir = com.winlator.cmod.feature.sync.google.GameSaveBackupManager.retroSaveDir(context, shortcut), onCloudSyncToggle = { enabled -> cloudSyncEnabled = enabled setShortcutCloudSyncEnabled(shortcut, enabled) @@ -1426,6 +1428,7 @@ internal fun UnifiedActivity.GOGGameSettingsDialog( HomeShortcutUiState( shortcut = shortcut, isPinned = shortcut?.let { LibraryShortcutUtils.hasPinnedHomeShortcut(context, it) } == true, + loaded = true, ) } } @@ -1761,6 +1764,7 @@ internal fun UnifiedActivity.LibraryGameDetailDialog( HomeShortcutUiState( shortcut = shortcut, isPinned = shortcut?.let { LibraryShortcutUtils.hasPinnedHomeShortcut(context, it) } == true, + loaded = true, ) } } @@ -1781,6 +1785,32 @@ internal fun UnifiedActivity.LibraryGameDetailDialog( } } val hasPinnedShortcut = pinnedShortcutOverride ?: homeShortcutState.isPinned + val librarySystemIdHint = if (isCustom) retroLibrarySystemIds.value[app.id] else null + val retroCaps = + remember(homeShortcutState.shortcut, homeShortcutState.loaded, isCustom, librarySystemIdHint) { + when { + !isCustom -> com.winlator.cmod.feature.retro.RetroShortcuts.LibraryCapabilities() + homeShortcutState.loaded -> + com.winlator.cmod.feature.retro.RetroShortcuts.libraryCapabilities(homeShortcutState.shortcut) + else -> + com.winlator.cmod.feature.retro.RetroShortcuts.libraryCapabilitiesForSystemId(librarySystemIdHint) + } + } + val isRetro = retroCaps.isRetro + val isExternalRetro = retroCaps.isExternal + val retroSystemId = retroCaps.systemId + val retroRomPath = + retroCaps.romPath + ?: homeShortcutState.shortcut + ?.getExtra(com.winlator.cmod.feature.retro.RetroShortcuts.KEY_ROM) + ?.takeIf { it.isNotEmpty() } + LaunchedEffect(retroSystemId, retroRomPath, isExternalRetro) { + val sid = retroSystemId + val rp = retroRomPath + if (sid != null && rp != null && !isExternalRetro) { + com.winlator.cmod.feature.retro.RetroAchievementsManager.prefetch(context, sid, rp) + } + } BackHandler(enabled = activePopup != null) { activePopup = null @@ -1870,7 +1900,14 @@ internal fun UnifiedActivity.LibraryGameDetailDialog( when { isGog -> "GOG" isEpic -> "Epic Games" - isCustom -> "Custom" + isCustom -> + retroCaps.sourceLabel + ?: librarySystemIdHint?.let { + com.winlator.cmod.feature.retro.RetroSystems + .fromId(it) + ?.badgeLabel + } + ?: "Custom" else -> "Steam" } @@ -2313,6 +2350,107 @@ internal fun UnifiedActivity.LibraryGameDetailDialog( ) } } + var showSaveTransfer by remember(app.id) { mutableStateOf(false) } + val retroSaveImportLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + if (uri != null) { + val sourceName = + context.contentResolver.query(uri, arrayOf(android.provider.OpenableColumns.DISPLAY_NAME), null, null, null)?.use { + if (it.moveToFirst()) it.getString(0) else null + } ?: "save" + val result = + runCatching { + val bytes = + context.contentResolver.openInputStream(uri)?.use { it.readBytes() } + ?: return@runCatching com.winlator.cmod.feature.retro.RetroSaveImport.Result.Invalid("Could not read the file.") + com.winlator.cmod.feature.retro.RetroSaveImport.import(context, app.name, sourceName, bytes) + }.getOrElse { com.winlator.cmod.feature.retro.RetroSaveImport.Result.Invalid("Could not read the file.") } + val message = + when (result) { + is com.winlator.cmod.feature.retro.RetroSaveImport.Result.Success -> + "Imported save (${result.name}, ${result.bytes / 1024} KB)" + is com.winlator.cmod.feature.retro.RetroSaveImport.Result.Invalid -> + "Import failed: ${result.reason}" + } + android.widget.Toast.makeText(context, message, android.widget.Toast.LENGTH_LONG).show() + } + } + val retroSaveExportLauncher = + rememberLauncherForActivityResult( + ActivityResultContracts.CreateDocument("application/octet-stream"), + ) { uri -> + if (uri != null) { + val ok = + runCatching { + val sram = + com.winlator.cmod.feature.retro.RetroSaveStates.sramFile(context, app.name) + if (!sram.isFile) return@runCatching false + context.contentResolver.openOutputStream(uri)?.use { it.write(sram.readBytes()) } + true + }.getOrDefault(false) + android.widget.Toast.makeText( + context, + context.getString( + if (ok) R.string.retro_save_transfer_export_ok else R.string.retro_save_transfer_export_failed, + ), + android.widget.Toast.LENGTH_SHORT, + ).show() + } + } + if (showSaveTransfer) { + androidx.compose.material3.AlertDialog( + onDismissRequest = { showSaveTransfer = false }, + title = { androidx.compose.material3.Text(stringResource(R.string.retro_save_transfer_title)) }, + text = { androidx.compose.material3.Text(stringResource(R.string.retro_save_transfer_message)) }, + confirmButton = { + androidx.compose.material3.TextButton(onClick = { + showSaveTransfer = false + retroSaveImportLauncher.launch(arrayOf("*/*")) + }) { androidx.compose.material3.Text(stringResource(R.string.retro_save_transfer_import)) } + }, + dismissButton = { + androidx.compose.material3.TextButton(onClick = { + val sram = com.winlator.cmod.feature.retro.RetroSaveStates.sramFile(context, app.name) + showSaveTransfer = false + if (!sram.isFile) { + android.widget.Toast.makeText( + context, + context.getString(R.string.retro_save_transfer_none), + android.widget.Toast.LENGTH_SHORT, + ).show() + } else { + retroSaveExportLauncher.launch("${app.name}.srm") + } + }) { androidx.compose.material3.Text(stringResource(R.string.retro_save_transfer_export)) } + }, + ) + } + // The 3D engine offers itself only for the few Gen 1 + // titles it supports, and only when its files are + // actually in the retro bundle. Compatibility is the + // ROM's SHA-1, so deciding it means hashing a file: + // done once off the composition thread, keyed on the + // shortcut, rather than on every recomposition. + val engine3dShortcut = homeShortcutState.shortcut + val engine3dKey = engine3dShortcut?.file?.absolutePath + var engine3dSupported by remember(engine3dKey) { mutableStateOf(false) } + var engine3dOn by remember(engine3dKey) { + mutableStateOf( + engine3dShortcut?.let { + com.winlator.cmod.feature.retro.Gen1EmbedLaunch.isEnabled(it) + } ?: false, + ) + } + LaunchedEffect(engine3dKey) { + engine3dSupported = + engine3dShortcut != null && + withContext(Dispatchers.IO) { + runCatching { + com.winlator.cmod.feature.retro.Gen1EmbedLaunch + .isCompatible(context, engine3dShortcut) + }.getOrDefault(false) + } + } LibraryGameLaunchScreen( appName = launchAppName, subtitle = subtitle, @@ -2325,9 +2463,31 @@ internal fun UnifiedActivity.LibraryGameDetailDialog( lastPlayedMillis = lastPlayed, installSizeText = installSizeText, isCustom = isCustom, + isRetro = isRetro, + showBootToDesktop = retroCaps.showBootToDesktop, + showSaveTransfer = retroCaps.showSaveTransfer, hasPinnedShortcut = hasPinnedShortcut, playEnabled = playEnabled, playDisabledLabel = playDisabledLabel, + altEngineLabel = + if (engine3dSupported) stringResource(R.string.retro_gs_engine_3d) else null, + altEngineEnabled = engine3dOn, + onAltEngineChange = + if (engine3dSupported && engine3dShortcut != null) { + { on -> + engine3dOn = on + // Written straight to the shortcut, so + // Play uses it immediately and the + // Graphics pane agrees with it. + engine3dShortcut.putExtra( + com.winlator.cmod.feature.retro.Gen1EmbedLaunch.KEY_ENGINE_3D, + if (on) "1" else "0", + ) + engine3dShortcut.saveData() + } + } else { + null + }, onBack = onDismissRequest, onPlay = { val containerManager = ContainerManager(context) @@ -2345,7 +2505,6 @@ internal fun UnifiedActivity.LibraryGameDetailDialog( onSettings = { val shortcut = resolveOrCreateShortcut() if (shortcut != null) { - // Layer the settings dialog on top; keep the detail dialog open underneath. ShortcutSettingsComposeDialog(this@LibraryGameDetailDialog, shortcut).show() } }, @@ -2362,9 +2521,46 @@ internal fun UnifiedActivity.LibraryGameDetailDialog( ) } }, - onAchievements = if (!isCustom && !isEpic && !isGog) { - { showAchievements = true } - } else null, + onAchievements = + when { + isRetro -> { + val sysId = retroSystemId + val rom = retroRomPath + if (sysId != null && rom != null && retroCaps.showAchievements) { + { + context.startActivity( + android.content.Intent( + context, + com.winlator.cmod.feature.retro.RetroAchievementsActivity::class.java, + ).apply { + putExtra( + com.winlator.cmod.feature.retro.RetroAchievementsActivity.EXTRA_SYSTEM_ID, + sysId, + ) + putExtra( + com.winlator.cmod.feature.retro.RetroAchievementsActivity.EXTRA_ROM_PATH, + rom, + ) + putExtra( + com.winlator.cmod.feature.retro.RetroAchievementsActivity.EXTRA_GAME_NAME, + app.name, + ) + putExtra( + com.winlator.cmod.feature.retro.RetroAchievementsActivity.EXTRA_IN_SESSION, + false, + ) + }, + ) + } + } else { + null + } + } + !isCustom && !isEpic && !isGog -> { + { showAchievements = true } + } + else -> null + }, onShortcut = { if (hasPinnedShortcut) { heroPopup = HeroLaunchPopup.RemoveShortcut @@ -2399,9 +2595,41 @@ internal fun UnifiedActivity.LibraryGameDetailDialog( } }, onCloudSaves = { activePopup = LibraryDetailPopup.CloudSaves }, + onSaveTransfer = + if (retroCaps.showSaveTransfer) { + { showSaveTransfer = true } + } else { + null + }, + onCheats = + if (retroCaps.showCheats && retroSystemId != null) { + { + context.startActivity( + android.content.Intent( + context, + com.winlator.cmod.feature.retro.RetroCheatsActivity::class.java, + ).apply { + putExtra( + com.winlator.cmod.feature.retro.RetroCheatsActivity.EXTRA_SYSTEM_ID, + retroSystemId, + ) + putExtra( + com.winlator.cmod.feature.retro.RetroCheatsActivity.EXTRA_GAME_NAME, + app.name, + ) + }, + ) + } + } else { + null + }, + cheatsEnabled = + !( + com.winlator.cmod.feature.retro.RetroAchievementsManager.isEnabled(context) && + com.winlator.cmod.feature.retro.RetroAchievementsManager.isLoggedIn(context) && + com.winlator.cmod.feature.retro.RetroAchievementsManager.isHardcorePreferred(context) + ), onUninstall = uninstallGame, - // Store source tag actions. Steam exposes verify/update/workshop; - // Epic and GOG expose verify/update for installed games. steamMenuEnabled = !isCustom && (!isEpic || epicGame?.isInstalled == true) && (!isGog || gogGame?.isInstalled == true), @@ -2411,7 +2639,7 @@ internal fun UnifiedActivity.LibraryGameDetailDialog( showCheckForUpdate = !isCustom && (!isEpic || epicGame?.isInstalled == true) && (!isGog || gogGame?.isInstalled == true), - showWorkshop = !isEpic && !isGog, + showWorkshop = !isCustom && !isEpic && !isGog, areSteamActionsEnabled = when { isEpic -> !hasBlockingEpicDownloadForLibrary @@ -2430,8 +2658,6 @@ internal fun UnifiedActivity.LibraryGameDetailDialog( } } if (started != null) { - // Hand off to the activity-root host so the - // pop-up + completion notice outlive this dialog. showTaskProgressPopup( started, if (isGog) gogGame!!.title else app.name, @@ -2457,7 +2683,7 @@ internal fun UnifiedActivity.LibraryGameDetailDialog( else -> startUpdateCheck(app.id, app.name) } }, - onWorkshop = { if (!isEpic && !isGog) showWorkshopDialog = true }, + onWorkshop = { if (!isEpic && !isGog && !isCustom) showWorkshopDialog = true }, ) when (heroPopup) { @@ -2631,6 +2857,7 @@ internal fun UnifiedActivity.LibraryGameDetailDialog( gameId = detailGameId, gameName = app.name, shortcut = detailShortcut, + retroSaveDir = com.winlator.cmod.feature.sync.google.GameSaveBackupManager.retroSaveDir(context, detailShortcut), onCloudSyncToggle = { enabled -> cloudSyncEnabled = enabled setShortcutCloudSyncEnabled(detailShortcut, enabled) @@ -2824,6 +3051,7 @@ internal fun UnifiedActivity.LibraryGameDetailDialog( gameId = detailGameId, gameName = app.name, shortcut = detailShortcut, + retroSaveDir = com.winlator.cmod.feature.sync.google.GameSaveBackupManager.retroSaveDir(context, detailShortcut), onCloudSyncToggle = { enabled -> cloudSyncEnabled = enabled setShortcutCloudSyncEnabled(detailShortcut, enabled) diff --git a/app/src/main/app/shell/UnifiedActivityHub.kt b/app/src/main/app/shell/UnifiedActivityHub.kt index 755c4987f..6f3744af4 100644 --- a/app/src/main/app/shell/UnifiedActivityHub.kt +++ b/app/src/main/app/shell/UnifiedActivityHub.kt @@ -1837,7 +1837,7 @@ internal fun UnifiedActivity.LibraryCarousel( var localLibraryRefreshKey by remember { mutableIntStateOf(0) } var shortcutsLoaded by remember { mutableStateOf(false) } var pullRefreshing by remember { mutableStateOf(false) } - LaunchedEffect(shortcutRefreshKey, localLibraryRefreshKey) { + LaunchedEffect(shortcutRefreshKey, localLibraryRefreshKey, com.winlator.cmod.feature.retro.RetroBoxart.artVersion.value) { shortcutsLoaded = false // Pull-to-refresh only: rescan disk so a manually moved game is picked up without faking a re-download. @@ -1851,11 +1851,13 @@ internal fun UnifiedActivity.LibraryCarousel( val shortcutScanResult = runCatching { withContext(Dispatchers.IO) { + runCatching { com.winlator.cmod.feature.retro.RetroRomScanner.scanConfiguredFolder(context) } val cm = ContainerManager(context) cm.upgradeShortcuts { localLibraryRefreshKey++ } val allShortcuts = cm.loadShortcuts() + val badges = HashMap() val apps = allShortcuts .mapNotNull { shortcut -> @@ -1875,6 +1877,13 @@ internal fun UnifiedActivity.LibraryCarousel( -(displayName.hashCode().and(0x7FFFFFFF) + 1) } + com.winlator.cmod.feature.retro.RetroSystems + .fromId( + shortcut.getExtra( + com.winlator.cmod.feature.retro.RetroShortcuts.KEY_SYSTEM, + ), + )?.let { badges[customId] = it.id } + SteamApp( id = customId, name = displayName, @@ -1887,13 +1896,14 @@ internal fun UnifiedActivity.LibraryCarousel( ) } - allShortcuts to apps + Triple(allShortcuts, apps, badges) } }.getOrNull() if (shortcutScanResult != null) { cachedShortcuts = shortcutScanResult.first customApps = shortcutScanResult.second + retroLibrarySystemIds.value = shortcutScanResult.third } shortcutsLoaded = true diff --git a/app/src/main/app/shell/UnifiedActivityLaunch.kt b/app/src/main/app/shell/UnifiedActivityLaunch.kt index d7691b39b..eb211455f 100644 --- a/app/src/main/app/shell/UnifiedActivityLaunch.kt +++ b/app/src/main/app/shell/UnifiedActivityLaunch.kt @@ -923,6 +923,22 @@ internal fun UnifiedActivity.launchCustomGame( return@launch } + if (com.winlator.cmod.feature.retro.RetroShortcuts.isRetroShortcut(shortcut)) { + // Asks RetroShortcuts which launcher this shortcut needs rather + // than reasoning about it here. Listing the embedded paths in two + // places is what sent 3D-enabled games to the libretro core: this + // caller knew about PS2 and Dolphin but not about the 3D engine. + if (com.winlator.cmod.feature.retro.RetroShortcuts.usesEmbeddedLauncher(context, shortcut)) { + withContext(Dispatchers.Main) { + com.winlator.cmod.feature.retro.RetroShortcuts.launch(context, shortcut) + } + return@launch + } + val retroIntent = com.winlator.cmod.feature.retro.RetroShortcuts.launchIntent(context, shortcut) + withContext(Dispatchers.Main) { launchGame(context, retroIntent) } + return@launch + } + // Backfill custom_name if missing (legacy shortcuts) if (shortcut.getExtra("custom_name").isEmpty()) { shortcut.putExtra("custom_name", gameName) diff --git a/app/src/main/app/shell/UnifiedActivityStartup.kt b/app/src/main/app/shell/UnifiedActivityStartup.kt index 0c587030d..887ef156d 100644 --- a/app/src/main/app/shell/UnifiedActivityStartup.kt +++ b/app/src/main/app/shell/UnifiedActivityStartup.kt @@ -464,6 +464,7 @@ internal fun UnifiedActivity.maybeAutoSignInGoogleOnLaunch() { if (authed) { com.winlator.cmod.feature.sync.google.GameSaveBackupManager .setDriveConnected(applicationContext, true) + retryPendingRetroCloudBackup() } } }.onFailure { diff --git a/app/src/main/app/shell/UnifiedActivityStores.kt b/app/src/main/app/shell/UnifiedActivityStores.kt index dc1441e8c..f466d40da 100644 --- a/app/src/main/app/shell/UnifiedActivityStores.kt +++ b/app/src/main/app/shell/UnifiedActivityStores.kt @@ -538,6 +538,9 @@ internal fun UnifiedActivity.GameCapsule( .clip(RoundedCornerShape(8.dp)), ) { ArtContent(Modifier.fillMaxSize()) + libraryBadgeLabel(app.id, isCustom)?.let { badge -> + RetroConsoleRibbon(badge, Modifier.align(Alignment.CenterStart)) + } } Spacer(Modifier.width(14.dp)) @@ -585,6 +588,9 @@ internal fun UnifiedActivity.GameCapsule( .clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)), ) { ArtContent(Modifier.fillMaxSize()) + libraryBadgeLabel(app.id, isCustom)?.let { badge -> + RetroConsoleRibbon(badge, Modifier.align(Alignment.CenterStart)) + } } Text( diff --git a/app/src/main/assets/dnas/dnas_bypass.json b/app/src/main/assets/dnas/dnas_bypass.json new file mode 100644 index 000000000..fb7610484 --- /dev/null +++ b/app/src/main/assets/dnas/dnas_bypass.json @@ -0,0 +1,514 @@ +{ + "SCES-51706": { + "title": "Amplitude", + "cheats": [ + { + "name": "DNAS Patch", + "codes": [ + "D04E7BFA 00000001", + "2025BE18 14400013" + ], + "auto": true + } + ] + }, + "SLUS-21165": { + "title": "Arc the Lad: End of Darkness", + "cheats": [ + { + "name": "DNAS Bypass by Harry62", + "codes": [ + "2011AF84 00000000" + ], + "auto": true + } + ] + }, + "SLES-53729": { + "title": "Battlefield 2: Modern Combat", + "cheats": [ + { + "name": "DNAS Bypass Code", + "codes": [ + "2021A268 00000000" + ], + "crc": "185D22A9", + "auto": true + }, + { + "name": "DNAS Bypass Code", + "codes": [ + "2021A078 00000000" + ], + "crc": "89CDE501", + "auto": true + } + ] + }, + "SLES-53730": { + "title": "Battlefield 2: Modern Combat", + "cheats": [ + { + "name": "DNAS Bypass Code", + "codes": [ + "2021A268 00000000" + ], + "auto": true + } + ] + }, + "SLPM-66206": { + "title": "Battlefield 2: Modern Combat", + "cheats": [ + { + "name": "DNAS Bypass Code", + "codes": [ + "2021A078 00000000" + ], + "auto": true + } + ] + }, + "SLUS-21026": { + "title": "Battlefield 2: Modern Combat", + "cheats": [ + { + "name": "DNAS Bypass", + "codes": [ + "2021A240 00000000" + ], + "auto": true + } + ] + }, + "SLES-52782": { + "title": "Call of Duty: Finest Hour", + "cheats": [ + { + "name": "DNAS Bypass Code", + "codes": [ + "D02942F0 0000F0B6", + "202942F0 00000000" + ], + "auto": true + } + ] + }, + "SCES-50781": { + "title": "Destruction Derby Arenas", + "cheats": [ + { + "name": "[MODE 3] DNAS Patch", + "codes": [ + "D1D8139C 24020006", + "01D81390 00000000", + "D1D8139C 24020006", + "01D8139C 00000005" + ], + "auto": true + } + ] + }, + "SCES-53033": { + "title": "Formula One 05", + "cheats": [ + { + "name": "DNAS bypass", + "codes": [ + "D039FBA4 24020006", + "0039FB98 00000000", + "D039FBA4 24020006", + "0039FBA4 00000005" + ], + "auto": true + } + ] + }, + "SLES-53667": { + "title": "Gauntlet: Seven Sorrows", + "cheats": [ + { + "name": "DNAS Bypass", + "codes": [ + "203842E0 03E00008", + "203842E4 00000000" + ], + "auto": true + } + ] + }, + "PAPX-90523": { + "title": "Gran Turismo 4 Online (Beta)", + "cheats": [ + { + "name": "DNAS Bypass Code", + "codes": [ + "D188D734 24020006", + "0188D728 00000000", + "D188D734 24020006", + "0188D734 00000005" + ], + "auto": true + } + ] + }, + "SCES-51977": { + "title": "Hardware: Online Arena", + "cheats": [ + { + "name": "DNAS Bypass", + "codes": [ + "D1D424A8 00000007", + "01D424A8 00000005", + "D1D424A8 00000006", + "01D424A8 00000005" + ], + "auto": true + } + ] + }, + "SLES-53585": { + "title": "Marvel Nemesis: Rise of the Imperfects", + "cheats": [ + { + "name": "DNAS Bypass", + "codes": [ + "2020249C 00000000" + ], + "auto": true + } + ] + }, + "SLUS-21281": { + "title": "Marvel Nemesis: Rise of the Imperfects", + "cheats": [ + { + "name": "DNAS Bypass", + "codes": [ + "2020249C 00000000", + "20509B9C 00000000" + ], + "auto": true + } + ] + }, + "SCES-51677": { + "title": "MyStreet", + "cheats": [ + { + "name": "DNAS Patch", + "codes": [ + "D19A40B4 24020006", + "019A40A8 00000000", + "D19A40B4 24020006", + "019A40B4 00000005" + ], + "auto": true + } + ] + }, + "SCES-52456": { + "title": "Ratchet & Clank 3", + "cheats": [ + { + "name": "DNAS Patch", + "codes": [ + "D04D55E4 24020006", + "004D55D8 00000000", + "D04D55E4 24020006", + "004D55E4 00000005" + ], + "auto": true + }, + { + "name": "DNAS Patch", + "codes": [ + "D178CAF4 24020006", + "0178CAE8 00000000", + "D178CAF4 24020006", + "0178CAF4 00000005" + ], + "auto": false + } + ] + }, + "SCUS-97474": { + "title": "SOCOM 3: U.S. Navy SEALs", + "cheats": [ + { + "name": "DNAS Bypass (Base version of the game)", + "codes": [ + "202859A0 03E00008", + "202859A4 00000000" + ], + "auto": true + }, + { + "name": "DNAS Bypass (Patch 2.3)", + "codes": [ + "2028AE10 03E00008", + "2028AE14 00000000" + ], + "auto": false + } + ] + }, + "SCUS-97489": { + "title": "SOCOM 3: U.S. Navy SEALs", + "cheats": [ + { + "name": "DNAS Bypass", + "codes": [ + "202461E0 03E00008", + "202461E4 00000000" + ], + "auto": true + } + ] + }, + "SCUS-97545": { + "title": "SOCOM: U.S. Navy SEALs Combined Assault", + "cheats": [ + { + "name": "DNAS Bypass (Works for both 1.0 and 1.4 version of the game)", + "codes": [ + "2029A2DC 24020001", + "2029BD90 03E00008", + "2029BD94 00000000" + ], + "auto": true + } + ] + }, + "SCES-52306": { + "title": "SOCOM II: U.S. Navy SEALs", + "cheats": [ + { + "name": "DNAS Bypass", + "codes": [ + "202D61B0 03E00008", + "202D61B4 00000000" + ], + "auto": true + } + ] + }, + "SCKA-20020": { + "title": "SOCOM II: U.S. Navy SEALs", + "cheats": [ + { + "name": "DNAS Bypass", + "codes": [ + "202D7818 24020001" + ], + "auto": true + } + ] + }, + "SCPS-15065": { + "title": "SOCOM II: U.S. Navy SEALs", + "cheats": [ + { + "name": "DNAS Bypass", + "codes": [ + "202DD648 24020001" + ], + "auto": true + } + ] + }, + "SCUS-97275": { + "title": "SOCOM II: U.S. Navy SEALs", + "cheats": [ + { + "name": "DNAS Bypass r0001", + "codes": [ + "202CC670 03E00008", + "202CC674 00000000" + ], + "auto": true + }, + { + "name": "DNAS Bypass r0004", + "codes": [ + "203953C0 03E00008", + "203953C4 00000000" + ], + "auto": true + } + ] + }, + "SCUS-97366": { + "title": "SOCOM II: U.S. Navy SEALs", + "cheats": [ + { + "name": "DNAS Bypass(Harry62)", + "codes": [ + "203953C0 03E00008", + "203953C4 00000000" + ], + "auto": true + } + ] + }, + "TCES-51904": { + "title": "SOCOM II: U.S. Navy SEALs", + "cheats": [ + { + "name": "DNAS Bypass", + "codes": [ + "2040F1C0 03E00008", + "2040F1C4 00000000" + ], + "auto": true + } + ] + }, + "SCKA-24008": { + "title": "SOCOM: U.S. Navy SEALs", + "cheats": [ + { + "name": "DNAS Bypass", + "codes": [ + "20379C48 00000000" + ], + "auto": true + } + ] + }, + "SCKA-90010": { + "title": "SOCOM: U.S. Navy SEALs", + "cheats": [ + { + "name": "DNAS Bypass", + "codes": [ + "20379548 00000000" + ], + "auto": true + } + ] + }, + "SCPS-15044": { + "title": "SOCOM: U.S. Navy SEALs", + "cheats": [ + { + "name": "DNAS Bypass", + "codes": [ + "203814C8 00000000" + ], + "auto": true + } + ] + }, + "SCES-52033": { + "title": "Syphon Filter: The Omega Strain", + "cheats": [ + { + "name": "DNAS Bypass", + "codes": [ + "20382390 03E00008", + "20382394 00000000" + ], + "auto": true + } + ] + }, + "SCKA-20032": { + "title": "Syphon Filter: The Omega Strain", + "cheats": [ + { + "name": "DNAS Bypass", + "codes": [ + "20384D30 03E00008", + "20384D34 00000000" + ], + "auto": true + } + ] + }, + "SCUS-97264": { + "title": "Syphon Filter: The Omega Strain", + "cheats": [ + { + "name": "DNAS Bypass", + "codes": [ + "2044B7A0 03E00008", + "2044B7A4 00000000" + ], + "auto": true + }, + { + "name": "DNAS Bypass", + "codes": [ + "20343940 03E00008", + "20343944 00000000" + ], + "auto": false + } + ] + }, + "SCUS-97397": { + "title": "Syphon Filter: The Omega Strain", + "cheats": [ + { + "name": "DNAS Bypass", + "codes": [ + "203563C0 03E00008", + "203563C4 00000000" + ], + "auto": true + } + ] + }, + "TCES-52033": { + "title": "Syphon Filter: The Omega Strain", + "cheats": [ + { + "name": "DNAS Bypass", + "codes": [ + "2037B960 03E00008", + "2037B964 00000000" + ], + "auto": true + } + ] + }, + "SCES-52389": { + "title": "WRC 4", + "cheats": [ + { + "name": "DNAS Bypass", + "codes": [ + "D05B4C88 00000000", + "205B4C88 00000001" + ], + "crc": "CDE7C999", + "auto": true + }, + { + "name": "DNAS Bypass", + "codes": [ + "D05B4E88 00000000", + "205B4E88 00000001" + ], + "crc": "AFD06CBA", + "auto": true + } + ] + }, + "TCES-53247": { + "title": "WRC: Rally Evolved", + "cheats": [ + { + "name": "DNAS Patch", + "codes": [ + "D19606AC 24020006", + "019606A0 00000000", + "D19606AC 24020006", + "019606AC 00000005" + ], + "auto": true + } + ] + } +} \ No newline at end of file diff --git a/app/src/main/assets/retro/GLideN64.custom.ini b/app/src/main/assets/retro/GLideN64.custom.ini new file mode 100644 index 000000000..842d2d54b --- /dev/null +++ b/app/src/main/assets/retro/GLideN64.custom.ini @@ -0,0 +1,360 @@ +; Custom game settings +[General] +version=13 + +[TWINE] +Good_Name=007 - The World Is Not Enough (E)(U) +frameBufferEmulation\N64DepthCompare=1 + +[40%20WINKS] +Good_Name=40 Winks (E) (M3) (Prototype) +graphics2D\enableNativeResTexrects=1 + +[BAKU-BOMBERMAN] +Good_Name=Baku Bomberman (J) [!] +generalEmulation\enableLegacyBlending=0 + +[BIOFREAKS] +Good_Name=Bio F.R.E.A.K.S. (E)(U) +frameBufferEmulation\copyToRDRAM=1 + +[BIOHAZARD%20II] +Good_Name=Biohazard 2 (J) +frameBufferEmulation\copyFromRDRAM=1 +frameBufferEmulation\copyToRDRAM=0 +frameBufferEmulation\copyDepthToRDRAM=0 + +[BOMBERMAN64E] +Good_Name=Bomberman 64 (E) [!] +generalEmulation\enableLegacyBlending=0 + +[BOMBERMAN64U] +Good_Name=Bomberman 64 (U) [!] +generalEmulation\enableLegacyBlending=0 + +[362D06B6] +Good_Name=Densha de Go! 64 (J) +generalEmulation\enableLegacyBlending=0 + +[68D128AE] +Good_Name=Densha de Go! 64 (J) (Localization Patch v1.01) +generalEmulation\enableLegacyBlending=0 + +[52150A67] +Good_Name=Bokujou Monogatari 2 (J) +frameBufferEmulation\N64DepthCompare=1 + +[67000C2B] +Good_Name=Eikou no Saint Andrews (J) +frameBufferEmulation\forceDepthBufferClear=1 + +[CAL%20SPEED] +Good_Name=California Speed (U) +frameBufferEmulation\bufferSwapMode=1 + +[CASTLEVANIA2] +Good_Name=Castlevania - Legacy Of Darkness (E)(U) +frameBufferEmulation\copyToRDRAM=1 + +[DMPJ] +Good_Name=Mario Artist Paint Studio (J) (64DD) +frameBufferEmulation\copyAuxToRDRAM=1 +frameBufferEmulation\copyFromRDRAM=1 +frameBufferEmulation\nativeResFactor=1 +generalEmulation\rdramImageDitheringMode=0 + +[DMTJ] +Good_Name=Mario Artist Talent Studio (J) (64DD) +frameBufferEmulation\copyAuxToRDRAM=1 + +[DINO%20PLANET] +Good_Name=Dinosaur Planet (Dec 2000 Beta) +frameBufferEmulation\copyToRDRAM=1 +frameBufferEmulation\copyAuxToRDRAM=1 + +[DONKEY%20KONG%2064] +Good_Name=Donkey Kong 64 (E)(J)(U) +frameBufferEmulation\copyDepthToRDRAM=0 +frameBufferEmulation\N64DepthCompare=0 + +[DR.MARIO%2064] +Good_Name=Dr. Mario 64 (U) +frameBufferEmulation\fbInfoDisabled=0 +frameBufferEmulation\copyFromRDRAM=1 +frameBufferEmulation\copyToRDRAM=0 + +[EXTREME_G] +Good_Name=Extreme-G (E) +frameBufferEmulation\N64DepthCompare=1 + +[EXTREME-G] +Good_Name=Extreme-G (J) +frameBufferEmulation\N64DepthCompare=1 + +[EXTREMEG] +Good_Name=Extreme-G (U) +frameBufferEmulation\N64DepthCompare=1 + +[EXTREME%20G%202] +Good_Name=Extreme-G XG2 (E) (U) +frameBufferEmulation\N64DepthCompare=1 + +[208E05CD] +Good_Name=Extreme-G XG2 (J) +frameBufferEmulation\N64DepthCompare=1 + +[F1%20POLE%20POSITION%2064] +Good_Name=F-1 Pole Position 64 (E)(U) +frameBufferEmulation\copyToRDRAM=1 + +[FLAPPYBIRD64] +Good_Name=FlappyBird64 +frameBufferEmulation\fbInfoDisabled=0 +frameBufferEmulation\copyFromRDRAM=1 +frameBufferEmulation\copyToRDRAM=0 + +[GOEMON2%20DERODERO] +Good_Name=Ganbare Goemon - Dero Dero Douchuu Obake Tenkomori (J) +graphics2D\enableNativeResTexrects=1 + +[GOEMONS%20GREAT%20ADV] +Good_Name=Goemons Great Adventure (U) +graphics2D\enableNativeResTexrects=1 + +[HARVESTMOON64] +Good_Name=Harvest Moon 64 (U) +frameBufferEmulation\N64DepthCompare=1 + +[5CFA0A2E] +Good_Name=Heiwa Pachinko World 64 (J) +frameBufferEmulation\copyToRDRAM=1 + +[HEXEN] +Good_Name=Hexen (E)(F)(G)(J)(U) +frameBufferEmulation\copyToRDRAM=1 + +[HUMAN%20GRAND%20PRIX] +Good_Name=Human Grand Prix - New Generation (J) +frameBufferEmulation\copyToRDRAM=1 + +[I%20S%20S%2064] +Good_Name=International Superstar Soccer 64 (E) (U) +frameBufferEmulation\N64DepthCompare=1 + +[JET%20FORCE%20GEMINI] +Good_Name=Jet Force Gemini (E)(U) +frameBufferEmulation\fbInfoDisabled=0 +frameBufferEmulation\copyAuxToRDRAM=1 +frameBufferEmulation\copyToRDRAM=0 + +[J%20F%20G%20DISPLAY] +Good_Name=Jet Force Gemini Kiosk Demo (U) +frameBufferEmulation\fbInfoDisabled=0 +frameBufferEmulation\copyAuxToRDRAM=1 +frameBufferEmulation\copyToRDRAM=0 + +[J%20WORLD%20SOCCER3] +Good_Name=Jikkyou World Soccer 3 (J) +frameBufferEmulation\N64DepthCompare=1 + +[301E07CC] +Good_Name=Mahjong Master (J) +frameBufferEmulation\N64DepthCompare=1 + +[DMGJ] +Good_Name=Mario Artist Polygon Studio (J) +frameBufferEmulation\copyAuxToRDRAM=1 + +[KEN%20GRIFFEY%20SLUGFEST] +Good_Name=Ken Griffey Jr.'s Slugfest +frameBufferEmulation\fbInfoDisabled=0 + +[KIRBY64] +Good_Name=Kirby 64 - The Crystal Shards (E)(J)(U) +graphics2D\enableNativeResTexrects=1 + +[MARIOGOLF64] +Good_Name=Mario Golf (E)(J)(U) +frameBufferEmulation\copyDepthToRDRAM=0 + +[MARIOKART64] +Good_Name=Mario Kart 64 (E)(J)(U) +graphics2D\enableNativeResTexrects=1 +graphics2D\enableTexCoordBounds=1 +frameBufferEmulation\copyToRDRAM=2 + +[MARIO%20STORY] +Good_Name=Mario Story (J) +frameBufferEmulation\copyToRDRAM=1 + +[MEGA%20MAN%2064] +Good_Name=Mega Man 64 (U) +graphics2D\correctTexrectCoords=2 + +[MEGAMAN%2064] +Good_Name=Mega Man 64 (Proto) +graphics2D\correctTexrectCoords=2 + +[MLB%20FEATURING%20K%20G%20JR] +Good_Name=Major League Baseball Featuring Ken Griffey Jr. +frameBufferEmulation\fbInfoDisabled=0 + +[MYSTICAL%20NINJA2%20SG] +Good_Name=Mystical Ninja 2 Starring Goemon (E) +graphics2D\enableNativeResTexrects=1 + +[NASCAR%202000] +Good_Name=NASCAR 2000 (U) +frameBufferEmulation\copyToRDRAM=1 + +[NASCAR%2099] +Good_Name=NASCAR 99 (U) +frameBufferEmulation\copyToRDRAM=1 + +[NUD-DMPJ-JPN_convert] +Good_Name=Mario Paint Studio (cart hack) +frameBufferEmulation\copyFromRDRAM=1 + +[NUD-DMTJ-JPN_convert] +Good_Name=Mario Artist Talent Studio (cart hack) +frameBufferEmulation\copyAuxToRDRAM=1 + +[OGREBATTLE64] +Good_Name=Ogre Battle 64 - Person of Lordly Caliber (U) +graphics2D\enableTexCoordBounds=1 + +[OLYMPIC%20HOCKEY] +Good_Name=Olympic Hockey Nagano '98 (E)(J)(U) +frameBufferEmulation\bufferSwapMode=1 + +[PAPER%20MARIO] +Good_Name=Paper Mario (E)(U) +frameBufferEmulation\copyToRDRAM=1 +graphics2D\enableTexCoordBounds=1 + +[PENNY%20RACERS] +Good_Name=Penny Racers (E)(U) +frameBufferEmulation\copyToRDRAM=0 + +[PERFECT%20STRIKER] +Good_Name=Jikkyou J.League Perfect Striker (J) +frameBufferEmulation\N64DepthCompare=1 + +[POKEMON%20SNAP] +Good_Name=Pokemon Snap (U) +generalEmulation\rdramImageDitheringMode=1 +frameBufferEmulation\copyAuxToRDRAM=1 +frameBufferEmulation\copyToRDRAM=1 +frameBufferEmulation\fbInfoDisabled=0 + +[POKEMON%20STADIUM] +Good_Name=Pokemon Stadium (U) +frameBufferEmulation\copyDepthToRDRAM=0 + +[POKEMON%20STADIUM%202] +Good_Name=Pokemon Stadium 2 (E)(F)(G)(I)(J)(S)(U) +frameBufferEmulation\copyToRDRAM=0 +frameBufferEmulation\copyDepthToRDRAM=0 + +[POKEMON%20STADIUM%20G%26S] +Good_Name=Pokemon Stadium Kin Gin (J) +frameBufferEmulation\copyToRDRAM=0 +frameBufferEmulation\copyDepthToRDRAM=0 + +[PUZZLE%20LEAGUE%20N64] +Good_Name=Pokemon Puzzle League (E)(F)(G)(U) +texture\enableHalosRemoval=1 + +[RAT%20ATTACK] +Good_Name=Rat Attack +frameBufferEmulation\fbInfoDisabled=0 + +[RESIDENT%20EVIL%20II] +Good_Name=Resident Evil 2 (E)(U) +frameBufferEmulation\copyFromRDRAM=1 +frameBufferEmulation\copyToRDRAM=0 +frameBufferEmulation\copyDepthToRDRAM=0 + +[ROCKMAN%20DASH] +Good_Name=Rockman Dash - Hagane no Boukenshin (J) +graphics2D\correctTexrectCoords=2 + +[RUSH%202] +Good_Name=Rush 2 - Extreme Racing USA (E)(U) +frameBufferEmulation\bufferSwapMode=1 +graphics2D\correctTexrectCoords=2 + +[SAN%20FRANCISCO%20RUSH] +Good_Name=San Francisco Rush Extreme Racing (U) +frameBufferEmulation\bufferSwapMode=1 +graphics2D\enableNativeResTexrects=2 + +[S.F.RUSH] +Good_Name=San Francisco Rush Extreme Racing (E) +frameBufferEmulation\bufferSwapMode=1 +graphics2D\enableNativeResTexrects=2 + +[S.F.%20RUSH] +Good_Name=San Francisco Rush Extreme Racing (U) +frameBufferEmulation\bufferSwapMode=1 +graphics2D\enableNativeResTexrects=2 + +[SHADOWMAN] +Good_Name=Shadow Man (B)(E)(F)(G)(U) +frameBufferEmulation\copyDepthToRDRAM=0 + +[SPACE%20INVADERS] +Good_Name=Space Invaders (U) +frameBufferEmulation\copyToRDRAM=0 + +[SILICON%20VALLEY] +Good_Name=Space Station Silicon Valley (U) +frameBufferEmulation\copyToRDRAM=1 + +[STAR%20TWINS] +Good_Name=Star Twins (J) +frameBufferEmulation\fbInfoDisabled=0 +frameBufferEmulation\copyAuxToRDRAM=1 +frameBufferEmulation\copyToRDRAM=0 + +[TEST] +Good_Name=Mario Artist Paint Studio (J) (1999-02-11 Prototype) (64DD) +frameBufferEmulation\copyAuxToRDRAM=1 +frameBufferEmulation\copyFromRDRAM=1 +generalEmulation\rdramImageDitheringMode=0 + +[TETRISPHERE] +Good_Name=Tetrisphere (E)(U) +graphics2D\correctTexrectCoords=2 + +[TIGGER%27S%20HONEY%20HUNT] +Good_Name=Tiggers Honey Hunt (E)(U) +frameBufferEmulation\N64DepthCompare=1 + +[TONIC%20TROUBLE] +Good_Name=Tonic Trouble (E)(U) +frameBufferEmulation\copyToRDRAM=1 + +[TG%20RALLY%202] +Good_Name=TG Rally 2 (E) +frameBufferEmulation\copyToRDRAM=1 + +[TOP%20GEAR%20RALLY%202] +Good_Name=Top Gear Rally 2 (E)(J)(U) +frameBufferEmulation\copyToRDRAM=1 + +[TUROK_DINOSAUR_HUNTE] +Good_Name=Turok - Dinosaur Hunter (E)(G)(U)(J) +frameBufferEmulation\copyDepthToRDRAM=1 + +[WAVE%20RACE%2064] +Good_Name=Wave Race 64 (E)(U)(J) +frameBufferEmulation\copyToRDRAM=1 + +[W.G.%203DHOCKEY] +Good_Name=Wayne Gretzky's 3D Hockey (E)(U) +frameBufferEmulation\bufferSwapMode=1 + +[WGHOCKEY] +Good_Name=Wayne Gretzky's 3D Hockey (J) +frameBufferEmulation\bufferSwapMode=1 diff --git a/app/src/main/engine/org/love2d/android/GameActivity.java b/app/src/main/engine/org/love2d/android/GameActivity.java new file mode 100644 index 000000000..271e90b61 --- /dev/null +++ b/app/src/main/engine/org/love2d/android/GameActivity.java @@ -0,0 +1,880 @@ +/** + * Copyright (c) 2006-2023 LOVE Development Team + * + * This software is provided 'as-is', without any express or implied + * warranty. In no event will the authors be held liable for any damages + * arising from the use of this software. + * + * Permission is granted to anyone to use this software for any purpose, + * including commercial applications, and to alter it and redistribute it + * freely, subject to the following restrictions: + * + * 1. The origin of this software must not be misrepresented; you must not + * claim that you wrote the original software. If you use this software + * in a product, an acknowledgment in the product documentation would be + * appreciated but is not required. + * 2. Altered source versions must be plainly marked as such, and must not be + * misrepresented as being the original software. + * 3. This notice may not be removed or altered from any source distribution. + **/ + +package org.love2d.android; + +// WinNative: this file is vendored unmodified from love-android 11.5a except +// for this import. Upstream resolves R from its own package; here the only +// R on the classpath is the host app's, and R.bool.embed is the single +// resource it reads (false, so the game path arrives via the launch Intent). +import com.winlator.cmod.R; +import org.love2d.sdl.SDLActivity; + +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import android.Manifest; +import android.app.AlertDialog; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.pm.ApplicationInfo; +import android.content.res.AssetManager; +import android.media.AudioManager; +import android.net.Uri; +import android.os.Bundle; +import android.os.Environment; +import android.os.Vibrator; +import android.util.Log; +import android.util.DisplayMetrics; +import android.view.*; +import android.content.pm.PackageManager; + +import androidx.annotation.Keep; +import androidx.core.app.ActivityCompat; + +public class GameActivity extends SDLActivity { + private static DisplayMetrics metrics = null; + private static String gamePath = ""; + private static Vibrator vibrator = null; + protected final int[] externalStorageRequestDummy = new int[1]; + protected final int[] recordAudioRequestDummy = new int[1]; + public static final int EXTERNAL_STORAGE_REQUEST_CODE = 2; + public static final int RECORD_AUDIO_REQUEST_CODE = 3; + public static final int FILE_PICKER_REQUEST_CODE = 4; + public static final int FILE_CREATE_REQUEST_CODE = 5; + /** @deprecated Prefer FILE_PICKER_REQUEST_CODE; kept for older call sites. */ + public static final int ROM_PICKER_REQUEST_CODE = FILE_PICKER_REQUEST_CODE; + // Mirrors conf.lua's t.identity ("pokemon-love2d"): where the picked file + // is dropped so RomImporter's existing folder scan finds it -- see + // src/import/RomImporter.lua and Filesystem::setIdentity (sets Android's + // save directory to getExternalFilesDir()/save/). + private static final String ROM_SAVE_IDENTITY = "pokemon-love2d"; + private static final String PICKED_ROM_FILENAME = "picked_rom.gb"; + private static final String PICKED_MOD_FILENAME = "picked_mod.zip"; + private static final String PICKED_SAVE_FILENAME = "picked_save.sav"; + private static final String PENDING_EXPORT_FILENAME = "pending_export.sav"; + private static final String EXPORT_DONE_FILENAME = "export_done.flag"; + // Written when a SAF pick cannot be read at all, with the destination + // basename as its body, so RomImporter:focus can say so in the launcher + // instead of leaving the player on "No ROM imported" (issue #442). + private static final String PICK_ERROR_FILENAME = "pick_error.flag"; + // Destination basename for the in-flight SAF pick (set by showFilePicker). + private String pendingPickFilename = PICKED_ROM_FILENAME; + // Suggested download name for the in-flight SAF create (set by showCreateDocument). + private String pendingCreateSuggestedName = "export.sav"; + private static boolean immersiveActive = false; + private static boolean needToCopyGameInArchive = false; + private boolean storagePermissionUnnecessary = false; + private boolean shortEdgesMode = false; + public boolean embed = false; + public int safeAreaTop = 0; + public int safeAreaLeft = 0; + public int safeAreaBottom = 0; + public int safeAreaRight = 0; + + private static native void nativeSetDefaultStreamValues(int sampleRate, int framesPerBurst); + + @Override + protected String[] getLibraries() { + return new String[] { + "c++_shared", + "mpg123", + "openal", + "love", + }; + } + + @Override + protected String getMainSharedObject() { + String[] libs = getLibraries(); + String libname = "lib" + libs[libs.length - 1] + ".so"; + + // Since Lollipop, you can simply pass "libname.so" to dlopen + // and it will resolve correct paths and load correct library. + // This is mandatory for extractNativeLibs=false support in + // Marshmallow. + if (android.os.Build.VERSION.SDK_INT >= 21) { + return libname; + } else { + return getContext().getApplicationInfo().nativeLibraryDir + "/" + libname; + } + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + Log.d("GameActivity", "started"); + + int res = checkCallingOrSelfPermission(Manifest.permission.VIBRATE); + if (res == PackageManager.PERMISSION_GRANTED) { + vibrator = (Vibrator) getSystemService(Context.VIBRATOR_SERVICE); + } else { + Log.d("GameActivity", "Vibration disabled: could not get vibration permission."); + } + + // These 2 variables must be reset or it will use the existing value. + gamePath = ""; + storagePermissionUnnecessary = false; + embed = getResources().getBoolean(R.bool.embed); + needToCopyGameInArchive = embed; + + if (!embed) { + Intent intent = getIntent(); + handleIntent(intent); + intent.setData(null); + } + + super.onCreate(savedInstanceState); + metrics = getResources().getDisplayMetrics(); + + // Set low-latency audio values + nativeSetDefaultStreamValues(getAudioFreq(), getAudioSMP()); + + if (android.os.Build.VERSION.SDK_INT >= 28) { + getWindow().getAttributes().layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVER; + shortEdgesMode = false; + } + } + + @Override + protected void onNewIntent(Intent intent) { + Log.d("GameActivity", "onNewIntent() with " + intent); + if (!embed) { + handleIntent(intent); + resetNative(); + startNative(); + } + } + + protected void handleIntent(Intent intent) { + Uri game = intent.getData(); + + if (!embed && game != null) { + String scheme = game.getScheme(); + String path = game.getPath(); + // If we have a game via the intent data we we try to figure out how we have to load it. We + // support the following variations: + // * a main.lua file: set gamePath to the directory containing main.lua + // * otherwise: set gamePath to the file + if (scheme.equals("file")) { + Log.d("GameActivity", "Received file:// intent with path: " + path); + // If we were given the path of a main.lua then use its + // directory. Otherwise use full path. + List path_segments = game.getPathSegments(); + if (path_segments.get(path_segments.size() - 1).equals("main.lua")) { + gamePath = path.substring(0, path.length() - "main.lua".length()); + } else { + gamePath = path; + } + } else if (scheme.equals("content")) { + Log.d("GameActivity", "Received content:// intent with path: " + path); + try { + String filename = "game.love"; + String[] pathSegments = path.split("/"); + if (pathSegments.length > 0) { + filename = pathSegments[pathSegments.length - 1]; + } + + // Sanitize filename to prevent PhysFS complaining later. + filename = filename.replaceAll("[^a-zA-Z0-9_\\\\-\\\\.]", "_"); + + String destination_file = this.getCacheDir().getPath() + "/" + filename; + InputStream data = getContentResolver().openInputStream(game); + + // copyAssetFile automatically closes the InputStream + if (copyAssetFile(data, destination_file)) { + gamePath = destination_file; + storagePermissionUnnecessary = true; + } + } catch (Exception e) { + Log.d("GameActivity", "could not read content uri " + game.toString() + ": " + e.getMessage()); + } + } else { + Log.e("GameActivity", "Unsupported scheme: '" + game.getScheme() + "'."); + + AlertDialog.Builder alert_dialog = new AlertDialog.Builder(this); + alert_dialog.setMessage("Could not load LÖVE game '" + path + + "' as it uses unsupported scheme '" + game.getScheme() + + "'. Please contact the developer."); + alert_dialog.setTitle("LÖVE for Android Error"); + alert_dialog.setPositiveButton("Exit", + new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int id) { + finish(); + } + }); + alert_dialog.setCancelable(false); + alert_dialog.create().show(); + } + } + + Log.d("GameActivity", "new gamePath: " + gamePath); + } + + private void copyGameInsideArchive() { + try { + // If we have a game.love in our assets folder copy it to the cache folder + // so that we can load it from native LÖVE code + AssetManager assetManager = getAssets(); + InputStream gameStream = assetManager.open("game.love"); + String destinationFile = this.getCacheDir().getPath() + "/game.love"; + + if (copyAssetFile(gameStream, destinationFile)) + gamePath = destinationFile; + else + gamePath = "game.love"; + storagePermissionUnnecessary = true; + } catch (IOException e) { + // There's no game.love in our assets + Log.d("GameActivity", "Could not open game.love from assets: " + e.getMessage()); + } + } + + protected void checkLovegameFolder() { + // If no game.love was found and embed flavor is not used, fall back to the game in + // /Android/data//games/lovegame + if (!embed) { + Log.d("GameActivity", "fallback to lovegame folder"); + File ext = getExternalFilesDir("games"); + if ((new File(ext, "/lovegame/main.lua")).exists()) { + gamePath = ext.getPath() + "/lovegame/"; + storagePermissionUnnecessary = true; + } else if (android.os.Build.VERSION.SDK_INT <= 28) { + // Try to fallback to /sdcard/lovegame in Android 9 and earlier too. + if (hasExternalStoragePermission()) { + ext = Environment.getExternalStorageDirectory(); + if ((new File(ext, "/lovegame/main.lua")).exists()) { + gamePath = ext.getPath() + "/lovegame/"; + storagePermissionUnnecessary = false; + } + } else { + Log.d("GameActivity", "Cannot load game from /sdcard/lovegame: permission not granted"); + } + } + + Log.d("GameActivity", "lovegame directory: " + gamePath); + } + } + + @Override + protected void onDestroy() { + if (vibrator != null) { + Log.d("GameActivity", "Cancelling vibration"); + vibrator.cancel(); + } + super.onDestroy(); + } + + @Override + protected void onPause() { + if (vibrator != null) { + Log.d("GameActivity", "Cancelling vibration"); + vibrator.cancel(); + } + super.onPause(); + } + + @Override + public void onResume() { + super.onResume(); + } + + @Keep + public void setImmersiveMode(boolean immersive_mode) { + if (android.os.Build.VERSION.SDK_INT >= 28) { + getWindow().getAttributes().layoutInDisplayCutoutMode = immersive_mode ? + WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES : + WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVER; + shortEdgesMode = immersive_mode; + } + + immersiveActive = immersive_mode; + } + + @Keep + public boolean getImmersiveMode() { + return immersiveActive; + } + + @Keep + public static String getGamePath() { + GameActivity self = (GameActivity) mSingleton; // use SDL provided one + Log.d("GameActivity", "called getGamePath(), game path = " + gamePath); + + if (gamePath.length() > 0) { + if (self.storagePermissionUnnecessary || self.hasExternalStoragePermission()) { + return gamePath; + } else { + Log.d("GameActivity", "cannot open game " + gamePath + ": no external storage permission given!"); + } + } else if (needToCopyGameInArchive) { + self.copyGameInsideArchive(); + } else { + self.checkLovegameFolder(); + } + + return gamePath; + } + + public static DisplayMetrics getMetrics() { + return metrics; + } + + @Keep + public static void vibrate(double seconds) { + if (vibrator != null) { + vibrator.vibrate((long) (seconds * 1000.)); + } + } + + @Keep + public static boolean openURLFromLOVE(String url) { + Log.d("GameActivity", "opening url = " + url); + return openURL(url) == 0; + } + + /** + * Shows the system document picker (Storage Access Framework) so the + * player can pick a ROM / mod / save from anywhere (Downloads, Drive, + * etc.) without needing to know where the app's external files folder + * is. Requires API 19+ (ACTION_OPEN_DOCUMENT); the picked file (if any) + * arrives later in onActivityResult, not synchronously here. + * + * @param destFilename basename under the app save identity (e.g. + * picked_rom.gb, picked_mod.zip, picked_save.sav) + */ + @Keep + public static boolean showFilePicker(String destFilename) { + if (android.os.Build.VERSION.SDK_INT < 19) return false; + GameActivity self = (GameActivity) mSingleton; + if (self == null) return false; + if (destFilename == null || destFilename.length() == 0) { + destFilename = PICKED_ROM_FILENAME; + } + // Reject path separators so a hostile JNI caller cannot escape the + // save identity directory. + if (destFilename.indexOf('/') >= 0 || destFilename.indexOf('\\') >= 0) { + Log.d("GameActivity", "refusing unsafe picker dest: " + destFilename); + return false; + } + + self.pendingPickFilename = destFilename; + Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); + intent.addCategory(Intent.CATEGORY_OPENABLE); + intent.setType("*/*"); + try { + self.startActivityForResult(intent, FILE_PICKER_REQUEST_CODE); + return true; + } catch (Exception e) { + Log.d("GameActivity", "could not open file picker: " + e.getMessage()); + return false; + } + } + + /** ROM convenience wrapper; prefer showFilePicker with an explicit name. */ + @Keep + public static boolean showRomFilePicker() { + return showFilePicker(PICKED_ROM_FILENAME); + } + + /** Mod .zip convenience wrapper used by love.system.pickFile("mod"). */ + @Keep + public static boolean showModFilePicker() { + return showFilePicker(PICKED_MOD_FILENAME); + } + + /** Battery .sav convenience wrapper used by love.system.pickFile("sav"). */ + @Keep + public static boolean showSaveFilePicker() { + return showFilePicker(PICKED_SAVE_FILENAME); + } + + /** + * Shows ACTION_CREATE_DOCUMENT so the player can save a staged export + * (pending_export.sav in the app save identity) to Downloads / Drive / + * etc. Suggested name is the dialog's default filename. + */ + @Keep + public static boolean showCreateDocument(String suggestedName) { + if (android.os.Build.VERSION.SDK_INT < 19) return false; + GameActivity self = (GameActivity) mSingleton; + if (self == null) return false; + if (suggestedName == null || suggestedName.length() == 0) { + suggestedName = "export.sav"; + } + if (suggestedName.indexOf('/') >= 0 || suggestedName.indexOf('\\') >= 0) { + Log.d("GameActivity", "refusing unsafe create name: " + suggestedName); + return false; + } + File source = new File( + new File(self.getExternalFilesDir(null), "save"), + ROM_SAVE_IDENTITY + "/" + PENDING_EXPORT_FILENAME); + if (!source.isFile()) { + Log.d("GameActivity", "no pending export at " + source); + return false; + } + + self.pendingCreateSuggestedName = suggestedName; + Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT); + intent.addCategory(Intent.CATEGORY_OPENABLE); + intent.setType("application/octet-stream"); + intent.putExtra(Intent.EXTRA_TITLE, suggestedName); + try { + self.startActivityForResult(intent, FILE_CREATE_REQUEST_CODE); + return true; + } catch (Exception e) { + Log.d("GameActivity", "could not open create-document picker: " + e.getMessage()); + return false; + } + } + + private File saveIdentityDir() { + return new File(new File(getExternalFilesDir(null), "save"), ROM_SAVE_IDENTITY); + } + + /** Drops a small flag file in the save identity for Lua to consume on focus. */ + private void writeSaveDirFlag(String name, String body) { + try { + FileOutputStream fos = new FileOutputStream(new File(saveIdentityDir(), name), false); + fos.write(body.getBytes()); + fos.close(); + } catch (IOException e) { + Log.d("GameActivity", "could not write " + name + ": " + e.getMessage()); + } + } + + private boolean copyFileToUri(File source, Uri destUri) { + InputStream in = null; + OutputStream out = null; + try { + in = new BufferedInputStream(new FileInputStream(source)); + out = getContentResolver().openOutputStream(destUri); + if (out == null) return false; + byte[] buf = new byte[8192]; + int n; + while ((n = in.read(buf)) != -1) { + out.write(buf, 0, n); + } + out.flush(); + return true; + } catch (IOException e) { + Log.d("GameActivity", "copy to URI failed: " + e.getMessage()); + return false; + } finally { + try { if (in != null) in.close(); } catch (IOException ignored) {} + try { if (out != null) out.close(); } catch (IOException ignored) {} + } + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + if (requestCode == FILE_CREATE_REQUEST_CODE) { + if (resultCode != RESULT_OK || data == null || data.getData() == null) { + Log.d("GameActivity", "create-document cancelled"); + return; + } + File source = new File(saveIdentityDir(), PENDING_EXPORT_FILENAME); + if (!source.isFile()) { + Log.d("GameActivity", "pending export missing at result time"); + return; + } + Uri uri = data.getData(); + if (copyFileToUri(source, uri)) { + // Signal Lua on next focus that the SAF export finished. + writeSaveDirFlag(EXPORT_DONE_FILENAME, "ok"); + // Keep pending_export.sav so a retry still works; Lua may remove it. + } else { + Log.d("GameActivity", "could not write export to " + uri); + } + return; + } + if (requestCode != FILE_PICKER_REQUEST_CODE) return; + if (resultCode != RESULT_OK || data == null || data.getData() == null) { + Log.d("GameActivity", "file picker returned no file (cancelled?)"); + return; + } + + Uri uri = data.getData(); + File destDir = saveIdentityDir(); + if (!destDir.exists() && !destDir.mkdirs()) { + Log.d("GameActivity", "could not create " + destDir); + return; + } + String destName = pendingPickFilename != null + ? pendingPickFilename : PICKED_ROM_FILENAME; + File destFile = new File(destDir, destName); + + // ACTION_OPEN_DOCUMENT is meant to land in the system documents UI, but + // some OEM shells (ColorOS) offer third-party file managers in a + // chooser, and those hand back either a provider URI this app has no + // grant for (SecurityException / FileNotFoundException) or a bare + // file:// path (unreadable without storage permission on targetSdk 34). + // Try the resolver, then the path, then tell Lua why nothing imported. + InputStream source = null; + try { + source = getContentResolver().openInputStream(uri); + } catch (Exception e) { + Log.d("GameActivity", "could not open picked file: " + e.getMessage()); + } + if (source == null && "file".equals(uri.getScheme()) && uri.getPath() != null) { + try { + source = new FileInputStream(uri.getPath()); + } catch (FileNotFoundException e) { + Log.d("GameActivity", "could not open picked path: " + e.getMessage()); + } + } + if (source == null) { + Log.d("GameActivity", "no readable stream for picked file " + uri); + writeSaveDirFlag(PICK_ERROR_FILENAME, destName); + return; + } + if (!copyAssetFile(source, destFile.getPath())) { + Log.d("GameActivity", "could not copy picked file to " + destFile); + // A truncated pick would only fail verification later, so drop it + // and report instead. + destFile.delete(); + writeSaveDirFlag(PICK_ERROR_FILENAME, destName); + } + } + + /** + * Copies a given file from the assets folder to the destination. + * + * @return true if successful + */ + boolean copyAssetFile(InputStream source, String destinationFileName) { + boolean success = false; + + BufferedOutputStream destination = null; + try { + destination = new BufferedOutputStream(new FileOutputStream(destinationFileName, false)); + } catch (IOException e) { + Log.d("GameActivity", "Could not open destination file: " + e.getMessage()); + } + + // perform the copying + int chunk_read; + int bytes_written = 0; + + assert (source != null && destination != null); + + try { + byte[] buf = new byte[1024]; + chunk_read = source.read(buf); + do { + destination.write(buf, 0, chunk_read); + bytes_written += chunk_read; + chunk_read = source.read(buf); + } while (chunk_read != -1); + } catch (IOException e) { + Log.d("GameActivity", "Copying failed:" + e.getMessage()); + } + + // close streams + try { + source.close(); + destination.close(); + success = true; + } catch (IOException e) { + Log.d("GameActivity", "Copying failed: " + e.getMessage()); + } + + Log.d("GameActivity", "Successfully copied stream to " + destinationFileName + " (" + bytes_written + " bytes written)."); + return success; + } + + @Keep + public boolean hasBackgroundMusic() { + AudioManager audioManager = (AudioManager) getSystemService(Context.AUDIO_SERVICE); + return audioManager.isMusicActive(); + } + + @Keep + public void showRecordingAudioPermissionMissingDialog() { + Log.d("GameActivity", "showRecordingAudioPermissionMissingDialog()"); + runOnUiThread(new Runnable() { + @Override + public void run() { + AlertDialog dialog = new AlertDialog.Builder(mSingleton) + .setTitle("Audio Recording Permission Missing") + .setMessage("It appears that this game uses mic capabilities. The game may not work correctly without mic permission!") + .setNeutralButton("Continue", new DialogInterface.OnClickListener() { + public void onClick(DialogInterface di, int id) { + synchronized (recordAudioRequestDummy) { + recordAudioRequestDummy.notify(); + } + } + }) + .create(); + dialog.show(); + } + }); + + synchronized (recordAudioRequestDummy) { + try { + recordAudioRequestDummy.wait(); + } catch (InterruptedException e) { + Log.d("GameActivity", "mic permission dialog", e); + } + } + } + + public void showExternalStoragePermissionMissingDialog() { + AlertDialog dialog = new AlertDialog.Builder(mSingleton) + .setTitle("Storage Permission Missing") + .setMessage("LÖVE for Android will not be able to run non-packaged games without storage permission.") + .setNeutralButton("Continue", null) + .create(); + dialog.show(); + } + + @Override + public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) { + if (grantResults.length > 0) { + Log.d("GameActivity", "Received a request permission result"); + + switch (requestCode) { + case EXTERNAL_STORAGE_REQUEST_CODE: { + if (grantResults[0] == PackageManager.PERMISSION_GRANTED) { + Log.d("GameActivity", "Permission granted"); + } else { + Log.d("GameActivity", "Did not get permission."); + if (ActivityCompat.shouldShowRequestPermissionRationale(this, Manifest.permission.READ_EXTERNAL_STORAGE)) { + showExternalStoragePermissionMissingDialog(); + } + } + + Log.d("GameActivity", "Unlocking LÖVE thread"); + synchronized (externalStorageRequestDummy) { + externalStorageRequestDummy[0] = grantResults[0]; + externalStorageRequestDummy.notify(); + } + break; + } + case RECORD_AUDIO_REQUEST_CODE: { + if (grantResults[0] == PackageManager.PERMISSION_GRANTED) { + Log.d("GameActivity", "Mic permission granted"); + } else { + Log.d("GameActivity", "Did not get mic permission."); + } + + Log.d("GameActivity", "Unlocking LÖVE thread"); + synchronized (recordAudioRequestDummy) { + recordAudioRequestDummy[0] = grantResults[0]; + recordAudioRequestDummy.notify(); + } + break; + } + default: + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + } + } + } + + @Keep + public boolean hasExternalStoragePermission() { + if (ActivityCompat.checkSelfPermission(this, + Manifest.permission.READ_EXTERNAL_STORAGE) + == PackageManager.PERMISSION_GRANTED) { + return true; + } + + Log.d("GameActivity", "Requesting permission and locking LÖVE thread until we have an answer."); + ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.READ_EXTERNAL_STORAGE}, EXTERNAL_STORAGE_REQUEST_CODE); + + synchronized (externalStorageRequestDummy) { + try { + externalStorageRequestDummy.wait(); + } catch (InterruptedException e) { + Log.d("GameActivity", "requesting external storage permission", e); + return false; + } + } + + return ActivityCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED; + } + + @Keep + public boolean hasRecordAudioPermission() { + return ActivityCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED; + } + + @Keep + public void requestRecordAudioPermission() { + if (ActivityCompat.checkSelfPermission(this, + Manifest.permission.RECORD_AUDIO) + == PackageManager.PERMISSION_GRANTED) { + return; + } + + Log.d("GameActivity", "Requesting mic permission and locking LÖVE thread until we have an answer."); + ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.RECORD_AUDIO}, RECORD_AUDIO_REQUEST_CODE); + + synchronized (recordAudioRequestDummy) { + try { + recordAudioRequestDummy.wait(); + } catch (InterruptedException e) { + Log.d("GameActivity", "requesting mic permission", e); + } + } + } + + @Keep + public boolean initializeSafeArea() { + if (android.os.Build.VERSION.SDK_INT >= 28 && shortEdgesMode) { + DisplayCutout cutout = getWindow().getDecorView().getRootWindowInsets().getDisplayCutout(); + + if (cutout != null) { + safeAreaTop = cutout.getSafeInsetTop(); + safeAreaLeft = cutout.getSafeInsetLeft(); + safeAreaBottom = cutout.getSafeInsetBottom(); + safeAreaRight = cutout.getSafeInsetRight(); + return true; + } + } + + return false; + } + + @Keep + public String[] buildFileTree() { + // Map key is path, value is directory flag + HashMap map = buildFileTree(getAssets(), "", new HashMap()); + ArrayList result = new ArrayList(); + + for (Map.Entry data: map.entrySet()) { + result.add((data.getValue() ? "d" : "f") + data.getKey()); + } + + String[] r = new String[result.size()]; + result.toArray(r); + return r; + } + + private HashMap buildFileTree(AssetManager assetManager, String dir, HashMap map) { + String strippedDir = dir.endsWith("/") ? dir.substring(0, dir.length() - 1) : dir; + + // Try open dir + try { + InputStream test = assetManager.open(strippedDir); + // It's a file + test.close(); + map.put(strippedDir, false); + } catch (FileNotFoundException e) { + // It's a directory + String[] list = null; + + // List files + try { + list = assetManager.list(strippedDir); + } catch (IOException e2) { + Log.e("GameActivity", strippedDir, e2); + } + + // Mark as file + map.put(dir, true); + + // This Object comparison is intentional. + if (strippedDir != dir) { + map.put(strippedDir, true); + } + + if (list != null) { + for (String path: list) { + buildFileTree(assetManager, dir + path + "/", map); + } + } + } catch (IOException e) { + Log.e("GameActivity", dir, e); + } + + return map; + } + + public int getAudioSMP() { + int smp = 256; + + if (android.os.Build.VERSION.SDK_INT >= 17) { + AudioManager a = (AudioManager) getSystemService(Context.AUDIO_SERVICE); + int b = Integer.parseInt(a.getProperty(AudioManager.PROPERTY_OUTPUT_FRAMES_PER_BUFFER)); + return b > 0 ? b : smp; + } + + return smp; + } + + public int getAudioFreq() { + int freq = 44100; + + if (android.os.Build.VERSION.SDK_INT >= 17) { + AudioManager a = (AudioManager) getSystemService(Context.AUDIO_SERVICE); + int b = Integer.parseInt(a.getProperty(AudioManager.PROPERTY_OUTPUT_SAMPLE_RATE)); + return b > 0 ? b : freq; + } + + return freq; + } + + public boolean isNativeLibsExtracted() { + ApplicationInfo appInfo = getApplicationInfo(); + + if (android.os.Build.VERSION.SDK_INT >= 23) { + return (appInfo.flags & ApplicationInfo.FLAG_EXTRACT_NATIVE_LIBS) != 0; + } + + return true; + } + + @Keep + public String getCRequirePath() { + ApplicationInfo applicationInfo = getApplicationInfo(); + + if (isNativeLibsExtracted()) { + return applicationInfo.nativeLibraryDir + "/?.so"; + } else { + // The native libs are inside the APK and can be loaded directly. + // FIXME: What about split APKs? + String abi; + + if (android.os.Build.VERSION.SDK_INT >= 21) { + abi = android.os.Build.SUPPORTED_ABIS[0]; + } else { + // This codepath should NEVER be taken as if isNativeLibsExtracted() + // returns false, it's 100% safe to assume we're on API level 23 or later. + abi = android.os.Build.CPU_ABI; + } + + return applicationInfo.sourceDir + "!/lib/" + abi + "/?.so"; + } + } +} diff --git a/app/src/main/engine/org/love2d/sdl/HIDDevice.java b/app/src/main/engine/org/love2d/sdl/HIDDevice.java new file mode 100644 index 000000000..6058f7d17 --- /dev/null +++ b/app/src/main/engine/org/love2d/sdl/HIDDevice.java @@ -0,0 +1,22 @@ +package org.love2d.sdl; + +import android.hardware.usb.UsbDevice; + +interface HIDDevice +{ + public int getId(); + public int getVendorId(); + public int getProductId(); + public String getSerialNumber(); + public int getVersion(); + public String getManufacturerName(); + public String getProductName(); + public UsbDevice getDevice(); + public boolean open(); + public int sendFeatureReport(byte[] report); + public int sendOutputReport(byte[] report); + public boolean getFeatureReport(byte[] report); + public void setFrozen(boolean frozen); + public void close(); + public void shutdown(); +} diff --git a/app/src/main/engine/org/love2d/sdl/HIDDeviceBLESteamController.java b/app/src/main/engine/org/love2d/sdl/HIDDeviceBLESteamController.java new file mode 100644 index 000000000..b9a58808f --- /dev/null +++ b/app/src/main/engine/org/love2d/sdl/HIDDeviceBLESteamController.java @@ -0,0 +1,650 @@ +package org.love2d.sdl; + +import android.content.Context; +import android.bluetooth.BluetoothDevice; +import android.bluetooth.BluetoothGatt; +import android.bluetooth.BluetoothGattCallback; +import android.bluetooth.BluetoothGattCharacteristic; +import android.bluetooth.BluetoothGattDescriptor; +import android.bluetooth.BluetoothManager; +import android.bluetooth.BluetoothProfile; +import android.bluetooth.BluetoothGattService; +import android.hardware.usb.UsbDevice; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; +import android.os.*; + +//import com.android.internal.util.HexDump; + +import java.lang.Runnable; +import java.util.Arrays; +import java.util.LinkedList; +import java.util.UUID; + +class HIDDeviceBLESteamController extends BluetoothGattCallback implements HIDDevice { + + private static final String TAG = "hidapi"; + private HIDDeviceManager mManager; + private BluetoothDevice mDevice; + private int mDeviceId; + private BluetoothGatt mGatt; + private boolean mIsRegistered = false; + private boolean mIsConnected = false; + private boolean mIsChromebook = false; + private boolean mIsReconnecting = false; + private boolean mFrozen = false; + private LinkedList mOperations; + GattOperation mCurrentOperation = null; + private Handler mHandler; + + private static final int TRANSPORT_AUTO = 0; + private static final int TRANSPORT_BREDR = 1; + private static final int TRANSPORT_LE = 2; + + private static final int CHROMEBOOK_CONNECTION_CHECK_INTERVAL = 10000; + + static public final UUID steamControllerService = UUID.fromString("100F6C32-1735-4313-B402-38567131E5F3"); + static public final UUID inputCharacteristic = UUID.fromString("100F6C33-1735-4313-B402-38567131E5F3"); + static public final UUID reportCharacteristic = UUID.fromString("100F6C34-1735-4313-B402-38567131E5F3"); + static private final byte[] enterValveMode = new byte[] { (byte)0xC0, (byte)0x87, 0x03, 0x08, 0x07, 0x00 }; + + static class GattOperation { + private enum Operation { + CHR_READ, + CHR_WRITE, + ENABLE_NOTIFICATION + } + + Operation mOp; + UUID mUuid; + byte[] mValue; + BluetoothGatt mGatt; + boolean mResult = true; + + private GattOperation(BluetoothGatt gatt, GattOperation.Operation operation, UUID uuid) { + mGatt = gatt; + mOp = operation; + mUuid = uuid; + } + + private GattOperation(BluetoothGatt gatt, GattOperation.Operation operation, UUID uuid, byte[] value) { + mGatt = gatt; + mOp = operation; + mUuid = uuid; + mValue = value; + } + + public void run() { + // This is executed in main thread + BluetoothGattCharacteristic chr; + + switch (mOp) { + case CHR_READ: + chr = getCharacteristic(mUuid); + //Log.v(TAG, "Reading characteristic " + chr.getUuid()); + if (!mGatt.readCharacteristic(chr)) { + Log.e(TAG, "Unable to read characteristic " + mUuid.toString()); + mResult = false; + break; + } + mResult = true; + break; + case CHR_WRITE: + chr = getCharacteristic(mUuid); + //Log.v(TAG, "Writing characteristic " + chr.getUuid() + " value=" + HexDump.toHexString(value)); + chr.setValue(mValue); + if (!mGatt.writeCharacteristic(chr)) { + Log.e(TAG, "Unable to write characteristic " + mUuid.toString()); + mResult = false; + break; + } + mResult = true; + break; + case ENABLE_NOTIFICATION: + chr = getCharacteristic(mUuid); + //Log.v(TAG, "Writing descriptor of " + chr.getUuid()); + if (chr != null) { + BluetoothGattDescriptor cccd = chr.getDescriptor(UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")); + if (cccd != null) { + int properties = chr.getProperties(); + byte[] value; + if ((properties & BluetoothGattCharacteristic.PROPERTY_NOTIFY) == BluetoothGattCharacteristic.PROPERTY_NOTIFY) { + value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE; + } else if ((properties & BluetoothGattCharacteristic.PROPERTY_INDICATE) == BluetoothGattCharacteristic.PROPERTY_INDICATE) { + value = BluetoothGattDescriptor.ENABLE_INDICATION_VALUE; + } else { + Log.e(TAG, "Unable to start notifications on input characteristic"); + mResult = false; + return; + } + + mGatt.setCharacteristicNotification(chr, true); + cccd.setValue(value); + if (!mGatt.writeDescriptor(cccd)) { + Log.e(TAG, "Unable to write descriptor " + mUuid.toString()); + mResult = false; + return; + } + mResult = true; + } + } + } + } + + public boolean finish() { + return mResult; + } + + private BluetoothGattCharacteristic getCharacteristic(UUID uuid) { + BluetoothGattService valveService = mGatt.getService(steamControllerService); + if (valveService == null) + return null; + return valveService.getCharacteristic(uuid); + } + + static public GattOperation readCharacteristic(BluetoothGatt gatt, UUID uuid) { + return new GattOperation(gatt, Operation.CHR_READ, uuid); + } + + static public GattOperation writeCharacteristic(BluetoothGatt gatt, UUID uuid, byte[] value) { + return new GattOperation(gatt, Operation.CHR_WRITE, uuid, value); + } + + static public GattOperation enableNotification(BluetoothGatt gatt, UUID uuid) { + return new GattOperation(gatt, Operation.ENABLE_NOTIFICATION, uuid); + } + } + + public HIDDeviceBLESteamController(HIDDeviceManager manager, BluetoothDevice device) { + mManager = manager; + mDevice = device; + mDeviceId = mManager.getDeviceIDForIdentifier(getIdentifier()); + mIsRegistered = false; + mIsChromebook = mManager.getContext().getPackageManager().hasSystemFeature("org.chromium.arc.device_management"); + mOperations = new LinkedList(); + mHandler = new Handler(Looper.getMainLooper()); + + mGatt = connectGatt(); + // final HIDDeviceBLESteamController finalThis = this; + // mHandler.postDelayed(new Runnable() { + // @Override + // public void run() { + // finalThis.checkConnectionForChromebookIssue(); + // } + // }, CHROMEBOOK_CONNECTION_CHECK_INTERVAL); + } + + public String getIdentifier() { + return String.format("SteamController.%s", mDevice.getAddress()); + } + + public BluetoothGatt getGatt() { + return mGatt; + } + + // Because on Chromebooks we show up as a dual-mode device, it will attempt to connect TRANSPORT_AUTO, which will use TRANSPORT_BREDR instead + // of TRANSPORT_LE. Let's force ourselves to connect low energy. + private BluetoothGatt connectGatt(boolean managed) { + if (Build.VERSION.SDK_INT >= 23 /* Android 6.0 (M) */) { + try { + return mDevice.connectGatt(mManager.getContext(), managed, this, TRANSPORT_LE); + } catch (Exception e) { + return mDevice.connectGatt(mManager.getContext(), managed, this); + } + } else { + return mDevice.connectGatt(mManager.getContext(), managed, this); + } + } + + private BluetoothGatt connectGatt() { + return connectGatt(false); + } + + protected int getConnectionState() { + + Context context = mManager.getContext(); + if (context == null) { + // We are lacking any context to get our Bluetooth information. We'll just assume disconnected. + return BluetoothProfile.STATE_DISCONNECTED; + } + + BluetoothManager btManager = (BluetoothManager)context.getSystemService(Context.BLUETOOTH_SERVICE); + if (btManager == null) { + // This device doesn't support Bluetooth. We should never be here, because how did + // we instantiate a device to start with? + return BluetoothProfile.STATE_DISCONNECTED; + } + + return btManager.getConnectionState(mDevice, BluetoothProfile.GATT); + } + + public void reconnect() { + + if (getConnectionState() != BluetoothProfile.STATE_CONNECTED) { + mGatt.disconnect(); + mGatt = connectGatt(); + } + + } + + protected void checkConnectionForChromebookIssue() { + if (!mIsChromebook) { + // We only do this on Chromebooks, because otherwise it's really annoying to just attempt + // over and over. + return; + } + + int connectionState = getConnectionState(); + + switch (connectionState) { + case BluetoothProfile.STATE_CONNECTED: + if (!mIsConnected) { + // We are in the Bad Chromebook Place. We can force a disconnect + // to try to recover. + Log.v(TAG, "Chromebook: We are in a very bad state; the controller shows as connected in the underlying Bluetooth layer, but we never received a callback. Forcing a reconnect."); + mIsReconnecting = true; + mGatt.disconnect(); + mGatt = connectGatt(false); + break; + } + else if (!isRegistered()) { + if (mGatt.getServices().size() > 0) { + Log.v(TAG, "Chromebook: We are connected to a controller, but never got our registration. Trying to recover."); + probeService(this); + } + else { + Log.v(TAG, "Chromebook: We are connected to a controller, but never discovered services. Trying to recover."); + mIsReconnecting = true; + mGatt.disconnect(); + mGatt = connectGatt(false); + break; + } + } + else { + Log.v(TAG, "Chromebook: We are connected, and registered. Everything's good!"); + return; + } + break; + + case BluetoothProfile.STATE_DISCONNECTED: + Log.v(TAG, "Chromebook: We have either been disconnected, or the Chromebook BtGatt.ContextMap bug has bitten us. Attempting a disconnect/reconnect, but we may not be able to recover."); + + mIsReconnecting = true; + mGatt.disconnect(); + mGatt = connectGatt(false); + break; + + case BluetoothProfile.STATE_CONNECTING: + Log.v(TAG, "Chromebook: We're still trying to connect. Waiting a bit longer."); + break; + } + + final HIDDeviceBLESteamController finalThis = this; + mHandler.postDelayed(new Runnable() { + @Override + public void run() { + finalThis.checkConnectionForChromebookIssue(); + } + }, CHROMEBOOK_CONNECTION_CHECK_INTERVAL); + } + + private boolean isRegistered() { + return mIsRegistered; + } + + private void setRegistered() { + mIsRegistered = true; + } + + private boolean probeService(HIDDeviceBLESteamController controller) { + + if (isRegistered()) { + return true; + } + + if (!mIsConnected) { + return false; + } + + Log.v(TAG, "probeService controller=" + controller); + + for (BluetoothGattService service : mGatt.getServices()) { + if (service.getUuid().equals(steamControllerService)) { + Log.v(TAG, "Found Valve steam controller service " + service.getUuid()); + + for (BluetoothGattCharacteristic chr : service.getCharacteristics()) { + if (chr.getUuid().equals(inputCharacteristic)) { + Log.v(TAG, "Found input characteristic"); + // Start notifications + BluetoothGattDescriptor cccd = chr.getDescriptor(UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")); + if (cccd != null) { + enableNotification(chr.getUuid()); + } + } + } + return true; + } + } + + if ((mGatt.getServices().size() == 0) && mIsChromebook && !mIsReconnecting) { + Log.e(TAG, "Chromebook: Discovered services were empty; this almost certainly means the BtGatt.ContextMap bug has bitten us."); + mIsConnected = false; + mIsReconnecting = true; + mGatt.disconnect(); + mGatt = connectGatt(false); + } + + return false; + } + + ////////////////////////////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////////////////////////////// + + private void finishCurrentGattOperation() { + GattOperation op = null; + synchronized (mOperations) { + if (mCurrentOperation != null) { + op = mCurrentOperation; + mCurrentOperation = null; + } + } + if (op != null) { + boolean result = op.finish(); // TODO: Maybe in main thread as well? + + // Our operation failed, let's add it back to the beginning of our queue. + if (!result) { + mOperations.addFirst(op); + } + } + executeNextGattOperation(); + } + + private void executeNextGattOperation() { + synchronized (mOperations) { + if (mCurrentOperation != null) + return; + + if (mOperations.isEmpty()) + return; + + mCurrentOperation = mOperations.removeFirst(); + } + + // Run in main thread + mHandler.post(new Runnable() { + @Override + public void run() { + synchronized (mOperations) { + if (mCurrentOperation == null) { + Log.e(TAG, "Current operation null in executor?"); + return; + } + + mCurrentOperation.run(); + // now wait for the GATT callback and when it comes, finish this operation + } + } + }); + } + + private void queueGattOperation(GattOperation op) { + synchronized (mOperations) { + mOperations.add(op); + } + executeNextGattOperation(); + } + + private void enableNotification(UUID chrUuid) { + GattOperation op = HIDDeviceBLESteamController.GattOperation.enableNotification(mGatt, chrUuid); + queueGattOperation(op); + } + + public void writeCharacteristic(UUID uuid, byte[] value) { + GattOperation op = HIDDeviceBLESteamController.GattOperation.writeCharacteristic(mGatt, uuid, value); + queueGattOperation(op); + } + + public void readCharacteristic(UUID uuid) { + GattOperation op = HIDDeviceBLESteamController.GattOperation.readCharacteristic(mGatt, uuid); + queueGattOperation(op); + } + + ////////////////////////////////////////////////////////////////////////////////////////////////////// + ////////////// BluetoothGattCallback overridden methods + ////////////////////////////////////////////////////////////////////////////////////////////////////// + + public void onConnectionStateChange(BluetoothGatt g, int status, int newState) { + //Log.v(TAG, "onConnectionStateChange status=" + status + " newState=" + newState); + mIsReconnecting = false; + if (newState == 2) { + mIsConnected = true; + // Run directly, without GattOperation + if (!isRegistered()) { + mHandler.post(new Runnable() { + @Override + public void run() { + mGatt.discoverServices(); + } + }); + } + } + else if (newState == 0) { + mIsConnected = false; + } + + // Disconnection is handled in SteamLink using the ACTION_ACL_DISCONNECTED Intent. + } + + public void onServicesDiscovered(BluetoothGatt gatt, int status) { + //Log.v(TAG, "onServicesDiscovered status=" + status); + if (status == 0) { + if (gatt.getServices().size() == 0) { + Log.v(TAG, "onServicesDiscovered returned zero services; something has gone horribly wrong down in Android's Bluetooth stack."); + mIsReconnecting = true; + mIsConnected = false; + gatt.disconnect(); + mGatt = connectGatt(false); + } + else { + probeService(this); + } + } + } + + public void onCharacteristicRead(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) { + //Log.v(TAG, "onCharacteristicRead status=" + status + " uuid=" + characteristic.getUuid()); + + if (characteristic.getUuid().equals(reportCharacteristic) && !mFrozen) { + mManager.HIDDeviceFeatureReport(getId(), characteristic.getValue()); + } + + finishCurrentGattOperation(); + } + + public void onCharacteristicWrite(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) { + //Log.v(TAG, "onCharacteristicWrite status=" + status + " uuid=" + characteristic.getUuid()); + + if (characteristic.getUuid().equals(reportCharacteristic)) { + // Only register controller with the native side once it has been fully configured + if (!isRegistered()) { + Log.v(TAG, "Registering Steam Controller with ID: " + getId()); + mManager.HIDDeviceConnected(getId(), getIdentifier(), getVendorId(), getProductId(), getSerialNumber(), getVersion(), getManufacturerName(), getProductName(), 0, 0, 0, 0); + setRegistered(); + } + } + + finishCurrentGattOperation(); + } + + public void onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic) { + // Enable this for verbose logging of controller input reports + //Log.v(TAG, "onCharacteristicChanged uuid=" + characteristic.getUuid() + " data=" + HexDump.dumpHexString(characteristic.getValue())); + + if (characteristic.getUuid().equals(inputCharacteristic) && !mFrozen) { + mManager.HIDDeviceInputReport(getId(), characteristic.getValue()); + } + } + + public void onDescriptorRead(BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status) { + //Log.v(TAG, "onDescriptorRead status=" + status); + } + + public void onDescriptorWrite(BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status) { + BluetoothGattCharacteristic chr = descriptor.getCharacteristic(); + //Log.v(TAG, "onDescriptorWrite status=" + status + " uuid=" + chr.getUuid() + " descriptor=" + descriptor.getUuid()); + + if (chr.getUuid().equals(inputCharacteristic)) { + boolean hasWrittenInputDescriptor = true; + BluetoothGattCharacteristic reportChr = chr.getService().getCharacteristic(reportCharacteristic); + if (reportChr != null) { + Log.v(TAG, "Writing report characteristic to enter valve mode"); + reportChr.setValue(enterValveMode); + gatt.writeCharacteristic(reportChr); + } + } + + finishCurrentGattOperation(); + } + + public void onReliableWriteCompleted(BluetoothGatt gatt, int status) { + //Log.v(TAG, "onReliableWriteCompleted status=" + status); + } + + public void onReadRemoteRssi(BluetoothGatt gatt, int rssi, int status) { + //Log.v(TAG, "onReadRemoteRssi status=" + status); + } + + public void onMtuChanged(BluetoothGatt gatt, int mtu, int status) { + //Log.v(TAG, "onMtuChanged status=" + status); + } + + ////////////////////////////////////////////////////////////////////////////////////////////////////// + //////// Public API + ////////////////////////////////////////////////////////////////////////////////////////////////////// + + @Override + public int getId() { + return mDeviceId; + } + + @Override + public int getVendorId() { + // Valve Corporation + final int VALVE_USB_VID = 0x28DE; + return VALVE_USB_VID; + } + + @Override + public int getProductId() { + // We don't have an easy way to query from the Bluetooth device, but we know what it is + final int D0G_BLE2_PID = 0x1106; + return D0G_BLE2_PID; + } + + @Override + public String getSerialNumber() { + // This will be read later via feature report by Steam + return "12345"; + } + + @Override + public int getVersion() { + return 0; + } + + @Override + public String getManufacturerName() { + return "Valve Corporation"; + } + + @Override + public String getProductName() { + return "Steam Controller"; + } + + @Override + public UsbDevice getDevice() { + return null; + } + + @Override + public boolean open() { + return true; + } + + @Override + public int sendFeatureReport(byte[] report) { + if (!isRegistered()) { + Log.e(TAG, "Attempted sendFeatureReport before Steam Controller is registered!"); + if (mIsConnected) { + probeService(this); + } + return -1; + } + + // We need to skip the first byte, as that doesn't go over the air + byte[] actual_report = Arrays.copyOfRange(report, 1, report.length - 1); + //Log.v(TAG, "sendFeatureReport " + HexDump.dumpHexString(actual_report)); + writeCharacteristic(reportCharacteristic, actual_report); + return report.length; + } + + @Override + public int sendOutputReport(byte[] report) { + if (!isRegistered()) { + Log.e(TAG, "Attempted sendOutputReport before Steam Controller is registered!"); + if (mIsConnected) { + probeService(this); + } + return -1; + } + + //Log.v(TAG, "sendFeatureReport " + HexDump.dumpHexString(report)); + writeCharacteristic(reportCharacteristic, report); + return report.length; + } + + @Override + public boolean getFeatureReport(byte[] report) { + if (!isRegistered()) { + Log.e(TAG, "Attempted getFeatureReport before Steam Controller is registered!"); + if (mIsConnected) { + probeService(this); + } + return false; + } + + //Log.v(TAG, "getFeatureReport"); + readCharacteristic(reportCharacteristic); + return true; + } + + @Override + public void close() { + } + + @Override + public void setFrozen(boolean frozen) { + mFrozen = frozen; + } + + @Override + public void shutdown() { + close(); + + BluetoothGatt g = mGatt; + if (g != null) { + g.disconnect(); + g.close(); + mGatt = null; + } + mManager = null; + mIsRegistered = false; + mIsConnected = false; + mOperations.clear(); + } + +} + diff --git a/app/src/main/engine/org/love2d/sdl/HIDDeviceManager.java b/app/src/main/engine/org/love2d/sdl/HIDDeviceManager.java new file mode 100644 index 000000000..f37fe6e72 --- /dev/null +++ b/app/src/main/engine/org/love2d/sdl/HIDDeviceManager.java @@ -0,0 +1,684 @@ +package org.love2d.sdl; + +import android.app.Activity; +import android.app.AlertDialog; +import android.app.PendingIntent; +import android.bluetooth.BluetoothAdapter; +import android.bluetooth.BluetoothDevice; +import android.bluetooth.BluetoothManager; +import android.bluetooth.BluetoothProfile; +import android.os.Build; +import android.util.Log; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.SharedPreferences; +import android.content.pm.PackageManager; +import android.hardware.usb.*; +import android.os.Handler; +import android.os.Looper; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; + +public class HIDDeviceManager { + private static final String TAG = "hidapi"; + private static final String ACTION_USB_PERMISSION = "org.love2d.sdl.USB_PERMISSION"; + + private static HIDDeviceManager sManager; + private static int sManagerRefCount = 0; + + public static HIDDeviceManager acquire(Context context) { + if (sManagerRefCount == 0) { + sManager = new HIDDeviceManager(context); + } + ++sManagerRefCount; + return sManager; + } + + public static void release(HIDDeviceManager manager) { + if (manager == sManager) { + --sManagerRefCount; + if (sManagerRefCount == 0) { + sManager.close(); + sManager = null; + } + } + } + + private Context mContext; + private HashMap mDevicesById = new HashMap(); + private HashMap mBluetoothDevices = new HashMap(); + private int mNextDeviceId = 0; + private SharedPreferences mSharedPreferences = null; + private boolean mIsChromebook = false; + private UsbManager mUsbManager; + private Handler mHandler; + private BluetoothManager mBluetoothManager; + private List mLastBluetoothDevices; + + private final BroadcastReceiver mUsbBroadcast = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + String action = intent.getAction(); + if (action.equals(UsbManager.ACTION_USB_DEVICE_ATTACHED)) { + UsbDevice usbDevice = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE); + handleUsbDeviceAttached(usbDevice); + } else if (action.equals(UsbManager.ACTION_USB_DEVICE_DETACHED)) { + UsbDevice usbDevice = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE); + handleUsbDeviceDetached(usbDevice); + } else if (action.equals(HIDDeviceManager.ACTION_USB_PERMISSION)) { + UsbDevice usbDevice = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE); + handleUsbDevicePermission(usbDevice, intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)); + } + } + }; + + private final BroadcastReceiver mBluetoothBroadcast = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + String action = intent.getAction(); + // Bluetooth device was connected. If it was a Steam Controller, handle it + if (action.equals(BluetoothDevice.ACTION_ACL_CONNECTED)) { + BluetoothDevice device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE); + Log.d(TAG, "Bluetooth device connected: " + device); + + if (isSteamController(device)) { + connectBluetoothDevice(device); + } + } + + // Bluetooth device was disconnected, remove from controller manager (if any) + if (action.equals(BluetoothDevice.ACTION_ACL_DISCONNECTED)) { + BluetoothDevice device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE); + Log.d(TAG, "Bluetooth device disconnected: " + device); + + disconnectBluetoothDevice(device); + } + } + }; + + private HIDDeviceManager(final Context context) { + mContext = context; + + HIDDeviceRegisterCallback(); + + mSharedPreferences = mContext.getSharedPreferences("hidapi", Context.MODE_PRIVATE); + mIsChromebook = mContext.getPackageManager().hasSystemFeature("org.chromium.arc.device_management"); + +// if (shouldClear) { +// SharedPreferences.Editor spedit = mSharedPreferences.edit(); +// spedit.clear(); +// spedit.commit(); +// } +// else + { + mNextDeviceId = mSharedPreferences.getInt("next_device_id", 0); + } + } + + public Context getContext() { + return mContext; + } + + public int getDeviceIDForIdentifier(String identifier) { + SharedPreferences.Editor spedit = mSharedPreferences.edit(); + + int result = mSharedPreferences.getInt(identifier, 0); + if (result == 0) { + result = mNextDeviceId++; + spedit.putInt("next_device_id", mNextDeviceId); + } + + spedit.putInt(identifier, result); + spedit.commit(); + return result; + } + + private void initializeUSB() { + mUsbManager = (UsbManager)mContext.getSystemService(Context.USB_SERVICE); + if (mUsbManager == null) { + return; + } + + /* + // Logging + for (UsbDevice device : mUsbManager.getDeviceList().values()) { + Log.i(TAG,"Path: " + device.getDeviceName()); + Log.i(TAG,"Manufacturer: " + device.getManufacturerName()); + Log.i(TAG,"Product: " + device.getProductName()); + Log.i(TAG,"ID: " + device.getDeviceId()); + Log.i(TAG,"Class: " + device.getDeviceClass()); + Log.i(TAG,"Protocol: " + device.getDeviceProtocol()); + Log.i(TAG,"Vendor ID " + device.getVendorId()); + Log.i(TAG,"Product ID: " + device.getProductId()); + Log.i(TAG,"Interface count: " + device.getInterfaceCount()); + Log.i(TAG,"---------------------------------------"); + + // Get interface details + for (int index = 0; index < device.getInterfaceCount(); index++) { + UsbInterface mUsbInterface = device.getInterface(index); + Log.i(TAG," ***** *****"); + Log.i(TAG," Interface index: " + index); + Log.i(TAG," Interface ID: " + mUsbInterface.getId()); + Log.i(TAG," Interface class: " + mUsbInterface.getInterfaceClass()); + Log.i(TAG," Interface subclass: " + mUsbInterface.getInterfaceSubclass()); + Log.i(TAG," Interface protocol: " + mUsbInterface.getInterfaceProtocol()); + Log.i(TAG," Endpoint count: " + mUsbInterface.getEndpointCount()); + + // Get endpoint details + for (int epi = 0; epi < mUsbInterface.getEndpointCount(); epi++) + { + UsbEndpoint mEndpoint = mUsbInterface.getEndpoint(epi); + Log.i(TAG," ++++ ++++ ++++"); + Log.i(TAG," Endpoint index: " + epi); + Log.i(TAG," Attributes: " + mEndpoint.getAttributes()); + Log.i(TAG," Direction: " + mEndpoint.getDirection()); + Log.i(TAG," Number: " + mEndpoint.getEndpointNumber()); + Log.i(TAG," Interval: " + mEndpoint.getInterval()); + Log.i(TAG," Packet size: " + mEndpoint.getMaxPacketSize()); + Log.i(TAG," Type: " + mEndpoint.getType()); + } + } + } + Log.i(TAG," No more devices connected."); + */ + + // Register for USB broadcasts and permission completions + IntentFilter filter = new IntentFilter(); + filter.addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED); + filter.addAction(UsbManager.ACTION_USB_DEVICE_DETACHED); + filter.addAction(HIDDeviceManager.ACTION_USB_PERMISSION); + mContext.registerReceiver(mUsbBroadcast, filter); + + for (UsbDevice usbDevice : mUsbManager.getDeviceList().values()) { + handleUsbDeviceAttached(usbDevice); + } + } + + UsbManager getUSBManager() { + return mUsbManager; + } + + private void shutdownUSB() { + try { + mContext.unregisterReceiver(mUsbBroadcast); + } catch (Exception e) { + // We may not have registered, that's okay + } + } + + private boolean isHIDDeviceInterface(UsbDevice usbDevice, UsbInterface usbInterface) { + if (usbInterface.getInterfaceClass() == UsbConstants.USB_CLASS_HID) { + return true; + } + if (isXbox360Controller(usbDevice, usbInterface) || isXboxOneController(usbDevice, usbInterface)) { + return true; + } + return false; + } + + private boolean isXbox360Controller(UsbDevice usbDevice, UsbInterface usbInterface) { + final int XB360_IFACE_SUBCLASS = 93; + final int XB360_IFACE_PROTOCOL = 1; // Wired + final int XB360W_IFACE_PROTOCOL = 129; // Wireless + final int[] SUPPORTED_VENDORS = { + 0x0079, // GPD Win 2 + 0x044f, // Thrustmaster + 0x045e, // Microsoft + 0x046d, // Logitech + 0x056e, // Elecom + 0x06a3, // Saitek + 0x0738, // Mad Catz + 0x07ff, // Mad Catz + 0x0e6f, // PDP + 0x0f0d, // Hori + 0x1038, // SteelSeries + 0x11c9, // Nacon + 0x12ab, // Unknown + 0x1430, // RedOctane + 0x146b, // BigBen + 0x1532, // Razer Sabertooth + 0x15e4, // Numark + 0x162e, // Joytech + 0x1689, // Razer Onza + 0x1949, // Lab126, Inc. + 0x1bad, // Harmonix + 0x20d6, // PowerA + 0x24c6, // PowerA + 0x2c22, // Qanba + 0x2dc8, // 8BitDo + 0x9886, // ASTRO Gaming + }; + + if (usbInterface.getInterfaceClass() == UsbConstants.USB_CLASS_VENDOR_SPEC && + usbInterface.getInterfaceSubclass() == XB360_IFACE_SUBCLASS && + (usbInterface.getInterfaceProtocol() == XB360_IFACE_PROTOCOL || + usbInterface.getInterfaceProtocol() == XB360W_IFACE_PROTOCOL)) { + int vendor_id = usbDevice.getVendorId(); + for (int supportedVid : SUPPORTED_VENDORS) { + if (vendor_id == supportedVid) { + return true; + } + } + } + return false; + } + + private boolean isXboxOneController(UsbDevice usbDevice, UsbInterface usbInterface) { + final int XB1_IFACE_SUBCLASS = 71; + final int XB1_IFACE_PROTOCOL = 208; + final int[] SUPPORTED_VENDORS = { + 0x03f0, // HP + 0x044f, // Thrustmaster + 0x045e, // Microsoft + 0x0738, // Mad Catz + 0x0e6f, // PDP + 0x0f0d, // Hori + 0x10f5, // Turtle Beach + 0x1532, // Razer Wildcat + 0x20d6, // PowerA + 0x24c6, // PowerA + 0x2dc8, // 8BitDo + 0x2e24, // Hyperkin + }; + + if (usbInterface.getId() == 0 && + usbInterface.getInterfaceClass() == UsbConstants.USB_CLASS_VENDOR_SPEC && + usbInterface.getInterfaceSubclass() == XB1_IFACE_SUBCLASS && + usbInterface.getInterfaceProtocol() == XB1_IFACE_PROTOCOL) { + int vendor_id = usbDevice.getVendorId(); + for (int supportedVid : SUPPORTED_VENDORS) { + if (vendor_id == supportedVid) { + return true; + } + } + } + return false; + } + + private void handleUsbDeviceAttached(UsbDevice usbDevice) { + connectHIDDeviceUSB(usbDevice); + } + + private void handleUsbDeviceDetached(UsbDevice usbDevice) { + List devices = new ArrayList(); + for (HIDDevice device : mDevicesById.values()) { + if (usbDevice.equals(device.getDevice())) { + devices.add(device.getId()); + } + } + for (int id : devices) { + HIDDevice device = mDevicesById.get(id); + mDevicesById.remove(id); + device.shutdown(); + HIDDeviceDisconnected(id); + } + } + + private void handleUsbDevicePermission(UsbDevice usbDevice, boolean permission_granted) { + for (HIDDevice device : mDevicesById.values()) { + if (usbDevice.equals(device.getDevice())) { + boolean opened = false; + if (permission_granted) { + opened = device.open(); + } + HIDDeviceOpenResult(device.getId(), opened); + } + } + } + + private void connectHIDDeviceUSB(UsbDevice usbDevice) { + synchronized (this) { + int interface_mask = 0; + for (int interface_index = 0; interface_index < usbDevice.getInterfaceCount(); interface_index++) { + UsbInterface usbInterface = usbDevice.getInterface(interface_index); + if (isHIDDeviceInterface(usbDevice, usbInterface)) { + // Check to see if we've already added this interface + // This happens with the Xbox Series X controller which has a duplicate interface 0, which is inactive + int interface_id = usbInterface.getId(); + if ((interface_mask & (1 << interface_id)) != 0) { + continue; + } + interface_mask |= (1 << interface_id); + + HIDDeviceUSB device = new HIDDeviceUSB(this, usbDevice, interface_index); + int id = device.getId(); + mDevicesById.put(id, device); + HIDDeviceConnected(id, device.getIdentifier(), device.getVendorId(), device.getProductId(), device.getSerialNumber(), device.getVersion(), device.getManufacturerName(), device.getProductName(), usbInterface.getId(), usbInterface.getInterfaceClass(), usbInterface.getInterfaceSubclass(), usbInterface.getInterfaceProtocol()); + } + } + } + } + + private void initializeBluetooth() { + Log.d(TAG, "Initializing Bluetooth"); + + if (Build.VERSION.SDK_INT <= 30 /* Android 11.0 (R) */ && + mContext.getPackageManager().checkPermission(android.Manifest.permission.BLUETOOTH, mContext.getPackageName()) != PackageManager.PERMISSION_GRANTED) { + Log.d(TAG, "Couldn't initialize Bluetooth, missing android.permission.BLUETOOTH"); + return; + } + + if (!mContext.getPackageManager().hasSystemFeature(PackageManager.FEATURE_BLUETOOTH_LE) || (Build.VERSION.SDK_INT < 18 /* Android 4.3 (JELLY_BEAN_MR2) */)) { + Log.d(TAG, "Couldn't initialize Bluetooth, this version of Android does not support Bluetooth LE"); + return; + } + + // Find bonded bluetooth controllers and create SteamControllers for them + mBluetoothManager = (BluetoothManager)mContext.getSystemService(Context.BLUETOOTH_SERVICE); + if (mBluetoothManager == null) { + // This device doesn't support Bluetooth. + return; + } + + BluetoothAdapter btAdapter = mBluetoothManager.getAdapter(); + if (btAdapter == null) { + // This device has Bluetooth support in the codebase, but has no available adapters. + return; + } + + // Get our bonded devices. + for (BluetoothDevice device : btAdapter.getBondedDevices()) { + + Log.d(TAG, "Bluetooth device available: " + device); + if (isSteamController(device)) { + connectBluetoothDevice(device); + } + + } + + // NOTE: These don't work on Chromebooks, to my undying dismay. + IntentFilter filter = new IntentFilter(); + filter.addAction(BluetoothDevice.ACTION_ACL_CONNECTED); + filter.addAction(BluetoothDevice.ACTION_ACL_DISCONNECTED); + mContext.registerReceiver(mBluetoothBroadcast, filter); + + if (mIsChromebook) { + mHandler = new Handler(Looper.getMainLooper()); + mLastBluetoothDevices = new ArrayList(); + + // final HIDDeviceManager finalThis = this; + // mHandler.postDelayed(new Runnable() { + // @Override + // public void run() { + // finalThis.chromebookConnectionHandler(); + // } + // }, 5000); + } + } + + private void shutdownBluetooth() { + try { + mContext.unregisterReceiver(mBluetoothBroadcast); + } catch (Exception e) { + // We may not have registered, that's okay + } + } + + // Chromebooks do not pass along ACTION_ACL_CONNECTED / ACTION_ACL_DISCONNECTED properly. + // This function provides a sort of dummy version of that, watching for changes in the + // connected devices and attempting to add controllers as things change. + public void chromebookConnectionHandler() { + if (!mIsChromebook) { + return; + } + + ArrayList disconnected = new ArrayList(); + ArrayList connected = new ArrayList(); + + List currentConnected = mBluetoothManager.getConnectedDevices(BluetoothProfile.GATT); + + for (BluetoothDevice bluetoothDevice : currentConnected) { + if (!mLastBluetoothDevices.contains(bluetoothDevice)) { + connected.add(bluetoothDevice); + } + } + for (BluetoothDevice bluetoothDevice : mLastBluetoothDevices) { + if (!currentConnected.contains(bluetoothDevice)) { + disconnected.add(bluetoothDevice); + } + } + + mLastBluetoothDevices = currentConnected; + + for (BluetoothDevice bluetoothDevice : disconnected) { + disconnectBluetoothDevice(bluetoothDevice); + } + for (BluetoothDevice bluetoothDevice : connected) { + connectBluetoothDevice(bluetoothDevice); + } + + final HIDDeviceManager finalThis = this; + mHandler.postDelayed(new Runnable() { + @Override + public void run() { + finalThis.chromebookConnectionHandler(); + } + }, 10000); + } + + public boolean connectBluetoothDevice(BluetoothDevice bluetoothDevice) { + Log.v(TAG, "connectBluetoothDevice device=" + bluetoothDevice); + synchronized (this) { + if (mBluetoothDevices.containsKey(bluetoothDevice)) { + Log.v(TAG, "Steam controller with address " + bluetoothDevice + " already exists, attempting reconnect"); + + HIDDeviceBLESteamController device = mBluetoothDevices.get(bluetoothDevice); + device.reconnect(); + + return false; + } + HIDDeviceBLESteamController device = new HIDDeviceBLESteamController(this, bluetoothDevice); + int id = device.getId(); + mBluetoothDevices.put(bluetoothDevice, device); + mDevicesById.put(id, device); + + // The Steam Controller will mark itself connected once initialization is complete + } + return true; + } + + public void disconnectBluetoothDevice(BluetoothDevice bluetoothDevice) { + synchronized (this) { + HIDDeviceBLESteamController device = mBluetoothDevices.get(bluetoothDevice); + if (device == null) + return; + + int id = device.getId(); + mBluetoothDevices.remove(bluetoothDevice); + mDevicesById.remove(id); + device.shutdown(); + HIDDeviceDisconnected(id); + } + } + + public boolean isSteamController(BluetoothDevice bluetoothDevice) { + // Sanity check. If you pass in a null device, by definition it is never a Steam Controller. + if (bluetoothDevice == null) { + return false; + } + + // If the device has no local name, we really don't want to try an equality check against it. + if (bluetoothDevice.getName() == null) { + return false; + } + + return bluetoothDevice.getName().equals("SteamController") && ((bluetoothDevice.getType() & BluetoothDevice.DEVICE_TYPE_LE) != 0); + } + + private void close() { + shutdownUSB(); + shutdownBluetooth(); + synchronized (this) { + for (HIDDevice device : mDevicesById.values()) { + device.shutdown(); + } + mDevicesById.clear(); + mBluetoothDevices.clear(); + HIDDeviceReleaseCallback(); + } + } + + public void setFrozen(boolean frozen) { + synchronized (this) { + for (HIDDevice device : mDevicesById.values()) { + device.setFrozen(frozen); + } + } + } + + ////////////////////////////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////////////////////////////// + + private HIDDevice getDevice(int id) { + synchronized (this) { + HIDDevice result = mDevicesById.get(id); + if (result == null) { + Log.v(TAG, "No device for id: " + id); + Log.v(TAG, "Available devices: " + mDevicesById.keySet()); + } + return result; + } + } + + ////////////////////////////////////////////////////////////////////////////////////////////////////// + ////////// JNI interface functions + ////////////////////////////////////////////////////////////////////////////////////////////////////// + + public boolean initialize(boolean usb, boolean bluetooth) { + Log.v(TAG, "initialize(" + usb + ", " + bluetooth + ")"); + + if (usb) { + initializeUSB(); + } + if (bluetooth) { + initializeBluetooth(); + } + return true; + } + + public boolean openDevice(int deviceID) { + Log.v(TAG, "openDevice deviceID=" + deviceID); + HIDDevice device = getDevice(deviceID); + if (device == null) { + HIDDeviceDisconnected(deviceID); + return false; + } + + // Look to see if this is a USB device and we have permission to access it + UsbDevice usbDevice = device.getDevice(); + if (usbDevice != null && !mUsbManager.hasPermission(usbDevice)) { + HIDDeviceOpenPending(deviceID); + try { + final int FLAG_MUTABLE = 0x02000000; // PendingIntent.FLAG_MUTABLE, but don't require SDK 31 + int flags; + if (Build.VERSION.SDK_INT >= 31 /* Android 12.0 (S) */) { + flags = FLAG_MUTABLE; + } else { + flags = 0; + } + mUsbManager.requestPermission(usbDevice, PendingIntent.getBroadcast(mContext, 0, new Intent(HIDDeviceManager.ACTION_USB_PERMISSION), flags)); + } catch (Exception e) { + Log.v(TAG, "Couldn't request permission for USB device " + usbDevice); + HIDDeviceOpenResult(deviceID, false); + } + return false; + } + + try { + return device.open(); + } catch (Exception e) { + Log.e(TAG, "Got exception: " + Log.getStackTraceString(e)); + } + return false; + } + + public int sendOutputReport(int deviceID, byte[] report) { + try { + //Log.v(TAG, "sendOutputReport deviceID=" + deviceID + " length=" + report.length); + HIDDevice device; + device = getDevice(deviceID); + if (device == null) { + HIDDeviceDisconnected(deviceID); + return -1; + } + + return device.sendOutputReport(report); + } catch (Exception e) { + Log.e(TAG, "Got exception: " + Log.getStackTraceString(e)); + } + return -1; + } + + public int sendFeatureReport(int deviceID, byte[] report) { + try { + //Log.v(TAG, "sendFeatureReport deviceID=" + deviceID + " length=" + report.length); + HIDDevice device; + device = getDevice(deviceID); + if (device == null) { + HIDDeviceDisconnected(deviceID); + return -1; + } + + return device.sendFeatureReport(report); + } catch (Exception e) { + Log.e(TAG, "Got exception: " + Log.getStackTraceString(e)); + } + return -1; + } + + public boolean getFeatureReport(int deviceID, byte[] report) { + try { + //Log.v(TAG, "getFeatureReport deviceID=" + deviceID); + HIDDevice device; + device = getDevice(deviceID); + if (device == null) { + HIDDeviceDisconnected(deviceID); + return false; + } + + return device.getFeatureReport(report); + } catch (Exception e) { + Log.e(TAG, "Got exception: " + Log.getStackTraceString(e)); + } + return false; + } + + public void closeDevice(int deviceID) { + try { + Log.v(TAG, "closeDevice deviceID=" + deviceID); + HIDDevice device; + device = getDevice(deviceID); + if (device == null) { + HIDDeviceDisconnected(deviceID); + return; + } + + device.close(); + } catch (Exception e) { + Log.e(TAG, "Got exception: " + Log.getStackTraceString(e)); + } + } + + + ////////////////////////////////////////////////////////////////////////////////////////////////////// + /////////////// Native methods + ////////////////////////////////////////////////////////////////////////////////////////////////////// + + private native void HIDDeviceRegisterCallback(); + private native void HIDDeviceReleaseCallback(); + + native void HIDDeviceConnected(int deviceID, String identifier, int vendorId, int productId, String serial_number, int release_number, String manufacturer_string, String product_string, int interface_number, int interface_class, int interface_subclass, int interface_protocol); + native void HIDDeviceOpenPending(int deviceID); + native void HIDDeviceOpenResult(int deviceID, boolean opened); + native void HIDDeviceDisconnected(int deviceID); + + native void HIDDeviceInputReport(int deviceID, byte[] report); + native void HIDDeviceFeatureReport(int deviceID, byte[] report); +} diff --git a/app/src/main/engine/org/love2d/sdl/HIDDeviceUSB.java b/app/src/main/engine/org/love2d/sdl/HIDDeviceUSB.java new file mode 100644 index 000000000..dcd8daa4c --- /dev/null +++ b/app/src/main/engine/org/love2d/sdl/HIDDeviceUSB.java @@ -0,0 +1,309 @@ +package org.love2d.sdl; + +import android.hardware.usb.*; +import android.os.Build; +import android.util.Log; +import java.util.Arrays; + +class HIDDeviceUSB implements HIDDevice { + + private static final String TAG = "hidapi"; + + protected HIDDeviceManager mManager; + protected UsbDevice mDevice; + protected int mInterfaceIndex; + protected int mInterface; + protected int mDeviceId; + protected UsbDeviceConnection mConnection; + protected UsbEndpoint mInputEndpoint; + protected UsbEndpoint mOutputEndpoint; + protected InputThread mInputThread; + protected boolean mRunning; + protected boolean mFrozen; + + public HIDDeviceUSB(HIDDeviceManager manager, UsbDevice usbDevice, int interface_index) { + mManager = manager; + mDevice = usbDevice; + mInterfaceIndex = interface_index; + mInterface = mDevice.getInterface(mInterfaceIndex).getId(); + mDeviceId = manager.getDeviceIDForIdentifier(getIdentifier()); + mRunning = false; + } + + public String getIdentifier() { + return String.format("%s/%x/%x/%d", mDevice.getDeviceName(), mDevice.getVendorId(), mDevice.getProductId(), mInterfaceIndex); + } + + @Override + public int getId() { + return mDeviceId; + } + + @Override + public int getVendorId() { + return mDevice.getVendorId(); + } + + @Override + public int getProductId() { + return mDevice.getProductId(); + } + + @Override + public String getSerialNumber() { + String result = null; + if (Build.VERSION.SDK_INT >= 21 /* Android 5.0 (LOLLIPOP) */) { + try { + result = mDevice.getSerialNumber(); + } + catch (SecurityException exception) { + //Log.w(TAG, "App permissions mean we cannot get serial number for device " + getDeviceName() + " message: " + exception.getMessage()); + } + } + if (result == null) { + result = ""; + } + return result; + } + + @Override + public int getVersion() { + return 0; + } + + @Override + public String getManufacturerName() { + String result = null; + if (Build.VERSION.SDK_INT >= 21 /* Android 5.0 (LOLLIPOP) */) { + result = mDevice.getManufacturerName(); + } + if (result == null) { + result = String.format("%x", getVendorId()); + } + return result; + } + + @Override + public String getProductName() { + String result = null; + if (Build.VERSION.SDK_INT >= 21 /* Android 5.0 (LOLLIPOP) */) { + result = mDevice.getProductName(); + } + if (result == null) { + result = String.format("%x", getProductId()); + } + return result; + } + + @Override + public UsbDevice getDevice() { + return mDevice; + } + + public String getDeviceName() { + return getManufacturerName() + " " + getProductName() + "(0x" + String.format("%x", getVendorId()) + "/0x" + String.format("%x", getProductId()) + ")"; + } + + @Override + public boolean open() { + mConnection = mManager.getUSBManager().openDevice(mDevice); + if (mConnection == null) { + Log.w(TAG, "Unable to open USB device " + getDeviceName()); + return false; + } + + // Force claim our interface + UsbInterface iface = mDevice.getInterface(mInterfaceIndex); + if (!mConnection.claimInterface(iface, true)) { + Log.w(TAG, "Failed to claim interfaces on USB device " + getDeviceName()); + close(); + return false; + } + + // Find the endpoints + for (int j = 0; j < iface.getEndpointCount(); j++) { + UsbEndpoint endpt = iface.getEndpoint(j); + switch (endpt.getDirection()) { + case UsbConstants.USB_DIR_IN: + if (mInputEndpoint == null) { + mInputEndpoint = endpt; + } + break; + case UsbConstants.USB_DIR_OUT: + if (mOutputEndpoint == null) { + mOutputEndpoint = endpt; + } + break; + } + } + + // Make sure the required endpoints were present + if (mInputEndpoint == null || mOutputEndpoint == null) { + Log.w(TAG, "Missing required endpoint on USB device " + getDeviceName()); + close(); + return false; + } + + // Start listening for input + mRunning = true; + mInputThread = new InputThread(); + mInputThread.start(); + + return true; + } + + @Override + public int sendFeatureReport(byte[] report) { + int res = -1; + int offset = 0; + int length = report.length; + boolean skipped_report_id = false; + byte report_number = report[0]; + + if (report_number == 0x0) { + ++offset; + --length; + skipped_report_id = true; + } + + res = mConnection.controlTransfer( + UsbConstants.USB_TYPE_CLASS | 0x01 /*RECIPIENT_INTERFACE*/ | UsbConstants.USB_DIR_OUT, + 0x09/*HID set_report*/, + (3/*HID feature*/ << 8) | report_number, + mInterface, + report, offset, length, + 1000/*timeout millis*/); + + if (res < 0) { + Log.w(TAG, "sendFeatureReport() returned " + res + " on device " + getDeviceName()); + return -1; + } + + if (skipped_report_id) { + ++length; + } + return length; + } + + @Override + public int sendOutputReport(byte[] report) { + int r = mConnection.bulkTransfer(mOutputEndpoint, report, report.length, 1000); + if (r != report.length) { + Log.w(TAG, "sendOutputReport() returned " + r + " on device " + getDeviceName()); + } + return r; + } + + @Override + public boolean getFeatureReport(byte[] report) { + int res = -1; + int offset = 0; + int length = report.length; + boolean skipped_report_id = false; + byte report_number = report[0]; + + if (report_number == 0x0) { + /* Offset the return buffer by 1, so that the report ID + will remain in byte 0. */ + ++offset; + --length; + skipped_report_id = true; + } + + res = mConnection.controlTransfer( + UsbConstants.USB_TYPE_CLASS | 0x01 /*RECIPIENT_INTERFACE*/ | UsbConstants.USB_DIR_IN, + 0x01/*HID get_report*/, + (3/*HID feature*/ << 8) | report_number, + mInterface, + report, offset, length, + 1000/*timeout millis*/); + + if (res < 0) { + Log.w(TAG, "getFeatureReport() returned " + res + " on device " + getDeviceName()); + return false; + } + + if (skipped_report_id) { + ++res; + ++length; + } + + byte[] data; + if (res == length) { + data = report; + } else { + data = Arrays.copyOfRange(report, 0, res); + } + mManager.HIDDeviceFeatureReport(mDeviceId, data); + + return true; + } + + @Override + public void close() { + mRunning = false; + if (mInputThread != null) { + while (mInputThread.isAlive()) { + mInputThread.interrupt(); + try { + mInputThread.join(); + } catch (InterruptedException e) { + // Keep trying until we're done + } + } + mInputThread = null; + } + if (mConnection != null) { + UsbInterface iface = mDevice.getInterface(mInterfaceIndex); + mConnection.releaseInterface(iface); + mConnection.close(); + mConnection = null; + } + } + + @Override + public void shutdown() { + close(); + mManager = null; + } + + @Override + public void setFrozen(boolean frozen) { + mFrozen = frozen; + } + + protected class InputThread extends Thread { + @Override + public void run() { + int packetSize = mInputEndpoint.getMaxPacketSize(); + byte[] packet = new byte[packetSize]; + while (mRunning) { + int r; + try + { + r = mConnection.bulkTransfer(mInputEndpoint, packet, packetSize, 1000); + } + catch (Exception e) + { + Log.v(TAG, "Exception in UsbDeviceConnection bulktransfer: " + e); + break; + } + if (r < 0) { + // Could be a timeout or an I/O error + } + if (r > 0) { + byte[] data; + if (r == packetSize) { + data = packet; + } else { + data = Arrays.copyOfRange(packet, 0, r); + } + + if (!mFrozen) { + mManager.HIDDeviceInputReport(mDeviceId, data); + } + } + } + } + } +} diff --git a/app/src/main/engine/org/love2d/sdl/SDL.java b/app/src/main/engine/org/love2d/sdl/SDL.java new file mode 100644 index 000000000..1c79abde4 --- /dev/null +++ b/app/src/main/engine/org/love2d/sdl/SDL.java @@ -0,0 +1,86 @@ +package org.love2d.sdl; + +import android.content.Context; + +import java.lang.Class; +import java.lang.reflect.Method; + +/** + SDL library initialization +*/ +public class SDL { + + // This function should be called first and sets up the native code + // so it can call into the Java classes + public static void setupJNI() { + SDLActivity.nativeSetupJNI(); + SDLAudioManager.nativeSetupJNI(); + SDLControllerManager.nativeSetupJNI(); + } + + // This function should be called each time the activity is started + public static void initialize() { + setContext(null); + + SDLActivity.initialize(); + SDLAudioManager.initialize(); + SDLControllerManager.initialize(); + } + + // This function stores the current activity (SDL or not) + public static void setContext(Context context) { + SDLAudioManager.setContext(context); + mContext = context; + } + + public static Context getContext() { + return mContext; + } + + public static void loadLibrary(String libraryName) throws UnsatisfiedLinkError, SecurityException, NullPointerException { + + if (libraryName == null) { + throw new NullPointerException("No library name provided."); + } + + try { + // Let's see if we have ReLinker available in the project. This is necessary for + // some projects that have huge numbers of local libraries bundled, and thus may + // trip a bug in Android's native library loader which ReLinker works around. (If + // loadLibrary works properly, ReLinker will simply use the normal Android method + // internally.) + // + // To use ReLinker, just add it as a dependency. For more information, see + // https://github.com/KeepSafe/ReLinker for ReLinker's repository. + // + Class relinkClass = mContext.getClassLoader().loadClass("com.getkeepsafe.relinker.ReLinker"); + Class relinkListenerClass = mContext.getClassLoader().loadClass("com.getkeepsafe.relinker.ReLinker$LoadListener"); + Class contextClass = mContext.getClassLoader().loadClass("android.content.Context"); + Class stringClass = mContext.getClassLoader().loadClass("java.lang.String"); + + // Get a 'force' instance of the ReLinker, so we can ensure libraries are reinstalled if + // they've changed during updates. + Method forceMethod = relinkClass.getDeclaredMethod("force"); + Object relinkInstance = forceMethod.invoke(null); + Class relinkInstanceClass = relinkInstance.getClass(); + + // Actually load the library! + Method loadMethod = relinkInstanceClass.getDeclaredMethod("loadLibrary", contextClass, stringClass, stringClass, relinkListenerClass); + loadMethod.invoke(relinkInstance, mContext, libraryName, null, null); + } + catch (final Throwable e) { + // Fall back + try { + System.loadLibrary(libraryName); + } + catch (final UnsatisfiedLinkError ule) { + throw ule; + } + catch (final SecurityException se) { + throw se; + } + } + } + + protected static Context mContext; +} diff --git a/app/src/main/engine/org/love2d/sdl/SDLActivity.java b/app/src/main/engine/org/love2d/sdl/SDLActivity.java new file mode 100644 index 000000000..f703e5ea6 --- /dev/null +++ b/app/src/main/engine/org/love2d/sdl/SDLActivity.java @@ -0,0 +1,2148 @@ +package org.love2d.sdl; + +import android.app.Activity; +import android.app.AlertDialog; +import android.app.Dialog; +import android.app.UiModeManager; +import android.content.ClipboardManager; +import android.content.ClipData; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.pm.ActivityInfo; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageManager; +import android.content.res.Configuration; +import android.graphics.Bitmap; +import android.graphics.Color; +import android.graphics.PorterDuff; +import android.graphics.drawable.Drawable; +import android.hardware.Sensor; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.os.Message; +import android.text.Editable; +import android.text.InputType; +import android.text.Selection; +import android.util.DisplayMetrics; +import android.util.Log; +import android.util.SparseArray; +import android.view.Display; +import android.view.Gravity; +import android.view.InputDevice; +import android.view.KeyEvent; +import android.view.PointerIcon; +import android.view.Surface; +import android.view.View; +import android.view.ViewGroup; +import android.view.Window; +import android.view.WindowManager; +import android.view.inputmethod.BaseInputConnection; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputConnection; +import android.view.inputmethod.InputMethodManager; +import android.widget.Button; +import android.widget.EditText; +import android.widget.LinearLayout; +import android.widget.RelativeLayout; +import android.widget.TextView; +import android.widget.Toast; + +import java.util.Hashtable; +import java.util.Locale; + + +/** + SDL Activity +*/ +public class SDLActivity extends Activity implements View.OnSystemUiVisibilityChangeListener { + private static final String TAG = "SDL"; + private static final int SDL_MAJOR_VERSION = 2; + private static final int SDL_MINOR_VERSION = 28; + private static final int SDL_MICRO_VERSION = 5; +/* + // Display InputType.SOURCE/CLASS of events and devices + // + // SDLActivity.debugSource(device.getSources(), "device[" + device.getName() + "]"); + // SDLActivity.debugSource(event.getSource(), "event"); + public static void debugSource(int sources, String prefix) { + int s = sources; + int s_copy = sources; + String cls = ""; + String src = ""; + int tst = 0; + int FLAG_TAINTED = 0x80000000; + + if ((s & InputDevice.SOURCE_CLASS_BUTTON) != 0) cls += " BUTTON"; + if ((s & InputDevice.SOURCE_CLASS_JOYSTICK) != 0) cls += " JOYSTICK"; + if ((s & InputDevice.SOURCE_CLASS_POINTER) != 0) cls += " POINTER"; + if ((s & InputDevice.SOURCE_CLASS_POSITION) != 0) cls += " POSITION"; + if ((s & InputDevice.SOURCE_CLASS_TRACKBALL) != 0) cls += " TRACKBALL"; + + + int s2 = s_copy & ~InputDevice.SOURCE_ANY; // keep class bits + s2 &= ~( InputDevice.SOURCE_CLASS_BUTTON + | InputDevice.SOURCE_CLASS_JOYSTICK + | InputDevice.SOURCE_CLASS_POINTER + | InputDevice.SOURCE_CLASS_POSITION + | InputDevice.SOURCE_CLASS_TRACKBALL); + + if (s2 != 0) cls += "Some_Unkown"; + + s2 = s_copy & InputDevice.SOURCE_ANY; // keep source only, no class; + + if (Build.VERSION.SDK_INT >= 23) { + tst = InputDevice.SOURCE_BLUETOOTH_STYLUS; + if ((s & tst) == tst) src += " BLUETOOTH_STYLUS"; + s2 &= ~tst; + } + + tst = InputDevice.SOURCE_DPAD; + if ((s & tst) == tst) src += " DPAD"; + s2 &= ~tst; + + tst = InputDevice.SOURCE_GAMEPAD; + if ((s & tst) == tst) src += " GAMEPAD"; + s2 &= ~tst; + + if (Build.VERSION.SDK_INT >= 21) { + tst = InputDevice.SOURCE_HDMI; + if ((s & tst) == tst) src += " HDMI"; + s2 &= ~tst; + } + + tst = InputDevice.SOURCE_JOYSTICK; + if ((s & tst) == tst) src += " JOYSTICK"; + s2 &= ~tst; + + tst = InputDevice.SOURCE_KEYBOARD; + if ((s & tst) == tst) src += " KEYBOARD"; + s2 &= ~tst; + + tst = InputDevice.SOURCE_MOUSE; + if ((s & tst) == tst) src += " MOUSE"; + s2 &= ~tst; + + if (Build.VERSION.SDK_INT >= 26) { + tst = InputDevice.SOURCE_MOUSE_RELATIVE; + if ((s & tst) == tst) src += " MOUSE_RELATIVE"; + s2 &= ~tst; + + tst = InputDevice.SOURCE_ROTARY_ENCODER; + if ((s & tst) == tst) src += " ROTARY_ENCODER"; + s2 &= ~tst; + } + tst = InputDevice.SOURCE_STYLUS; + if ((s & tst) == tst) src += " STYLUS"; + s2 &= ~tst; + + tst = InputDevice.SOURCE_TOUCHPAD; + if ((s & tst) == tst) src += " TOUCHPAD"; + s2 &= ~tst; + + tst = InputDevice.SOURCE_TOUCHSCREEN; + if ((s & tst) == tst) src += " TOUCHSCREEN"; + s2 &= ~tst; + + if (Build.VERSION.SDK_INT >= 18) { + tst = InputDevice.SOURCE_TOUCH_NAVIGATION; + if ((s & tst) == tst) src += " TOUCH_NAVIGATION"; + s2 &= ~tst; + } + + tst = InputDevice.SOURCE_TRACKBALL; + if ((s & tst) == tst) src += " TRACKBALL"; + s2 &= ~tst; + + tst = InputDevice.SOURCE_ANY; + if ((s & tst) == tst) src += " ANY"; + s2 &= ~tst; + + if (s == FLAG_TAINTED) src += " FLAG_TAINTED"; + s2 &= ~FLAG_TAINTED; + + if (s2 != 0) src += " Some_Unkown"; + + Log.v(TAG, prefix + "int=" + s_copy + " CLASS={" + cls + " } source(s):" + src); + } +*/ + + public static boolean mIsResumedCalled, mHasFocus; + public static final boolean mHasMultiWindow = (Build.VERSION.SDK_INT >= 24 /* Android 7.0 (N) */); + + // Cursor types + // private static final int SDL_SYSTEM_CURSOR_NONE = -1; + private static final int SDL_SYSTEM_CURSOR_ARROW = 0; + private static final int SDL_SYSTEM_CURSOR_IBEAM = 1; + private static final int SDL_SYSTEM_CURSOR_WAIT = 2; + private static final int SDL_SYSTEM_CURSOR_CROSSHAIR = 3; + private static final int SDL_SYSTEM_CURSOR_WAITARROW = 4; + private static final int SDL_SYSTEM_CURSOR_SIZENWSE = 5; + private static final int SDL_SYSTEM_CURSOR_SIZENESW = 6; + private static final int SDL_SYSTEM_CURSOR_SIZEWE = 7; + private static final int SDL_SYSTEM_CURSOR_SIZENS = 8; + private static final int SDL_SYSTEM_CURSOR_SIZEALL = 9; + private static final int SDL_SYSTEM_CURSOR_NO = 10; + private static final int SDL_SYSTEM_CURSOR_HAND = 11; + + protected static final int SDL_ORIENTATION_UNKNOWN = 0; + protected static final int SDL_ORIENTATION_LANDSCAPE = 1; + protected static final int SDL_ORIENTATION_LANDSCAPE_FLIPPED = 2; + protected static final int SDL_ORIENTATION_PORTRAIT = 3; + protected static final int SDL_ORIENTATION_PORTRAIT_FLIPPED = 4; + + protected static int mCurrentOrientation; + protected static Locale mCurrentLocale; + + // Handle the state of the native layer + public enum NativeState { + INIT, RESUMED, PAUSED + } + + public static NativeState mNextNativeState; + public static NativeState mCurrentNativeState; + + public static boolean mExitCalledFromJava; // love2d-mod: allow restarting of the native thread + + /** If shared libraries (e.g. SDL or the native application) could not be loaded. */ + public static boolean mBrokenLibraries = true; + + // Main components + protected static SDLActivity mSingleton; + protected static SDLSurface mSurface; + protected static DummyEdit mTextEdit; + protected static boolean mScreenKeyboardShown; + protected static ViewGroup mLayout; + protected static SDLClipboardHandler mClipboardHandler; + protected static Hashtable mCursors; + protected static int mLastCursorID; + protected static SDLGenericMotionListener_API12 mMotionListener; + protected static HIDDeviceManager mHIDDeviceManager; + + // This is what SDL runs in. It invokes SDL_main(), eventually + protected static Thread mSDLThread; + + protected static SDLGenericMotionListener_API12 getMotionListener() { + if (mMotionListener == null) { + if (Build.VERSION.SDK_INT >= 26 /* Android 8.0 (O) */) { + mMotionListener = new SDLGenericMotionListener_API26(); + } else if (Build.VERSION.SDK_INT >= 24 /* Android 7.0 (N) */) { + mMotionListener = new SDLGenericMotionListener_API24(); + } else { + mMotionListener = new SDLGenericMotionListener_API12(); + } + } + + return mMotionListener; + } + + /** + * This method returns the name of the shared object with the application entry point + * It can be overridden by derived classes. + */ + protected String getMainSharedObject() { + String library; + String[] libraries = SDLActivity.mSingleton.getLibraries(); + if (libraries.length > 0) { + library = "lib" + libraries[libraries.length - 1] + ".so"; + } else { + library = "libmain.so"; + } + return getContext().getApplicationInfo().nativeLibraryDir + "/" + library; + } + + /** + * This method returns the name of the application entry point + * It can be overridden by derived classes. + */ + protected String getMainFunction() { + return "SDL_main"; + } + + /** + * This method is called by SDL before loading the native shared libraries. + * It can be overridden to provide names of shared libraries to be loaded. + * The default implementation returns the defaults. It never returns null. + * An array returned by a new implementation must at least contain "SDL2". + * Also keep in mind that the order the libraries are loaded may matter. + * @return names of shared libraries to be loaded (e.g. "SDL2", "main"). + */ + protected String[] getLibraries() { + return new String[] { + "SDL2", + // "SDL2_image", + // "SDL2_mixer", + // "SDL2_net", + // "SDL2_ttf", + "main" + }; + } + + // Load the .so + public void loadLibraries() { + for (String lib : getLibraries()) { + SDL.loadLibrary(lib); + } + } + + /** + * This method is called by SDL before starting the native application thread. + * It can be overridden to provide the arguments after the application name. + * The default implementation returns an empty array. It never returns null. + * @return arguments for the native application. + */ + protected String[] getArguments() { + return new String[0]; + } + + public static void initialize() { + // The static nature of the singleton and Android quirkyness force us to initialize everything here + // Otherwise, when exiting the app and returning to it, these variables *keep* their pre exit values + mSingleton = null; + mSurface = null; + mTextEdit = null; + mLayout = null; + mClipboardHandler = null; + mCursors = new Hashtable(); + mLastCursorID = 0; + mSDLThread = null; + mExitCalledFromJava = false; // love2d-mod: allow restarting of the native thread + mIsResumedCalled = false; + mHasFocus = true; + mNextNativeState = NativeState.INIT; + mCurrentNativeState = NativeState.INIT; + } + + protected SDLSurface createSDLSurface(Context context) { + return new SDLSurface(context); + } + + // Setup + @Override + protected void onCreate(Bundle savedInstanceState) { + Log.v(TAG, "Device: " + Build.DEVICE); + Log.v(TAG, "Model: " + Build.MODEL); + Log.v(TAG, "onCreate()"); + super.onCreate(savedInstanceState); + + try { + Thread.currentThread().setName("SDLActivity"); + } catch (Exception e) { + Log.v(TAG, "modify thread properties failed " + e.toString()); + } + + // Load shared libraries + String errorMsgBrokenLib = ""; + try { + loadLibraries(); + mBrokenLibraries = false; /* success */ + } catch(UnsatisfiedLinkError e) { + System.err.println(e.getMessage()); + mBrokenLibraries = true; + errorMsgBrokenLib = e.getMessage(); + } catch(Exception e) { + System.err.println(e.getMessage()); + mBrokenLibraries = true; + errorMsgBrokenLib = e.getMessage(); + } + + if (!mBrokenLibraries) { + String expected_version = String.valueOf(SDL_MAJOR_VERSION) + "." + + String.valueOf(SDL_MINOR_VERSION) + "." + + String.valueOf(SDL_MICRO_VERSION); + String version = nativeGetVersion(); + if (!version.equals(expected_version)) { + mBrokenLibraries = true; + errorMsgBrokenLib = "SDL C/Java version mismatch (expected " + expected_version + ", got " + version + ")"; + } + } + + if (mBrokenLibraries) { + mSingleton = this; + AlertDialog.Builder dlgAlert = new AlertDialog.Builder(this); + dlgAlert.setMessage("An error occurred while trying to start the application. Please try again and/or reinstall." + + System.getProperty("line.separator") + + System.getProperty("line.separator") + + "Error: " + errorMsgBrokenLib); + dlgAlert.setTitle("SDL Error"); + dlgAlert.setPositiveButton("Exit", + new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog,int id) { + // if this button is clicked, close current activity + SDLActivity.mSingleton.finish(); + } + }); + dlgAlert.setCancelable(false); + dlgAlert.create().show(); + + return; + } + + startNative(); // love2d-mod: allow restarting of the native thread + } + + // love2d-mod-start: allow restarting of the native thread + public void startNative() { + boolean hadSDLThread = SDLActivity.mSDLThread != null; + + // Set up JNI + SDL.setupJNI(); + + // Initialize state + SDL.initialize(); + + // So we can call stuff from static callbacks + mSingleton = this; + SDL.setContext(this); + + mClipboardHandler = new SDLClipboardHandler(); + + mHIDDeviceManager = HIDDeviceManager.acquire(this); + + // Set up the surface + mSurface = createSDLSurface(this); + + mLayout = new RelativeLayout(this); + mLayout.addView(mSurface); + + // Get our current screen orientation and pass it down. + mCurrentOrientation = SDLActivity.getCurrentOrientation(); + // Only record current orientation + SDLActivity.onNativeOrientationChanged(mCurrentOrientation); + + try { + if (Build.VERSION.SDK_INT < 24 /* Android 7.0 (N) */) { + mCurrentLocale = getContext().getResources().getConfiguration().locale; + } else { + mCurrentLocale = getContext().getResources().getConfiguration().getLocales().get(0); + } + } catch(Exception ignored) { + } + + setContentView(mLayout); + + setWindowStyle(false); + + getWindow().getDecorView().setOnSystemUiVisibilityChangeListener(this); + + // Get filename from "Open with" of another application + Intent intent = getIntent(); + if (intent != null && intent.getData() != null) { + String filename = intent.getData().getPath(); + if (filename != null) { + Log.v(TAG, "Got filename: " + filename); + SDLActivity.onNativeDropFile(filename); + } + } + + if (hadSDLThread) { + resumeNativeThread(); + } + } + // love2d-mod-end: allow restarting of the native thread + + protected void pauseNativeThread() { + mNextNativeState = NativeState.PAUSED; + mIsResumedCalled = false; + + if (SDLActivity.mBrokenLibraries) { + return; + } + + SDLActivity.handleNativeState(); + } + + protected void resumeNativeThread() { + mNextNativeState = NativeState.RESUMED; + mIsResumedCalled = true; + + if (SDLActivity.mBrokenLibraries) { + return; + } + + SDLActivity.handleNativeState(); + } + + // Events + @Override + protected void onPause() { + Log.v(TAG, "onPause()"); + super.onPause(); + + if (mHIDDeviceManager != null) { + mHIDDeviceManager.setFrozen(true); + } + if (!mHasMultiWindow) { + pauseNativeThread(); + } + } + + @Override + protected void onResume() { + Log.v(TAG, "onResume()"); + super.onResume(); + + if (mHIDDeviceManager != null) { + mHIDDeviceManager.setFrozen(false); + } + if (!mHasMultiWindow) { + resumeNativeThread(); + } + } + + @Override + protected void onStop() { + Log.v(TAG, "onStop()"); + super.onStop(); + if (mHasMultiWindow) { + pauseNativeThread(); + } + } + + @Override + protected void onStart() { + Log.v(TAG, "onStart()"); + super.onStart(); + if (mHasMultiWindow) { + resumeNativeThread(); + } + } + + public static int getCurrentOrientation() { + int result = SDL_ORIENTATION_UNKNOWN; + + Activity activity = (Activity)getContext(); + if (activity == null) { + return result; + } + Display display = activity.getWindowManager().getDefaultDisplay(); + + switch (display.getRotation()) { + case Surface.ROTATION_0: + result = SDL_ORIENTATION_PORTRAIT; + break; + + case Surface.ROTATION_90: + result = SDL_ORIENTATION_LANDSCAPE; + break; + + case Surface.ROTATION_180: + result = SDL_ORIENTATION_PORTRAIT_FLIPPED; + break; + + case Surface.ROTATION_270: + result = SDL_ORIENTATION_LANDSCAPE_FLIPPED; + break; + } + + return result; + } + + @Override + public void onWindowFocusChanged(boolean hasFocus) { + super.onWindowFocusChanged(hasFocus); + Log.v(TAG, "onWindowFocusChanged(): " + hasFocus); + + if (SDLActivity.mBrokenLibraries) { + return; + } + + mHasFocus = hasFocus; + if (hasFocus) { + mNextNativeState = NativeState.RESUMED; + SDLActivity.getMotionListener().reclaimRelativeMouseModeIfNeeded(); + + SDLActivity.handleNativeState(); + nativeFocusChanged(true); + + } else { + nativeFocusChanged(false); + if (!mHasMultiWindow) { + mNextNativeState = NativeState.PAUSED; + SDLActivity.handleNativeState(); + } + } + } + + @Override + public void onLowMemory() { + Log.v(TAG, "onLowMemory()"); + super.onLowMemory(); + + if (SDLActivity.mBrokenLibraries) { + return; + } + + SDLActivity.nativeLowMemory(); + } + + @Override + public void onConfigurationChanged(Configuration newConfig) { + Log.v(TAG, "onConfigurationChanged()"); + super.onConfigurationChanged(newConfig); + + if (SDLActivity.mBrokenLibraries) { + return; + } + + if (mCurrentLocale == null || !mCurrentLocale.equals(newConfig.locale)) { + mCurrentLocale = newConfig.locale; + SDLActivity.onNativeLocaleChanged(); + } + } + + @Override + protected void onDestroy() { + Log.v(TAG, "onDestroy()"); + + if (mHIDDeviceManager != null) { + HIDDeviceManager.release(mHIDDeviceManager); + mHIDDeviceManager = null; + } + + SDLAudioManager.release(this); + + if (SDLActivity.mBrokenLibraries) { + super.onDestroy(); + return; + } + + appQuitFinish(); // love2d-mod: allow restarting of the native thread + super.onDestroy(); + } + + // love2d-mod-start: allow restarting of the native thread + public void resetNative() { + Log.v("SDL", "resetNative()"); + + SDLActivity.mExitCalledFromJava = true; // love2d-mod: allow restarting of the native thread + + appQuitFinish(); + } + + private void appQuitFinish() { + if (SDLActivity.mSDLThread != null) { + + // Send Quit event to "SDLThread" thread + SDLActivity.nativeSendQuit(); + + // Wait for "SDLThread" thread to end + try { + SDLActivity.mSDLThread.join(); + } catch(Exception e) { + Log.v(TAG, "Problem stopping SDLThread: " + e); + } + } + + SDLActivity.nativeQuit(); + } + // love2d-mod-end: allow restarting of the native thread + + @Override + public void onBackPressed() { + // Check if we want to block the back button in case of mouse right click. + // + // If we do, the normal hardware back button will no longer work and people have to use home, + // but the mouse right click will work. + // + boolean trapBack = SDLActivity.nativeGetHintBoolean("SDL_ANDROID_TRAP_BACK_BUTTON", false); + if (trapBack) { + // Exit and let the mouse handler handle this button (if appropriate) + return; + } + + // Default system back button behavior. + if (!isFinishing()) { + super.onBackPressed(); + } + } + + // Called by JNI from SDL. + public static void manualBackButton() { + mSingleton.pressBackButton(); + } + + // Used to get us onto the activity's main thread + public void pressBackButton() { + runOnUiThread(new Runnable() { + @Override + public void run() { + if (!SDLActivity.this.isFinishing()) { + SDLActivity.this.superOnBackPressed(); + } + } + }); + } + + // Used to access the system back behavior. + public void superOnBackPressed() { + super.onBackPressed(); + } + + @Override + public boolean dispatchKeyEvent(KeyEvent event) { + + if (SDLActivity.mBrokenLibraries) { + return false; + } + + int keyCode = event.getKeyCode(); + // Ignore certain special keys so they're handled by Android + if (keyCode == KeyEvent.KEYCODE_VOLUME_DOWN || + keyCode == KeyEvent.KEYCODE_VOLUME_UP || + keyCode == KeyEvent.KEYCODE_CAMERA || + keyCode == KeyEvent.KEYCODE_ZOOM_IN || /* API 11 */ + keyCode == KeyEvent.KEYCODE_ZOOM_OUT /* API 11 */ + ) { + return false; + } + return super.dispatchKeyEvent(event); + } + + /* Transition to next state */ + public static void handleNativeState() { + + if (mNextNativeState == mCurrentNativeState) { + // Already in same state, discard. + return; + } + + // Try a transition to init state + if (mNextNativeState == NativeState.INIT) { + + mCurrentNativeState = mNextNativeState; + return; + } + + // Try a transition to paused state + if (mNextNativeState == NativeState.PAUSED) { + if (mSDLThread != null) { + nativePause(); + } + if (mSurface != null) { + mSurface.handlePause(); + } + mCurrentNativeState = mNextNativeState; + return; + } + + // Try a transition to resumed state + if (mNextNativeState == NativeState.RESUMED) { + if (mSurface.mIsSurfaceReady && mHasFocus && mIsResumedCalled) { + if (mSDLThread == null) { + // This is the entry point to the C app. + // Start up the C app thread and enable sensor input for the first time + // FIXME: Why aren't we enabling sensor input at start? + + mSDLThread = new Thread(new SDLMain(), "SDLThread"); + mSurface.enableSensor(Sensor.TYPE_ACCELEROMETER, true); + mSDLThread.start(); + + // No nativeResume(), don't signal Android_ResumeSem + } else { + nativeResume(); + } + mSurface.handleResume(); + + mCurrentNativeState = mNextNativeState; + } + } + } + + // Messages from the SDLMain thread + static final int COMMAND_CHANGE_TITLE = 1; + static final int COMMAND_CHANGE_WINDOW_STYLE = 2; + static final int COMMAND_TEXTEDIT_HIDE = 3; + static final int COMMAND_SET_KEEP_SCREEN_ON = 5; + + protected static final int COMMAND_USER = 0x8000; + + protected static boolean mFullscreenModeActive; + + /** + * This method is called by SDL if SDL did not handle a message itself. + * This happens if a received message contains an unsupported command. + * Method can be overwritten to handle Messages in a different class. + * @param command the command of the message. + * @param param the parameter of the message. May be null. + * @return if the message was handled in overridden method. + */ + protected boolean onUnhandledMessage(int command, Object param) { + return false; + } + + /** + * A Handler class for Messages from native SDL applications. + * It uses current Activities as target (e.g. for the title). + * static to prevent implicit references to enclosing object. + */ + protected static class SDLCommandHandler extends Handler { + @Override + public void handleMessage(Message msg) { + Context context = SDL.getContext(); + if (context == null) { + Log.e(TAG, "error handling message, getContext() returned null"); + return; + } + switch (msg.arg1) { + case COMMAND_CHANGE_TITLE: + if (context instanceof Activity) { + ((Activity) context).setTitle((String)msg.obj); + } else { + Log.e(TAG, "error handling message, getContext() returned no Activity"); + } + break; + case COMMAND_CHANGE_WINDOW_STYLE: + if (Build.VERSION.SDK_INT >= 19 /* Android 4.4 (KITKAT) */) { + if (context instanceof Activity) { + Window window = ((Activity) context).getWindow(); + if (window != null) { + if ((msg.obj instanceof Integer) && ((Integer) msg.obj != 0)) { + int flags = View.SYSTEM_UI_FLAG_FULLSCREEN | + View.SYSTEM_UI_FLAG_HIDE_NAVIGATION | + View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY | + View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | + View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | + View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.INVISIBLE; + window.getDecorView().setSystemUiVisibility(flags); + window.addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); + window.clearFlags(WindowManager.LayoutParams.FLAG_FORCE_NOT_FULLSCREEN); + SDLActivity.mFullscreenModeActive = true; + } else { + int flags = View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.SYSTEM_UI_FLAG_VISIBLE; + window.getDecorView().setSystemUiVisibility(flags); + window.addFlags(WindowManager.LayoutParams.FLAG_FORCE_NOT_FULLSCREEN); + window.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); + SDLActivity.mFullscreenModeActive = false; + } + } + } else { + Log.e(TAG, "error handling message, getContext() returned no Activity"); + } + } + break; + case COMMAND_TEXTEDIT_HIDE: + if (mTextEdit != null) { + // Note: On some devices setting view to GONE creates a flicker in landscape. + // Setting the View's sizes to 0 is similar to GONE but without the flicker. + // The sizes will be set to useful values when the keyboard is shown again. + mTextEdit.setLayoutParams(new RelativeLayout.LayoutParams(0, 0)); + + InputMethodManager imm = (InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE); + imm.hideSoftInputFromWindow(mTextEdit.getWindowToken(), 0); + + mScreenKeyboardShown = false; + + mSurface.requestFocus(); + } + break; + case COMMAND_SET_KEEP_SCREEN_ON: + { + if (context instanceof Activity) { + Window window = ((Activity) context).getWindow(); + if (window != null) { + if ((msg.obj instanceof Integer) && ((Integer) msg.obj != 0)) { + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + } else { + window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + } + } + } + break; + } + default: + if ((context instanceof SDLActivity) && !((SDLActivity) context).onUnhandledMessage(msg.arg1, msg.obj)) { + Log.e(TAG, "error handling message, command is " + msg.arg1); + } + } + } + } + + // Handler for the messages + Handler commandHandler = new SDLCommandHandler(); + + // Send a message from the SDLMain thread + boolean sendCommand(int command, Object data) { + Message msg = commandHandler.obtainMessage(); + msg.arg1 = command; + msg.obj = data; + boolean result = commandHandler.sendMessage(msg); + + if (Build.VERSION.SDK_INT >= 19 /* Android 4.4 (KITKAT) */) { + if (command == COMMAND_CHANGE_WINDOW_STYLE) { + // Ensure we don't return until the resize has actually happened, + // or 500ms have passed. + + boolean bShouldWait = false; + + if (data instanceof Integer) { + // Let's figure out if we're already laid out fullscreen or not. + Display display = ((WindowManager) getSystemService(Context.WINDOW_SERVICE)).getDefaultDisplay(); + DisplayMetrics realMetrics = new DisplayMetrics(); + display.getRealMetrics(realMetrics); + + boolean bFullscreenLayout = ((realMetrics.widthPixels == mSurface.getWidth()) && + (realMetrics.heightPixels == mSurface.getHeight())); + + if ((Integer) data == 1) { + // If we aren't laid out fullscreen or actively in fullscreen mode already, we're going + // to change size and should wait for surfaceChanged() before we return, so the size + // is right back in native code. If we're already laid out fullscreen, though, we're + // not going to change size even if we change decor modes, so we shouldn't wait for + // surfaceChanged() -- which may not even happen -- and should return immediately. + bShouldWait = !bFullscreenLayout; + } else { + // If we're laid out fullscreen (even if the status bar and nav bar are present), + // or are actively in fullscreen, we're going to change size and should wait for + // surfaceChanged before we return, so the size is right back in native code. + bShouldWait = bFullscreenLayout; + } + } + + if (bShouldWait && (SDLActivity.getContext() != null)) { + // We'll wait for the surfaceChanged() method, which will notify us + // when called. That way, we know our current size is really the + // size we need, instead of grabbing a size that's still got + // the navigation and/or status bars before they're hidden. + // + // We'll wait for up to half a second, because some devices + // take a surprisingly long time for the surface resize, but + // then we'll just give up and return. + // + synchronized (SDLActivity.getContext()) { + try { + SDLActivity.getContext().wait(500); + } catch (InterruptedException ie) { + ie.printStackTrace(); + } + } + } + } + } + + return result; + } + + // C functions we call + public static native String nativeGetVersion(); + public static native int nativeSetupJNI(); + public static native int nativeRunMain(String library, String function, Object arguments); + public static native void nativeLowMemory(); + public static native void nativeSendQuit(); + public static native void nativeQuit(); + public static native void nativePause(); + public static native void nativeResume(); + public static native void nativeFocusChanged(boolean hasFocus); + public static native void onNativeDropFile(String filename); + public static native void nativeSetScreenResolution(int surfaceWidth, int surfaceHeight, int deviceWidth, int deviceHeight, float rate); + public static native void onNativeResize(); + public static native void onNativeKeyDown(int keycode); + public static native void onNativeKeyUp(int keycode); + public static native boolean onNativeSoftReturnKey(); + public static native void onNativeKeyboardFocusLost(); + public static native void onNativeMouse(int button, int action, float x, float y, boolean relative); + public static native void onNativeTouch(int touchDevId, int pointerFingerId, + int action, float x, + float y, float p); + public static native void onNativeAccel(float x, float y, float z); + public static native void onNativeClipboardChanged(); + public static native void onNativeSurfaceCreated(); + public static native void onNativeSurfaceChanged(); + public static native void onNativeSurfaceDestroyed(); + public static native String nativeGetHint(String name); + public static native boolean nativeGetHintBoolean(String name, boolean default_value); + public static native void nativeSetenv(String name, String value); + public static native void onNativeOrientationChanged(int orientation); + public static native void nativeAddTouch(int touchId, String name); + public static native void nativePermissionResult(int requestCode, boolean result); + public static native void onNativeLocaleChanged(); + + /** + * This method is called by SDL using JNI. + */ + public static boolean setActivityTitle(String title) { + // Called from SDLMain() thread and can't directly affect the view + return mSingleton.sendCommand(COMMAND_CHANGE_TITLE, title); + } + + /** + * This method is called by SDL using JNI. + */ + public static void setWindowStyle(boolean fullscreen) { + // Called from SDLMain() thread and can't directly affect the view + mSingleton.sendCommand(COMMAND_CHANGE_WINDOW_STYLE, fullscreen ? 1 : 0); + } + + /** + * This method is called by SDL using JNI. + * This is a static method for JNI convenience, it calls a non-static method + * so that is can be overridden + */ + public static void setOrientation(int w, int h, boolean resizable, String hint) + { + if (mSingleton != null) { + mSingleton.setOrientationBis(w, h, resizable, hint); + } + } + + /** + * This can be overridden + */ + public void setOrientationBis(int w, int h, boolean resizable, String hint) + { + int orientation_landscape = -1; + int orientation_portrait = -1; + + /* If set, hint "explicitly controls which UI orientations are allowed". */ + if (hint.contains("LandscapeRight") && hint.contains("LandscapeLeft")) { + orientation_landscape = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE; + } else if (hint.contains("LandscapeLeft")) { + orientation_landscape = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE; + } else if (hint.contains("LandscapeRight")) { + orientation_landscape = ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE; + } + + /* exact match to 'Portrait' to distinguish with PortraitUpsideDown */ + boolean contains_Portrait = hint.contains("Portrait ") || hint.endsWith("Portrait"); + + if (contains_Portrait && hint.contains("PortraitUpsideDown")) { + orientation_portrait = ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT; + } else if (contains_Portrait) { + orientation_portrait = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT; + } else if (hint.contains("PortraitUpsideDown")) { + orientation_portrait = ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT; + } + + boolean is_landscape_allowed = (orientation_landscape != -1); + boolean is_portrait_allowed = (orientation_portrait != -1); + int req; /* Requested orientation */ + + /* No valid hint, nothing is explicitly allowed */ + if (!is_portrait_allowed && !is_landscape_allowed) { + if (resizable) { + /* All orientations are allowed */ + req = ActivityInfo.SCREEN_ORIENTATION_FULL_SENSOR; + } else { + /* Fixed window and nothing specified. Get orientation from w/h of created window */ + req = (w > h ? ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE : ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT); + } + } else { + /* At least one orientation is allowed */ + if (resizable) { + if (is_portrait_allowed && is_landscape_allowed) { + /* hint allows both landscape and portrait, promote to full sensor */ + req = ActivityInfo.SCREEN_ORIENTATION_FULL_SENSOR; + } else { + /* Use the only one allowed "orientation" */ + req = (is_landscape_allowed ? orientation_landscape : orientation_portrait); + } + } else { + /* Fixed window and both orientations are allowed. Choose one. */ + if (is_portrait_allowed && is_landscape_allowed) { + req = (w > h ? orientation_landscape : orientation_portrait); + } else { + /* Use the only one allowed "orientation" */ + req = (is_landscape_allowed ? orientation_landscape : orientation_portrait); + } + } + } + + Log.v(TAG, "setOrientation() requestedOrientation=" + req + " width=" + w +" height="+ h +" resizable=" + resizable + " hint=" + hint); + mSingleton.setRequestedOrientation(req); + } + + /** + * This method is called by SDL using JNI. + */ + public static void minimizeWindow() { + + if (mSingleton == null) { + return; + } + + Intent startMain = new Intent(Intent.ACTION_MAIN); + startMain.addCategory(Intent.CATEGORY_HOME); + startMain.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + mSingleton.startActivity(startMain); + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean shouldMinimizeOnFocusLoss() { +/* + if (Build.VERSION.SDK_INT >= 24) { + if (mSingleton == null) { + return true; + } + + if (mSingleton.isInMultiWindowMode()) { + return false; + } + + if (mSingleton.isInPictureInPictureMode()) { + return false; + } + } + + return true; +*/ + return false; + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean isScreenKeyboardShown() + { + if (mTextEdit == null) { + return false; + } + + if (!mScreenKeyboardShown) { + return false; + } + + InputMethodManager imm = (InputMethodManager) SDL.getContext().getSystemService(Context.INPUT_METHOD_SERVICE); + return imm.isAcceptingText(); + + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean supportsRelativeMouse() + { + // DeX mode in Samsung Experience 9.0 and earlier doesn't support relative mice properly under + // Android 7 APIs, and simply returns no data under Android 8 APIs. + // + // This is fixed in Samsung Experience 9.5, which corresponds to Android 8.1.0, and + // thus SDK version 27. If we are in DeX mode and not API 27 or higher, as a result, + // we should stick to relative mode. + // + if (Build.VERSION.SDK_INT < 27 /* Android 8.1 (O_MR1) */ && isDeXMode()) { + return false; + } + + return SDLActivity.getMotionListener().supportsRelativeMouse(); + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean setRelativeMouseEnabled(boolean enabled) + { + if (enabled && !supportsRelativeMouse()) { + return false; + } + + return SDLActivity.getMotionListener().setRelativeMouseEnabled(enabled); + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean sendMessage(int command, int param) { + if (mSingleton == null) { + return false; + } + return mSingleton.sendCommand(command, param); + } + + /** + * This method is called by SDL using JNI. + */ + public static Context getContext() { + return SDL.getContext(); + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean isAndroidTV() { + UiModeManager uiModeManager = (UiModeManager) getContext().getSystemService(UI_MODE_SERVICE); + if (uiModeManager.getCurrentModeType() == Configuration.UI_MODE_TYPE_TELEVISION) { + return true; + } + if (Build.MANUFACTURER.equals("MINIX") && Build.MODEL.equals("NEO-U1")) { + return true; + } + if (Build.MANUFACTURER.equals("Amlogic") && Build.MODEL.equals("X96-W")) { + return true; + } + return Build.MANUFACTURER.equals("Amlogic") && Build.MODEL.startsWith("TV"); + } + + public static double getDiagonal() + { + DisplayMetrics metrics = new DisplayMetrics(); + Activity activity = (Activity)getContext(); + if (activity == null) { + return 0.0; + } + activity.getWindowManager().getDefaultDisplay().getMetrics(metrics); + + double dWidthInches = metrics.widthPixels / (double)metrics.xdpi; + double dHeightInches = metrics.heightPixels / (double)metrics.ydpi; + + return Math.sqrt((dWidthInches * dWidthInches) + (dHeightInches * dHeightInches)); + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean isTablet() { + // If our diagonal size is seven inches or greater, we consider ourselves a tablet. + return (getDiagonal() >= 7.0); + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean isChromebook() { + if (getContext() == null) { + return false; + } + return getContext().getPackageManager().hasSystemFeature("org.chromium.arc.device_management"); + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean isDeXMode() { + if (Build.VERSION.SDK_INT < 24 /* Android 7.0 (N) */) { + return false; + } + try { + final Configuration config = getContext().getResources().getConfiguration(); + final Class configClass = config.getClass(); + return configClass.getField("SEM_DESKTOP_MODE_ENABLED").getInt(configClass) + == configClass.getField("semDesktopModeEnabled").getInt(config); + } catch(Exception ignored) { + return false; + } + } + + /** + * This method is called by SDL using JNI. + */ + public static DisplayMetrics getDisplayDPI() { + return getContext().getResources().getDisplayMetrics(); + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean getManifestEnvironmentVariables() { + try { + if (getContext() == null) { + return false; + } + + ApplicationInfo applicationInfo = getContext().getPackageManager().getApplicationInfo(getContext().getPackageName(), PackageManager.GET_META_DATA); + Bundle bundle = applicationInfo.metaData; + if (bundle == null) { + return false; + } + String prefix = "SDL_ENV."; + final int trimLength = prefix.length(); + for (String key : bundle.keySet()) { + if (key.startsWith(prefix)) { + String name = key.substring(trimLength); + String value = bundle.get(key).toString(); + nativeSetenv(name, value); + } + } + /* environment variables set! */ + return true; + } catch (Exception e) { + Log.v(TAG, "exception " + e.toString()); + } + return false; + } + + // This method is called by SDLControllerManager's API 26 Generic Motion Handler. + public static View getContentView() { + return mLayout; + } + + static class ShowTextInputTask implements Runnable { + /* + * This is used to regulate the pan&scan method to have some offset from + * the bottom edge of the input region and the top edge of an input + * method (soft keyboard) + */ + static final int HEIGHT_PADDING = 15; + + public int x, y, w, h; + + public ShowTextInputTask(int x, int y, int w, int h) { + this.x = x; + this.y = y; + this.w = w; + this.h = h; + + /* Minimum size of 1 pixel, so it takes focus. */ + if (this.w <= 0) { + this.w = 1; + } + if (this.h + HEIGHT_PADDING <= 0) { + this.h = 1 - HEIGHT_PADDING; + } + } + + @Override + public void run() { + RelativeLayout.LayoutParams params = new RelativeLayout.LayoutParams(w, h + HEIGHT_PADDING); + params.leftMargin = x; + params.topMargin = y; + + if (mTextEdit == null) { + mTextEdit = new DummyEdit(SDL.getContext()); + + mLayout.addView(mTextEdit, params); + } else { + mTextEdit.setLayoutParams(params); + } + + mTextEdit.setVisibility(View.VISIBLE); + mTextEdit.requestFocus(); + + InputMethodManager imm = (InputMethodManager) SDL.getContext().getSystemService(Context.INPUT_METHOD_SERVICE); + imm.showSoftInput(mTextEdit, 0); + + mScreenKeyboardShown = true; + } + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean showTextInput(int x, int y, int w, int h) { + // Transfer the task to the main thread as a Runnable + return mSingleton.commandHandler.post(new ShowTextInputTask(x, y, w, h)); + } + + public static boolean isTextInputEvent(KeyEvent event) { + + // Key pressed with Ctrl should be sent as SDL_KEYDOWN/SDL_KEYUP and not SDL_TEXTINPUT + if (event.isCtrlPressed()) { + return false; + } + + return event.isPrintingKey() || event.getKeyCode() == KeyEvent.KEYCODE_SPACE; + } + + public static boolean handleKeyEvent(View v, int keyCode, KeyEvent event, InputConnection ic) { + int deviceId = event.getDeviceId(); + int source = event.getSource(); + + if (source == InputDevice.SOURCE_UNKNOWN) { + InputDevice device = InputDevice.getDevice(deviceId); + if (device != null) { + source = device.getSources(); + } + } + +// if (event.getAction() == KeyEvent.ACTION_DOWN) { +// Log.v("SDL", "key down: " + keyCode + ", deviceId = " + deviceId + ", source = " + source); +// } else if (event.getAction() == KeyEvent.ACTION_UP) { +// Log.v("SDL", "key up: " + keyCode + ", deviceId = " + deviceId + ", source = " + source); +// } + + // Dispatch the different events depending on where they come from + // Some SOURCE_JOYSTICK, SOURCE_DPAD or SOURCE_GAMEPAD are also SOURCE_KEYBOARD + // So, we try to process them as JOYSTICK/DPAD/GAMEPAD events first, if that fails we try them as KEYBOARD + // + // Furthermore, it's possible a game controller has SOURCE_KEYBOARD and + // SOURCE_JOYSTICK, while its key events arrive from the keyboard source + // So, retrieve the device itself and check all of its sources + if (SDLControllerManager.isDeviceSDLJoystick(deviceId)) { + // Note that we process events with specific key codes here + if (event.getAction() == KeyEvent.ACTION_DOWN) { + if (SDLControllerManager.onNativePadDown(deviceId, keyCode) == 0) { + return true; + } + } else if (event.getAction() == KeyEvent.ACTION_UP) { + if (SDLControllerManager.onNativePadUp(deviceId, keyCode) == 0) { + return true; + } + } + } + + if ((source & InputDevice.SOURCE_MOUSE) == InputDevice.SOURCE_MOUSE) { + // on some devices key events are sent for mouse BUTTON_BACK/FORWARD presses + // they are ignored here because sending them as mouse input to SDL is messy + if ((keyCode == KeyEvent.KEYCODE_BACK) || (keyCode == KeyEvent.KEYCODE_FORWARD)) { + switch (event.getAction()) { + case KeyEvent.ACTION_DOWN: + case KeyEvent.ACTION_UP: + // mark the event as handled or it will be handled by system + // handling KEYCODE_BACK by system will call onBackPressed() + return true; + } + } + } + + if (event.getAction() == KeyEvent.ACTION_DOWN) { + if (isTextInputEvent(event)) { + if (ic != null) { + ic.commitText(String.valueOf((char) event.getUnicodeChar()), 1); + } else { + SDLInputConnection.nativeCommitText(String.valueOf((char) event.getUnicodeChar()), 1); + } + } + onNativeKeyDown(keyCode); + return true; + } else if (event.getAction() == KeyEvent.ACTION_UP) { + onNativeKeyUp(keyCode); + return true; + } + + return false; + } + + /** + * This method is called by SDL using JNI. + */ + public static Surface getNativeSurface() { + if (SDLActivity.mSurface == null) { + return null; + } + return SDLActivity.mSurface.getNativeSurface(); + } + + // Input + + /** + * This method is called by SDL using JNI. + */ + public static void initTouch() { + int[] ids = InputDevice.getDeviceIds(); + + for (int id : ids) { + InputDevice device = InputDevice.getDevice(id); + /* Allow SOURCE_TOUCHSCREEN and also Virtual InputDevices because they can send TOUCHSCREEN events */ + if (device != null && ((device.getSources() & InputDevice.SOURCE_TOUCHSCREEN) == InputDevice.SOURCE_TOUCHSCREEN + || device.isVirtual())) { + + int touchDevId = device.getId(); + /* + * Prevent id to be -1, since it's used in SDL internal for synthetic events + * Appears when using Android emulator, eg: + * adb shell input mouse tap 100 100 + * adb shell input touchscreen tap 100 100 + */ + if (touchDevId < 0) { + touchDevId -= 1; + } + nativeAddTouch(touchDevId, device.getName()); + } + } + } + + // Messagebox + + /** Result of current messagebox. Also used for blocking the calling thread. */ + protected final int[] messageboxSelection = new int[1]; + + /** + * This method is called by SDL using JNI. + * Shows the messagebox from UI thread and block calling thread. + * buttonFlags, buttonIds and buttonTexts must have same length. + * @param buttonFlags array containing flags for every button. + * @param buttonIds array containing id for every button. + * @param buttonTexts array containing text for every button. + * @param colors null for default or array of length 5 containing colors. + * @return button id or -1. + */ + public int messageboxShowMessageBox( + final int flags, + final String title, + final String message, + final int[] buttonFlags, + final int[] buttonIds, + final String[] buttonTexts, + final int[] colors) { + + messageboxSelection[0] = -1; + + // sanity checks + + if ((buttonFlags.length != buttonIds.length) && (buttonIds.length != buttonTexts.length)) { + return -1; // implementation broken + } + + // collect arguments for Dialog + + final Bundle args = new Bundle(); + args.putInt("flags", flags); + args.putString("title", title); + args.putString("message", message); + args.putIntArray("buttonFlags", buttonFlags); + args.putIntArray("buttonIds", buttonIds); + args.putStringArray("buttonTexts", buttonTexts); + args.putIntArray("colors", colors); + + // trigger Dialog creation on UI thread + + runOnUiThread(new Runnable() { + @Override + public void run() { + messageboxCreateAndShow(args); + } + }); + + // block the calling thread + + synchronized (messageboxSelection) { + try { + messageboxSelection.wait(); + } catch (InterruptedException ex) { + ex.printStackTrace(); + return -1; + } + } + + // return selected value + + return messageboxSelection[0]; + } + + protected void messageboxCreateAndShow(Bundle args) { + + // TODO set values from "flags" to messagebox dialog + + // get colors + + int[] colors = args.getIntArray("colors"); + int backgroundColor; + int textColor; + int buttonBorderColor; + int buttonBackgroundColor; + int buttonSelectedColor; + if (colors != null) { + int i = -1; + backgroundColor = colors[++i]; + textColor = colors[++i]; + buttonBorderColor = colors[++i]; + buttonBackgroundColor = colors[++i]; + buttonSelectedColor = colors[++i]; + } else { + backgroundColor = Color.TRANSPARENT; + textColor = Color.TRANSPARENT; + buttonBorderColor = Color.TRANSPARENT; + buttonBackgroundColor = Color.TRANSPARENT; + buttonSelectedColor = Color.TRANSPARENT; + } + + // create dialog with title and a listener to wake up calling thread + + final AlertDialog dialog = new AlertDialog.Builder(this).create(); + dialog.setTitle(args.getString("title")); + dialog.setCancelable(false); + dialog.setOnDismissListener(new DialogInterface.OnDismissListener() { + @Override + public void onDismiss(DialogInterface unused) { + synchronized (messageboxSelection) { + messageboxSelection.notify(); + } + } + }); + + // create text + + TextView message = new TextView(this); + message.setGravity(Gravity.CENTER); + message.setText(args.getString("message")); + if (textColor != Color.TRANSPARENT) { + message.setTextColor(textColor); + } + + // create buttons + + int[] buttonFlags = args.getIntArray("buttonFlags"); + int[] buttonIds = args.getIntArray("buttonIds"); + String[] buttonTexts = args.getStringArray("buttonTexts"); + + final SparseArray