diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
index 0000000000..df78d64e9a
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -0,0 +1,67 @@
+name: Build
+
+on:
+ push:
+ branches:
+ - master
+ - "claude/**"
+ pull_request:
+ branches:
+ - master
+ workflow_dispatch:
+
+jobs:
+ build:
+ name: Build Release APK
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ submodules: recursive
+
+ - name: Set up JDK 17
+ uses: actions/setup-java@v4
+ with:
+ java-version: '17'
+ distribution: 'temurin'
+ cache: gradle
+
+ # Use the official release keystore if the secret is available,
+ # otherwise generate a keystore using the hardcoded credentials from
+ # build.gradle.kts so the release build type can sign the APK.
+ - name: Create release keystore from secret
+ if: ${{ secrets.RELEASE_KEYSTORE != '' }}
+ run: |
+ echo "${{ secrets.RELEASE_KEYSTORE }}" > release.keystore.asc
+ gpg -d --passphrase "${{ secrets.RELEASE_KEYSTORE_PASSPHRASE }}" --batch release.keystore.asc > release.jks
+
+ - name: Generate release keystore (CI fallback)
+ if: ${{ secrets.RELEASE_KEYSTORE == '' }}
+ run: |
+ keytool -genkeypair -v \
+ -keystore release.jks \
+ -alias lemuroid \
+ -keyalg RSA \
+ -keysize 2048 \
+ -validity 10000 \
+ -storepass lemuroid \
+ -keypass lemuroid \
+ -dname "CN=Lemuroid CI, O=Lemuroid, C=US"
+
+ - name: Grant execute permission for gradlew
+ run: chmod +x gradlew
+
+ # freeDynamicRelease: 'free' (open-source) + 'dynamic' (cores downloaded at runtime).
+ # Matches what publish.yml uses; avoids bundling compiled .so core files which require
+ # the lemuroid-cores submodule to be fully built.
+ - name: Build release APK
+ run: ./gradlew assembleFreeDynamicRelease
+
+ - name: Upload release APK
+ uses: actions/upload-artifact@v4
+ with:
+ name: lemuroid-release-${{ github.sha }}
+ path: lemuroid-app/build/outputs/apk/freeDynamic/release/lemuroid-app-free-dynamic-release.apk
+ retention-days: 30
diff --git a/lemuroid-app/src/main/AndroidManifest.xml b/lemuroid-app/src/main/AndroidManifest.xml
index ed9b150c75..94dfcf6803 100644
--- a/lemuroid-app/src/main/AndroidManifest.xml
+++ b/lemuroid-app/src/main/AndroidManifest.xml
@@ -52,6 +52,7 @@
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|mnc|colorMode|density|fontScale|fontWeightAdjustment|keyboard|layoutDirection|locale|mcc|navigation|smallestScreenSize|touchscreen|uiMode"
android:exported="true"
android:theme="@style/LemuroidMaterialTheme.Game">
+
@@ -60,6 +61,23 @@
android:pathPattern="/play-game/id/.*"
android:scheme="lemuroid" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{
+ val gameId = uri.pathSegments?.let { it[it.size - 1].toInt() }!!
+ loadGameById(gameId)
+ }
+ "file", "content" -> loadGameByFileUri(uri)
+ else -> throw IllegalArgumentException("Unsupported URI scheme: ${uri?.scheme}")
+ }
} catch (e: Throwable) {
displayErrorMessage()
}
@@ -82,13 +95,38 @@ class ExternalGameLauncherActivity : ImmersiveActivity() {
}
}
- private suspend fun loadGame(gameId: Int) {
+ private suspend fun loadGameById(gameId: Int) {
waitPendingOperations()
val game =
retrogradeDatabase.gameDao().selectById(gameId)
?: throw IllegalArgumentException("Game not found: $gameId")
+ launchGame(game)
+ }
+
+ private suspend fun loadGameByFileUri(uri: Uri) {
+ waitPendingOperations()
+
+ val uriString = uri.toString()
+
+ // Try exact URI match first
+ var game: Game? = retrogradeDatabase.gameDao().selectByFileUri(uriString)
+
+ // Fall back to filename match (handles scheme differences between launchers and indexer)
+ if (game == null) {
+ val fileName = uri.lastPathSegment
+ ?: throw IllegalArgumentException("Cannot determine filename from URI: $uriString")
+ game = retrogradeDatabase.gameDao().selectByFileName(fileName)
+ }
+
+ val resolvedGame = game
+ ?: throw IllegalArgumentException("ROM not found in library. Please scan your library first.")
+
+ launchGame(resolvedGame)
+ }
+
+ private suspend fun launchGame(game: Game) {
delay(animationDuration().toLong())
val gameLaunchSuccessful = gameLauncher.launchGameAsync(
diff --git a/retrograde-app-shared/src/main/java/com/swordfish/lemuroid/lib/library/db/dao/GameDao.kt b/retrograde-app-shared/src/main/java/com/swordfish/lemuroid/lib/library/db/dao/GameDao.kt
index 92bffd8b5a..aea7be760d 100644
--- a/retrograde-app-shared/src/main/java/com/swordfish/lemuroid/lib/library/db/dao/GameDao.kt
+++ b/retrograde-app-shared/src/main/java/com/swordfish/lemuroid/lib/library/db/dao/GameDao.kt
@@ -36,6 +36,9 @@ interface GameDao {
@Query("SELECT * FROM games WHERE fileUri = :fileUri")
fun selectByFileUri(fileUri: String): Game?
+ @Query("SELECT * FROM games WHERE fileName = :fileName LIMIT 1")
+ suspend fun selectByFileName(fileName: String): Game?
+
@Query("SELECT * FROM games WHERE lastIndexedAt < :lastIndexedAt")
fun selectByLastIndexedAtLessThan(lastIndexedAt: Long): List