blob: 154b78b1b9d5e6a4088444ab3f121542c4c0efed [file] [log] [blame]
/*
* 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);
}