Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
@@ -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
18 changes: 18 additions & 0 deletions lemuroid-app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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">
<!-- Internal deep link: lemuroid://packageName/play-game/id/{gameId} -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
Expand All @@ -60,6 +61,23 @@
android:pathPattern="/play-game/id/.*"
android:scheme="lemuroid" />
</intent-filter>
<!-- Direct ROM launch via content:// URIs (ES-DE, Beacon, file managers) -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="content" android:mimeType="application/octet-stream" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="content" android:mimeType="*/*" />
</intent-filter>
<!-- Direct ROM launch via file:// URIs (legacy launchers) -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="file" android:mimeType="*/*" android:host="*" />
</intent-filter>
</activity>

<activity
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.swordfish.lemuroid.app.shared.game

import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.View
import androidx.core.view.isVisible
Expand All @@ -19,6 +20,7 @@ import com.swordfish.lemuroid.common.coroutines.safeLaunch
import com.swordfish.lemuroid.common.longAnimationDuration
import com.swordfish.lemuroid.lib.core.CoresSelection
import com.swordfish.lemuroid.lib.library.db.RetrogradeDatabase
import com.swordfish.lemuroid.lib.library.db.entity.Game
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.delay
Expand All @@ -34,6 +36,10 @@ import javax.inject.Inject
* This activity is used as an entry point when launching games from external shortcuts. This activity
* still runs in the main process so it can peek into background job status and wait for them to
* complete.
*
* Supports the following intent types:
* - Internal deep link: lemuroid://packageName/play-game/id/{gameId}
* - Direct ROM file launch via content:// or file:// URI (ES-DE, Beacon, file managers)
*/
@OptIn(FlowPreview::class)
class ExternalGameLauncherActivity : ImmersiveActivity() {
Expand All @@ -56,12 +62,19 @@ class ExternalGameLauncherActivity : ImmersiveActivity() {

setContentView(R.layout.activity_loading)
if (savedInstanceState == null) {
val gameId = intent.data?.pathSegments?.let { it[it.size - 1].toInt() }!!
val uri = intent.data

lifecycleScope.launch {
loadingState.value = true
try {
loadGame(gameId)
when (uri?.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()
}
Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Game>

Expand Down