diff --git a/tools/cargo_makepad/src/android/java/dev/makepad/android/MakepadActivity.java b/tools/cargo_makepad/src/android/java/dev/makepad/android/MakepadActivity.java index 11f87d7585..812952ca21 100644 --- a/tools/cargo_makepad/src/android/java/dev/makepad/android/MakepadActivity.java +++ b/tools/cargo_makepad/src/android/java/dev/makepad/android/MakepadActivity.java @@ -615,6 +615,13 @@ public void onGlobalLayout() { @SuppressWarnings("deprecation") @Override public boolean onKey(View v, int keyCode, KeyEvent event) { + // Back is not a surface key. Left unhandled it reaches the activity, + // which delivers it once through dispatchBackPressed: from onKeyUp -> + // onBackPressed below API 33, or from the OnBackInvokedCallback on + // API 33+ (which also carries the system edge gesture). Forwarding it + // here as well produced a second native BackPressed: one closed the + // current panel and the next left the app. + if (keyCode == KeyEvent.KEYCODE_BACK) return false; if (event.getAction() == KeyEvent.ACTION_DOWN && keyCode != 0) { int metaState = event.getMetaState(); boolean isRepeat = event.getRepeatCount() > 0; @@ -1115,6 +1122,7 @@ public class MakepadActivity private FrameLayout mRootLayout; private FrameLayout mApplicationOverlay; private ApplicationExtension mApplicationExtension; + private android.window.OnBackInvokedCallback mBackInvokedCallback; /** Optional app Java client. Implementations enqueue blocking work on workers. */ public interface ApplicationExtension { @@ -1124,7 +1132,16 @@ public interface ApplicationExtension { void onIntent(Intent intent); void onDestroy(); default boolean onActivityResult(int requestCode, int resultCode, Intent data) { return false; } + /** Return true to consume Back before it reaches the native widget tree. */ default boolean onBackPressed() { return false; } + /** + * True when the extension registers its own Android 13+ + * OnBackInvokedCallback (calling {@link MakepadActivity#onBackPressed}). + * The activity then registers none and drops the compatibility Back + * key, so each Back is delivered once. Most extensions should leave + * this false and implement {@link #onBackPressed} instead. + */ + default boolean usesSystemBackCallback() { return false; } } private void createApplicationExtension() { @@ -1532,6 +1549,7 @@ public void onCreate(Bundle savedInstanceState) { } MakepadNative.activityOnCreate(this); createApplicationExtension(); + registerBackInvokedCallback(); registerPhysicalKeyboardListener(); mVideoPlaybackThread = new HandlerThread("VideoPlayerThread"); @@ -1635,6 +1653,7 @@ protected void onStop() { @Override protected void onDestroy() { + unregisterBackInvokedCallback(); if (mApplicationExtension != null) { mApplicationExtension.onDestroy(); mApplicationExtension = null; @@ -1717,6 +1736,12 @@ public void onLowMemory() { // key (BACK included) and forwards it to Rust, so onBackPressed never runs. @Override public boolean dispatchKeyEvent(KeyEvent event) { + // An extension that owns the system Back callback receives every Back + // through it; its compatibility key must not navigate a second time. + if (Build.VERSION.SDK_INT >= 33 && event.getKeyCode() == KeyEvent.KEYCODE_BACK + && extensionOwnsSystemBack()) { + return true; + } if (mQrScanning && event.getKeyCode() == KeyEvent.KEYCODE_BACK) { if (event.getAction() == KeyEvent.ACTION_UP) closeQrScanner("cancelled"); return true; @@ -1727,11 +1752,47 @@ public boolean dispatchKeyEvent(KeyEvent event) { @Override @SuppressWarnings("deprecation") public void onBackPressed() { + dispatchBackPressed(); + } + + /** The single delivery point for Back: key, button and system gesture. */ + private void dispatchBackPressed() { + if (mQrScanning) { + closeQrScanner("cancelled"); + return; + } if (mApplicationExtension != null && mApplicationExtension.onBackPressed()) return; - super.onBackPressed(); + // No super.onBackPressed(): Activity's default finishes (or, on API + // 31+, backgrounds) the activity before the widget tree has seen the + // event. Navigation belongs to the native BackPressed handlers. MakepadNative.onBackPressed(); } + private boolean extensionOwnsSystemBack() { + return mApplicationExtension != null && mApplicationExtension.usesSystemBackCallback(); + } + + // Android 13+ delivers Back, including the system edge gesture, to + // OnBackInvokedCallbacks when the manifest opts in + // (android:enableOnBackInvokedCallback, which cargo-makepad writes); + // onBackPressed is then no longer called. PRIORITY_DEFAULT leaves the + // IME's own Back (dismissing the keyboard) ahead of app navigation. + private void registerBackInvokedCallback() { + if (Build.VERSION.SDK_INT < 33 || mBackInvokedCallback != null || extensionOwnsSystemBack()) { + return; + } + mBackInvokedCallback = this::dispatchBackPressed; + getOnBackInvokedDispatcher().registerOnBackInvokedCallback( + android.window.OnBackInvokedDispatcher.PRIORITY_DEFAULT, mBackInvokedCallback); + } + + private void unregisterBackInvokedCallback() { + if (Build.VERSION.SDK_INT >= 33 && mBackInvokedCallback != null) { + getOnBackInvokedDispatcher().unregisterOnBackInvokedCallback(mBackInvokedCallback); + mBackInvokedCallback = null; + } + } + @Override public void onWindowFocusChanged(boolean hasFocus) { super.onWindowFocusChanged(hasFocus); diff --git a/tools/cargo_makepad/src/android/mod.rs b/tools/cargo_makepad/src/android/mod.rs index 3931c6c79d..1003138347 100644 --- a/tools/cargo_makepad/src/android/mod.rs +++ b/tools/cargo_makepad/src/android/mod.rs @@ -99,6 +99,7 @@ impl AndroidVariant { android:configChanges="orientation|screenSize|keyboardHidden" android:exported="true" android:launchMode="singleTask" + android:enableOnBackInvokedCallback="true" android:windowSoftInputMode="adjustNothing|stateUnchanged" android:theme="@style/MakepadLaunchTheme"> @@ -193,6 +194,7 @@ impl AndroidVariant { android:excludeFromRecents="false" android:exported="true" android:launchMode="singleTask" + android:enableOnBackInvokedCallback="true" android:screenOrientation="landscape" android:windowSoftInputMode="adjustNothing|stateUnchanged" android:theme="@style/MakepadLaunchTheme" @@ -209,6 +211,7 @@ impl AndroidVariant { android:excludeFromRecents="false" android:exported="true" android:launchMode="singleTask" + android:enableOnBackInvokedCallback="true" android:screenOrientation="landscape" android:windowSoftInputMode="adjustNothing|stateUnchanged" android:theme="@style/MakepadLaunchTheme" @@ -283,6 +286,7 @@ Self::Quest=>format!(r#" android:name="{class_name}" android:theme="@android:style/Theme.Black.NoTitleBar.Fullscreen" android:launchMode="singleTask" + android:enableOnBackInvokedCallback="true" android:screenOrientation="landscape" android:excludeFromRecents="false" android:configChanges="screenSize|screenLayout|orientation|keyboardHidden|keyboard|navigation|uiMode" @@ -301,6 +305,7 @@ Self::Quest=>format!(r#" android:excludeFromRecents="false" android:exported="true" android:launchMode="singleTask" + android:enableOnBackInvokedCallback="true" android:screenOrientation="landscape" android:theme="@android:style/Theme.Black.NoTitleBar.Fullscreen" > @@ -927,6 +932,22 @@ mod tests { assert!(xml.contains("android:launchMode=\"singleTask\"")); } + #[test] + fn every_activity_opts_into_back_invoked_callbacks() { + // MakepadActivity registers one OnBackInvokedCallback on API 33+; + // without the opt-in Android ignores it and finishes the activity + // on a system Back gesture instead. + for variant in [AndroidVariant::Default, AndroidVariant::Quest] { + let xml = variant.manifest_xml(&test_args("App", "dev.makepad.app")); + assert_eq!( + xml.matches("android:launchMode=\"singleTask\"").count(), + xml.matches("android:enableOnBackInvokedCallback=\"true\"").count(), + "every activity needs the Back opt-in: {xml}" + ); + assert!(xml.contains("android:enableOnBackInvokedCallback=\"true\"")); + } + } + #[test] fn quest_manifest_uses_splash_and_app_themes() { let xml = AndroidVariant::Quest.manifest_xml(&test_args("App", "dev.makepad.app"));