updater stuff

This commit is contained in:
bryanthaboi
2026-08-20 17:30:48 -04:00
parent dbecc345e3
commit ada0d8abe1
22 changed files with 596 additions and 56 deletions
+15 -5
View File
@@ -93,8 +93,9 @@ transport, exactly as a missing curl does.
love-android 11.5a expects:
- **JDK 17**
- Android SDK with **API 34**
- Android SDK with **API 36** (Android 16; latest 36.x Build-Tools)
- NDK **25.2.9519653** (Apple Silicon host supported)
- **minSdk 19** (Android 4.4), **targetSdk 36** (Android 16)
Set `ANDROID_SDK_ROOT` (or `ANDROID_HOME`), or let the script write
`local.properties` when it finds `~/Library/Android/sdk`.
@@ -122,15 +123,24 @@ scripts, tests, and mobile build sources are excluded.
| `app.application_id` | `com.theboisclub.pokemonred` |
| `app.name` | Pokemon Red |
| `app.orientation` | `fullUser`. This is only the manifest default: SDL requests FULL_SENSOR at window creation (resizable window, no `SDL_HINT_ORIENTATIONS`), and `GameActivity.setOrientationBis` remaps that to FULL_USER so the device's rotation lock is honoured. |
| `app.version_name` / `app.version_code` | set from `--version X.Y.Z` (code = major*10000 + minor*100 + patch); left as-is if `--version` is omitted |
| Permissions | RECORD_AUDIO / WRITE_EXTERNAL_STORAGE stripped; VIBRATE + BLUETOOTH + INTERNET (link play, mod index) + ACTIVITY_RECOGNITION (step bridge) kept |
| `app.version_name` / `app.version_code` | set from `--version X.Y.Z` (code = major*1,000,000 + minor*1,000 + patch); left as-is if `--version` is omitted |
| Permissions | RECORD_AUDIO / WRITE_EXTERNAL_STORAGE stripped; VIBRATE + BLUETOOTH + INTERNET (link play, mod index) + ACTIVITY_RECOGNITION (step bridge) kept; REQUEST_INSTALL_PACKAGES is limited to the user-confirmed full-update installer |
## Releases
`.github/workflows/release.yml` builds the APK with `--version` set to the
release version and publishes it alongside the macOS/Windows/Linux builds as
`PokemonRed-<version>-android.apk`.
`gen1recomp-<version>-android.apk`.
## Signing
Signed with the default Android keystore (no setup required).
Production APKs are built with `scripts/build_android.sh --release`. They must
be signed with the same long-lived certificate as the currently installed app:
Android's Package Installer rejects an update with a different signing
certificate. Store that keystore and its passwords only in CI secrets, expose
them as `GEN1RECOMP_ANDROID_KEYSTORE`,
`GEN1RECOMP_ANDROID_KEYSTORE_PASSWORD`, `GEN1RECOMP_ANDROID_KEY_ALIAS`, and
`GEN1RECOMP_ANDROID_KEY_PASSWORD`, and never commit the keystore. A newly
created certificate cannot update users who have an APK signed by a different
legacy key; those users need one final manual reinstall before in-app updates
can take over.
+1 -1
View File
@@ -41,7 +41,7 @@ Quick Start:
Before you start, install JDK 17 (not later not earlier). If you intend to build from Android Studio, skip this step as
Android Studio bundles its own JDK 17.
Install Android SDK with SDK API 34 (34.x.y) and Android NDK 25.2.9519653, set the environment variable
Install Android SDK with SDK API 36 (latest 36.x Build-Tools) and Android NDK 25.2.9519653, set the environment variable
`ANDROID_SDK_ROOT` to your Android SDK location and run:
```
+27 -3
View File
@@ -10,9 +10,12 @@ android {
applicationId project.properties["app.application_id"]
versionCode project.properties["app.version_code"].toInteger()
versionName project.properties["app.version_name"]
minSdk 16
compileSdk 34
targetSdk 34
// NDK r25 no longer supports API 16; API 19 is Android 4.4 and keeps
// the native toolchain and package-installer bridge on a supported ABI.
minSdk 19
// Android 16 / API 36: current Android distribution target.
compileSdk 36
targetSdk 36
def getAppName = {
def nameArray = project.properties["app.name_byte_array"]
@@ -38,10 +41,31 @@ android {
ORIENTATION:project.properties["app.orientation"],
]
}
// Release signing lives outside the repository. The release build script
// requires all five values below, while debug builds intentionally remain
// usable without them.
def releaseStore = System.getenv("GEN1RECOMP_ANDROID_KEYSTORE")
def releaseStorePassword = System.getenv("GEN1RECOMP_ANDROID_KEYSTORE_PASSWORD")
def releaseKeyAlias = System.getenv("GEN1RECOMP_ANDROID_KEY_ALIAS")
def releaseKeyPassword = System.getenv("GEN1RECOMP_ANDROID_KEY_PASSWORD")
def hasReleaseSigning = releaseStore && releaseStorePassword && releaseKeyAlias && releaseKeyPassword
if (hasReleaseSigning) {
signingConfigs {
release {
storeFile file(releaseStore)
storePassword releaseStorePassword
keyAlias releaseKeyAlias
keyPassword releaseKeyPassword
}
}
}
buildTypes {
release {
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
if (hasReleaseSigning) signingConfig signingConfigs.release
}
}
flavorDimensions = ['mode', 'recording']
@@ -8,6 +8,10 @@
the link screen shows as "(Operation not permitted)" (issue #287).
scripts/build_android.sh must not strip this again. -->
<uses-permission android:name="android.permission.INTERNET" />
<!-- Required only to hand a checksum-verified, user-selected GitHub release
APK to Android's own Package Installer. Android still shows the install
confirmation and enforces package/signing-key/version compatibility. -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<!-- Step bridge: love.system.syncHealthSteps reads the hardware step
counter, which Android 10+ gates behind this runtime permission.
Requested only on the first sync call (the Pokéwalker mod's SYNC
@@ -35,6 +39,18 @@
<meta-data
android:name="android.allow_multiple_resumed_activities"
android:value="true" />
<!-- The full-update APK is copied into this small cache subdirectory
before it is handed to Package Installer. Keep the provider private
and expose only that directory, never a storage root. -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.full_update_provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/full_update_paths" />
</provider>
<activity
android:name="org.love2d.android.GameActivity"
android:exported="true"
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Deliberately narrow: FileProvider may grant only the staged APK, never
arbitrary app, external, or shared storage. -->
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<cache-path name="full_update" path="full-update/" />
</paths>
+2 -1
View File
@@ -18,7 +18,8 @@ buildscript {
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.1.1'
// Android 16 / API 36 requires Android Gradle Plugin 8.9+.
classpath 'com.android.tools.build:gradle:8.9.2'
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
-1
View File
@@ -15,7 +15,6 @@ app.version_name=11.5a
# No need to modify anything past this line!
android.enableJetifier=false
android.useAndroidX=true
android.defaults.buildfeatures.buildconfig=true
android.nonTransitiveRClass=true
android.nonFinalResIds=true
app.name=gen1recomp
+1 -1
View File
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.1-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
networkTimeout=10000
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
+3 -3
View File
@@ -10,9 +10,9 @@ android {
ndkVersion '25.2.9519653'
defaultConfig {
minSdk 16
compileSdk 34
targetSdk 34
minSdk 19
compileSdk 36
targetSdk 36
externalNativeBuild {
ndkBuild {
arguments "-j" + Runtime.runtime.availableProcessors()
@@ -283,6 +283,40 @@ bool restartApp()
return result;
}
bool installApk(const char *path)
{
if (path == nullptr || path[0] == '\0')
return false;
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
// This may be called from Lua's main thread, but use the activity object
// class just like httpDownload so a future worker caller does not depend on
// the system JNI class loader finding the app class.
void *rawActivity = SDL_AndroidGetActivity();
if (rawActivity == nullptr)
return false;
jobject activityObj = (jobject) rawActivity;
jclass activity = env->GetObjectClass(activityObj);
env->DeleteLocalRef(activityObj);
jmethodID method = env->GetStaticMethodID(activity, "installApk",
"(Ljava/lang/String;Ljava/lang/String;)Z");
if (method == nullptr)
{
env->ExceptionClear();
env->DeleteLocalRef(activity);
return false;
}
jstring jpath = env->NewStringUTF(path);
jstring jroot = env->NewStringUTF(bridgeSaveDirectory());
jboolean result = env->CallStaticBooleanMethod(activity, method, jpath, jroot);
env->DeleteLocalRef(jroot);
env->DeleteLocalRef(jpath);
env->DeleteLocalRef(activity);
return result;
}
bool updateAppShortcuts(const std::vector<std::string> &versions)
{
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
@@ -90,6 +90,12 @@ bool syncHealthSteps();
**/
bool restartApp();
/**
* Stages a checksum-verified APK from the current save directory and starts
* Android's user-confirmed Package Installer flow. Android-only.
**/
bool installApk(const char *path);
/**
* Dynamic App Shortcuts: updates Android ShortcutManager with ready game versions.
**/
@@ -245,6 +245,16 @@ bool System::restartApp() const
#endif
}
bool System::installApk(const char *path) const
{
#ifdef LOVE_ANDROID
return love::android::installApk(path);
#else
LOVE_UNUSED(path);
return false;
#endif
}
bool System::updateShortcuts(const std::vector<std::string> &versions) const
{
#ifdef LOVE_ANDROID
@@ -143,6 +143,9 @@ public:
**/
virtual bool restartApp() const;
/** Starts Android's user-confirmed install flow for a verified APK. */
virtual bool installApk(const char *path) const;
virtual bool updateShortcuts(const std::vector<std::string> &versions) const;
virtual std::string getLaunchGame() const;
@@ -132,6 +132,13 @@ int w_restartApp(lua_State *L)
return 1;
}
int w_installApk(lua_State *L)
{
const char *path = luaL_checkstring(L, 1);
luax_pushboolean(L, instance()->installApk(path));
return 1;
}
int w_httpDownload(lua_State *L)
{
const char *url = luaL_checkstring(L, 1);
@@ -325,6 +332,7 @@ static const luaL_Reg functions[] =
{ "createFile", w_createFile },
{ "syncHealthSteps", w_syncHealthSteps },
{ "restartApp", w_restartApp },
{ "installApk", w_installApk },
{ "updateShortcuts", w_updateShortcuts },
{ "getLaunchGame", w_getLaunchGame },
{ "httpDownload", w_httpDownload },
@@ -45,6 +45,7 @@ import android.app.AlarmManager;
import android.app.AlertDialog;
import android.app.PendingIntent;
import android.content.Context;
import android.content.ClipData;
import android.content.DialogInterface;
import android.content.Intent;
import android.content.SharedPreferences;
@@ -77,6 +78,7 @@ import android.view.*;
import androidx.annotation.Keep;
import androidx.core.app.ActivityCompat;
import androidx.core.content.FileProvider;
public class GameActivity extends SDLActivity {
private static DisplayMetrics metrics = null;
@@ -696,6 +698,103 @@ public class GameActivity extends SDLActivity {
return true; // unreachable, but keeps the JNI signature honest
}
/**
* Stages a verified release APK in cache and asks Android's Package
* Installer to update this package. This never silently installs an APK:
* the platform owns both the unknown-sources consent and final install
* confirmation. `updateRoot` comes from the native save directory and is
* checked before any file is read, so a Lua caller cannot turn this into a
* general-purpose local-file sharing bridge.
*/
@Keep
public static boolean installApk(final String sourcePath, final String updateRoot) {
final GameActivity self = (GameActivity) mSingleton;
if (self == null || sourcePath == null || updateRoot == null) return false;
final File source;
try {
source = new File(sourcePath).getCanonicalFile();
File root = new File(updateRoot, "updates").getCanonicalFile();
String rootPath = root.getPath() + File.separator;
if (!source.getPath().startsWith(rootPath)
|| !source.isFile() || source.length() == 0
|| !source.getName().matches("gen1recomp-[0-9]+\\.[0-9]+\\.[0-9]+-android\\.apk")) {
return false;
}
} catch (IOException e) {
Log.d("GameActivity", "invalid update APK path: " + e.getMessage());
return false;
}
// Android 8+ lets the user decide whether this app is trusted to
// request package installs. Send them to the per-app setting first;
// they deliberately tap Install again after granting it.
if (android.os.Build.VERSION.SDK_INT >= 26
&& !self.getPackageManager().canRequestPackageInstalls()) {
try {
Intent settings = new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
Uri.parse("package:" + self.getPackageName()));
self.startActivity(settings);
return true;
} catch (Exception e) {
Log.d("GameActivity", "could not open install-source settings: " + e.getMessage());
return false;
}
}
// Copying an APK can be large; keep both I/O and checksum-verified
// source access off the UI thread. The FileProvider exposes this cache
// child only after it has been fully written and renamed.
new Thread(new Runnable() {
@Override public void run() {
File stagedDir = new File(self.getCacheDir(), "full-update");
File partial = new File(stagedDir, "update.apk.part");
File staged = new File(stagedDir, "update.apk");
try {
if (!stagedDir.exists() && !stagedDir.mkdirs()) return;
copyFile(source, partial);
if (staged.exists() && !staged.delete()) return;
if (!partial.renameTo(staged)) return;
self.runOnUiThread(new Runnable() {
@Override public void run() { launchPackageInstaller(self, staged); }
});
} catch (Exception e) {
Log.d("GameActivity", "could not stage update APK: " + e.getMessage());
} finally {
if (partial.exists()) partial.delete();
}
}
}, "gen1recomp-apk-stage").start();
return true;
}
private static void copyFile(File source, File destination) throws IOException {
InputStream in = new BufferedInputStream(new FileInputStream(source));
OutputStream out = new BufferedOutputStream(new FileOutputStream(destination));
try {
byte[] buffer = new byte[32768];
int count;
while ((count = in.read(buffer)) != -1) out.write(buffer, 0, count);
} finally {
try { out.close(); } catch (IOException ignored) {}
try { in.close(); } catch (IOException ignored) {}
}
}
private static void launchPackageInstaller(GameActivity activity, File apk) {
try {
Context context = activity.getApplicationContext();
Uri uri = FileProvider.getUriForFile(context,
context.getPackageName() + ".full_update_provider", apk);
Intent install = new Intent(Intent.ACTION_INSTALL_PACKAGE);
install.setData(uri);
install.setClipData(ClipData.newRawUri("apk", uri));
install.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
activity.startActivity(install);
} catch (Exception e) {
Log.d("GameActivity", "could not open package installer: " + e.getMessage());
}
}
@Keep
public static String getLaunchGame() {
return initialGame != null ? initialGame : "";