// Copyright 2015 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. package org.chromium.chrome.browser.webapps; import android.content.Context; import android.content.SharedPreferences; import android.os.SystemClock; import android.support.annotation.IntDef; import android.util.Log; import org.chromium.base.ContextUtils; import org.chromium.base.ThreadUtils; import org.chromium.base.VisibleForTesting; import org.chromium.base.metrics.RecordHistogram; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Set; import java.util.concurrent.TimeUnit; /** * Before Lollipop, the only way to create multiple retargetable instances of the same Activity * was to explicitly define them in the Manifest. Given that the user can potentially have an * unlimited number of shortcuts for launching these Activities, we have to actively assign * shortcuts when they launch. Activities are reused in order of when they were last used, with * the least recently used ones reassigned first. * * In order to accommodate a limited number of WebappActivities with a potentially unlimited number * of webapps, we have to rotate the available WebappActivities between the webapps we start up. * Activities are reused in order of when they were last used, with the least recently used * ones culled first. * * It is impossible to know whether Tasks have been removed from the Recent Task list without the * GET_TASKS permission. As a result, the list of Activities inside the Recent Task list will * be highly unlikely to match the list maintained in memory. Instead, we store the mapping as it * was the last time we changed it, which allows us to launch webapps in the WebappActivity they * were most recently associated with in cases where a user restarts a webapp from the Recent Tasks. * Note that in situations where the user manually clears the app data, we will again have an * incorrect mapping. * * Unless otherwise noted, all methods MUST be called on the UI thread to avoid threading issues. * * EXAMPLE: * - 3 Activities are available for assignment (0, 1, 2). * - 4 webapps exist (X, Y, Z, W). * * ACTION EFFECT ACTIVITY LIST * 0) Clean slate (0 -) (1 -) (2 -) * 1) Start X Assigned to Activity 0 and pushed back. (1 -) (2 -) (0 X) * 2) Start Y Assigned to Activity 1 and pushed back. (2 -) (0 X) (1 Y) * 3) Start Z Assigned to Activity 2 and pushed back. (0 X) (1 Y) (2 Z) * 4) Restart Y Re-assigned to Activity 1 and pushed back. (0 X) (2 Z) (1 Y) * 4) Start W Assigned to Activity 0 and pushed back. X evicted. (2 Z) (1 Y) (0 W) * 5) Restart X Assigned to Activity 2 and pushed back. Z evicted. (1 Y) (0 W) (2 X) */ public class ActivityAssigner { private static final String TAG = "ActivityAssigner"; // Don't ever change this. 10 is enough for everyone. static final int NUM_WEBAPP_ACTIVITIES = 10; // A sanity check limit to ensure that we aren't reading an unreasonable number of preferences. // This number is different from above because the number of WebappActivities available may // change. static final int MAX_WEBAPP_ACTIVITIES_EVER = 100; // Don't ever change the package. Left for backwards compatibility. @VisibleForTesting static final String PREF_PACKAGE[] = { "com.google.android.apps.chrome.webapps", "com.google.android.apps.chrome.webapps.webapk", "com.google.android.apps.chrome.cct" }; static final String PREF_NUM_SAVED_ENTRIES[] = { "ActivityAssigner.numSavedEntries", "ActivityAssigner.numSavedEntries.webapk", "ActivityAssigner.numSavedEntries.cct" }; static final String PREF_ACTIVITY_INDEX[] = { "ActivityAssigner.activityIndex", "ActivityAssigner.activityIndex.webapk", "ActivityAssigner.activityIndex.cct" }; static final String PREF_ACTIVITY_ID[] = { "ActivityAssigner.webappId", "ActivityAssigner.webappId.webapk", "ActivityAssigner.cctId" }; static final int INVALID_ACTIVITY_INDEX = -1; @Retention(RetentionPolicy.SOURCE) @IntDef({ WEBAPP_NAMESPACE, WEBAPK_NAMESPACE, SEPARATE_TASK_CCT_NAMESPACE }) private @interface ActivityAssignerNamespace {} public static final int WEBAPP_NAMESPACE = 0; public static final int WEBAPK_NAMESPACE = 1; public static final int SEPARATE_TASK_CCT_NAMESPACE = 2; static final int NAMESPACE_COUNT = 3; private static final Object LOCK = new Object(); private static List<ActivityAssigner> sInstances; private final Context mContext; private final List<ActivityEntry> mActivityList; private final String mPrefPackage; private final String mPrefNumSavedEntries; private final String mPrefActivityIndex; private final String mPrefActivityId; /** * Pre-load shared prefs to avoid being blocked on the * disk access async task in the future. */ public static void warmUpSharedPrefs(Context context) { for (int i = 0; i < NAMESPACE_COUNT; ++i) { context.getSharedPreferences(PREF_PACKAGE[i], Context.MODE_PRIVATE); } } static class ActivityEntry { final int mActivityIndex; final String mWebappId; ActivityEntry(int activity, String webapp) { mActivityIndex = activity; mWebappId = webapp; } } /** * Returns the singleton instance, creating it if necessary. * @param namespace The namespace of the Activities being assigned. */ public static ActivityAssigner instance(@ActivityAssignerNamespace int namespace) { ThreadUtils.assertOnUiThread(); synchronized (LOCK) { if (sInstances == null) { sInstances = new ArrayList<ActivityAssigner>(NAMESPACE_COUNT); for (int i = 0; i < NAMESPACE_COUNT; ++i) { sInstances.add(new ActivityAssigner(i)); } } } return sInstances.get(namespace); } private ActivityAssigner(int activityTypeIndex) { mContext = ContextUtils.getApplicationContext(); mPrefPackage = PREF_PACKAGE[activityTypeIndex]; mPrefNumSavedEntries = PREF_NUM_SAVED_ENTRIES[activityTypeIndex]; mPrefActivityIndex = PREF_ACTIVITY_INDEX[activityTypeIndex]; mPrefActivityId = PREF_ACTIVITY_ID[activityTypeIndex]; mActivityList = new ArrayList<ActivityEntry>(); restoreActivityList(); } @VisibleForTesting String getPreferenceNumberOfSavedEntries() { return mPrefNumSavedEntries; } /** * Assigns the app with the given ID to one of the available Activity instances. * If we know that the app was previously launched in one of the Activities, re-use it. * Otherwise, take the least recently used ID and use that. * @param webappId ID of the webapp. * @return Index of the Activity to use for the webapp. */ public int assign(String webappId) { // Reuse a running Activity with the same ID, if it exists. int activityIndex = checkIfAssigned(webappId); // Allocate the one in the front of the list. if (activityIndex == INVALID_ACTIVITY_INDEX) { activityIndex = mActivityList.get(0).mActivityIndex; ActivityEntry newEntry = new ActivityEntry(activityIndex, webappId); mActivityList.set(0, newEntry); } markActivityUsed(activityIndex, webappId); return activityIndex; } /** * Checks if the webapp with the given ID has been assigned to an Activity already. * @param webappId ID of the webapp being displayed. * @return Index of the Activity for the webapp if assigned, INVALID_ACTIVITY_INDEX otherwise. */ int checkIfAssigned(String webappId) { if (webappId == null) { return INVALID_ACTIVITY_INDEX; } // Go backwards in the queue to catch more recent instances of any duplicated webapps. for (int i = mActivityList.size() - 1; i >= 0; i--) { if (webappId.equals(mActivityList.get(i).mWebappId)) { return mActivityList.get(i).mActivityIndex; } } return INVALID_ACTIVITY_INDEX; } /** * Moves an Activity to the back of the queue, indicating that the app is still in use and * shouldn't be killed. * @param activityIndex Index of the Activity in the LRU buffer. * @param webappId The ID of the app being shown in the Activity. */ public void markActivityUsed(int activityIndex, String webappId) { // Find the entry corresponding to the Activity. int elementIndex = findActivityElement(activityIndex); if (elementIndex == -1) { Log.e(TAG, "Failed to find WebappActivity entry: " + activityIndex + ", " + webappId); return; } // We have to reassign the app ID in case Activities get repurposed. ActivityEntry updatedEntry = new ActivityEntry(activityIndex, webappId); mActivityList.remove(elementIndex); mActivityList.add(updatedEntry); storeActivityList(); } /** * Finds the index of the ActivityElement corresponding to the given activityIndex. * @param activityIndex Index of the activity to find. * @return The index of the ActivityElement in the activity list, or -1 if it couldn't be found. */ private int findActivityElement(int activityIndex) { for (int elementIndex = 0; elementIndex < mActivityList.size(); elementIndex++) { if (mActivityList.get(elementIndex).mActivityIndex == activityIndex) { return elementIndex; } } return -1; } /** * Returns the current mapping between Activities and apps. */ @VisibleForTesting List<ActivityEntry> getEntries() { return mActivityList; } /** * Restores/creates the mapping between apps and activities. * The logic is slightly complicated to future-proof against situations where the number of * Activity is changed. */ private void restoreActivityList() { boolean isMapDirty = false; mActivityList.clear(); // Create a Set of indices corresponding to every possible Activity. // As ActivityEntries are read, they are and removed from this list to indicate that the // Activity has already been assigned. Set<Integer> availableWebapps = new HashSet<Integer>(); for (int i = 0; i < NUM_WEBAPP_ACTIVITIES; ++i) { availableWebapps.add(i); } // Restore any entries that were previously saved. If it seems that the preferences have // been corrupted somehow, just discard the whole map. SharedPreferences prefs = mContext.getSharedPreferences(mPrefPackage, Context.MODE_PRIVATE); try { long time = SystemClock.elapsedRealtime(); final int numSavedEntries = prefs.getInt(mPrefNumSavedEntries, 0); try { RecordHistogram.recordTimesHistogram("Android.StrictMode.WebappSharedPrefs", SystemClock.elapsedRealtime() - time, TimeUnit.MILLISECONDS); } catch (UnsatisfiedLinkError error) { // Intentionally ignored - it's ok to miss recording the metric occasionally. } if (numSavedEntries <= NUM_WEBAPP_ACTIVITIES) { for (int i = 0; i < numSavedEntries; ++i) { String currentActivityIndexPref = mPrefActivityIndex + i; String currentWebappIdPref = mPrefActivityId + i; int activityIndex = prefs.getInt(currentActivityIndexPref, i); String webappId = prefs.getString(currentWebappIdPref, null); ActivityEntry entry = new ActivityEntry(activityIndex, webappId); if (availableWebapps.remove(entry.mActivityIndex)) { mActivityList.add(entry); } else { // If the same activity was assigned to two different entries, or if the // number of activities changed, discard it and mark that it needs to be // rewritten. isMapDirty = true; } } } } catch (ClassCastException exception) { // Something went wrong reading the preferences. Nuke everything. mActivityList.clear(); availableWebapps.clear(); for (int i = 0; i < NUM_WEBAPP_ACTIVITIES; ++i) { availableWebapps.add(i); } } // Add entries for any missing Activities. for (Integer availableIndex : availableWebapps) { ActivityEntry entry = new ActivityEntry(availableIndex, null); mActivityList.add(entry); isMapDirty = true; } if (isMapDirty) { storeActivityList(); } } /** * Saves the mapping between apps and Activities. */ private void storeActivityList() { SharedPreferences prefs = mContext.getSharedPreferences(mPrefPackage, Context.MODE_PRIVATE); SharedPreferences.Editor editor = prefs.edit(); editor.clear(); editor.putInt(mPrefNumSavedEntries, mActivityList.size()); for (int i = 0; i < mActivityList.size(); ++i) { String currentActivityIndexPref = mPrefActivityIndex + i; String currentWebappIdPref = mPrefActivityId + i; editor.putInt(currentActivityIndexPref, mActivityList.get(i).mActivityIndex); editor.putString(currentWebappIdPref, mActivityList.get(i).mWebappId); } editor.apply(); } }