// Copyright 2014 The Chromium Authors // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. package org.chromium.base; import android.annotation.SuppressLint; import android.app.Activity; import android.app.Application; import android.app.Application.ActivityLifecycleCallbacks; import android.content.SharedPreferences; import android.os.Bundle; import android.view.Window; import androidx.annotation.AnyThread; import androidx.annotation.MainThread; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; import org.jni_zero.CalledByNative; import org.jni_zero.JNINamespace; import org.jni_zero.NativeMethods; import org.chromium.build.BuildConfig; import java.lang.reflect.Field; import java.lang.reflect.InvocationHandler; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.lang.reflect.Proxy; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import javax.annotation.concurrent.GuardedBy; /** * Provides information about the current activity's status, and a way * to register / unregister listeners for state changes. * TODO(https://crbug.com/470582): ApplicationStatus will not work on WebView/WebLayer, and * should be moved out of base and into //chrome. It should not be relied upon for //components. */ @JNINamespace("base::android") public class ApplicationStatus { private static final String TOOLBAR_CALLBACK_WRAPPER_CLASS = "androidx.appcompat.app.ToolbarActionBar$ToolbarCallbackWrapper"; private static class ActivityInfo { private int mStatus = ActivityState.DESTROYED; private ObserverList mListeners = new ObserverList<>(); /** * @return The current {@link ActivityState} of the activity. */ @ActivityState public int getStatus() { return mStatus; } /** * @param status The new {@link ActivityState} of the activity. */ public void setStatus(@ActivityState int status) { mStatus = status; } /** * @return A list of {@link ActivityStateListener}s listening to this activity. */ public ObserverList getListeners() { return mListeners; } } /** A map of which observers listen to state changes from which {@link Activity}. */ private static final Map sActivityInfo = Collections.synchronizedMap(new HashMap()); /** A map to cache TaskId for each {@link Activity}. */ public static final Map sActivityTaskId = Collections.synchronizedMap(new HashMap()); // Shared preferences key for TaskId caching of an activity. private static final String CACHE_ACTIVITY_TASKID_KEY = "cache_activity_taskid_enabled"; @SuppressLint("SupportAnnotationUsage") @ApplicationState @GuardedBy("sActivityInfo") // The getStateForApplication() historically returned ApplicationState.HAS_DESTROYED_ACTIVITIES // when no activity has been observed. private static int sCurrentApplicationState = ApplicationState.UNKNOWN; /** Last activity that was shown (or null if none or it was destroyed). */ @SuppressLint("StaticFieldLeak") private static Activity sActivity; /** A lazily initialized listener that forwards application state changes to native. */ private static ApplicationStateListener sNativeApplicationStateListener; /** A list of observers to be notified when any {@link Activity} has a state change. */ private static ObserverList sGeneralActivityStateListeners; /** * A list of observers to be notified when the visibility state of this {@link Application} * changes. See {@link #getStateForApplication()}. */ private static ObserverList sApplicationStateListeners; /** * A list of observers to be notified when the window focus changes. * See {@link #registerWindowFocusChangedListener}. */ private static ObserverList sWindowFocusListeners; /** A list of observers to be notified when the visibility of any task changes. */ private static ObserverList sTaskVisibilityListeners; /** Interface to be implemented by listeners. */ public interface ApplicationStateListener { /** * Called when the application's state changes. * * @param newState The application state. */ void onApplicationStateChange(@ApplicationState int newState); } /** Interface to be implemented by listeners. */ public interface ActivityStateListener { /** * Called when the activity's state changes. * * @param activity The activity that had a state change. * @param newState New activity state. */ void onActivityStateChange(Activity activity, @ActivityState int newState); } /** Interface to be implemented by listeners for window focus events. */ public interface WindowFocusChangedListener { /** * Called when the window focus changes for {@code activity}. * * @param activity The {@link Activity} that has a window focus changed event. * @param hasFocus Whether or not {@code activity} gained or lost focus. */ public void onWindowFocusChanged(Activity activity, boolean hasFocus); } /** Interface to be implemented by listeners for task visibility changes. */ public interface TaskVisibilityListener { /** * Called when the visibility of a task changes. * * @param taskId The unique Id of the task that changed visibility. * @param isVisible The new visibility state of the task. */ void onTaskVisibilityChanged(int taskId, boolean isVisible); } private ApplicationStatus() {} /** * Registers a listener to receive window focus updates on activities in this application. * * @param listener Listener to receive window focus events. */ @MainThread public static void registerWindowFocusChangedListener(WindowFocusChangedListener listener) { assert isInitialized(); if (sWindowFocusListeners == null) sWindowFocusListeners = new ObserverList<>(); sWindowFocusListeners.addObserver(listener); } /** * Unregisters a listener from receiving window focus updates on activities in this application. * * @param listener Listener that doesn't want to receive window focus events. */ @MainThread public static void unregisterWindowFocusChangedListener(WindowFocusChangedListener listener) { if (sWindowFocusListeners == null) return; sWindowFocusListeners.removeObserver(listener); } /** * Register a listener to receive task visibility updates. * * @param listener Listener to receive task visibility events. */ @MainThread public static void registerTaskVisibilityListener(TaskVisibilityListener listener) { assert isInitialized(); if (sTaskVisibilityListeners == null) sTaskVisibilityListeners = new ObserverList<>(); sTaskVisibilityListeners.addObserver(listener); } /** * Unregisters a listener from receiving task visibility updates. * * @param listener Listener that doesn't want to receive task visibility events. */ @MainThread public static void unregisterTaskVisibilityListener(TaskVisibilityListener listener) { if (sTaskVisibilityListeners == null) return; sTaskVisibilityListeners.removeObserver(listener); } public static void setCachingEnabled(boolean enabled) { SharedPreferences.Editor editor = ContextUtils.getAppSharedPreferences().edit(); editor.putBoolean(CACHE_ACTIVITY_TASKID_KEY, enabled).apply(); } public static boolean isCachingEnabled() { try (StrictModeContext ignored = StrictModeContext.allowDiskReads()) { return ContextUtils.getAppSharedPreferences() .getBoolean(CACHE_ACTIVITY_TASKID_KEY, false); } } public static int getTaskId(Activity activity) { if (!isCachingEnabled()) return activity.getTaskId(); if (!sActivityTaskId.containsKey(activity)) { synchronized (sActivityTaskId) { sActivityTaskId.put(activity, activity.getTaskId()); } } return sActivityTaskId.get(activity); } /** * Intercepts calls to an existing Window.Callback. Most invocations are passed on directly * to the composed Window.Callback but enables intercepting/manipulating others. *

* This is used to relay window focus changes throughout the app and remedy a bug in the * appcompat library. */ @VisibleForTesting static class WindowCallbackProxy implements InvocationHandler { private final Window.Callback mCallback; private final Activity mActivity; public WindowCallbackProxy(Activity activity, Window.Callback callback) { mCallback = callback; mActivity = activity; } @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { if (method.getName().equals("onWindowFocusChanged") && args.length == 1 && args[0] instanceof Boolean) { onWindowFocusChanged((boolean) args[0]); return null; } else { try { return method.invoke(mCallback, args); } catch (InvocationTargetException e) { // Special-case for when a method is not defined on the underlying // Window.Callback object. Because we're using a Proxy to forward all method // calls, this breaks the Android framework's handling for apps built against // an older SDK. The framework expects an AbstractMethodError but due to // reflection it becomes wrapped inside an InvocationTargetException. Undo the // wrapping to signal the framework accordingly. if (e.getCause() instanceof AbstractMethodError) { throw e.getCause(); } throw e; } } } public void onWindowFocusChanged(boolean hasFocus) { mCallback.onWindowFocusChanged(hasFocus); if (sWindowFocusListeners != null) { for (WindowFocusChangedListener listener : sWindowFocusListeners) { listener.onWindowFocusChanged(mActivity, hasFocus); } } } } public static boolean isInitialized() { synchronized (sActivityInfo) { return sCurrentApplicationState != ApplicationState.UNKNOWN; } } /** * Initializes the activity status for a specified application. * * @param application The application whose status you wish to monitor. */ @MainThread public static void initialize(Application application) { assert !isInitialized(); synchronized (sActivityInfo) { sCurrentApplicationState = ApplicationState.HAS_DESTROYED_ACTIVITIES; } registerWindowFocusChangedListener( new WindowFocusChangedListener() { @Override public void onWindowFocusChanged(Activity activity, boolean hasFocus) { if (!hasFocus || activity == sActivity) return; int state = getStateForActivity(activity); if (state != ActivityState.DESTROYED && state != ActivityState.STOPPED) { sActivity = activity; } // TODO(dtrainor): Notify of active activity change? } }); application.registerActivityLifecycleCallbacks( new ActivityLifecycleCallbacks() { @Override public void onActivityCreated( final Activity activity, Bundle savedInstanceState) { onStateChange(activity, ActivityState.CREATED); Window.Callback callback = activity.getWindow().getCallback(); activity.getWindow() .setCallback(createWindowCallbackProxy(activity, callback)); } @Override public void onActivityDestroyed(Activity activity) { onStateChange(activity, ActivityState.DESTROYED); checkCallback(activity); } @Override public void onActivityPaused(Activity activity) { onStateChange(activity, ActivityState.PAUSED); checkCallback(activity); } @Override public void onActivityResumed(Activity activity) { onStateChange(activity, ActivityState.RESUMED); checkCallback(activity); } @Override public void onActivitySaveInstanceState(Activity activity, Bundle outState) { checkCallback(activity); } @Override public void onActivityStarted(Activity activity) { onStateChange(activity, ActivityState.STARTED); checkCallback(activity); } @Override public void onActivityStopped(Activity activity) { onStateChange(activity, ActivityState.STOPPED); checkCallback(activity); } private void checkCallback(Activity activity) { if (BuildConfig.ENABLE_ASSERTS) { assert reachesWindowCallback(activity.getWindow().getCallback()); } } }); } @VisibleForTesting static Window.Callback createWindowCallbackProxy(Activity activity, Window.Callback callback) { return (Window.Callback) Proxy.newProxyInstance( Window.Callback.class.getClassLoader(), new Class[] {Window.Callback.class}, new ApplicationStatus.WindowCallbackProxy(activity, callback)); } /** * Tries to trace down to our WindowCallbackProxy from the given callback. * Since the callback can be overwritten by embedder code we try to ensure * that there at least seem to be a reference back to our callback by * checking the declared fields of the given callback using reflection. */ @VisibleForTesting static boolean reachesWindowCallback(@Nullable Window.Callback callback) { if (callback == null) return false; if (callback.getClass().getName().equals(TOOLBAR_CALLBACK_WRAPPER_CLASS)) { // We're actually not going to get called, see AndroidX report here: // https://issuetracker.google.com/issues/155165145. // But this was accepted in the old code as well so mimic that until // AndroidX is fixed and updated. return true; } if (Proxy.isProxyClass(callback.getClass())) { return Proxy.getInvocationHandler(callback) instanceof ApplicationStatus.WindowCallbackProxy; } for (Class c = callback.getClass(); c != Object.class; c = c.getSuperclass()) { for (Field f : c.getDeclaredFields()) { if (f.getType().isAssignableFrom(Window.Callback.class)) { boolean isAccessible = f.isAccessible(); f.setAccessible(true); Window.Callback fieldCb; try { fieldCb = (Window.Callback) f.get(callback); } catch (IllegalAccessException ex) { continue; } finally { f.setAccessible(isAccessible); } if (reachesWindowCallback(fieldCb)) { return true; } } } } return false; } /** * Must be called by the main activity when it changes state. * * @param activity Current activity. * @param newState New state value. */ private static void onStateChange(Activity activity, @ActivityState int newState) { assert activity != null; if (sActivity == null || newState == ActivityState.CREATED || newState == ActivityState.RESUMED || newState == ActivityState.STARTED) { sActivity = activity; } int oldApplicationState = getStateForApplication(); boolean oldTaskVisibility = isTaskVisible(getTaskId(activity)); ActivityInfo info; synchronized (sActivityInfo) { if (newState == ActivityState.CREATED) { assert !sActivityInfo.containsKey(activity); sActivityInfo.put(activity, new ActivityInfo()); } info = sActivityInfo.get(activity); info.setStatus(newState); // Remove before calling listeners so that isEveryActivityDestroyed() returns false when // this was the last activity. if (newState == ActivityState.DESTROYED) { sActivityInfo.remove(activity); if (activity == sActivity) sActivity = null; } sCurrentApplicationState = determineApplicationStateLocked(); } // Notify all state observers that are specifically listening to this activity. for (ActivityStateListener listener : info.getListeners()) { listener.onActivityStateChange(activity, newState); } // Notify all state observers that are listening globally for all activity state // changes. if (sGeneralActivityStateListeners != null) { for (ActivityStateListener listener : sGeneralActivityStateListeners) { listener.onActivityStateChange(activity, newState); } } boolean taskVisibility = isTaskVisible(getTaskId(activity)); if (taskVisibility != oldTaskVisibility && sTaskVisibilityListeners != null) { for (TaskVisibilityListener listener : sTaskVisibilityListeners) { listener.onTaskVisibilityChanged(getTaskId(activity), taskVisibility); } } int applicationState = getStateForApplication(); if (applicationState != oldApplicationState && sApplicationStateListeners != null) { for (ApplicationStateListener listener : sApplicationStateListeners) { listener.onApplicationStateChange(applicationState); } } synchronized (sActivityTaskId) { if (newState == ActivityState.DESTROYED) { sActivityTaskId.remove(activity); } } } /** Testing method to update the state of the specified activity. */ @VisibleForTesting @MainThread public static void onStateChangeForTesting(Activity activity, int newState) { onStateChange(activity, newState); } /** * @return The most recent focused {@link Activity} tracked by this class. Being focused means * out of all the activities tracked here, it has most recently gained window focus. */ @MainThread public static Activity getLastTrackedFocusedActivity() { return sActivity; } /** * @return A {@link List} of all non-destroyed {@link Activity}s. */ @AnyThread public static List getRunningActivities() { assert isInitialized(); synchronized (sActivityInfo) { return new ArrayList<>(sActivityInfo.keySet()); } } /** * Query the state for a given activity. If the activity is not being tracked, this will * return {@link ActivityState#DESTROYED}. * *

* Please note that Chrome can have multiple activities running simultaneously. Please also * look at {@link #getStateForApplication()} for more details. * *

* When relying on this method, be familiar with the expected life cycle state * transitions: * * Activity Lifecycle * * *

* During activity transitions (activity B launching in front of activity A), A will completely * paused before the creation of activity B begins. * *

* A basic flow for activity A starting, followed by activity B being opened and then closed: *

    *
  • -- Starting Activity A -- *
  • Activity A - ActivityState.CREATED *
  • Activity A - ActivityState.STARTED *
  • Activity A - ActivityState.RESUMED *
  • -- Starting Activity B -- *
  • Activity A - ActivityState.PAUSED *
  • Activity B - ActivityState.CREATED *
  • Activity B - ActivityState.STARTED *
  • Activity B - ActivityState.RESUMED *
  • Activity A - ActivityState.STOPPED *
  • -- Closing Activity B, Activity A regaining focus -- *
  • Activity B - ActivityState.PAUSED *
  • Activity A - ActivityState.STARTED *
  • Activity A - ActivityState.RESUMED *
  • Activity B - ActivityState.STOPPED *
  • Activity B - ActivityState.DESTROYED *
* * @param activity The activity whose state is to be returned. * @return The state of the specified activity (see {@link ActivityState}). */ @ActivityState @AnyThread public static int getStateForActivity(@Nullable Activity activity) { assert isInitialized(); if (activity == null) return ActivityState.DESTROYED; ActivityInfo info = sActivityInfo.get(activity); return info != null ? info.getStatus() : ActivityState.DESTROYED; } /** * @return The state of the application (see {@link ApplicationState}). */ @AnyThread @ApplicationState @CalledByNative public static int getStateForApplication() { synchronized (sActivityInfo) { return sCurrentApplicationState; } } /** * Checks whether or not any Activity in this Application is visible to the user. Note that * this includes the PAUSED state, which can happen when the Activity is temporarily covered * by another Activity's Fragment (e.g.). * * @return Whether any Activity under this Application is visible. */ @AnyThread @CalledByNative public static boolean hasVisibleActivities() { assert isInitialized(); int state = getStateForApplication(); return state == ApplicationState.HAS_RUNNING_ACTIVITIES || state == ApplicationState.HAS_PAUSED_ACTIVITIES; } /** * Checks to see if there are any active Activity instances being watched by ApplicationStatus. * * @return True if all Activities have been destroyed. */ @AnyThread public static boolean isEveryActivityDestroyed() { assert isInitialized(); return sActivityInfo.isEmpty(); } /** * Returns the visibility of the task with the given taskId. A task is visible if any of its * Activities are in RESUMED or PAUSED state. * * @param taskId The id of the task whose visibility needs to be checked. * @return Whether the task is visible or not. */ @AnyThread public static boolean isTaskVisible(int taskId) { assert isInitialized(); for (Map.Entry entry : sActivityInfo.entrySet()) { if (getTaskId(entry.getKey()) == taskId) { @ActivityState int state = entry.getValue().getStatus(); if (state == ActivityState.RESUMED || state == ActivityState.PAUSED) { return true; } } } return false; } /** * Registers the given listener to receive state changes for all activities. * * @param listener Listener to receive state changes. */ @MainThread public static void registerStateListenerForAllActivities(ActivityStateListener listener) { assert isInitialized(); if (sGeneralActivityStateListeners == null) { sGeneralActivityStateListeners = new ObserverList<>(); } sGeneralActivityStateListeners.addObserver(listener); } /** * Registers the given listener to receive state changes for {@code activity}. After a call to * {@link ActivityStateListener#onActivityStateChange(Activity, int)} with * {@link ActivityState#DESTROYED} all listeners associated with that particular * {@link Activity} are removed. * * @param listener Listener to receive state changes. * @param activity Activity to track or {@code null} to track all activities. */ @MainThread @SuppressLint("NewApi") public static void registerStateListenerForActivity( ActivityStateListener listener, Activity activity) { assert isInitialized(); assert activity != null; ActivityInfo info = sActivityInfo.get(activity); assert info != null : "destroyed: " + activity.isDestroyed() + " finishing: " + activity.isFinishing(); assert info.getStatus() != ActivityState.DESTROYED; info.getListeners().addObserver(listener); } /** * Unregisters the given listener from receiving activity state changes. * * @param listener Listener that doesn't want to receive state changes. */ @MainThread public static void unregisterActivityStateListener(ActivityStateListener listener) { if (sGeneralActivityStateListeners != null) { sGeneralActivityStateListeners.removeObserver(listener); } // Loop through all observer lists for all activities and remove the listener. synchronized (sActivityInfo) { for (ActivityInfo info : sActivityInfo.values()) { info.getListeners().removeObserver(listener); } } } /** * Registers the given listener to receive state changes for the application. * * @param listener Listener to receive state state changes. */ @MainThread public static void registerApplicationStateListener(ApplicationStateListener listener) { if (sApplicationStateListeners == null) { sApplicationStateListeners = new ObserverList<>(); } sApplicationStateListeners.addObserver(listener); } /** * Unregisters the given listener from receiving state changes. * * @param listener Listener that doesn't want to receive state changes. */ @MainThread public static void unregisterApplicationStateListener(ApplicationStateListener listener) { if (sApplicationStateListeners == null) return; sApplicationStateListeners.removeObserver(listener); } /** * Robolectric JUnit tests create a new application between each test, while all the context * in static classes isn't reset. This function allows to reset the application status to avoid * being in a dirty state. */ @MainThread public static void destroyForJUnitTests() { synchronized (sActivityInfo) { if (sApplicationStateListeners != null) sApplicationStateListeners.clear(); if (sGeneralActivityStateListeners != null) sGeneralActivityStateListeners.clear(); if (sTaskVisibilityListeners != null) sTaskVisibilityListeners.clear(); sActivityInfo.clear(); if (sWindowFocusListeners != null) sWindowFocusListeners.clear(); sCurrentApplicationState = ApplicationState.UNKNOWN; sActivity = null; sNativeApplicationStateListener = null; } } /** Mark all Activities as destroyed to avoid side-effects in future test. */ @MainThread public static void resetActivitiesForInstrumentationTests() { assert ThreadUtils.runningOnUiThread(); synchronized (sActivityInfo) { // Copy the set to avoid concurrent modifications to the underlying set. for (Activity activity : new HashSet<>(sActivityInfo.keySet())) { assert activity.getApplication() == null : "Real activities that are launched should be closed by test code " + "and not rely on this cleanup of mocks."; onStateChangeForTesting(activity, ActivityState.DESTROYED); } } } /** * Registers the single thread-safe native activity status listener. * This handles the case where the caller is not on the main thread. * Note that this is used by a leaky singleton object from the native * side, hence lifecycle management is greatly simplified. */ @CalledByNative private static void registerThreadSafeNativeApplicationStateListener() { ThreadUtils.runOnUiThread( new Runnable() { @Override public void run() { if (sNativeApplicationStateListener != null) return; sNativeApplicationStateListener = new ApplicationStateListener() { @Override public void onApplicationStateChange(int newState) { ApplicationStatusJni.get() .onApplicationStateChange(newState); } }; registerApplicationStateListener(sNativeApplicationStateListener); } }); } /** * Determines the current application state as defined by {@link ApplicationState}. This will * loop over all the activities and check their state to determine what the general application * state should be. * * @return HAS_RUNNING_ACTIVITIES if any activity is not paused, stopped, or destroyed. * HAS_PAUSED_ACTIVITIES if none are running and one is paused. * HAS_STOPPED_ACTIVITIES if none are running/paused and one is stopped. * HAS_DESTROYED_ACTIVITIES if none are running/paused/stopped. */ @ApplicationState @GuardedBy("sActivityInfo") private static int determineApplicationStateLocked() { boolean hasPausedActivity = false; boolean hasStoppedActivity = false; for (ActivityInfo info : sActivityInfo.values()) { int state = info.getStatus(); if (state != ActivityState.PAUSED && state != ActivityState.STOPPED && state != ActivityState.DESTROYED) { return ApplicationState.HAS_RUNNING_ACTIVITIES; } else if (state == ActivityState.PAUSED) { hasPausedActivity = true; } else if (state == ActivityState.STOPPED) { hasStoppedActivity = true; } } if (hasPausedActivity) return ApplicationState.HAS_PAUSED_ACTIVITIES; if (hasStoppedActivity) return ApplicationState.HAS_STOPPED_ACTIVITIES; return ApplicationState.HAS_DESTROYED_ACTIVITIES; } @NativeMethods interface Natives { // Called to notify the native side of state changes. // IMPORTANT: This is always called on the main thread! void onApplicationStateChange(@ApplicationState int newState); } }