From 76311da535b9ceba5e3b1455b196158923897abf Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Thu, 1 Oct 2026 09:05:38 -0700 Subject: [PATCH] Android: deliver Back once, including the Android 13+ system gesture Every Back (key, navigation button, system edge gesture) now goes through one MakepadActivity.dispatchBackPressed(): an open QR scanner closes, the application extension may consume it, otherwise MakepadNative.onBackPressed. super.onBackPressed() no longer finishes or backgrounds the activity before the widget tree has handled the event. The surface leaves KEYCODE_BACK unhandled instead of forwarding it to Rust as KeyDown(Back) (which became a second BackPressed). Below API 33 the activity's onKeyUp -> onBackPressed delivers it; on API 33+ an OnBackInvokedCallback (PRIORITY_DEFAULT, so the IME's Back stays first) does, and cargo-makepad's manifests opt every activity in with android:enableOnBackInvokedCallback. ApplicationExtension.usesSystemBackCallback() lets an extension that owns its own system callback keep it: the activity then registers none and drops the compatibility Back key. This upstreams OctoSense's tools/runtime-patches/makepad-system-back.patch with the same interface. Co-Authored-By: Claude Opus 5.5 --- .../dev/makepad/android/MakepadActivity.java | 63 ++++++++++++++++++- tools/cargo_makepad/src/android/mod.rs | 21 +++++++ 2 files changed, 83 insertions(+), 1 deletion(-) 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"));