| /* |
| * Copyright (C) 2018 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| package com.android.launcher3.model; |
| |
| import static android.content.ContentResolver.SCHEME_CONTENT; |
| |
| import static com.android.launcher3.Utilities.newContentObserver; |
| |
| import android.annotation.TargetApi; |
| import android.app.RemoteAction; |
| import android.content.ContentProviderClient; |
| import android.content.ContentResolver; |
| import android.content.Context; |
| import android.content.Intent; |
| import android.content.IntentFilter; |
| import android.content.pm.LauncherApps; |
| import android.database.ContentObserver; |
| import android.net.Uri; |
| import android.os.Build; |
| import android.os.Bundle; |
| import android.os.DeadObjectException; |
| import android.os.Handler; |
| import android.os.Looper; |
| import android.os.Process; |
| import android.os.UserHandle; |
| import android.text.TextUtils; |
| import android.util.ArrayMap; |
| import android.util.Log; |
| |
| import androidx.annotation.MainThread; |
| import androidx.annotation.Nullable; |
| import androidx.annotation.WorkerThread; |
| |
| import com.android.launcher3.BaseDraggingActivity; |
| import com.android.launcher3.InvariantDeviceProfile; |
| import com.android.launcher3.LauncherProvider; |
| import com.android.launcher3.LauncherSettings; |
| import com.android.launcher3.R; |
| import com.android.launcher3.config.FeatureFlags; |
| import com.android.launcher3.model.data.ItemInfo; |
| import com.android.launcher3.popup.RemoteActionShortcut; |
| import com.android.launcher3.popup.SystemShortcut; |
| import com.android.launcher3.util.BgObjectWithLooper; |
| import com.android.launcher3.util.MainThreadInitializedObject; |
| import com.android.launcher3.util.PackageManagerHelper; |
| import com.android.launcher3.util.Preconditions; |
| import com.android.launcher3.util.SimpleBroadcastReceiver; |
| |
| import java.util.Arrays; |
| import java.util.HashMap; |
| import java.util.Map; |
| |
| /** |
| * Data model for digital wellbeing status of apps. |
| */ |
| @TargetApi(Build.VERSION_CODES.Q) |
| public final class WellbeingModel extends BgObjectWithLooper { |
| private static final String TAG = "WellbeingModel"; |
| private static final int[] RETRY_TIMES_MS = {5000, 15000, 30000}; |
| private static final boolean DEBUG = false; |
| |
| private static final int UNKNOWN_MINIMAL_DEVICE_STATE = 0; |
| private static final int IN_MINIMAL_DEVICE = 2; |
| |
| // Welbeing contract |
| private static final String PATH_ACTIONS = "actions"; |
| private static final String PATH_MINIMAL_DEVICE = "minimal_device"; |
| private static final String METHOD_GET_MINIMAL_DEVICE_CONFIG = "get_minimal_device_config"; |
| private static final String METHOD_GET_ACTIONS = "get_actions"; |
| private static final String EXTRA_ACTIONS = "actions"; |
| private static final String EXTRA_ACTION = "action"; |
| private static final String EXTRA_MAX_NUM_ACTIONS_SHOWN = "max_num_actions_shown"; |
| private static final String EXTRA_PACKAGES = "packages"; |
| private static final String EXTRA_SUCCESS = "success"; |
| private static final String EXTRA_MINIMAL_DEVICE_STATE = "minimal_device_state"; |
| private static final String DB_NAME_MINIMAL_DEVICE = "minimal.db"; |
| |
| public static final MainThreadInitializedObject<WellbeingModel> INSTANCE = |
| new MainThreadInitializedObject<>(WellbeingModel::new); |
| |
| private final Context mContext; |
| private final String mWellbeingProviderPkg; |
| |
| private Handler mWorkerHandler; |
| private ContentObserver mContentObserver; |
| |
| private final Object mModelLock = new Object(); |
| // Maps the action Id to the corresponding RemoteAction |
| private final Map<String, RemoteAction> mActionIdMap = new ArrayMap<>(); |
| private final Map<String, String> mPackageToActionId = new HashMap<>(); |
| |
| private boolean mIsInTest; |
| |
| private WellbeingModel(final Context context) { |
| mContext = context; |
| mWellbeingProviderPkg = mContext.getString(R.string.wellbeing_provider_pkg); |
| initializeInBackground("WellbeingHandler"); |
| } |
| |
| @Override |
| protected void onInitialized(Looper looper) { |
| mWorkerHandler = new Handler(looper); |
| mContentObserver = newContentObserver(mWorkerHandler, this::onWellbeingUriChanged); |
| if (!TextUtils.isEmpty(mWellbeingProviderPkg)) { |
| mContext.registerReceiver( |
| new SimpleBroadcastReceiver(t -> restartObserver()), |
| PackageManagerHelper.getPackageFilter(mWellbeingProviderPkg, |
| Intent.ACTION_PACKAGE_ADDED, Intent.ACTION_PACKAGE_CHANGED, |
| Intent.ACTION_PACKAGE_REMOVED, Intent.ACTION_PACKAGE_DATA_CLEARED, |
| Intent.ACTION_PACKAGE_RESTARTED), |
| null, mWorkerHandler); |
| |
| IntentFilter filter = new IntentFilter(Intent.ACTION_PACKAGE_ADDED); |
| filter.addAction(Intent.ACTION_PACKAGE_REMOVED); |
| filter.addDataScheme("package"); |
| mContext.registerReceiver(new SimpleBroadcastReceiver(this::onAppPackageChanged), |
| filter, null, mWorkerHandler); |
| |
| restartObserver(); |
| } |
| } |
| |
| @WorkerThread |
| private void onWellbeingUriChanged(Uri uri) { |
| Preconditions.assertNonUiThread(); |
| if (DEBUG || mIsInTest) { |
| Log.d(TAG, "ContentObserver.onChange() called with: uri = [" + uri + "]"); |
| } |
| if (uri.getPath().contains(PATH_ACTIONS)) { |
| // Wellbeing reports that app actions have changed. |
| updateAllPackages(); |
| } else if (uri.getPath().contains(PATH_MINIMAL_DEVICE)) { |
| // Wellbeing reports that minimal device state or config is changed. |
| if (!FeatureFlags.ENABLE_MINIMAL_DEVICE.get()) { |
| return; |
| } |
| |
| // Temporary bug fix for b/169771796. Wellbeing provides the layout configuration when |
| // minimal device is enabled. We always want to reload the configuration from Wellbeing |
| // since the layout configuration might have changed. |
| mContext.deleteDatabase(DB_NAME_MINIMAL_DEVICE); |
| |
| final Bundle extras = new Bundle(); |
| String dbFile; |
| if (isInMinimalDeviceMode()) { |
| dbFile = DB_NAME_MINIMAL_DEVICE; |
| extras.putString(LauncherProvider.KEY_LAYOUT_PROVIDER_AUTHORITY, |
| mWellbeingProviderPkg + ".api"); |
| } else { |
| dbFile = InvariantDeviceProfile.INSTANCE.get(mContext).dbFile; |
| } |
| LauncherSettings.Settings.call(mContext.getContentResolver(), |
| LauncherSettings.Settings.METHOD_SWITCH_DATABASE, |
| dbFile, extras); |
| } |
| } |
| |
| public void setInTest(boolean inTest) { |
| mIsInTest = inTest; |
| } |
| |
| @WorkerThread |
| private void restartObserver() { |
| final ContentResolver resolver = mContext.getContentResolver(); |
| resolver.unregisterContentObserver(mContentObserver); |
| Uri actionsUri = apiBuilder().path(PATH_ACTIONS).build(); |
| Uri minimalDeviceUri = apiBuilder().path(PATH_MINIMAL_DEVICE).build(); |
| try { |
| resolver.registerContentObserver( |
| actionsUri, true /* notifyForDescendants */, mContentObserver); |
| resolver.registerContentObserver( |
| minimalDeviceUri, true /* notifyForDescendants */, mContentObserver); |
| } catch (Exception e) { |
| Log.e(TAG, "Failed to register content observer for " + actionsUri + ": " + e); |
| if (mIsInTest) throw new RuntimeException(e); |
| } |
| updateAllPackages(); |
| } |
| |
| @MainThread |
| private SystemShortcut getShortcutForApp(String packageName, int userId, |
| BaseDraggingActivity activity, ItemInfo info) { |
| Preconditions.assertUIThread(); |
| // Work profile apps are not recognized by digital wellbeing. |
| if (userId != UserHandle.myUserId()) { |
| if (DEBUG || mIsInTest) { |
| Log.d(TAG, "getShortcutForApp [" + packageName + "]: not current user"); |
| } |
| return null; |
| } |
| |
| synchronized (mModelLock) { |
| String actionId = mPackageToActionId.get(packageName); |
| final RemoteAction action = actionId != null ? mActionIdMap.get(actionId) : null; |
| if (action == null) { |
| if (DEBUG || mIsInTest) { |
| Log.d(TAG, "getShortcutForApp [" + packageName + "]: no action"); |
| } |
| return null; |
| } |
| if (DEBUG || mIsInTest) { |
| Log.d(TAG, |
| "getShortcutForApp [" + packageName + "]: action: '" + action.getTitle() |
| + "'"); |
| } |
| return new RemoteActionShortcut(action, activity, info); |
| } |
| } |
| |
| private Uri.Builder apiBuilder() { |
| return new Uri.Builder() |
| .scheme(SCHEME_CONTENT) |
| .authority(mWellbeingProviderPkg + ".api"); |
| } |
| |
| @WorkerThread |
| private boolean isInMinimalDeviceMode() { |
| if (!FeatureFlags.ENABLE_MINIMAL_DEVICE.get()) { |
| return false; |
| } |
| if (DEBUG || mIsInTest) { |
| Log.d(TAG, "isInMinimalDeviceMode() called"); |
| } |
| Preconditions.assertNonUiThread(); |
| |
| final Uri contentUri = apiBuilder().build(); |
| try (ContentProviderClient client = mContext.getContentResolver() |
| .acquireUnstableContentProviderClient(contentUri)) { |
| final Bundle remoteBundle = client == null ? null : client.call( |
| METHOD_GET_MINIMAL_DEVICE_CONFIG, null /* args */, null /* extras */); |
| return remoteBundle != null |
| && remoteBundle.getInt(EXTRA_MINIMAL_DEVICE_STATE, |
| UNKNOWN_MINIMAL_DEVICE_STATE) == IN_MINIMAL_DEVICE; |
| } catch (Exception e) { |
| Log.e(TAG, "Failed to retrieve data from " + contentUri + ": " + e); |
| if (mIsInTest) throw new RuntimeException(e); |
| } |
| if (DEBUG || mIsInTest) Log.i(TAG, "isInMinimalDeviceMode(): finished"); |
| return false; |
| } |
| |
| @WorkerThread |
| private boolean updateActions(String[] packageNames) { |
| if (packageNames.length == 0) { |
| return true; |
| } |
| if (DEBUG || mIsInTest) { |
| Log.d(TAG, "retrieveActions() called with: packageNames = [" + String.join(", ", |
| packageNames) + "]"); |
| } |
| Preconditions.assertNonUiThread(); |
| |
| Uri contentUri = apiBuilder().build(); |
| final Bundle remoteActionBundle; |
| try (ContentProviderClient client = mContext.getContentResolver() |
| .acquireUnstableContentProviderClient(contentUri)) { |
| if (client == null) { |
| if (DEBUG || mIsInTest) Log.i(TAG, "retrieveActions(): null provider"); |
| return false; |
| } |
| |
| // Prepare wellbeing call parameters. |
| final Bundle params = new Bundle(); |
| params.putStringArray(EXTRA_PACKAGES, packageNames); |
| params.putInt(EXTRA_MAX_NUM_ACTIONS_SHOWN, 1); |
| // Perform wellbeing call . |
| remoteActionBundle = client.call(METHOD_GET_ACTIONS, null, params); |
| if (!remoteActionBundle.getBoolean(EXTRA_SUCCESS, true)) return false; |
| |
| synchronized (mModelLock) { |
| // Remove the entries for requested packages, and then update the fist with what we |
| // got from service |
| Arrays.stream(packageNames).forEach(mPackageToActionId::remove); |
| |
| // The result consists of sub-bundles, each one is per a remote action. Each |
| // sub-bundle has a RemoteAction and a list of packages to which the action applies. |
| for (String actionId : |
| remoteActionBundle.getStringArray(EXTRA_ACTIONS)) { |
| final Bundle actionBundle = remoteActionBundle.getBundle(actionId); |
| mActionIdMap.put(actionId, |
| actionBundle.getParcelable(EXTRA_ACTION)); |
| |
| final String[] packagesForAction = |
| actionBundle.getStringArray(EXTRA_PACKAGES); |
| if (DEBUG || mIsInTest) { |
| Log.d(TAG, "....actionId: " + actionId + ", packages: " + String.join(", ", |
| packagesForAction)); |
| } |
| for (String packageName : packagesForAction) { |
| mPackageToActionId.put(packageName, actionId); |
| } |
| } |
| } |
| } catch (DeadObjectException e) { |
| Log.i(TAG, "retrieveActions(): DeadObjectException"); |
| return false; |
| } catch (Exception e) { |
| Log.e(TAG, "Failed to retrieve data from " + contentUri + ": " + e); |
| if (mIsInTest) throw new RuntimeException(e); |
| return true; |
| } |
| if (DEBUG || mIsInTest) Log.i(TAG, "retrieveActions(): finished"); |
| return true; |
| } |
| |
| @WorkerThread |
| private void updateActionsWithRetry(int retryCount, @Nullable String packageName) { |
| if (DEBUG || mIsInTest) { |
| Log.i(TAG, |
| "updateActionsWithRetry(); retryCount: " + retryCount + ", package: " |
| + packageName); |
| } |
| String[] packageNames = TextUtils.isEmpty(packageName) |
| ? mContext.getSystemService(LauncherApps.class) |
| .getActivityList(null, Process.myUserHandle()).stream() |
| .map(li -> li.getApplicationInfo().packageName).distinct() |
| .toArray(String[]::new) |
| : new String[]{packageName}; |
| |
| mWorkerHandler.removeCallbacksAndMessages(packageName); |
| if (updateActions(packageNames)) { |
| return; |
| } |
| if (retryCount >= RETRY_TIMES_MS.length) { |
| // To many retries, skip |
| return; |
| } |
| mWorkerHandler.postDelayed( |
| () -> { |
| if (DEBUG || mIsInTest) Log.i(TAG, "Retrying; attempt " + (retryCount + 1)); |
| updateActionsWithRetry(retryCount + 1, packageName); |
| }, |
| packageName, RETRY_TIMES_MS[retryCount]); |
| } |
| |
| @WorkerThread |
| private void updateAllPackages() { |
| if (DEBUG || mIsInTest) Log.i(TAG, "updateAllPackages"); |
| updateActionsWithRetry(0, null); |
| } |
| |
| @WorkerThread |
| private void onAppPackageChanged(Intent intent) { |
| if (DEBUG || mIsInTest) Log.d(TAG, "Changes in apps: intent = [" + intent + "]"); |
| Preconditions.assertNonUiThread(); |
| |
| final String packageName = intent.getData().getSchemeSpecificPart(); |
| if (packageName == null || packageName.length() == 0) { |
| // they sent us a bad intent |
| return; |
| } |
| final String action = intent.getAction(); |
| if (Intent.ACTION_PACKAGE_REMOVED.equals(action)) { |
| mWorkerHandler.removeCallbacksAndMessages(packageName); |
| synchronized (mModelLock) { |
| mPackageToActionId.remove(packageName); |
| } |
| } else if (Intent.ACTION_PACKAGE_ADDED.equals(action)) { |
| updateActionsWithRetry(0, packageName); |
| } |
| } |
| |
| /** |
| * Shortcut factory for generating wellbeing action |
| */ |
| public static final SystemShortcut.Factory SHORTCUT_FACTORY = |
| (activity, info) -> (info.getTargetComponent() == null) ? null : INSTANCE.get(activity) |
| .getShortcutForApp( |
| info.getTargetComponent().getPackageName(), info.user.getIdentifier(), |
| activity, info); |
| } |