Implemented workaround for Kivy/SDL bug where on_resume is not dispatched on Android

This commit is contained in:
Mark Qvist 2025-11-01 23:33:49 +01:00
parent 42d859f96e
commit 9a3cda077c
6 changed files with 698 additions and 16 deletions

View file

@ -0,0 +1,646 @@
package org.kivy.android;
import java.io.InputStream;
import java.io.FileWriter;
import java.io.File;
import java.io.IOException;
import java.lang.reflect.InvocationTargetException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Iterator;
import java.util.List;
import java.util.Timer;
import java.util.TimerTask;
import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.content.pm.ActivityInfo;
import android.content.pm.PackageManager;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Color;
import android.graphics.PixelFormat;
import android.os.AsyncTask;
import android.os.Bundle;
import android.os.PowerManager;
import android.util.Log;
import android.view.inputmethod.InputMethodManager;
import android.view.SurfaceView;
import android.view.ViewGroup;
import android.view.View;
import android.widget.ImageView;
import android.widget.Toast;
import android.content.res.Resources.NotFoundException;
import org.libsdl.app.SDLActivity;
import org.kivy.android.launcher.Project;
import org.renpy.android.ResourceManager;
public class PythonActivity extends SDLActivity {
private static final String TAG = "PythonActivity";
public static PythonActivity mActivity = null;
public boolean activityPaused = false;
private ResourceManager resourceManager = null;
private Bundle mMetaData = null;
private PowerManager.WakeLock mWakeLock = null;
public String getAppRoot() {
String app_root = getFilesDir().getAbsolutePath() + "/app";
return app_root;
}
@Override
protected void onCreate(Bundle savedInstanceState) {
Log.v(TAG, "PythonActivity onCreate running");
resourceManager = new ResourceManager(this);
Log.v(TAG, "About to do super onCreate");
super.onCreate(savedInstanceState);
Log.v(TAG, "Did super onCreate");
this.mActivity = this;
this.showLoadingScreen(this.getLoadingScreen());
new UnpackFilesTask().execute(getAppRoot());
}
public void loadLibraries() {
String app_root = new String(getAppRoot());
File app_root_file = new File(app_root);
PythonUtil.loadLibraries(app_root_file,
new File(getApplicationInfo().nativeLibraryDir));
}
/**
* Show an error using a toast. (Only makes sense from non-UI
* threads.)
*/
public void toastError(final String msg) {
final Activity thisActivity = this;
runOnUiThread(new Runnable () {
public void run() {
Toast.makeText(thisActivity, msg, Toast.LENGTH_LONG).show();
}
});
// Wait to show the error.
synchronized (this) {
try {
this.wait(1000);
} catch (InterruptedException e) {
}
}
}
private class UnpackFilesTask extends AsyncTask<String, Void, String> {
@Override
protected String doInBackground(String... params) {
File app_root_file = new File(params[0]);
Log.v(TAG, "Ready to unpack");
PythonUtil.unpackAsset(mActivity, "private", app_root_file, true);
PythonUtil.unpackPyBundle(mActivity, getApplicationInfo().nativeLibraryDir + "/" + "libpybundle", app_root_file, false);
return null;
}
@Override
protected void onPostExecute(String result) {
// Figure out the directory where the game is. If the game was
// given to us via an intent, then we use the scheme-specific
// part of that intent to determine the file to launch. We
// also use the android.txt file to determine the orientation.
//
// Otherwise, we use the public data, if we have it, or the
// private data if we do not.
mActivity.finishLoad();
// finishLoad called setContentView with the SDL view, which
// removed the loading screen. However, we still need it to
// show until the app is ready to render, so pop it back up
// on top of the SDL view.
mActivity.showLoadingScreen(getLoadingScreen());
String app_root_dir = getAppRoot();
if (getIntent() != null && getIntent().getAction() != null &&
getIntent().getAction().equals("org.kivy.LAUNCH")) {
File path = new File(getIntent().getData().getSchemeSpecificPart());
Project p = Project.scanDirectory(path);
String entry_point = getEntryPoint(p.dir);
SDLActivity.nativeSetenv("ANDROID_ENTRYPOINT", p.dir + "/" + entry_point);
SDLActivity.nativeSetenv("ANDROID_ARGUMENT", p.dir);
SDLActivity.nativeSetenv("ANDROID_APP_PATH", p.dir);
if (p != null) {
if (p.landscape) {
setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE);
} else {
setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);
}
}
// Let old apps know they started.
try {
FileWriter f = new FileWriter(new File(path, ".launch"));
f.write("started");
f.close();
} catch (IOException e) {
// pass
}
} else {
String entry_point = getEntryPoint(app_root_dir);
SDLActivity.nativeSetenv("ANDROID_ENTRYPOINT", entry_point);
SDLActivity.nativeSetenv("ANDROID_ARGUMENT", app_root_dir);
SDLActivity.nativeSetenv("ANDROID_APP_PATH", app_root_dir);
}
String mFilesDirectory = mActivity.getFilesDir().getAbsolutePath();
Log.v(TAG, "Setting env vars for start.c and Python to use");
SDLActivity.nativeSetenv("ANDROID_PRIVATE", mFilesDirectory);
SDLActivity.nativeSetenv("ANDROID_UNPACK", app_root_dir);
SDLActivity.nativeSetenv("PYTHONHOME", app_root_dir);
SDLActivity.nativeSetenv("PYTHONPATH", app_root_dir + ":" + app_root_dir + "/lib");
SDLActivity.nativeSetenv("PYTHONOPTIMIZE", "2");
try {
Log.v(TAG, "Access to our meta-data...");
mActivity.mMetaData = mActivity.getPackageManager().getApplicationInfo(
mActivity.getPackageName(), PackageManager.GET_META_DATA).metaData;
PowerManager pm = (PowerManager) mActivity.getSystemService(Context.POWER_SERVICE);
if ( mActivity.mMetaData.getInt("wakelock") == 1 ) {
mActivity.mWakeLock = pm.newWakeLock(PowerManager.SCREEN_BRIGHT_WAKE_LOCK, "Screen On");
mActivity.mWakeLock.acquire();
}
if ( mActivity.mMetaData.getInt("surface.transparent") != 0 ) {
Log.v(TAG, "Surface will be transparent.");
getSurface().setZOrderOnTop(true);
getSurface().getHolder().setFormat(PixelFormat.TRANSPARENT);
} else {
Log.i(TAG, "Surface will NOT be transparent");
}
} catch (PackageManager.NameNotFoundException e) {
}
// Launch app if that hasn't been done yet:
if (mActivity.mHasFocus && (
// never went into proper resume state:
mActivity.mCurrentNativeState == NativeState.INIT ||
(
// resumed earlier but wasn't ready yet
mActivity.mCurrentNativeState == NativeState.RESUMED &&
mActivity.mSDLThread == null
))) {
// Because sometimes the app will get stuck here and never
// actually run, ensure that it gets launched if we're active:
mActivity.resumeNativeThread();
}
}
@Override
protected void onPreExecute() {
}
@Override
protected void onProgressUpdate(Void... values) {
}
}
public static ViewGroup getLayout() {
return mLayout;
}
public static SurfaceView getSurface() {
return mSurface;
}
//----------------------------------------------------------------------------
// Listener interface for onNewIntent
//
public interface NewIntentListener {
void onNewIntent(Intent intent);
}
private List<NewIntentListener> newIntentListeners = null;
public void registerNewIntentListener(NewIntentListener listener) {
if ( this.newIntentListeners == null )
this.newIntentListeners = Collections.synchronizedList(new ArrayList<NewIntentListener>());
this.newIntentListeners.add(listener);
}
public void unregisterNewIntentListener(NewIntentListener listener) {
if ( this.newIntentListeners == null )
return;
this.newIntentListeners.remove(listener);
}
@Override
protected void onNewIntent(Intent intent) {
if ( this.newIntentListeners == null )
return;
this.onResume();
synchronized ( this.newIntentListeners ) {
Iterator<NewIntentListener> iterator = this.newIntentListeners.iterator();
while ( iterator.hasNext() ) {
(iterator.next()).onNewIntent(intent);
}
}
}
//----------------------------------------------------------------------------
// Listener interface for onActivityResult
//
public interface ActivityResultListener {
void onActivityResult(int requestCode, int resultCode, Intent data);
}
private List<ActivityResultListener> activityResultListeners = null;
public void registerActivityResultListener(ActivityResultListener listener) {
if ( this.activityResultListeners == null )
this.activityResultListeners = Collections.synchronizedList(new ArrayList<ActivityResultListener>());
this.activityResultListeners.add(listener);
}
public void unregisterActivityResultListener(ActivityResultListener listener) {
if ( this.activityResultListeners == null )
return;
this.activityResultListeners.remove(listener);
}
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent intent) {
if ( this.activityResultListeners == null )
return;
this.onResume();
synchronized ( this.activityResultListeners ) {
Iterator<ActivityResultListener> iterator = this.activityResultListeners.iterator();
while ( iterator.hasNext() )
(iterator.next()).onActivityResult(requestCode, resultCode, intent);
}
}
public static void start_service(
String serviceTitle,
String serviceDescription,
String pythonServiceArgument
) {
_do_start_service(
serviceTitle, serviceDescription, pythonServiceArgument, true
);
}
public static void start_service_not_as_foreground(
String serviceTitle,
String serviceDescription,
String pythonServiceArgument
) {
_do_start_service(
serviceTitle, serviceDescription, pythonServiceArgument, false
);
}
public static void _do_start_service(
String serviceTitle,
String serviceDescription,
String pythonServiceArgument,
boolean showForegroundNotification
) {
Intent serviceIntent = new Intent(PythonActivity.mActivity, PythonService.class);
String argument = PythonActivity.mActivity.getFilesDir().getAbsolutePath();
String app_root_dir = PythonActivity.mActivity.getAppRoot();
String entry_point = PythonActivity.mActivity.getEntryPoint(app_root_dir + "/service");
serviceIntent.putExtra("androidPrivate", argument);
serviceIntent.putExtra("androidArgument", app_root_dir);
serviceIntent.putExtra("serviceEntrypoint", "service/" + entry_point);
serviceIntent.putExtra("pythonName", "python");
serviceIntent.putExtra("pythonHome", app_root_dir);
serviceIntent.putExtra("pythonPath", app_root_dir + ":" + app_root_dir + "/lib");
serviceIntent.putExtra("serviceStartAsForeground",
(showForegroundNotification ? "true" : "false")
);
serviceIntent.putExtra("serviceTitle", serviceTitle);
serviceIntent.putExtra("serviceDescription", serviceDescription);
serviceIntent.putExtra("pythonServiceArgument", pythonServiceArgument);
PythonActivity.mActivity.startService(serviceIntent);
}
public static void stop_service() {
Intent serviceIntent = new Intent(PythonActivity.mActivity, PythonService.class);
PythonActivity.mActivity.stopService(serviceIntent);
}
/** Loading screen view **/
public static ImageView mImageView = null;
public static View mLottieView = null;
/** Whether main routine/actual app has started yet **/
protected boolean mAppConfirmedActive = false;
/** Timer for delayed loading screen removal. **/
protected Timer loadingScreenRemovalTimer = null;
// Overridden since it's called often, to check whether to remove the
// loading screen:
@Override
protected boolean sendCommand(int command, Object data) {
boolean result = super.sendCommand(command, data);
considerLoadingScreenRemoval();
return result;
}
/** Confirm that the app's main routine has been launched.
**/
@Override
public void appConfirmedActive() {
if (!mAppConfirmedActive) {
Log.v(TAG, "appConfirmedActive() -> preparing loading screen removal");
mAppConfirmedActive = true;
considerLoadingScreenRemoval();
}
}
/** This is called from various places to check whether the app's main
* routine has been launched already, and if it has, then the loading
* screen will be removed.
**/
public void considerLoadingScreenRemoval() {
if (loadingScreenRemovalTimer != null)
return;
runOnUiThread(new Runnable() {
public void run() {
if (((PythonActivity)PythonActivity.mSingleton).mAppConfirmedActive &&
loadingScreenRemovalTimer == null) {
// Remove loading screen but with a delay.
// (app can use p4a's android.loadingscreen module to
// do it quicker if it wants to)
// get a handler (call from main thread)
// this will run when timer elapses
TimerTask removalTask = new TimerTask() {
@Override
public void run() {
// post a runnable to the handler
runOnUiThread(new Runnable() {
@Override
public void run() {
PythonActivity activity =
((PythonActivity)PythonActivity.mSingleton);
if (activity != null)
activity.removeLoadingScreen();
}
});
}
};
loadingScreenRemovalTimer = new Timer();
loadingScreenRemovalTimer.schedule(removalTask, 5000);
}
}
});
}
public void removeLoadingScreen() {
runOnUiThread(new Runnable() {
public void run() {
View view = mLottieView != null ? mLottieView : mImageView;
if (view != null && view.getParent() != null) {
((ViewGroup)view.getParent()).removeView(view);
mLottieView = null;
mImageView = null;
}
}
});
}
public String getEntryPoint(String search_dir) {
/* Get the main file (.pyc|.py) depending on if we
* have a compiled version or not.
*/
List<String> entryPoints = new ArrayList<String>();
entryPoints.add("main.pyc"); // python 3 compiled files
for (String value : entryPoints) {
File mainFile = new File(search_dir + "/" + value);
if (mainFile.exists()) {
return value;
}
}
return "main.py";
}
protected void showLoadingScreen(View view) {
try {
if (mLayout == null) {
setContentView(view);
} else if (view.getParent() == null) {
mLayout.addView(view);
}
} catch (IllegalStateException e) {
// The loading screen can be attempted to be applied twice if app
// is tabbed in/out, quickly.
// (Gives error "The specified child already has a parent.
// You must call removeView() on the child's parent first.")
}
}
protected void setBackgroundColor(View view) {
/*
* Set the presplash loading screen background color
* https://developer.android.com/reference/android/graphics/Color.html
* Parse the color string, and return the corresponding color-int.
* If the string cannot be parsed, throws an IllegalArgumentException exception.
* Supported formats are: #RRGGBB #AARRGGBB or one of the following names:
* 'red', 'blue', 'green', 'black', 'white', 'gray', 'cyan', 'magenta', 'yellow',
* 'lightgray', 'darkgray', 'grey', 'lightgrey', 'darkgrey', 'aqua', 'fuchsia',
* 'lime', 'maroon', 'navy', 'olive', 'purple', 'silver', 'teal'.
*/
String backgroundColor = resourceManager.getString("presplash_color");
if (backgroundColor != null) {
try {
view.setBackgroundColor(Color.parseColor(backgroundColor));
} catch (IllegalArgumentException e) {}
}
}
protected View getLoadingScreen() {
// If we have an mLottieView or mImageView already, then do
// nothing because it will have already been made the content
// view or added to the layout.
if (mLottieView != null || mImageView != null) {
// we already have a splash screen
return mLottieView != null ? mLottieView : mImageView;
}
// first try to load the lottie one
try {
mLottieView = getLayoutInflater().inflate(
this.resourceManager.getIdentifier("lottie", "layout"),
mLayout,
false
);
try {
if (mLayout == null) {
setContentView(mLottieView);
} else if (PythonActivity.mLottieView.getParent() == null) {
mLayout.addView(mLottieView);
}
} catch (IllegalStateException e) {
// The loading screen can be attempted to be applied twice if app
// is tabbed in/out, quickly.
// (Gives error "The specified child already has a parent.
// You must call removeView() on the child's parent first.")
}
setBackgroundColor(mLottieView);
return mLottieView;
}
catch (NotFoundException e) {
Log.v("SDL", "couldn't find lottie layout or animation, trying static splash");
}
// no lottie asset, try to load the static image then
int presplashId = this.resourceManager.getIdentifier("presplash", "drawable");
InputStream is = this.getResources().openRawResource(presplashId);
Bitmap bitmap = null;
try {
bitmap = BitmapFactory.decodeStream(is);
} finally {
try {
is.close();
} catch (IOException e) {};
}
mImageView = new ImageView(this);
mImageView.setImageBitmap(bitmap);
setBackgroundColor(mImageView);
mImageView.setLayoutParams(new ViewGroup.LayoutParams(
ViewGroup.LayoutParams.FILL_PARENT,
ViewGroup.LayoutParams.FILL_PARENT));
mImageView.setScaleType(ImageView.ScaleType.FIT_CENTER);
return mImageView;
}
@Override
protected void onPause() {
if (this.mWakeLock != null && mWakeLock.isHeld()) {
this.mWakeLock.release();
}
this.activityPaused = true;
Log.v(TAG, "onPause()");
try {
super.onPause();
} catch (UnsatisfiedLinkError e) {
// Catch pause while still in loading screen failing to
// call native function (since it's not yet loaded)
}
}
@Override
protected void onResume() {
if (this.mWakeLock != null) {
this.mWakeLock.acquire();
}
this.activityPaused = false;
Log.v(TAG, "onResume() in PythonActivity");
try {
super.onResume();
} catch (UnsatisfiedLinkError e) {
// Catch resume while still in loading screen failing to
// call native function (since it's not yet loaded)
}
considerLoadingScreenRemoval();
}
@Override
public void onWindowFocusChanged(boolean hasFocus) {
try {
super.onWindowFocusChanged(hasFocus);
} catch (UnsatisfiedLinkError e) {
// Catch window focus while still in loading screen failing to
// call native function (since it's not yet loaded)
}
considerLoadingScreenRemoval();
}
/**
* Used by android.permissions p4a module to register a call back after
* requesting runtime permissions
**/
public interface PermissionsCallback {
void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults);
}
private PermissionsCallback permissionCallback;
private boolean havePermissionsCallback = false;
public void addPermissionsCallback(PermissionsCallback callback) {
permissionCallback = callback;
havePermissionsCallback = true;
Log.v(TAG, "addPermissionsCallback(): Added callback for onRequestPermissionsResult");
}
@Override
public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
Log.v(TAG, "onRequestPermissionsResult()");
if (havePermissionsCallback) {
Log.v(TAG, "onRequestPermissionsResult passed to callback");
permissionCallback.onRequestPermissionsResult(requestCode, permissions, grantResults);
}
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
}
/**
* Used by android.permissions p4a module to check a permission
**/
public boolean checkCurrentPermission(String permission) {
if (android.os.Build.VERSION.SDK_INT < 23)
return true;
try {
java.lang.reflect.Method methodCheckPermission =
Activity.class.getMethod("checkSelfPermission", String.class);
Object resultObj = methodCheckPermission.invoke(this, permission);
int result = Integer.parseInt(resultObj.toString());
if (result == PackageManager.PERMISSION_GRANTED)
return true;
} catch (IllegalAccessException | NoSuchMethodException |
InvocationTargetException e) {
}
return false;
}
/**
* Used by android.permissions p4a module to request runtime permissions
**/
public void requestPermissionsWithRequestCode(String[] permissions, int requestCode) {
if (android.os.Build.VERSION.SDK_INT < 23)
return;
try {
java.lang.reflect.Method methodRequestPermission =
Activity.class.getMethod("requestPermissions",
String[].class, int.class);
methodRequestPermission.invoke(this, permissions, requestCode);
} catch (IllegalAccessException | NoSuchMethodException |
InvocationTargetException e) {
}
}
public void requestPermissions(String[] permissions) {
requestPermissionsWithRequestCode(permissions, 1);
}
public static void changeKeyboard(int inputType) {
if (SDLActivity.keyboardInputType != inputType){
SDLActivity.keyboardInputType = inputType;
InputMethodManager imm = (InputMethodManager) getContext().getSystemService(Context.INPUT_METHOD_SERVICE);
imm.restartInput(mTextEdit);
}
}
}