/* * Copyright (C) 2021 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.systemui.people.widget; import static com.android.systemui.people.PeopleBackupFollowUpJob.SHARED_FOLLOW_UP; import static com.android.systemui.people.PeopleSpaceUtils.DEBUG; import static com.android.systemui.people.PeopleSpaceUtils.INVALID_USER_ID; import static com.android.systemui.people.PeopleSpaceUtils.USER_ID; import android.app.backup.BackupDataInputStream; import android.app.backup.BackupDataOutput; import android.app.backup.SharedPreferencesBackupHelper; import android.app.people.IPeopleManager; import android.appwidget.AppWidgetManager; import android.content.ComponentName; import android.content.ContentProvider; import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; import android.content.pm.PackageInfo; import android.content.pm.PackageManager; import android.net.Uri; import android.os.ParcelFileDescriptor; import android.os.ServiceManager; import android.os.UserHandle; import android.preference.PreferenceManager; import android.text.TextUtils; import android.util.Log; import com.android.internal.annotations.VisibleForTesting; import com.android.systemui.people.PeopleBackupFollowUpJob; import com.android.systemui.people.SharedPreferencesHelper; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Set; import java.util.stream.Collectors; /** * Helper class to backup and restore Conversations widgets storage. * It is used by SystemUI's BackupHelper agent. * TODO(b/192334798): Lock access to storage using PeopleSpaceWidgetManager's lock. */ public class PeopleBackupHelper extends SharedPreferencesBackupHelper { private static final String TAG = "PeopleBackupHelper"; public static final String ADD_USER_ID_TO_URI = "add_user_id_to_uri_"; public static final String SHARED_BACKUP = "shared_backup"; private final Context mContext; private final UserHandle mUserHandle; private final PackageManager mPackageManager; private final IPeopleManager mIPeopleManager; private final AppWidgetManager mAppWidgetManager; /** * Types of entries stored in the default SharedPreferences file for Conversation widgets. * Widget ID corresponds to a pair [widgetId, contactURI]. * PeopleTileKey corresponds to a pair [PeopleTileKey, {widgetIds}]. * Contact URI corresponds to a pair [Contact URI, {widgetIds}]. */ enum SharedFileEntryType { UNKNOWN, WIDGET_ID, PEOPLE_TILE_KEY, CONTACT_URI } /** * Returns the file names that should be backed up and restored by SharedPreferencesBackupHelper * infrastructure. */ public static List getFilesToBackup() { return Collections.singletonList(SHARED_BACKUP); } public PeopleBackupHelper(Context context, UserHandle userHandle, String[] sharedPreferencesKey) { super(context, sharedPreferencesKey); mContext = context; mUserHandle = userHandle; mPackageManager = context.getPackageManager(); mIPeopleManager = IPeopleManager.Stub.asInterface( ServiceManager.getService(Context.PEOPLE_SERVICE)); mAppWidgetManager = AppWidgetManager.getInstance(context); } @VisibleForTesting public PeopleBackupHelper(Context context, UserHandle userHandle, String[] sharedPreferencesKey, PackageManager packageManager, IPeopleManager peopleManager) { super(context, sharedPreferencesKey); mContext = context; mUserHandle = userHandle; mPackageManager = packageManager; mIPeopleManager = peopleManager; mAppWidgetManager = AppWidgetManager.getInstance(context); } /** * Reads values from default storage, backs them up appropriately to a specified backup file, * and calls super's performBackup, which backs up the values of the backup file. */ @Override public void performBackup(ParcelFileDescriptor oldState, BackupDataOutput data, ParcelFileDescriptor newState) { if (DEBUG) Log.d(TAG, "Backing up conversation widgets, writing to: " + SHARED_BACKUP); // Open default value for readings values. SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(mContext); if (sp.getAll().isEmpty()) { if (DEBUG) Log.d(TAG, "No information to be backed up, finishing."); return; } // Open backup file for writing. SharedPreferences backupSp = mContext.getSharedPreferences( SHARED_BACKUP, Context.MODE_PRIVATE); SharedPreferences.Editor backupEditor = backupSp.edit(); backupEditor.clear(); // Fetch Conversations widgets corresponding to this user. List existingWidgets = getExistingWidgetsForUser(mUserHandle.getIdentifier()); if (existingWidgets.isEmpty()) { if (DEBUG) Log.d(TAG, "No existing Conversations widgets, returning."); return; } // Writes each entry to backup file. sp.getAll().entrySet().forEach(entry -> backupKey(entry, backupEditor, existingWidgets)); backupEditor.apply(); super.performBackup(oldState, data, newState); } /** * Restores backed up values to backup file via super's restoreEntity, then transfers them * back to regular storage. Restore operations for each users are done in sequence, so we can * safely use the same backup file names. */ @Override public void restoreEntity(BackupDataInputStream data) { if (DEBUG) Log.d(TAG, "Restoring Conversation widgets."); super.restoreEntity(data); // Open backup file for reading values. SharedPreferences backupSp = mContext.getSharedPreferences( SHARED_BACKUP, Context.MODE_PRIVATE); // Open default file and follow-up file for writing. SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(mContext); SharedPreferences.Editor editor = sp.edit(); SharedPreferences followUp = mContext.getSharedPreferences( SHARED_FOLLOW_UP, Context.MODE_PRIVATE); SharedPreferences.Editor followUpEditor = followUp.edit(); // Writes each entry back to default value. boolean shouldScheduleJob = false; for (Map.Entry entry : backupSp.getAll().entrySet()) { boolean restored = restoreKey(entry, editor, followUpEditor, backupSp); if (!restored) { shouldScheduleJob = true; } } editor.apply(); followUpEditor.apply(); SharedPreferencesHelper.clear(backupSp); // If any of the widgets is not yet available, schedule a follow-up job to check later. if (shouldScheduleJob) { if (DEBUG) Log.d(TAG, "At least one shortcut is not available, scheduling follow-up."); PeopleBackupFollowUpJob.scheduleJob(mContext); } updateWidgets(mContext); } /** Backs up an entry from default file to backup file. */ public void backupKey(Map.Entry entry, SharedPreferences.Editor backupEditor, List existingWidgets) { String key = entry.getKey(); if (TextUtils.isEmpty(key)) { return; } SharedFileEntryType entryType = getEntryType(entry); switch(entryType) { case WIDGET_ID: backupWidgetIdKey(key, String.valueOf(entry.getValue()), backupEditor, existingWidgets); break; case PEOPLE_TILE_KEY: backupPeopleTileKey(key, (Set) entry.getValue(), backupEditor, existingWidgets); break; case CONTACT_URI: backupContactUriKey(key, (Set) entry.getValue(), backupEditor); break; case UNKNOWN: default: Log.w(TAG, "Key not identified, skipping: " + key); } } /** * Tries to restore an entry from backup file to default file. * Returns true if restore is finished, false if it needs to be checked later. */ boolean restoreKey(Map.Entry entry, SharedPreferences.Editor editor, SharedPreferences.Editor followUpEditor, SharedPreferences backupSp) { String key = entry.getKey(); SharedFileEntryType keyType = getEntryType(entry); int storedUserId = backupSp.getInt(ADD_USER_ID_TO_URI + key, INVALID_USER_ID); switch (keyType) { case WIDGET_ID: restoreWidgetIdKey(key, String.valueOf(entry.getValue()), editor, storedUserId); return true; case PEOPLE_TILE_KEY: return restorePeopleTileKeyAndCorrespondingWidgetFile( key, (Set) entry.getValue(), editor, followUpEditor); case CONTACT_URI: restoreContactUriKey(key, (Set) entry.getValue(), editor, storedUserId); return true; case UNKNOWN: default: Log.e(TAG, "Key not identified, skipping:" + key); return true; } } /** * Backs up a [widgetId, contactURI] pair, if widget id corresponds to current user. * If contact URI has a user id, stores it so it can be re-added on restore. */ private void backupWidgetIdKey(String key, String uriString, SharedPreferences.Editor editor, List existingWidgets) { if (!existingWidgets.contains(key)) { if (DEBUG) Log.d(TAG, "Widget: " + key + " does't correspond to this user, skipping."); return; } Uri uri = Uri.parse(uriString); if (ContentProvider.uriHasUserId(uri)) { if (DEBUG) Log.d(TAG, "Contact URI value has user ID, removing from: " + uri); int userId = ContentProvider.getUserIdFromUri(uri); editor.putInt(ADD_USER_ID_TO_URI + key, userId); uri = ContentProvider.getUriWithoutUserId(uri); } if (DEBUG) Log.d(TAG, "Backing up widgetId key: " + key + " . Value: " + uri.toString()); editor.putString(key, uri.toString()); } /** Restores a [widgetId, contactURI] pair, and a potential {@code storedUserId}. */ private void restoreWidgetIdKey(String key, String uriString, SharedPreferences.Editor editor, int storedUserId) { Uri uri = Uri.parse(uriString); if (storedUserId != INVALID_USER_ID) { uri = ContentProvider.createContentUriForUser(uri, UserHandle.of(storedUserId)); if (DEBUG) Log.d(TAG, "UserId was removed from URI on back up, re-adding as:" + uri); } if (DEBUG) Log.d(TAG, "Restoring widgetId key: " + key + " . Value: " + uri.toString()); editor.putString(key, uri.toString()); } /** * Backs up a [PeopleTileKey, {widgetIds}] pair, if PeopleTileKey's user is the same as current * user, stripping out the user id. */ private void backupPeopleTileKey(String key, Set widgetIds, SharedPreferences.Editor editor, List existingWidgets) { PeopleTileKey peopleTileKey = PeopleTileKey.fromString(key); if (peopleTileKey.getUserId() != mUserHandle.getIdentifier()) { if (DEBUG) Log.d(TAG, "PeopleTileKey corresponds to different user, skipping backup."); return; } Set filteredWidgets = widgetIds.stream() .filter(id -> existingWidgets.contains(id)) .collect(Collectors.toSet()); if (filteredWidgets.isEmpty()) { return; } peopleTileKey.setUserId(INVALID_USER_ID); if (DEBUG) { Log.d(TAG, "Backing up PeopleTileKey key: " + peopleTileKey.toString() + ". Value: " + filteredWidgets); } editor.putStringSet(peopleTileKey.toString(), filteredWidgets); } /** * Restores a [PeopleTileKey, {widgetIds}] pair, restoring the user id. Checks if the * corresponding shortcut exists, and if not, we should schedule a follow up to check later. * Also restores corresponding [widgetId, PeopleTileKey], which is not backed up since the * information can be inferred from this. * Returns true if restore is finished, false if we should check if shortcut is available later. */ private boolean restorePeopleTileKeyAndCorrespondingWidgetFile(String key, Set widgetIds, SharedPreferences.Editor editor, SharedPreferences.Editor followUpEditor) { PeopleTileKey peopleTileKey = PeopleTileKey.fromString(key); // Should never happen, as type of key has been checked. if (peopleTileKey == null) { if (DEBUG) Log.d(TAG, "PeopleTileKey key to be restored is null, skipping."); return true; } peopleTileKey.setUserId(mUserHandle.getIdentifier()); if (!PeopleTileKey.isValid(peopleTileKey)) { if (DEBUG) Log.d(TAG, "PeopleTileKey key to be restored is not valid, skipping."); return true; } boolean restored = isReadyForRestore( mIPeopleManager, mPackageManager, peopleTileKey); if (!restored) { if (DEBUG) Log.d(TAG, "Adding key to follow-up storage: " + peopleTileKey.toString()); // Follow-up file stores shortcuts that need to be checked later, and possibly wiped // from our storage. followUpEditor.putStringSet(peopleTileKey.toString(), widgetIds); } if (DEBUG) { Log.d(TAG, "Restoring PeopleTileKey key: " + peopleTileKey.toString() + " . Value: " + widgetIds); } editor.putStringSet(peopleTileKey.toString(), widgetIds); restoreWidgetIdFiles(mContext, widgetIds, peopleTileKey); return restored; } /** * Backs up a [contactURI, {widgetIds}] pair. If contactURI contains a userId, we back up * this entry in the corresponding user. If it doesn't, we back it up as user 0. * If contact URI has a user id, stores it so it can be re-added on restore. * We do not take existing widgets for this user into consideration. */ private void backupContactUriKey(String key, Set widgetIds, SharedPreferences.Editor editor) { Uri uri = Uri.parse(String.valueOf(key)); if (ContentProvider.uriHasUserId(uri)) { int userId = ContentProvider.getUserIdFromUri(uri); if (DEBUG) Log.d(TAG, "Contact URI has user Id: " + userId); if (userId == mUserHandle.getIdentifier()) { uri = ContentProvider.getUriWithoutUserId(uri); if (DEBUG) { Log.d(TAG, "Backing up contactURI key: " + uri.toString() + " . Value: " + widgetIds); } editor.putInt(ADD_USER_ID_TO_URI + uri.toString(), userId); editor.putStringSet(uri.toString(), widgetIds); } else { if (DEBUG) Log.d(TAG, "ContactURI corresponds to different user, skipping."); } } else if (mUserHandle.isSystem()) { if (DEBUG) { Log.d(TAG, "Backing up contactURI key: " + uri.toString() + " . Value: " + widgetIds); } editor.putStringSet(uri.toString(), widgetIds); } } /** Restores a [contactURI, {widgetIds}] pair, and a potential {@code storedUserId}. */ private void restoreContactUriKey(String key, Set widgetIds, SharedPreferences.Editor editor, int storedUserId) { Uri uri = Uri.parse(key); if (storedUserId != INVALID_USER_ID) { uri = ContentProvider.createContentUriForUser(uri, UserHandle.of(storedUserId)); if (DEBUG) Log.d(TAG, "UserId was removed from URI on back up, re-adding as:" + uri); } if (DEBUG) { Log.d(TAG, "Restoring contactURI key: " + uri.toString() + " . Value: " + widgetIds); } editor.putStringSet(uri.toString(), widgetIds); } /** Restores the widget-specific files that contain PeopleTileKey information. */ public static void restoreWidgetIdFiles(Context context, Set widgetIds, PeopleTileKey key) { for (String id : widgetIds) { if (DEBUG) Log.d(TAG, "Restoring widget Id file: " + id + " . Value: " + key); SharedPreferences dest = context.getSharedPreferences(id, Context.MODE_PRIVATE); SharedPreferencesHelper.setPeopleTileKey(dest, key); } } private List getExistingWidgetsForUser(int userId) { List existingWidgets = new ArrayList<>(); int[] ids = mAppWidgetManager.getAppWidgetIds( new ComponentName(mContext, PeopleSpaceWidgetProvider.class)); for (int id : ids) { String idString = String.valueOf(id); SharedPreferences sp = mContext.getSharedPreferences(idString, Context.MODE_PRIVATE); if (sp.getInt(USER_ID, INVALID_USER_ID) == userId) { existingWidgets.add(idString); } } if (DEBUG) Log.d(TAG, "Existing widgets: " + existingWidgets); return existingWidgets; } /** * Returns whether {@code key} corresponds to a shortcut that is ready for restore, either * because it is available or because it never will be. If not ready, we schedule a job to check * again later. */ public static boolean isReadyForRestore(IPeopleManager peopleManager, PackageManager packageManager, PeopleTileKey key) { if (DEBUG) Log.d(TAG, "Checking if we should schedule a follow up job : " + key); if (!PeopleTileKey.isValid(key)) { if (DEBUG) Log.d(TAG, "Key is invalid, should not follow up."); return true; } try { PackageInfo info = packageManager.getPackageInfoAsUser( key.getPackageName(), 0, key.getUserId()); } catch (PackageManager.NameNotFoundException e) { if (DEBUG) Log.d(TAG, "Package is not installed, should follow up."); return false; } try { boolean isConversation = peopleManager.isConversation( key.getPackageName(), key.getUserId(), key.getShortcutId()); if (DEBUG) { Log.d(TAG, "Checked if shortcut exists, should follow up: " + !isConversation); } return isConversation; } catch (Exception e) { if (DEBUG) Log.d(TAG, "Error checking if backed up info is a shortcut."); return false; } } /** Parses default file {@code entry} to determine the entry's type.*/ public static SharedFileEntryType getEntryType(Map.Entry entry) { String key = entry.getKey(); if (key == null) { return SharedFileEntryType.UNKNOWN; } try { int id = Integer.parseInt(key); try { String contactUri = (String) entry.getValue(); } catch (Exception e) { Log.w(TAG, "Malformed value, skipping:" + entry.getValue()); return SharedFileEntryType.UNKNOWN; } return SharedFileEntryType.WIDGET_ID; } catch (NumberFormatException ignored) { } try { Set widgetIds = (Set) entry.getValue(); } catch (Exception e) { Log.w(TAG, "Malformed value, skipping:" + entry.getValue()); return SharedFileEntryType.UNKNOWN; } PeopleTileKey peopleTileKey = PeopleTileKey.fromString(key); if (peopleTileKey != null) { return SharedFileEntryType.PEOPLE_TILE_KEY; } try { Uri uri = Uri.parse(key); return SharedFileEntryType.CONTACT_URI; } catch (Exception e) { return SharedFileEntryType.UNKNOWN; } } /** Sends a broadcast to update the existing Conversation widgets. */ public static void updateWidgets(Context context) { int[] widgetIds = AppWidgetManager.getInstance(context) .getAppWidgetIds(new ComponentName(context, PeopleSpaceWidgetProvider.class)); if (DEBUG) { for (int id : widgetIds) { Log.d(TAG, "Calling update to widget: " + id); } } if (widgetIds != null && widgetIds.length != 0) { Intent intent = new Intent(context, PeopleSpaceWidgetProvider.class); intent.setAction(AppWidgetManager.ACTION_APPWIDGET_UPDATE); intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, widgetIds); context.sendBroadcast(intent); } } }