From 31e8fe8fd902c49e286d34dabe21f6b18661dd1e Mon Sep 17 00:00:00 2001 From: Braden Farmer Date: Tue, 18 Aug 2020 22:23:55 -0600 Subject: [PATCH] Implement support for Play Store builds using Play Feature Delivery (Java/Gradle) --- .../retroactivity/RetroActivityCommon.java | 307 +++++++++++++++++- pkg/android/phoenix/.gitignore | 8 +- pkg/android/phoenix/AndroidManifest.xml | 2 + pkg/android/phoenix/build.gradle | 32 +- pkg/android/phoenix/init_modules.sh | 100 ++++++ pkg/android/phoenix/module_list.txt | 5 + .../module_template/AndroidManifest.xml | 17 + .../phoenix/module_template/build.gradle | 49 +++ 8 files changed, 516 insertions(+), 4 deletions(-) create mode 100755 pkg/android/phoenix/init_modules.sh create mode 100644 pkg/android/phoenix/module_list.txt create mode 100644 pkg/android/phoenix/module_template/AndroidManifest.xml create mode 100644 pkg/android/phoenix/module_template/build.gradle diff --git a/pkg/android/phoenix-common/src/com/retroarch/browser/retroactivity/RetroActivityCommon.java b/pkg/android/phoenix-common/src/com/retroarch/browser/retroactivity/RetroActivityCommon.java index 9c5c0c7f05..38b602cac8 100644 --- a/pkg/android/phoenix-common/src/com/retroarch/browser/retroactivity/RetroActivityCommon.java +++ b/pkg/android/phoenix-common/src/com/retroarch/browser/retroactivity/RetroActivityCommon.java @@ -1,5 +1,14 @@ package com.retroarch.browser.retroactivity; +import com.google.android.play.core.splitinstall.SplitInstallManager; +import com.google.android.play.core.splitinstall.SplitInstallManagerFactory; +import com.google.android.play.core.splitinstall.SplitInstallRequest; +import com.google.android.play.core.splitinstall.SplitInstallSessionState; +import com.google.android.play.core.splitinstall.SplitInstallStateUpdatedListener; +import com.google.android.play.core.splitinstall.model.SplitInstallSessionStatus; +import com.google.android.play.core.tasks.OnFailureListener; +import com.google.android.play.core.tasks.OnSuccessListener; +import com.retroarch.BuildConfig; import com.retroarch.browser.preferences.util.UserPreferences; import android.annotation.TargetApi; import android.app.NativeActivity; @@ -10,6 +19,8 @@ import android.content.Intent; import android.content.IntentFilter; import android.content.pm.ActivityInfo; import android.media.AudioAttributes; +import android.os.Bundle; +import android.system.Os; import android.view.InputDevice; import android.view.Surface; import android.view.WindowManager; @@ -20,7 +31,14 @@ import android.os.PowerManager; import android.os.Vibrator; import android.os.VibrationEffect; import android.util.Log; -import java.lang.Math; + +import java.io.File; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; import java.util.concurrent.CountDownLatch; import java.util.Locale; @@ -40,9 +58,60 @@ public class RetroActivityCommon extends NativeActivity public static int FRONTEND_ORIENTATION_270 = 3; public static int RETRO_RUMBLE_STRONG = 0; public static int RETRO_RUMBLE_WEAK = 1; + public static int INSTALL_STATUS_DOWNLOADING = 0; + public static int INSTALL_STATUS_INSTALLING = 1; + public static int INSTALL_STATUS_INSTALLED = 2; + public static int INSTALL_STATUS_FAILED = 3; public boolean sustainedPerformanceMode = true; public int screenOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED; + private final SplitInstallStateUpdatedListener listener = new SplitInstallStateUpdatedListener() { + @Override + public void onStateUpdate(SplitInstallSessionState state) { + List moduleNames = state.moduleNames(); + String[] coreNames = new String[moduleNames.size()]; + + for(int i = 0; i < moduleNames.size(); i++) { + coreNames[i] = unsanitizeCoreName(moduleNames.get(i)); + } + + switch(state.status()) { + case SplitInstallSessionStatus.DOWNLOADING: + coreInstallStatusChanged(coreNames, INSTALL_STATUS_DOWNLOADING, state.bytesDownloaded(), state.totalBytesToDownload()); + break; + case SplitInstallSessionStatus.INSTALLING: + coreInstallStatusChanged(coreNames, INSTALL_STATUS_INSTALLING, state.bytesDownloaded(), state.totalBytesToDownload()); + break; + case SplitInstallSessionStatus.INSTALLED: + updateSymlinks(); + + coreInstallStatusChanged(coreNames, INSTALL_STATUS_INSTALLED, state.bytesDownloaded(), state.totalBytesToDownload()); + break; + case SplitInstallSessionStatus.FAILED: + coreInstallStatusChanged(coreNames, INSTALL_STATUS_FAILED, state.bytesDownloaded(), state.totalBytesToDownload()); + break; + } + } + }; + + @Override + protected void onCreate(Bundle savedInstanceState) { + updateSymlinks(); + + SplitInstallManager manager = SplitInstallManagerFactory.create(this); + manager.registerListener(listener); + + super.onCreate(savedInstanceState); + } + + @Override + protected void onDestroy() { + SplitInstallManager manager = SplitInstallManagerFactory.create(this); + manager.unregisterListener(listener); + + super.onDestroy(); + } + public void doVibrate(int id, int effect, int strength, int oneShot) { Vibrator vibrator = null; @@ -297,4 +366,240 @@ public class RetroActivityCommon extends NativeActivity Log.i("RetroActivity", "hasOldOrientation? " + hasOldOrientation + " newOrientation: " + newConfig.orientation + " oldOrientation: " + oldOrientation); } + + /** + * Checks if this version of RetroArch is a Play Store build. + * + * @return true if this is a Play Store build, false otherwise + */ + public boolean isPlayStoreBuild() { + Log.i("RetroActivity", "isPlayStoreBuild: " + BuildConfig.PLAY_STORE_BUILD); + + return BuildConfig.PLAY_STORE_BUILD; + } + + /** + * Gets the list of available cores that can be downloaded as Dynamic Feature Modules. + * + * @return the list of available cores + */ + public String[] getAvailableCores() { + int id = getResources().getIdentifier("module_names_" + sanitizeCoreName(Build.CPU_ABI), "array", getPackageName()); + + String[] returnVal = getResources().getStringArray(id); + Log.i("RetroActivity", "getAvailableCores: " + Arrays.toString(returnVal)); + return returnVal; + } + + /** + * Gets the list of cores that are currently installed as Dynamic Feature Modules. + * + * @return the list of installed cores + */ + public String[] getInstalledCores() { + SplitInstallManager manager = SplitInstallManagerFactory.create(this); + String[] modules = manager.getInstalledModules().toArray(new String[0]); + List cores = new ArrayList<>(); + + SharedPreferences prefs = UserPreferences.getPreferences(this); + + for(int i = 0; i < modules.length; i++) { + String coreName = unsanitizeCoreName(modules[i]); + if(!prefs.getBoolean("core_deleted_" + coreName, false)) { + cores.add(coreName); + } + } + + String[] returnVal = cores.toArray(new String[0]); + Log.i("RetroActivity", "getInstalledCores: " + Arrays.toString(returnVal)); + return returnVal; + } + + /** + * Asks the system to download a core. + * + * @param coreName Name of the core to install + */ + public void downloadCore(final String coreName) { + Log.i("RetroActivity", "downloadCore: " + coreName); + + SharedPreferences prefs = UserPreferences.getPreferences(this); + prefs.edit().remove("core_deleted_" + coreName).apply(); + + SplitInstallManager manager = SplitInstallManagerFactory.create(this); + SplitInstallRequest request = SplitInstallRequest.newBuilder() + .addModule(sanitizeCoreName(coreName)) + .build(); + + manager.startInstall(request) + .addOnSuccessListener(new OnSuccessListener() { + @Override + public void onSuccess(Integer result) { + coreInstallInitiated(coreName, true); + } + }) + + .addOnFailureListener(new OnFailureListener() { + @Override + public void onFailure(Exception e) { + coreInstallInitiated(coreName, false); + } + }); + } + + /** + * Asks the system to delete a core. + * + * Note that the actual module deletion will not happen immediately (the OS will delete + * it whenever it feels like it), but the symlink will still be immediately removed. + * + * @param coreName Name of the core to delete + */ + public void deleteCore(String coreName) { + Log.i("RetroActivity", "deleteCore: " + coreName); + + String newFilename = getCorePath() + coreName + "_libretro_android.so"; + new File(newFilename).delete(); + + SharedPreferences prefs = UserPreferences.getPreferences(this); + prefs.edit().putBoolean("core_deleted_" + coreName, true).apply(); + + SplitInstallManager manager = SplitInstallManagerFactory.create(this); + manager.deferredUninstall(Collections.singletonList(sanitizeCoreName(coreName))); + } + + + + /////////////// JNI methods /////////////// + + + + /** + * Called when a core install is initiated. + * + * @param coreName Name of the core that the install is initiated for. + * @param successful true if success, false if failure + */ + private native void coreInstallInitiated(String coreName, boolean successful); + + /** + * Called when the status of a core install has changed. + * + * @param coreNames Names of all cores that are currently being downloaded. + * @param status One of INSTALL_STATUS_DOWNLOADING, INSTALL_STATUS_INSTALLING, + * INSTALL_STATUS_INSTALLED, or INSTALL_STATUS_FAILED + * @param bytesDownloaded Number of bytes downloaded. + * @param totalBytesToDownload Total number of bytes to download. + */ + private native void coreInstallStatusChanged(String[] coreNames, int status, long bytesDownloaded, long totalBytesToDownload); + + + + /////////////// Private methods /////////////// + + + + /** + * Sanitizes a core name so that it can be used when dealing with + * Dynamic Feature Modules. Needed because Gradle modules cannot use + * dashes, but we have at least one core name ("mesen-s") that uses them. + * + * @param coreName Name of the core to sanitize. + * @return The sanitized core name. + */ + private String sanitizeCoreName(String coreName) { + return coreName.replace('-', '_'); + } + + /** + * Unsanitizes a core name from its module name. + * + * @param coreName Name of the core to unsanitize. + * @return The unsanitized core name. + */ + private String unsanitizeCoreName(String coreName) { + if(coreName.equals("mesen_s")) { + return "mesen-s"; + } + + return coreName; + } + + /** + * Gets the path to the RetroArch cores directory. + * + * @return The path to the RetroArch cores directory + */ + private String getCorePath() { + return getApplicationInfo().dataDir + "/cores/"; + } + + /** + * Triggers a symlink update in the known places that Dynamic Feature Modules + * are installed to. + */ + private void updateSymlinks() { + traverseFilesystem(getFilesDir()); + traverseFilesystem(new File(getApplicationInfo().nativeLibraryDir)); + } + + /** + * Traverse the filesystem, looking for native libraries. + * Symlinks any libraries it finds to the main RetroArch "cores" folder, + * updating any existing symlinks with the correct path to the native libraries. + * + * This is necessary because Dynamic Feature Modules are first downloaded + * and installed to a temporary location on disk, before being moved + * to a more permanent location by the system at a later point. + * + * This could probably be done in native code instead, if that's preferred. + * + * @param file The parent directory of the tree to traverse. + * @param cores List of cores to update. + * @param filenames List of filenames to update. + */ + private void traverseFilesystem(File file) { + File[] list = file.listFiles(); + if(list == null) return; + + // Check each file in a directory to see if it's a native library. + for(int i = 0; i < list.length; i++) { + File child = list[i]; + String name = child.getName(); + + if(name.startsWith("lib") && name.endsWith(".so") && !name.contains("retroarch-activity")) { + // Found a native library! + String core = name.subSequence(3, name.length() - 3).toString(); + String filename = child.getAbsolutePath(); + + SharedPreferences prefs = UserPreferences.getPreferences(this); + if(!prefs.getBoolean("core_deleted_" + core, false)) { + // Generate the destination filename and delete any existing symlinks / cores + String newFilename = getCorePath() + core + "_libretro_android.so"; + new File(newFilename).delete(); + + try { + // On Android 5.0+, use the official API for creating a symlink. + if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + Os.symlink(filename, newFilename); + } else { + // On older versions, resort to using reflection instead. + Class clazz = Class.forName("libcore.io.Libcore"); + Field field = clazz.getDeclaredField("os"); + field.setAccessible(true); + + Object os = field.get(null); + Method method = os.getClass().getMethod("symlink", String.class, String.class); + method.invoke(os, filename, newFilename); + } + } catch (Exception e) { + // Symlink failed to be created. Should never happen. + } + } + } else if(file.isDirectory()) { + // Found another directory, so traverse it + traverseFilesystem(child); + } + } + } } diff --git a/pkg/android/phoenix/.gitignore b/pkg/android/phoenix/.gitignore index ebb4538796..8308c56420 100644 --- a/pkg/android/phoenix/.gitignore +++ b/pkg/android/phoenix/.gitignore @@ -2,4 +2,10 @@ .externalNativeBuild build phoenix.iml - +output.json +keystore.properties +modules/ +settings.gradle +dynamic_features.gradle +res/values/core_names.xml +res/values/module_names_*.xml diff --git a/pkg/android/phoenix/AndroidManifest.xml b/pkg/android/phoenix/AndroidManifest.xml index fade5385a4..7551234520 100644 --- a/pkg/android/phoenix/AndroidManifest.xml +++ b/pkg/android/phoenix/AndroidManifest.xml @@ -16,12 +16,14 @@ diff --git a/pkg/android/phoenix/build.gradle b/pkg/android/phoenix/build.gradle index 5a3ab150ac..76365b45fd 100644 --- a/pkg/android/phoenix/build.gradle +++ b/pkg/android/phoenix/build.gradle @@ -37,12 +37,14 @@ android { productFlavors { normal { resValue "string", "app_name", "RetroArch" + buildConfigField "boolean", "PLAY_STORE_BUILD", "false" dimension "variant" } aarch64 { applicationIdSuffix '.aarch64' resValue "string", "app_name", "RetroArch (AArch64)" + buildConfigField "boolean", "PLAY_STORE_BUILD", "false" dimension "variant" ndk { @@ -52,12 +54,29 @@ android { ra32 { applicationIdSuffix '.ra32' resValue "string", "app_name", "RetroArch (32-bit)" + buildConfigField "boolean", "PLAY_STORE_BUILD", "false" dimension "variant" ndk { abiFilters 'armeabi-v7a', 'x86' } } + playStoreNormal { + resValue "string", "app_name", "RetroArch" + buildConfigField "boolean", "PLAY_STORE_BUILD", "true" + + dimension "variant" + } + playStoreAarch64 { + applicationIdSuffix '.aarch64' + resValue "string", "app_name", "RetroArch (AArch64)" + buildConfigField "boolean", "PLAY_STORE_BUILD", "true" + + dimension "variant" + ndk { + abiFilters 'arm64-v8a', 'x86_64' + } + } } sourceSets { @@ -67,10 +86,10 @@ android { java.srcDirs = ['src', '../phoenix-common/src'] jniLibs.srcDir '../phoenix-common/libs' jni.srcDirs = [] - res.srcDirs = ['../phoenix-common/res'] + res.srcDirs = ['res', '../phoenix-common/res'] } aarch64 { - res.srcDirs = ['res64'] + res.srcDirs = ['res', 'res64'] } } @@ -104,3 +123,12 @@ android { } } } + +dependencies { + implementation 'com.google.android.play:core:1.8.0' +} + +def dynamicFeatures = file("dynamic_features.gradle") +if(dynamicFeatures.exists()) { + apply from: "dynamic_features.gradle" +} diff --git a/pkg/android/phoenix/init_modules.sh b/pkg/android/phoenix/init_modules.sh new file mode 100755 index 0000000000..520a6ea251 --- /dev/null +++ b/pkg/android/phoenix/init_modules.sh @@ -0,0 +1,100 @@ +#!/bin/bash + +# This script generates Gradle modules for each Android core, +# so that they can be served by Google Play as Dynamic Feature Modules. +# Run "./init_modules.sh" to generate modules, or "./init_modules.sh clean" to remove them + +# These paths assume that this script is running inside libretro-super, +# and that the compiled Android cores are available while this script is run +RECIPES_PATH="../../../../recipes/android" +INFO_PATH="../../../../dist/info" +CORES_PATH="../../../../dist/android" + +# Get the list of Android cores to generate modules for +CORES_LIST=$(cat module_list.txt) + +# The below command would generate a module for every single Android core, +# but Dynamic Feature Modules enforces a 50-module limit +#CORES_LIST=$(find $RECIPES_PATH -type f ! -name '*.*' -exec cat {} + | awk '{ split($1, test, " "); print test[1] }' | grep "\S") + +# Delete any leftover files from previous script runs +rm -rf modules +rm -f res/values/core_names.xml +rm -f res/values/module_names_*.xml +rm -f dynamic_features.gradle +rm -f settings.gradle + +if [[ $1 = clean ]] ; then + exit 1 +fi + +# Make directory for modules to be stored in +mkdir -p modules +mkdir -p res/values + +# Begin generating files with necessary metadata +# for compiling Dynamic Feature Modules +echo "" >> res/values/core_names.xml +echo "android {" >> dynamic_features.gradle +echo "dynamicFeatures = [" >> dynamic_features.gradle + +for arch in armeabi-v7a arm64-v8a x86 x86_64 +do + SANITIZED_ARCH_NAME=$(echo $arch | sed "s/-/_/g") + echo "" >> res/values/module_names_$arch.xml + echo "" >> res/values/module_names_$arch.xml +done + +# Time to generate a module for each core! +while IFS= read -r core; do + SANITIZED_CORE_NAME=$(echo $core | sed "s/-/_/g") + DISPLAY_NAME=$(cat $INFO_PATH/${core}_libretro.info | grep "display_name" | cut -d'"' -f 2) + + echo "Generating module for $core..." + + # Make a copy of the template + cp -r module_template modules/$SANITIZED_CORE_NAME + + # Write the name of the core into AndroidManifest.xml + if [[ "$OSTYPE" == "darwin"* ]] + then + sed -i '' "s/%CORE_NAME%/$SANITIZED_CORE_NAME/g" modules/$SANITIZED_CORE_NAME/AndroidManifest.xml + else + sed -i "s/%CORE_NAME%/$SANITIZED_CORE_NAME/g" modules/$SANITIZED_CORE_NAME/AndroidManifest.xml + fi + + # Create a libs directory for each architecture, + # and copy the libretro core into each directory + for arch in armeabi-v7a arm64-v8a x86 x86_64 + do + mkdir -p modules/$SANITIZED_CORE_NAME/libs/$arch + + if [[ -e $CORES_PATH/$arch/${core}_libretro_android.so ]] + then + ln -s ../../../../$CORES_PATH/$arch/${core}_libretro_android.so modules/$SANITIZED_CORE_NAME/libs/$arch/lib$core.so + else + touch modules/$SANITIZED_CORE_NAME/libs/$arch/lib$core.so + fi + + if [[ -s "modules/$SANITIZED_CORE_NAME/libs/$arch/lib$core.so" ]] + then + echo "$core" >> res/values/module_names_$arch.xml + fi + done + + # Write metadata about the module into the corresponding files + echo "$DISPLAY_NAME" >> res/values/core_names.xml + echo "':modules:$SANITIZED_CORE_NAME'," >> dynamic_features.gradle + echo "include ':modules:$SANITIZED_CORE_NAME'" >> settings.gradle +done <<< "$CORES_LIST" + +# Finish generating the metadata files +echo "" >> res/values/core_names.xml +echo "]" >> dynamic_features.gradle +echo "}" >> dynamic_features.gradle + +for arch in armeabi-v7a arm64-v8a x86 x86_64 +do + echo "" >> res/values/module_names_$arch.xml + echo "" >> res/values/module_names_$arch.xml +done diff --git a/pkg/android/phoenix/module_list.txt b/pkg/android/phoenix/module_list.txt new file mode 100644 index 0000000000..5fb0a060fc --- /dev/null +++ b/pkg/android/phoenix/module_list.txt @@ -0,0 +1,5 @@ +genesis_plus_gx +mesen-s +dolphin +mupen64plus_next_gles3 +flycast diff --git a/pkg/android/phoenix/module_template/AndroidManifest.xml b/pkg/android/phoenix/module_template/AndroidManifest.xml new file mode 100644 index 0000000000..65a1c2d29d --- /dev/null +++ b/pkg/android/phoenix/module_template/AndroidManifest.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + diff --git a/pkg/android/phoenix/module_template/build.gradle b/pkg/android/phoenix/module_template/build.gradle new file mode 100644 index 0000000000..6cf55bf041 --- /dev/null +++ b/pkg/android/phoenix/module_template/build.gradle @@ -0,0 +1,49 @@ +apply plugin: 'com.android.dynamic-feature' + +android { + compileSdkVersion 28 + defaultConfig { + minSdkVersion 16 + targetSdkVersion 28 + } + + flavorDimensions "variant" + + productFlavors { + normal { + dimension "variant" + } + aarch64 { + dimension "variant" + ndk { + abiFilters 'arm64-v8a', 'x86_64' + } + } + ra32 { + dimension "variant" + ndk { + abiFilters 'armeabi-v7a', 'x86' + } + } + playStoreNormal { + dimension "variant" + } + playStoreAarch64 { + dimension "variant" + ndk { + abiFilters 'arm64-v8a', 'x86_64' + } + } + } + + sourceSets { + main { + manifest.srcFile 'AndroidManifest.xml' + jniLibs.srcDirs = ['libs'] + } + } +} + +dependencies { + implementation rootProject +}