From 3b6c522942ca1b3d3f5e2115e0a4e781063747d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wojciech=20Dr=C3=B3=C5=BCd=C5=BC?= Date: Thu, 1 Feb 2024 09:07:15 -0800 Subject: [PATCH] Add `onUserLeaveHint` support to ReactActivityDelegate (#42741) Summary: This PR adds `onUserLeaveHint` support into the `ReactActivityDelegate`. It allows modules to receive an event every time user moves the app into the background. This is slightly different than `onPause` - it's called only when the user intentionally moves the app into the background, e.g. when receiving a call `onPause` should be called but `onUserLeaveHint` shouldn't. This feature is especially useful for libraries implementing features like Picture in Picture (PiP), where using `onUserLeaveHint` is the [recommended way of auto-entering PiP](https://developer.android.com/develop/ui/views/picture-in-picture#:~:text=You%20might%20want%20to%20include%20logic%20that%20switches%20an%20activity%20into%20PiP%20mode%20instead%20of%20going%20into%20the%20background.%20For%20example%2C%20Google%20Maps%20switches%20to%20PiP%20mode%20if%20the%20user%20presses%20the%20home%20or%20recents%20button%20while%20the%20app%20is%20navigating.%20You%20can%20catch%20this%20case%20by%20overriding%20onUserLeaveHint()%3A) for android < 12. ## Changelog: [ANDROID] [ADDED] - Added `onUserLeaveHint` support into `ReactActivityDelegate` Pull Request resolved: https://github.com/facebook/react-native/pull/42741 Test Plan: Tested in the `rn-tester` app - callbacks are correctly called on both old and new architecture. Reviewed By: javache Differential Revision: D53279501 Pulled By: cortinico fbshipit-source-id: 491fc062421da7e05b78dc818b22cd1ee79af791 --- .../ReactAndroid/api/ReactAndroid.api | 8 +++ .../com/facebook/react/ReactActivity.java | 6 ++ .../facebook/react/ReactActivityDelegate.java | 4 ++ .../com/facebook/react/ReactDelegate.java | 10 +++ .../main/java/com/facebook/react/ReactHost.kt | 6 ++ .../facebook/react/ReactInstanceManager.java | 32 ++++++++++ .../react/bridge/ActivityEventListener.java | 3 + .../facebook/react/bridge/ReactContext.java | 11 ++++ .../facebook/react/runtime/ReactHostImpl.java | 63 ++++++++++--------- 9 files changed, 112 insertions(+), 31 deletions(-) diff --git a/packages/react-native/ReactAndroid/api/ReactAndroid.api b/packages/react-native/ReactAndroid/api/ReactAndroid.api index 2a5f753722afab..9c141166f0a782 100644 --- a/packages/react-native/ReactAndroid/api/ReactAndroid.api +++ b/packages/react-native/ReactAndroid/api/ReactAndroid.api @@ -99,6 +99,7 @@ public abstract class com/facebook/react/ReactActivity : androidx/appcompat/app/ protected fun onPause ()V public fun onRequestPermissionsResult (I[Ljava/lang/String;[I)V protected fun onResume ()V + public fun onUserLeaveHint ()V public fun onWindowFocusChanged (Z)V public fun requestPermissions ([Ljava/lang/String;ILcom/facebook/react/modules/core/PermissionListener;)V } @@ -130,6 +131,7 @@ public class com/facebook/react/ReactActivityDelegate { protected fun onPause ()V public fun onRequestPermissionsResult (I[Ljava/lang/String;[I)V protected fun onResume ()V + protected fun onUserLeaveHint ()V public fun onWindowFocusChanged (Z)V public fun requestPermissions ([Ljava/lang/String;ILcom/facebook/react/modules/core/PermissionListener;)V } @@ -154,6 +156,7 @@ public class com/facebook/react/ReactDelegate { public fun onHostDestroy ()V public fun onHostPause ()V public fun onHostResume ()V + public fun onUserLeaveHint ()V public fun shouldShowDevMenuOrReload (ILandroid/view/KeyEvent;)Z } @@ -199,6 +202,7 @@ public abstract interface class com/facebook/react/ReactHost { public abstract fun onBackPressed ()Z public abstract fun onHostDestroy ()V public abstract fun onHostDestroy (Landroid/app/Activity;)V + public abstract fun onHostLeaveHint (Landroid/app/Activity;)V public abstract fun onHostPause ()V public abstract fun onHostPause (Landroid/app/Activity;)V public abstract fun onHostResume (Landroid/app/Activity;)V @@ -240,6 +244,7 @@ public class com/facebook/react/ReactInstanceManager { public fun onHostResume (Landroid/app/Activity;)V public fun onHostResume (Landroid/app/Activity;Lcom/facebook/react/modules/core/DefaultHardwareBackBtnHandler;)V public fun onNewIntent (Landroid/content/Intent;)V + public fun onUserLeaveHint (Landroid/app/Activity;)V public fun onWindowFocusChange (Z)V public fun recreateReactContextInBackground ()V public fun removeReactInstanceEventListener (Lcom/facebook/react/ReactInstanceEventListener;)V @@ -474,6 +479,7 @@ public class com/facebook/react/animated/NativeAnimatedNodesManager : com/facebo public abstract interface class com/facebook/react/bridge/ActivityEventListener { public abstract fun onActivityResult (Landroid/app/Activity;IILandroid/content/Intent;)V public abstract fun onNewIntent (Landroid/content/Intent;)V + public fun onUserLeaveHint (Landroid/app/Activity;)V } public class com/facebook/react/bridge/Arguments { @@ -1092,6 +1098,7 @@ public class com/facebook/react/bridge/ReactContext : android/content/ContextWra public fun onHostPause ()V public fun onHostResume (Landroid/app/Activity;)V public fun onNewIntent (Landroid/app/Activity;Landroid/content/Intent;)V + public fun onUserLeaveHint (Landroid/app/Activity;)V public fun onWindowFocusChange (Z)V public fun registerSegment (ILjava/lang/String;Lcom/facebook/react/bridge/Callback;)V public fun removeActivityEventListener (Lcom/facebook/react/bridge/ActivityEventListener;)V @@ -3621,6 +3628,7 @@ public class com/facebook/react/runtime/ReactHostImpl : com/facebook/react/React public fun onBackPressed ()Z public fun onHostDestroy ()V public fun onHostDestroy (Landroid/app/Activity;)V + public fun onHostLeaveHint (Landroid/app/Activity;)V public fun onHostPause ()V public fun onHostPause (Landroid/app/Activity;)V public fun onHostResume (Landroid/app/Activity;)V diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/ReactActivity.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/ReactActivity.java index e6fd3fdd534bb6..0f5c8d01730fb6 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/ReactActivity.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/ReactActivity.java @@ -105,6 +105,12 @@ public void onNewIntent(Intent intent) { } } + @Override + public void onUserLeaveHint() { + super.onUserLeaveHint(); + mDelegate.onUserLeaveHint(); + } + @Override public void requestPermissions( String[] permissions, int requestCode, PermissionListener listener) { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/ReactActivityDelegate.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/ReactActivityDelegate.java index 9df5a6800f71be..f53ff3f2095681 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/ReactActivityDelegate.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/ReactActivityDelegate.java @@ -123,6 +123,10 @@ protected void loadApp(String appKey) { getPlainActivity().setContentView(mReactDelegate.getReactRootView()); } + protected void onUserLeaveHint() { + mReactDelegate.onUserLeaveHint(); + } + protected void onPause() { mReactDelegate.onHostPause(); } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/ReactDelegate.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/ReactDelegate.java index d64e45a1363601..48ca1919403c1f 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/ReactDelegate.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/ReactDelegate.java @@ -99,6 +99,16 @@ public void onHostResume() { } } + public void onUserLeaveHint() { + if (ReactFeatureFlags.enableBridgelessArchitecture) { + mReactHost.onHostLeaveHint(mActivity); + } else { + if (getReactNativeHost().hasInstance()) { + getReactNativeHost().getReactInstanceManager().onUserLeaveHint(mActivity); + } + } + } + public void onHostPause() { if (ReactFeatureFlags.enableBridgelessArchitecture) { mReactHost.onHostPause(mActivity); diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/ReactHost.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/ReactHost.kt index eda0bf44f12f14..fc8b9b68f575c1 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/ReactHost.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/ReactHost.kt @@ -57,6 +57,12 @@ interface ReactHost { /** To be called when the host activity is resumed. */ fun onHostResume(activity: Activity?) + /** + * To be called when the host activity is about to go into the background as the result of user + * choice. + */ + fun onHostLeaveHint(activity: Activity?) + /** To be called when the host activity is paused. */ fun onHostPause(activity: Activity?) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/ReactInstanceManager.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/ReactInstanceManager.java index b0788f87da4990..50968b7c733e88 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/ReactInstanceManager.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/ReactInstanceManager.java @@ -585,6 +585,38 @@ public void onHostPause() { moveToBeforeResumeLifecycleState(); } + /** + * This method should be called from {@link Activity#onUserLeaveHint()}. It notifies all listening + * modules that the user is about to leave the activity. The passed Activity is has to be the + * current Activity. + * + * @param activity the activity being backgrounded as a result of user action + */ + @ThreadConfined(UI) + public void onUserLeaveHint(@Nullable Activity activity) { + if (mRequireActivity) { + Assertions.assertCondition(mCurrentActivity != null); + } + + if (mCurrentActivity != null) { + Assertions.assertCondition( + activity == mCurrentActivity, + "Called onUserLeaveHint on an activity that is not the current activity, this is incorrect! " + + "Current activity: " + + mCurrentActivity.getClass().getSimpleName() + + " " + + "Leaving activity: " + + activity.getClass().getSimpleName()); + + UiThreadUtil.assertOnUiThread(); + + ReactContext currentContext = getCurrentReactContext(); + if (currentContext != null) { + currentContext.onUserLeaveHint(activity); + } + } + } + /** * Call this from {@link Activity#onPause()}. This notifies any listening modules so they can do * any necessary cleanup. The passed Activity is the current Activity being paused. This will diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/ActivityEventListener.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/ActivityEventListener.java index cf6c9bbffe6255..0c960e36813f3b 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/ActivityEventListener.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/ActivityEventListener.java @@ -22,4 +22,7 @@ public interface ActivityEventListener { /** Called when a new intent is passed to the activity */ void onNewIntent(Intent intent); + + /** Called when host activity receives an {@link Activity#onUserLeaveHint()} call. */ + default void onUserLeaveHint(Activity activity) {}; } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/ReactContext.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/ReactContext.java index d247714c51eaa5..8711cb133c7556 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/ReactContext.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/ReactContext.java @@ -314,6 +314,17 @@ public void onHostResume(@Nullable Activity activity) { ReactMarker.logMarker(ReactMarkerConstants.ON_HOST_RESUME_END); } + @ThreadConfined(UI) + public void onUserLeaveHint(@Nullable Activity activity) { + for (ActivityEventListener listener : mActivityEventListeners) { + try { + listener.onUserLeaveHint(activity); + } catch (RuntimeException e) { + handleException(e); + } + } + } + @ThreadConfined(UI) public void onNewIntent(@Nullable Activity activity, Intent intent) { UiThreadUtil.assertOnUiThread(); diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/runtime/ReactHostImpl.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/runtime/ReactHostImpl.java index cd929cc3b50d24..332e618ac85899 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/runtime/ReactHostImpl.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/runtime/ReactHostImpl.java @@ -276,8 +276,7 @@ public void onHostResume( @ThreadConfined(UI) @Override public void onHostResume(final @Nullable Activity activity) { - final String method = "onHostResume(activity)"; - log(method); + log("onHostResume(activity)"); setCurrentActivity(activity); ReactContext currentContext = getCurrentReactContext(); @@ -286,11 +285,21 @@ public void onHostResume(final @Nullable Activity activity) { mReactLifecycleStateManager.moveToOnHostResume(currentContext, getCurrentActivity()); } + @ThreadConfined(UI) + @Override + public void onHostLeaveHint(final @Nullable Activity activity) { + log("onUserLeaveHint(activity)"); + + ReactContext currentContext = getCurrentReactContext(); + if (currentContext != null) { + currentContext.onUserLeaveHint(activity); + } + } + @ThreadConfined(UI) @Override public void onHostPause(final @Nullable Activity activity) { - final String method = "onHostPause(activity)"; - log(method); + log("onHostPause(activity)"); ReactContext currentContext = getCurrentReactContext(); @@ -317,8 +326,7 @@ public void onHostPause(final @Nullable Activity activity) { @ThreadConfined(UI) @Override public void onHostPause() { - final String method = "onHostPause()"; - log(method); + log("onHostPause()"); ReactContext currentContext = getCurrentReactContext(); @@ -331,8 +339,7 @@ public void onHostPause() { @ThreadConfined(UI) @Override public void onHostDestroy() { - final String method = "onHostDestroy()"; - log(method); + log("onHostDestroy()"); // TODO(T137233065): Disable DevSupportManager here moveToHostDestroy(getCurrentReactContext()); @@ -341,8 +348,7 @@ public void onHostDestroy() { @ThreadConfined(UI) @Override public void onHostDestroy(@Nullable Activity activity) { - final String method = "onHostDestroy(activity)"; - log(method); + log("onHostDestroy(activity)"); Activity currentActivity = getCurrentActivity(); @@ -475,12 +481,11 @@ public TaskInterface reload(String reason) { */ @Override public TaskInterface destroy(String reason, @Nullable Exception ex) { - final String method = "destroy()"; return Task.call( () -> { if (mReloadTask != null) { log( - method, + "destroy()", "Reloading React Native. Waiting for reload to finish before destroying React Native."); return mReloadTask.continueWithTask( task -> getOrCreateDestroyTask(reason, ex), mBGExecutor); @@ -641,17 +646,15 @@ DefaultHardwareBackBtnHandler getDefaultBackButtonHandler() { */ /* package */ Task callFunctionOnModule( final String moduleName, final String methodName, final NativeArray args) { - final String method = "callFunctionOnModule(\"" + moduleName + "\", \"" + methodName + "\")"; return callWithExistingReactInstance( - method, + "callFunctionOnModule(\"" + moduleName + "\", \"" + methodName + "\")", reactInstance -> { reactInstance.callFunctionOnModule(moduleName, methodName, args); }); } /* package */ void attachSurface(ReactSurfaceImpl surface) { - final String method = "attachSurface(surfaceId = " + surface.getSurfaceID() + ")"; - log(method); + log("attachSurface(surfaceId = " + surface.getSurfaceID() + ")"); synchronized (mAttachedSurfaces) { mAttachedSurfaces.add(surface); @@ -659,8 +662,7 @@ DefaultHardwareBackBtnHandler getDefaultBackButtonHandler() { } /* package */ void detachSurface(ReactSurfaceImpl surface) { - final String method = "detachSurface(surfaceId = " + surface.getSurfaceID() + ")"; - log(method); + log("detachSurface(surfaceId = " + surface.getSurfaceID() + ")"); synchronized (mAttachedSurfaces) { mAttachedSurfaces.remove(surface); @@ -707,9 +709,8 @@ public void removeBeforeDestroyListener(@NonNull Function0 onBeforeDestroy @ThreadConfined("ReactHost") private Task getOrCreateStartTask() { - final String method = "getOrCreateStartTask()"; if (mStartTask == null) { - log(method, "Schedule"); + log("getOrCreateStartTask()", "Schedule"); mStartTask = waitThenCallGetOrCreateReactInstanceTask() .continueWithTask( @@ -756,7 +757,6 @@ private void raiseSoftException(String method, String message, @Nullable Throwab private Task callWithExistingReactInstance( final String callingMethod, final VeniceThenable continuation) { - final String method = "callWithExistingReactInstance(" + callingMethod + ")"; return mReactInstanceTaskRef .get() @@ -764,7 +764,9 @@ private Task callWithExistingReactInstance( task -> { final ReactInstance reactInstance = task.getResult(); if (reactInstance == null) { - raiseSoftException(method, "Execute: ReactInstance null. Dropping work."); + raiseSoftException( + "callWithExistingReactInstance(" + callingMethod + ")", + "Execute: ReactInstance null. Dropping work."); return FALSE; } @@ -776,7 +778,6 @@ private Task callWithExistingReactInstance( private Task callAfterGetOrCreateReactInstance( final String callingMethod, final VeniceThenable runnable) { - final String method = "callAfterGetOrCreateReactInstance(" + callingMethod + ")"; return getOrCreateReactInstance() .onSuccess( @@ -784,7 +785,9 @@ private Task callAfterGetOrCreateReactInstance( task -> { final ReactInstance reactInstance = task.getResult(); if (reactInstance == null) { - raiseSoftException(method, "Execute: ReactInstance is null"); + raiseSoftException( + "callAfterGetOrCreateReactInstance(" + callingMethod + ")", + "Execute: ReactInstance is null"); return null; } @@ -803,10 +806,9 @@ private Task callAfterGetOrCreateReactInstance( } private BridgelessReactContext getOrCreateReactContext() { - final String method = "getOrCreateReactContext()"; return mBridgelessReactContextRef.getOrCreate( () -> { - log(method, "Creating BridgelessReactContext"); + log("getOrCreateReactContext()", "Creating BridgelessReactContext"); return new BridgelessReactContext(mContext, ReactHostImpl.this); }); } @@ -929,7 +931,7 @@ class Result { final boolean isManagerResumed = mReactLifecycleStateManager.getLifecycleState() == LifecycleState.RESUMED; - /** + /* * ReactContext.onHostResume() should only be called when the user navigates to * the first React Native screen. * @@ -953,7 +955,7 @@ class Result { mReactLifecycleStateManager.moveToOnHostResume( reactContext, getCurrentActivity()); } else { - /** + /* * Call ReactContext.onHostResume() only when already in the resumed state * which aligns with the bridge https://fburl.com/diffusion/2qhxmudv. */ @@ -979,8 +981,7 @@ class Result { } private Task getJsBundleLoader() { - final String method = "getJSBundleLoader()"; - log(method); + log("getJSBundleLoader()"); if (DEV && mAllowPackagerServerAccess) { return isMetroRunning() @@ -1012,7 +1013,7 @@ private Task getJsBundleLoader() { private Task isMetroRunning() { final String method = "isMetroRunning()"; - log(method); + log("isMetroRunning()"); final TaskCompletionSource taskCompletionSource = new TaskCompletionSource<>(); final DevSupportManager asyncDevSupportManager = getDevSupportManager();