From e5fffb8ecff347f6f7c40363d032b2345a38528f Mon Sep 17 00:00:00 2001 From: Nishant Kumar Singh Date: Mon, 25 Oct 2021 12:40:30 +0000 Subject: [PATCH 1/4] Add the backup logic for app-locales. Bug: 203266156 Test: atest FrameworksServicesTests:LocaleManagerBackupRestoreTest Change-Id: I38dae4f797b184d255479fca235de5af9a3bf3d1 --- .../locales/LocaleManagerBackupHelper.java | 151 ++++++++++++++++++ .../server/locales/LocaleManagerInternal.java | 31 ++++ .../server/locales/LocaleManagerService.java | 33 +++- 3 files changed, 214 insertions(+), 1 deletion(-) create mode 100644 services/core/java/com/android/server/locales/LocaleManagerBackupHelper.java create mode 100644 services/core/java/com/android/server/locales/LocaleManagerInternal.java diff --git a/services/core/java/com/android/server/locales/LocaleManagerBackupHelper.java b/services/core/java/com/android/server/locales/LocaleManagerBackupHelper.java new file mode 100644 index 0000000000000..6b5e7d73b9849 --- /dev/null +++ b/services/core/java/com/android/server/locales/LocaleManagerBackupHelper.java @@ -0,0 +1,151 @@ +/* + * 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.server.locales; + +import static com.android.server.locales.LocaleManagerService.DEBUG; + +import android.annotation.NonNull; +import android.app.backup.BackupManager; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageManagerInternal; +import android.os.Binder; +import android.os.LocaleList; +import android.os.RemoteException; +import android.util.Slog; +import android.util.TypedXmlSerializer; +import android.util.Xml; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.io.UnsupportedEncodingException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; + +/** + * Helper class for managing backup and restore of app-specific locales. + */ +final class LocaleManagerBackupHelper { + private static final String TAG = "LocaleManagerBkpHelper"; // must be < 23 chars + private final LocaleManagerService mLocaleManagerService; + private final PackageManagerInternal mPackageManagerInternal; + private static final String SYSTEM_BACKUP_PACKAGE_KEY = "android"; + + LocaleManagerBackupHelper(LocaleManagerService localeManagerService, + PackageManagerInternal pmInternal) { + mLocaleManagerService = localeManagerService; + mPackageManagerInternal = pmInternal; + } + + /** + * @see LocaleManagerInternal#getBackupPayload(int userId) + */ + public byte[] getBackupPayload(int userId) { + if (DEBUG) { + Slog.d(TAG, "getBackupPayload invoked for user id " + userId); + } + + ArrayList pkgStates = new ArrayList<>(); + for (ApplicationInfo appInfo : mPackageManagerInternal.getInstalledApplications(/*flags*/0, + userId, Binder.getCallingUid())) { + try { + LocaleList appLocales = mLocaleManagerService.getApplicationLocales( + appInfo.packageName, + userId); + // Backup locales only for apps which do have app-specific overrides. + if (!appLocales.isEmpty()) { + if (DEBUG) { + Slog.d(TAG, "Add package=" + appInfo.packageName + " locales=" + + appLocales.toLanguageTags() + " to backup payload"); + } + pkgStates.add(new BackupPackageState(appInfo.packageName, + appLocales.toLanguageTags())); + } + } catch (RemoteException | IllegalArgumentException e) { + Slog.e(TAG, "Exception when getting locales for package: " + appInfo.packageName, + e); + } + } + + if (pkgStates.isEmpty()) { + if (DEBUG) { + Slog.d(TAG, "Final payload=null"); + } + // Returning null here will ensure deletion of the entry for LMS from the backup data. + return null; + } + + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + try { + writeToXml(out, pkgStates); + } catch (IOException e) { + Slog.e(TAG, "Could not write to xml for backup ", e); + return null; + } + + if (DEBUG) { + try { + Slog.d(TAG, "Final payload=" + out.toString("UTF-8")); + } catch (UnsupportedEncodingException e) { + Slog.w(TAG, "Could not encode payload to UTF-8", e); + } + } + return out.toByteArray(); + } + + /** + * Notifies the backup manager to include the "android" package in the next backup pass. + */ + public void notifyBackupManager() { + BackupManager.dataChanged(SYSTEM_BACKUP_PACKAGE_KEY); + } + + + /** + * Converts the list of app backup data into a serialized xml stream. + */ + private static void writeToXml(OutputStream stream, + @NonNull ArrayList pkgStates) throws IOException { + TypedXmlSerializer out = Xml.newFastSerializer(); + out.setOutput(stream, StandardCharsets.UTF_8.name()); + out.startDocument(/* encoding= */ null, /* standalone= */ true); + out.startTag(/* namespace= */ null, TAG); + + for (BackupPackageState pkgState : pkgStates) { + out.startTag(/* namespace= */ null, "package"); + out.attribute(/* namespace= */ null, "name", pkgState.mPackageName); + out.attribute(/* namespace= */ null, "locales", pkgState.mLanguageTags); + out.endTag(/*namespace= */ null, "package"); + } + + out.endTag(/* namespace= */ null, TAG); + out.endDocument(); + } + + /** + * Data structure containing the backup data details for an app. + */ + private static class BackupPackageState { + final @NonNull String mPackageName; + final @NonNull String mLanguageTags; + + BackupPackageState(@NonNull String packageNameArg, @NonNull String languageTagsArg) { + mPackageName = packageNameArg; + mLanguageTags = languageTagsArg; + } + } +} diff --git a/services/core/java/com/android/server/locales/LocaleManagerInternal.java b/services/core/java/com/android/server/locales/LocaleManagerInternal.java new file mode 100644 index 0000000000000..4b1fbe7f954c5 --- /dev/null +++ b/services/core/java/com/android/server/locales/LocaleManagerInternal.java @@ -0,0 +1,31 @@ +/* + * 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.server.locales; + +import android.annotation.Nullable; + +/** + * System-server internal interface to the {@link LocaleManagerService}. + * + * @hide Only for use within the system server. + */ +public abstract class LocaleManagerInternal { + /** + * Returns the app-specific locales to be backed up as a data-blob. + */ + public abstract @Nullable byte[] getBackupPayload(int userId); +} diff --git a/services/core/java/com/android/server/locales/LocaleManagerService.java b/services/core/java/com/android/server/locales/LocaleManagerService.java index 457c2fd6fc419..6d029f1a9fd4e 100644 --- a/services/core/java/com/android/server/locales/LocaleManagerService.java +++ b/services/core/java/com/android/server/locales/LocaleManagerService.java @@ -20,6 +20,7 @@ import static java.util.Objects.requireNonNull; import android.Manifest; import android.annotation.NonNull; +import android.annotation.Nullable; import android.annotation.UserIdInt; import android.app.ActivityManagerInternal; import android.app.ILocaleManager; @@ -29,6 +30,7 @@ import android.content.pm.PackageManager; import android.content.pm.PackageManagerInternal; import android.os.Binder; import android.os.LocaleList; +import android.os.Process; import android.os.RemoteException; import android.os.ResultReceiver; import android.os.ShellCallback; @@ -51,11 +53,14 @@ import java.io.PrintWriter; */ public class LocaleManagerService extends SystemService { private static final String TAG = "LocaleManagerService"; - private final Context mContext; + final Context mContext; private final LocaleManagerService.LocaleManagerBinderService mBinderService; private ActivityTaskManagerInternal mActivityTaskManagerInternal; private ActivityManagerInternal mActivityManagerInternal; private PackageManagerInternal mPackageManagerInternal; + + private LocaleManagerBackupHelper mBackupHelper; + public static final boolean DEBUG = false; public LocaleManagerService(Context context) { @@ -82,6 +87,24 @@ public class LocaleManagerService extends SystemService { @Override public void onStart() { publishBinderService(Context.LOCALE_SERVICE, mBinderService); + mBackupHelper = new LocaleManagerBackupHelper(LocaleManagerService.this, + mPackageManagerInternal); + LocalServices.addService(LocaleManagerInternal.class, new LocaleManagerInternalImpl()); + } + + private final class LocaleManagerInternalImpl extends LocaleManagerInternal { + + @Override + public @Nullable byte[] getBackupPayload(int userId) { + checkCallerIsSystem(); + return mBackupHelper.getBackupPayload(userId); + } + + private void checkCallerIsSystem() { + if (Binder.getCallingUid() != Process.SYSTEM_UID) { + throw new SecurityException("Caller is not system."); + } + } } private final class LocaleManagerBinderService extends ILocaleManager.Stub { @@ -110,6 +133,12 @@ public class LocaleManagerService extends SystemService { (new LocaleManagerShellCommand(mBinderService)) .exec(this, in, out, err, args, callback, resultReceiver); } + + } + + @VisibleForTesting + LocaleManagerBackupHelper getBackupHelper() { + return mBackupHelper; } /** @@ -161,6 +190,8 @@ public class LocaleManagerService extends SystemService { notifyAppWhoseLocaleChanged(appPackageName, userId, locales); notifyInstallerOfAppWhoseLocaleChanged(appPackageName, userId, locales); notifyRegisteredReceivers(appPackageName, userId, locales); + + mBackupHelper.notifyBackupManager(); } } From 972120fcd665159ac81834d78f01e7879dae5b86 Mon Sep 17 00:00:00 2001 From: Nishant Kumar Singh Date: Mon, 25 Oct 2021 13:54:36 +0000 Subject: [PATCH 2/4] Add the restore logic for app-locales. Bug: 203266156 Test: atest FrameworksServicesTests:LocaleManagerBackupRestoreTest Change-Id: I0d93cac8875598020ce0ddd65048fe9c09335c9b --- .../locales/LocaleManagerBackupHelper.java | 512 +++++++++++++++++- .../server/locales/LocaleManagerInternal.java | 9 + .../server/locales/LocaleManagerService.java | 11 +- 3 files changed, 506 insertions(+), 26 deletions(-) diff --git a/services/core/java/com/android/server/locales/LocaleManagerBackupHelper.java b/services/core/java/com/android/server/locales/LocaleManagerBackupHelper.java index 6b5e7d73b9849..b2bd47b55cfb9 100644 --- a/services/core/java/com/android/server/locales/LocaleManagerBackupHelper.java +++ b/services/core/java/com/android/server/locales/LocaleManagerBackupHelper.java @@ -16,39 +16,206 @@ package com.android.server.locales; +import static android.os.UserHandle.USER_NULL; + import static com.android.server.locales.LocaleManagerService.DEBUG; import android.annotation.NonNull; +import android.annotation.Nullable; +import android.annotation.UserIdInt; import android.app.backup.BackupManager; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; import android.content.pm.ApplicationInfo; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; import android.content.pm.PackageManagerInternal; +import android.os.BestClock; import android.os.Binder; +import android.os.Environment; +import android.os.HandlerThread; import android.os.LocaleList; +import android.os.Process; import android.os.RemoteException; +import android.os.SystemClock; +import android.os.UserHandle; +import android.text.TextUtils; +import android.util.AtomicFile; import android.util.Slog; +import android.util.SparseArray; +import android.util.TypedXmlPullParser; import android.util.TypedXmlSerializer; import android.util.Xml; +import com.android.internal.annotations.VisibleForTesting; +import com.android.internal.content.PackageMonitor; +import com.android.internal.util.XmlUtils; + +import libcore.io.IoUtils; + +import org.xmlpull.v1.XmlPullParser; +import org.xmlpull.v1.XmlPullParserException; + +import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileOutputStream; import java.io.IOException; +import java.io.InputStream; import java.io.OutputStream; import java.io.UnsupportedEncodingException; import java.nio.charset.StandardCharsets; -import java.util.ArrayList; +import java.time.Clock; +import java.time.Duration; +import java.time.ZoneOffset; +import java.util.HashMap; +import java.util.regex.Matcher; +import java.util.regex.Pattern; /** * Helper class for managing backup and restore of app-specific locales. */ -final class LocaleManagerBackupHelper { +class LocaleManagerBackupHelper { private static final String TAG = "LocaleManagerBkpHelper"; // must be < 23 chars + + // Tags and attributes for xml. + private static final String LOCALES_XML_TAG = "locales"; + private static final String PACKAGE_XML_TAG = "package"; + private static final String ATTR_PACKAGE_NAME = "name"; + private static final String ATTR_LOCALES = "locales"; + private static final String ATTR_CREATION_TIME_MILLIS = "creationTimeMillis"; + + private static final String STAGE_FILE_NAME = "staged_locales"; + private static final String SYSTEM_BACKUP_PACKAGE_KEY = "android"; + + private static final Pattern STAGE_FILE_NAME_PATTERN = Pattern.compile( + TextUtils.formatSimple("(^%s_)(\\d+)(\\.xml$)", STAGE_FILE_NAME)); + private static final int USER_ID_GROUP_INDEX_IN_PATTERN = 2; + private static final Duration STAGE_FILE_RETENTION_PERIOD = Duration.ofDays(3); + private final LocaleManagerService mLocaleManagerService; private final PackageManagerInternal mPackageManagerInternal; - private static final String SYSTEM_BACKUP_PACKAGE_KEY = "android"; + private final File mStagedLocalesDir; + private final Clock mClock; + private final Context mContext; + private final Object mStagedDataLock = new Object(); + + // Staged data map keyed by user-id to handle multi-user scenario / work profiles. We are using + // SparseArray because it is more memory-efficient than a HashMap. + private final SparseArray mStagedData = new SparseArray<>(); + + private final PackageMonitor mPackageMonitor; + private final BroadcastReceiver mUserMonitor; LocaleManagerBackupHelper(LocaleManagerService localeManagerService, PackageManagerInternal pmInternal) { + this(localeManagerService.mContext, localeManagerService, pmInternal, + new File(Environment.getDataSystemCeDirectory(), + "app_locales"), getDefaultClock()); + } + + private static @NonNull Clock getDefaultClock() { + return new BestClock(ZoneOffset.UTC, SystemClock.currentNetworkTimeClock(), + Clock.systemUTC()); + } + + @VisibleForTesting LocaleManagerBackupHelper(Context context, + LocaleManagerService localeManagerService, + PackageManagerInternal pmInternal, File stagedLocalesDir, Clock clock) { + mContext = context; mLocaleManagerService = localeManagerService; mPackageManagerInternal = pmInternal; + mClock = clock; + mStagedLocalesDir = stagedLocalesDir; + + loadAllStageFiles(); + + HandlerThread broadcastHandlerThread = new HandlerThread(TAG, + Process.THREAD_PRIORITY_BACKGROUND); + broadcastHandlerThread.start(); + + mPackageMonitor = new PackageMonitorImpl(); + mPackageMonitor.register(context, broadcastHandlerThread.getLooper(), + UserHandle.ALL, + true); + mUserMonitor = new UserMonitor(); + IntentFilter filter = new IntentFilter(); + filter.addAction(Intent.ACTION_USER_REMOVED); + context.registerReceiverAsUser(mUserMonitor, UserHandle.ALL, filter, + null, broadcastHandlerThread.getThreadHandler()); + } + + @VisibleForTesting + BroadcastReceiver getUserMonitor() { + return mUserMonitor; + } + + @VisibleForTesting + PackageMonitor getPackageMonitor() { + return mPackageMonitor; + } + + /** + * Loads the staged data into memory by reading all the files in the staged directory. + * + *

Note: We don't ned to hold the lock here because this is only called in the + * constructor (before any broadcast receivers are registered). + */ + private void loadAllStageFiles() { + File[] files = mStagedLocalesDir.listFiles(); + if (files == null) { + return; + } + for (File file : files) { + String fileName = file.getName(); + Matcher matcher = STAGE_FILE_NAME_PATTERN.matcher(fileName); + if (!matcher.matches()) { + file.delete(); + Slog.w(TAG, TextUtils.formatSimple("Deleted %s. Reason: %s.", fileName, + "Unrecognized file")); + continue; + } + try { + final int userId = Integer.parseInt(matcher.group(USER_ID_GROUP_INDEX_IN_PATTERN)); + StagedData stagedData = readStageFile(file); + if (stagedData != null) { + mStagedData.put(userId, stagedData); + } else { + file.delete(); + Slog.w(TAG, TextUtils.formatSimple("Deleted %s. Reason: %s.", fileName, + "Could not read file")); + } + } catch (NumberFormatException e) { + file.delete(); + Slog.w(TAG, TextUtils.formatSimple("Deleted %s. Reason: %s.", fileName, + "Could not parse user id from file name")); + } + } + } + + /** + * Loads the stage file from the disk and parses it into a list of app backups. + */ + private @Nullable StagedData readStageFile(@NonNull File file) { + InputStream stagedDataInputStream = null; + AtomicFile stageFile = new AtomicFile(file); + try { + stagedDataInputStream = stageFile.openRead(); + final TypedXmlPullParser parser = Xml.newFastPullParser(); + parser.setInput(stagedDataInputStream, StandardCharsets.UTF_8.name()); + + XmlUtils.beginDocument(parser, LOCALES_XML_TAG); + long creationTimeMillis = parser.getAttributeLong(/* namespace= */ null, + ATTR_CREATION_TIME_MILLIS); + return new StagedData(creationTimeMillis, readFromXml(parser)); + } catch (IOException | XmlPullParserException e) { + Slog.e(TAG, "Could not parse stage file ", e); + } finally { + IoUtils.closeQuietly(stagedDataInputStream); + } + return null; } /** @@ -59,7 +226,11 @@ final class LocaleManagerBackupHelper { Slog.d(TAG, "getBackupPayload invoked for user id " + userId); } - ArrayList pkgStates = new ArrayList<>(); + synchronized (mStagedDataLock) { + cleanStagedDataForOldEntriesLocked(); + } + + HashMap pkgStates = new HashMap<>(); for (ApplicationInfo appInfo : mPackageManagerInternal.getInstalledApplications(/*flags*/0, userId, Binder.getCallingUid())) { try { @@ -72,8 +243,7 @@ final class LocaleManagerBackupHelper { Slog.d(TAG, "Add package=" + appInfo.packageName + " locales=" + appLocales.toLanguageTags() + " to backup payload"); } - pkgStates.add(new BackupPackageState(appInfo.packageName, - appLocales.toLanguageTags())); + pkgStates.put(appInfo.packageName, appLocales.toLanguageTags()); } } catch (RemoteException | IllegalArgumentException e) { Slog.e(TAG, "Exception when getting locales for package: " + appInfo.packageName, @@ -91,7 +261,9 @@ final class LocaleManagerBackupHelper { final ByteArrayOutputStream out = new ByteArrayOutputStream(); try { - writeToXml(out, pkgStates); + // Passing arbitrary value for creationTimeMillis since it is ignored when forStage + // is false. + writeToXml(out, pkgStates, /* forStage= */ false, /* creationTimeMillis= */ -1); } catch (IOException e) { Slog.e(TAG, "Could not write to xml for backup ", e); return null; @@ -107,6 +279,72 @@ final class LocaleManagerBackupHelper { return out.toByteArray(); } + private void cleanStagedDataForOldEntriesLocked() { + for (int i = 0; i < mStagedData.size(); i++) { + int userId = mStagedData.keyAt(i); + StagedData stagedData = mStagedData.get(userId); + if (stagedData.mCreationTimeMillis + < mClock.millis() - STAGE_FILE_RETENTION_PERIOD.toMillis()) { + deleteStagedDataLocked(userId); + } + } + } + + /** + * @see LocaleManagerInternal#stageAndApplyRestoredPayload(byte[] payload, int userId) + */ + public void stageAndApplyRestoredPayload(byte[] payload, int userId) { + if (DEBUG) { + Slog.d(TAG, "stageAndApplyRestoredPayload user=" + userId + " payload=" + + (payload != null ? new String(payload, StandardCharsets.UTF_8) : null)); + } + if (payload == null) { + Slog.e(TAG, "stageAndApplyRestoredPayload: no payload to restore for user " + userId); + return; + } + + final ByteArrayInputStream inputStream = new ByteArrayInputStream(payload); + + HashMap pkgStates = new HashMap<>(); + try { + // Parse the input blob into a list of BackupPackageState. + final TypedXmlPullParser parser = Xml.newFastPullParser(); + parser.setInput(inputStream, StandardCharsets.UTF_8.name()); + + XmlUtils.beginDocument(parser, LOCALES_XML_TAG); + pkgStates = readFromXml(parser); + } catch (IOException | XmlPullParserException e) { + Slog.e(TAG, "Could not parse payload ", e); + } + + // We need a lock here to prevent race conditions when accessing the stage file. + // It might happen that a restore was triggered (manually using bmgr cmd) and at the same + // time a new package is added. We want to ensure that both these operations aren't + // performed simultaneously. + synchronized (mStagedDataLock) { + // Backups for apps which are yet to be installed. + mStagedData.put(userId, new StagedData(mClock.millis(), new HashMap<>())); + + for (String pkgName : pkgStates.keySet()) { + String languageTags = pkgStates.get(pkgName); + // Check if the application is already installed for the concerned user. + if (isPackageInstalledForUser(pkgName, userId)) { + // Don't apply the restore if the locales have already been set for the app. + checkExistingLocalesAndApplyRestore(pkgName, languageTags, userId); + } else { + // Stage the data if the app isn't installed. + mStagedData.get(userId).mPackageStates.put(pkgName, languageTags); + if (DEBUG) { + Slog.d(TAG, "Add locales=" + languageTags + + " package=" + pkgName + " for lazy restore."); + } + } + } + + writeStageFileLocked(userId); + } + } + /** * Notifies the backup manager to include the "android" package in the next backup pass. */ @@ -114,38 +352,264 @@ final class LocaleManagerBackupHelper { BackupManager.dataChanged(SYSTEM_BACKUP_PACKAGE_KEY); } + private boolean isPackageInstalledForUser(String packageName, int userId) { + PackageInfo pkgInfo = null; + try { + pkgInfo = mContext.getPackageManager().getPackageInfoAsUser( + packageName, /* flags= */ 0, userId); + } catch (PackageManager.NameNotFoundException e) { + if (DEBUG) { + Slog.d(TAG, "Could not get package info for " + packageName, e); + } + } + return pkgInfo != null; + } + + /** + * Checks if locales already exist for the application and applies the restore accordingly. + *

+ * The user might change the locales for an application before the restore is applied. In this + * case, we want to keep the user settings and discard the restore. + */ + private void checkExistingLocalesAndApplyRestore(@NonNull String pkgName, + @NonNull String languageTags, int userId) { + try { + LocaleList currLocales = mLocaleManagerService.getApplicationLocales( + pkgName, + userId); + if (!currLocales.isEmpty()) { + return; + } + } catch (RemoteException e) { + Slog.e(TAG, "Could not check for current locales before restoring", e); + } + + // Restore the locale immediately + try { + mLocaleManagerService.setApplicationLocales(pkgName, userId, + LocaleList.forLanguageTags(languageTags)); + if (DEBUG) { + Slog.d(TAG, "Restored locales=" + languageTags + " for package=" + pkgName); + } + } catch (RemoteException | IllegalArgumentException e) { + Slog.e(TAG, "Could not restore locales for " + pkgName, e); + } + } + + /** + * Converts the list of app backups into xml and writes it onto the disk. + */ + private void writeStageFileLocked(int userId) { + StagedData stagedData = mStagedData.get(userId); + if (stagedData.mPackageStates.isEmpty()) { + deleteStagedDataLocked(userId); + return; + } + + final FileOutputStream stagedDataOutputStream; + AtomicFile stageFile = new AtomicFile( + new File(mStagedLocalesDir, + TextUtils.formatSimple("%s_%d.xml", STAGE_FILE_NAME, userId))); + try { + stagedDataOutputStream = stageFile.startWrite(); + } catch (IOException e) { + Slog.e(TAG, "Failed to save stage file"); + return; + } + + try { + writeToXml(stagedDataOutputStream, stagedData.mPackageStates, /* forStage= */ true, + stagedData.mCreationTimeMillis); + stageFile.finishWrite(stagedDataOutputStream); + if (DEBUG) { + Slog.d(TAG, "Stage file written."); + } + } catch (IOException e) { + Slog.e(TAG, "Could not write stage file", e); + stageFile.failWrite(stagedDataOutputStream); + } + } + + private void deleteStagedDataLocked(@UserIdInt int userId) { + AtomicFile stageFile = getStageFileIfExistsLocked(userId); + if (stageFile != null) { + stageFile.delete(); + } + mStagedData.remove(userId); + } + + private @Nullable AtomicFile getStageFileIfExistsLocked(@UserIdInt int userId) { + final File stageFile = new File(mStagedLocalesDir, + TextUtils.formatSimple("%s_%d.xml", STAGE_FILE_NAME, userId)); + return stageFile.isFile() ? new AtomicFile(stageFile) + : null; + } + + /** + * Parses the backup data from the serialized xml input stream. + */ + private @NonNull HashMap readFromXml(XmlPullParser parser) + throws IOException, XmlPullParserException { + HashMap packageStates = new HashMap<>(); + int depth = parser.getDepth(); + while (XmlUtils.nextElementWithin(parser, depth)) { + if (parser.getName().equals(PACKAGE_XML_TAG)) { + String packageName = parser.getAttributeValue(/* namespace= */ null, + ATTR_PACKAGE_NAME); + String languageTags = parser.getAttributeValue(/* namespace= */ null, ATTR_LOCALES); + + if (!TextUtils.isEmpty(packageName) && !TextUtils.isEmpty(languageTags)) { + packageStates.put(packageName, languageTags); + } + } + } + return packageStates; + } /** * Converts the list of app backup data into a serialized xml stream. + * + * @param forStage Flag to indicate whether this method is called for the purpose of + * staging the data. Note that if this is false, {@code creationTimeMillis} is ignored because + * we only need it for the stage data. + * @param creationTimeMillis The timestamp when the stage data was created. This is required + * to determine when to delete the stage data. */ private static void writeToXml(OutputStream stream, - @NonNull ArrayList pkgStates) throws IOException { + @NonNull HashMap pkgStates, boolean forStage, long creationTimeMillis) + throws IOException { + if (pkgStates.isEmpty()) { + // No need to write anything at all if pkgStates is empty. + return; + } + TypedXmlSerializer out = Xml.newFastSerializer(); out.setOutput(stream, StandardCharsets.UTF_8.name()); out.startDocument(/* encoding= */ null, /* standalone= */ true); - out.startTag(/* namespace= */ null, TAG); + out.startTag(/* namespace= */ null, LOCALES_XML_TAG); - for (BackupPackageState pkgState : pkgStates) { - out.startTag(/* namespace= */ null, "package"); - out.attribute(/* namespace= */ null, "name", pkgState.mPackageName); - out.attribute(/* namespace= */ null, "locales", pkgState.mLanguageTags); - out.endTag(/*namespace= */ null, "package"); + if (forStage) { + out.attribute(/* namespace= */ null, ATTR_CREATION_TIME_MILLIS, + Long.toString(creationTimeMillis)); } - out.endTag(/* namespace= */ null, TAG); + for (String pkg : pkgStates.keySet()) { + out.startTag(/* namespace= */ null, PACKAGE_XML_TAG); + out.attribute(/* namespace= */ null, ATTR_PACKAGE_NAME, pkg); + out.attribute(/* namespace= */ null, ATTR_LOCALES, pkgStates.get(pkg)); + out.endTag(/*namespace= */ null, PACKAGE_XML_TAG); + } + + out.endTag(/* namespace= */ null, LOCALES_XML_TAG); out.endDocument(); } - /** - * Data structure containing the backup data details for an app. - */ - private static class BackupPackageState { - final @NonNull String mPackageName; - final @NonNull String mLanguageTags; + private static class StagedData { + final long mCreationTimeMillis; + final HashMap mPackageStates; - BackupPackageState(@NonNull String packageNameArg, @NonNull String languageTagsArg) { - mPackageName = packageNameArg; - mLanguageTags = languageTagsArg; + StagedData(long creationTimeMillis, HashMap pkgStates) { + mCreationTimeMillis = creationTimeMillis; + mPackageStates = pkgStates; + } + } + + /** + * Broadcast listener to capture user removed event. + * + *

The stage file is deleted when a user is removed. + */ + private final class UserMonitor extends BroadcastReceiver { + @Override + public void onReceive(Context context, Intent intent) { + try { + String action = intent.getAction(); + if (action.equals(Intent.ACTION_USER_REMOVED)) { + final int userId = intent.getIntExtra(Intent.EXTRA_USER_HANDLE, USER_NULL); + synchronized (mStagedDataLock) { + deleteStagedDataLocked(userId); + } + } + } catch (Exception e) { + Slog.e(TAG, "Exception in user monitor.", e); + } + } + } + + /** + * Helper to monitor package states. + * + *

We're interested in package added, package data cleared and package removed events. + */ + private final class PackageMonitorImpl extends PackageMonitor { + @Override + public void onPackageAdded(String packageName, int uid) { + try { + synchronized (mStagedDataLock) { + int userId = UserHandle.getUserId(uid); + if (mStagedData.contains(userId)) { + // Perform lazy restore only if the staged data exists. + doLazyRestoreLocked(packageName, userId); + } + } + } catch (Exception e) { + Slog.e(TAG, "Exception in onPackageAdded.", e); + } + } + + @Override + public void onPackageDataCleared(String packageName, int uid) { + try { + notifyBackupManager(); + } catch (Exception e) { + Slog.e(TAG, "Exception in onPackageDataCleared.", e); + } + } + + @Override + public void onPackageRemoved(String packageName, int uid) { + try { + notifyBackupManager(); + } catch (Exception e) { + Slog.e(TAG, "Exception in onPackageRemoved.", e); + } + } + } + + /** + * Performs lazy restore from the staged data. + * + *

This is invoked by the package monitor on the package added callback. + */ + private void doLazyRestoreLocked(String packageName, int userId) { + if (DEBUG) { + Slog.d(TAG, "doLazyRestore package=" + packageName + " user=" + userId); + } + + // Check if the package is installed indeed + if (!isPackageInstalledForUser(packageName, userId)) { + Slog.e(TAG, packageName + " not installed for user " + userId + + ". Could not restore locales from stage file"); + return; + } + + StagedData stagedData = mStagedData.get(userId); + for (String pkgName : stagedData.mPackageStates.keySet()) { + String languageTags = stagedData.mPackageStates.get(pkgName); + + if (pkgName.equals(packageName)) { + + checkExistingLocalesAndApplyRestore(pkgName, languageTags, userId); + + // Remove the restored entry from the staged data list. + stagedData.mPackageStates.remove(pkgName); + // Update the file on the disk. + writeStageFileLocked(userId); + + // No need to loop further after restoring locales because the staged data will + // contain at most one entry for the newly added package. + break; + } } } } diff --git a/services/core/java/com/android/server/locales/LocaleManagerInternal.java b/services/core/java/com/android/server/locales/LocaleManagerInternal.java index 4b1fbe7f954c5..2db92a53552b8 100644 --- a/services/core/java/com/android/server/locales/LocaleManagerInternal.java +++ b/services/core/java/com/android/server/locales/LocaleManagerInternal.java @@ -28,4 +28,13 @@ public abstract class LocaleManagerInternal { * Returns the app-specific locales to be backed up as a data-blob. */ public abstract @Nullable byte[] getBackupPayload(int userId); + + /** + * Restores the app-locales that were previously backed up. + * + *

This method will parse the input data blob and restore the locales for apps which are + * present on the device. It will stage the locale data for the apps which are not installed + * at the time this is called, to be referenced later when the app is installed. + */ + public abstract void stageAndApplyRestoredPayload(byte[] payload, int userId); } diff --git a/services/core/java/com/android/server/locales/LocaleManagerService.java b/services/core/java/com/android/server/locales/LocaleManagerService.java index 6d029f1a9fd4e..c5352c934f894 100644 --- a/services/core/java/com/android/server/locales/LocaleManagerService.java +++ b/services/core/java/com/android/server/locales/LocaleManagerService.java @@ -70,6 +70,8 @@ public class LocaleManagerService extends SystemService { mActivityTaskManagerInternal = LocalServices.getService(ActivityTaskManagerInternal.class); mActivityManagerInternal = LocalServices.getService(ActivityManagerInternal.class); mPackageManagerInternal = LocalServices.getService(PackageManagerInternal.class); + mBackupHelper = new LocaleManagerBackupHelper(this, + mPackageManagerInternal); } @VisibleForTesting @@ -82,13 +84,13 @@ public class LocaleManagerService extends SystemService { mActivityTaskManagerInternal = activityTaskManagerInternal; mActivityManagerInternal = activityManagerInternal; mPackageManagerInternal = packageManagerInternal; + mBackupHelper = new LocaleManagerBackupHelper(this, + mPackageManagerInternal); } @Override public void onStart() { publishBinderService(Context.LOCALE_SERVICE, mBinderService); - mBackupHelper = new LocaleManagerBackupHelper(LocaleManagerService.this, - mPackageManagerInternal); LocalServices.addService(LocaleManagerInternal.class, new LocaleManagerInternalImpl()); } @@ -100,6 +102,11 @@ public class LocaleManagerService extends SystemService { return mBackupHelper.getBackupPayload(userId); } + @Override + public void stageAndApplyRestoredPayload(byte[] payload, int userId) { + mBackupHelper.stageAndApplyRestoredPayload(payload, userId); + } + private void checkCallerIsSystem() { if (Binder.getCallingUid() != Process.SYSTEM_UID) { throw new SecurityException("Caller is not system."); From a791f236f85757550188cf67399bb0b403bd56b9 Mon Sep 17 00:00:00 2001 From: Nishant Kumar Singh Date: Fri, 5 Nov 2021 11:02:55 +0000 Subject: [PATCH 3/4] Add unit tests for the app-locales backup logic. Bug: 204986584 Test: atest FrameworksServicesTests:LocaleManagerBackupRestoreTest Change-Id: Iac73c15f9b28aeb3bfc3f7af381cd49df443194e --- .../server/locales/LocaleManagerService.java | 11 +- .../LocaleManagerBackupRestoreTest.java | 199 ++++++++++++++++++ .../locales/LocaleManagerServiceTest.java | 15 +- .../ShadowLocaleManagerBackupHelper.java | 36 ++++ 4 files changed, 250 insertions(+), 11 deletions(-) create mode 100644 services/tests/servicestests/src/com/android/server/locales/LocaleManagerBackupRestoreTest.java create mode 100644 services/tests/servicestests/src/com/android/server/locales/ShadowLocaleManagerBackupHelper.java diff --git a/services/core/java/com/android/server/locales/LocaleManagerService.java b/services/core/java/com/android/server/locales/LocaleManagerService.java index c5352c934f894..6aabdb5b05df2 100644 --- a/services/core/java/com/android/server/locales/LocaleManagerService.java +++ b/services/core/java/com/android/server/locales/LocaleManagerService.java @@ -77,15 +77,15 @@ public class LocaleManagerService extends SystemService { @VisibleForTesting LocaleManagerService(Context context, ActivityTaskManagerInternal activityTaskManagerInternal, ActivityManagerInternal activityManagerInternal, - PackageManagerInternal packageManagerInternal) { + PackageManagerInternal packageManagerInternal, + LocaleManagerBackupHelper localeManagerBackupHelper) { super(context); mContext = context; mBinderService = new LocaleManagerBinderService(); mActivityTaskManagerInternal = activityTaskManagerInternal; mActivityManagerInternal = activityManagerInternal; mPackageManagerInternal = packageManagerInternal; - mBackupHelper = new LocaleManagerBackupHelper(this, - mPackageManagerInternal); + mBackupHelper = localeManagerBackupHelper; } @Override @@ -143,11 +143,6 @@ public class LocaleManagerService extends SystemService { } - @VisibleForTesting - LocaleManagerBackupHelper getBackupHelper() { - return mBackupHelper; - } - /** * Sets the current UI locales for a specified app. */ diff --git a/services/tests/servicestests/src/com/android/server/locales/LocaleManagerBackupRestoreTest.java b/services/tests/servicestests/src/com/android/server/locales/LocaleManagerBackupRestoreTest.java new file mode 100644 index 0000000000000..9cc59a1070d7a --- /dev/null +++ b/services/tests/servicestests/src/com/android/server/locales/LocaleManagerBackupRestoreTest.java @@ -0,0 +1,199 @@ +/* + * 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.server.locales; + +import static junit.framework.Assert.assertEquals; +import static junit.framework.Assert.assertNull; + +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; + +import android.content.Context; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageManagerInternal; +import android.os.Environment; +import android.os.LocaleList; +import android.os.RemoteException; +import android.os.SimpleClock; +import android.util.TypedXmlPullParser; +import android.util.Xml; + +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import com.android.internal.util.XmlUtils; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.xmlpull.v1.XmlPullParserException; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.ZoneOffset; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Unit tests for the {@link LocaleManagerInternal}. + */ +@RunWith(AndroidJUnit4.class) +public class LocaleManagerBackupRestoreTest { + private static final String DEFAULT_PACKAGE_NAME = "com.android.myapp"; + private static final String DEFAULT_LOCALE_TAGS = "en-XC,ar-XB"; + private static final String STAGE_LOCALES_XML_TAG = "locales"; + private static final int DEFAULT_USER_ID = 0; + private static final int INVALID_UID = -1; + private static final LocaleList DEFAULT_LOCALES = + LocaleList.forLanguageTags(DEFAULT_LOCALE_TAGS); + private static final Map DEFAULT_PACKAGE_LOCALES_MAP = Map.of( + DEFAULT_PACKAGE_NAME, DEFAULT_LOCALE_TAGS); + + private LocaleManagerBackupHelper mBackupHelper; + private long mCurrentTimeMillis; + + @Mock + private Context mMockContext; + @Mock + private PackageManagerInternal mMockPackageManagerInternal; + @Mock + private LocaleManagerService mMockLocaleManagerService; + + private final Clock mClock = new SimpleClock(ZoneOffset.UTC) { + @Override + public long millis() { + return currentTimeMillis(); + } + }; + + private long currentTimeMillis() { + return mCurrentTimeMillis; + } + + private void setCurrentTimeMillis(long currentTimeMillis) { + mCurrentTimeMillis = currentTimeMillis; + } + + @Before + public void setUp() throws Exception { + mMockContext = mock(Context.class); + mMockPackageManagerInternal = mock(PackageManagerInternal.class); + + mMockLocaleManagerService = mock(LocaleManagerService.class); + + mBackupHelper = new LocaleManagerBackupHelper(mMockContext, mMockLocaleManagerService, + mMockPackageManagerInternal, + new File(Environment.getExternalStorageDirectory(), "lmsUnitTests"), + mClock); + } + + @Test + public void testBackupPayload_noAppsInstalled_returnsNull() throws Exception { + doReturn(List.of()).when(mMockPackageManagerInternal) + .getInstalledApplications(anyLong(), anyInt(), anyInt()); + + assertNull(mBackupHelper.getBackupPayload(DEFAULT_USER_ID)); + } + + @Test + public void testBackupPayload_noAppLocalesSet_returnsNull() throws Exception { + setUpLocalesForPackage(DEFAULT_PACKAGE_NAME, LocaleList.getEmptyLocaleList()); + setUpDummyAppForPackageManager(DEFAULT_PACKAGE_NAME); + + assertNull(mBackupHelper.getBackupPayload(DEFAULT_USER_ID)); + } + + @Test + public void testBackupPayload_appLocalesSet_returnsNonNullBlob() throws Exception { + setUpLocalesForPackage(DEFAULT_PACKAGE_NAME, DEFAULT_LOCALES); + setUpDummyAppForPackageManager(DEFAULT_PACKAGE_NAME); + + byte[] payload = mBackupHelper.getBackupPayload(DEFAULT_USER_ID); + verifyPayloadForAppLocales(DEFAULT_PACKAGE_LOCALES_MAP, payload); + } + + @Test + public void testBackupPayload_exceptionInGetLocalesAllPackages_returnsNull() throws Exception { + setUpDummyAppForPackageManager(DEFAULT_PACKAGE_NAME); + doThrow(new RemoteException("mock")).when(mMockLocaleManagerService).getApplicationLocales( + anyString(), anyInt()); + + assertNull(mBackupHelper.getBackupPayload(DEFAULT_USER_ID)); + } + + @Test + public void testBackupPayload_exceptionInGetLocalesSomePackages_appsWithExceptionNotBackedUp() + throws Exception { + // Set up two apps. + ApplicationInfo defaultAppInfo = new ApplicationInfo(); + ApplicationInfo anotherAppInfo = new ApplicationInfo(); + defaultAppInfo.packageName = DEFAULT_PACKAGE_NAME; + anotherAppInfo.packageName = "com.android.anotherapp"; + doReturn(List.of(defaultAppInfo, anotherAppInfo)).when(mMockPackageManagerInternal) + .getInstalledApplications(anyLong(), anyInt(), anyInt()); + + setUpLocalesForPackage(DEFAULT_PACKAGE_NAME, DEFAULT_LOCALES); + // Exception when getting locales for anotherApp. + doThrow(new RemoteException("mock")).when(mMockLocaleManagerService).getApplicationLocales( + eq(anotherAppInfo.packageName), anyInt()); + + byte[] payload = mBackupHelper.getBackupPayload(DEFAULT_USER_ID); + verifyPayloadForAppLocales(DEFAULT_PACKAGE_LOCALES_MAP, payload); + } + + private void setUpLocalesForPackage(String packageName, LocaleList locales) throws Exception { + doReturn(locales).when(mMockLocaleManagerService).getApplicationLocales( + eq(packageName), anyInt()); + } + + private void setUpDummyAppForPackageManager(String packageName) { + ApplicationInfo dummyApp = new ApplicationInfo(); + dummyApp.packageName = packageName; + doReturn(List.of(dummyApp)).when(mMockPackageManagerInternal) + .getInstalledApplications(anyLong(), anyInt(), anyInt()); + } + + private void verifyPayloadForAppLocales(Map expectedPkgLocalesMap, + byte[] payload) + throws IOException, XmlPullParserException { + final ByteArrayInputStream stream = new ByteArrayInputStream(payload); + final TypedXmlPullParser parser = Xml.newFastPullParser(); + parser.setInput(stream, StandardCharsets.UTF_8.name()); + + Map backupDataMap = new HashMap<>(); + XmlUtils.beginDocument(parser, STAGE_LOCALES_XML_TAG); + int depth = parser.getDepth(); + while (XmlUtils.nextElementWithin(parser, depth)) { + if (parser.getName().equals("package")) { + String packageName = parser.getAttributeValue(null, "name"); + String languageTags = parser.getAttributeValue(null, "locales"); + backupDataMap.put(packageName, languageTags); + } + } + + assertEquals(expectedPkgLocalesMap, backupDataMap); + } +} diff --git a/services/tests/servicestests/src/com/android/server/locales/LocaleManagerServiceTest.java b/services/tests/servicestests/src/com/android/server/locales/LocaleManagerServiceTest.java index ddc58b23f6352..658f8d52d74b4 100644 --- a/services/tests/servicestests/src/com/android/server/locales/LocaleManagerServiceTest.java +++ b/services/tests/servicestests/src/com/android/server/locales/LocaleManagerServiceTest.java @@ -30,6 +30,7 @@ import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import android.Manifest; @@ -60,7 +61,7 @@ public class LocaleManagerServiceTest { private static final int DEFAULT_USER_ID = 0; private static final int DEFAULT_UID = Binder.getCallingUid() + 100; private static final int INVALID_UID = -1; - private static final String DEFAULT_LOCALE_TAGS = "en-XC, ar-XB"; + private static final String DEFAULT_LOCALE_TAGS = "en-XC,ar-XB"; private static final LocaleList DEFAULT_LOCALES = LocaleList.forLanguageTags(DEFAULT_LOCALE_TAGS); private static final InstallSourceInfo DEFAULT_INSTALL_SOURCE_INFO = new InstallSourceInfo( @@ -68,6 +69,7 @@ public class LocaleManagerServiceTest { /* originatingPackageName = */ null, /* installingPackageName = */ null); private LocaleManagerService mLocaleManagerService; + private LocaleManagerBackupHelper mMockBackupHelper; @Mock private Context mMockContext; @@ -104,8 +106,9 @@ public class LocaleManagerServiceTest { .handleIncomingUser(anyInt(), anyInt(), eq(DEFAULT_USER_ID), anyBoolean(), anyInt(), anyString(), anyString()); + mMockBackupHelper = mock(ShadowLocaleManagerBackupHelper.class); mLocaleManagerService = new LocaleManagerService(mMockContext, mMockActivityTaskManager, - mMockActivityManager, mMockPackageManagerInternal); + mMockActivityManager, mMockPackageManagerInternal, mMockBackupHelper); } @Test(expected = SecurityException.class) @@ -122,6 +125,7 @@ public class LocaleManagerServiceTest { verify(mMockContext).enforceCallingOrSelfPermission( eq(android.Manifest.permission.CHANGE_CONFIGURATION), anyString()); + verify(mMockBackupHelper, times(0)).notifyBackupManager(); assertNoLocalesStored(mFakePackageConfigurationUpdater.getStoredLocales()); } } @@ -133,6 +137,7 @@ public class LocaleManagerServiceTest { DEFAULT_USER_ID, LocaleList.getEmptyLocaleList()); fail("Expected NullPointerException"); } finally { + verify(mMockBackupHelper, times(0)).notifyBackupManager(); assertNoLocalesStored(mFakePackageConfigurationUpdater.getStoredLocales()); } } @@ -146,6 +151,7 @@ public class LocaleManagerServiceTest { /* locales = */ null); fail("Expected NullPointerException"); } finally { + verify(mMockBackupHelper, times(0)).notifyBackupManager(); assertNoLocalesStored(mFakePackageConfigurationUpdater.getStoredLocales()); } } @@ -163,6 +169,7 @@ public class LocaleManagerServiceTest { DEFAULT_LOCALES); assertEquals(DEFAULT_LOCALES, mFakePackageConfigurationUpdater.getStoredLocales()); + verify(mMockBackupHelper, times(1)).notifyBackupManager(); } @@ -175,6 +182,7 @@ public class LocaleManagerServiceTest { DEFAULT_LOCALES); assertEquals(DEFAULT_LOCALES, mFakePackageConfigurationUpdater.getStoredLocales()); + verify(mMockBackupHelper, times(1)).notifyBackupManager(); } @Test(expected = IllegalArgumentException.class) @@ -187,6 +195,7 @@ public class LocaleManagerServiceTest { fail("Expected IllegalArgumentException"); } finally { assertNoLocalesStored(mFakePackageConfigurationUpdater.getStoredLocales()); + verify(mMockBackupHelper, times(0)).notifyBackupManager(); } } @@ -217,7 +226,7 @@ public class LocaleManagerServiceTest { .when(mMockActivityTaskManager).getApplicationConfig(anyString(), anyInt()); LocaleList locales = mLocaleManagerService.getApplicationLocales( - DEFAULT_PACKAGE_NAME, DEFAULT_USER_ID); + DEFAULT_PACKAGE_NAME, DEFAULT_USER_ID); assertEquals(LocaleList.getEmptyLocaleList(), locales); } diff --git a/services/tests/servicestests/src/com/android/server/locales/ShadowLocaleManagerBackupHelper.java b/services/tests/servicestests/src/com/android/server/locales/ShadowLocaleManagerBackupHelper.java new file mode 100644 index 0000000000000..93972c32a50f0 --- /dev/null +++ b/services/tests/servicestests/src/com/android/server/locales/ShadowLocaleManagerBackupHelper.java @@ -0,0 +1,36 @@ +/* + * 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.server.locales; + +import android.content.Context; +import android.content.pm.PackageManagerInternal; + +import java.io.File; +import java.time.Clock; + +/** + * Shadow for {@link LocaleManagerBackupHelper} to enable mocking it for tests. + * + *

{@link LocaleManagerBackupHelper} is a package private class and hence not mockable directly. + */ +public class ShadowLocaleManagerBackupHelper extends LocaleManagerBackupHelper { + ShadowLocaleManagerBackupHelper(Context context, + LocaleManagerService localeManagerService, + PackageManagerInternal pmInternal, File stagedLocalesDir, Clock clock) { + super(context, localeManagerService, pmInternal, stagedLocalesDir, clock); + } +} From 0879d125095b59435f0384181406e0f5db68edfb Mon Sep 17 00:00:00 2001 From: Nishant Kumar Singh Date: Mon, 22 Nov 2021 09:47:01 +0000 Subject: [PATCH 4/4] Add unit tests for app-locales restore logic. Bug: 204986584 Test: atest FrameworksServicesTests:LocaleManagerBackupRestoreTest Change-Id: I225615b2c4114e8b3a45d705ea5df1e2b982612d --- .../LocaleManagerBackupRestoreTest.java | 559 +++++++++++++++++- 1 file changed, 550 insertions(+), 9 deletions(-) diff --git a/services/tests/servicestests/src/com/android/server/locales/LocaleManagerBackupRestoreTest.java b/services/tests/servicestests/src/com/android/server/locales/LocaleManagerBackupRestoreTest.java index 9cc59a1070d7a..70e78eb419994 100644 --- a/services/tests/servicestests/src/com/android/server/locales/LocaleManagerBackupRestoreTest.java +++ b/services/tests/servicestests/src/com/android/server/locales/LocaleManagerBackupRestoreTest.java @@ -17,28 +17,44 @@ package com.android.server.locales; import static junit.framework.Assert.assertEquals; +import static junit.framework.Assert.assertFalse; import static junit.framework.Assert.assertNull; +import static junit.framework.Assert.assertTrue; +import static org.junit.Assert.assertNotNull; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import android.content.BroadcastReceiver; import android.content.Context; +import android.content.Intent; import android.content.pm.ApplicationInfo; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; import android.content.pm.PackageManagerInternal; +import android.os.Binder; import android.os.Environment; import android.os.LocaleList; import android.os.RemoteException; import android.os.SimpleClock; +import android.util.AtomicFile; import android.util.TypedXmlPullParser; +import android.util.TypedXmlSerializer; import android.util.Xml; import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.android.internal.content.PackageMonitor; import com.android.internal.util.XmlUtils; import org.junit.Before; @@ -48,10 +64,15 @@ import org.mockito.Mock; import org.xmlpull.v1.XmlPullParserException; import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; import java.io.File; +import java.io.FileOutputStream; import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; import java.nio.charset.StandardCharsets; import java.time.Clock; +import java.time.Duration; import java.time.ZoneOffset; import java.util.HashMap; import java.util.List; @@ -64,13 +85,19 @@ import java.util.Map; public class LocaleManagerBackupRestoreTest { private static final String DEFAULT_PACKAGE_NAME = "com.android.myapp"; private static final String DEFAULT_LOCALE_TAGS = "en-XC,ar-XB"; - private static final String STAGE_LOCALES_XML_TAG = "locales"; + private static final String TEST_LOCALES_XML_TAG = "locales"; private static final int DEFAULT_USER_ID = 0; - private static final int INVALID_UID = -1; + private static final int WORK_PROFILE_USER_ID = 10; + private static final int DEFAULT_UID = Binder.getCallingUid() + 100; + private static final long DEFAULT_CREATION_TIME_MILLIS = 1000; + private static final Duration RETENTION_PERIOD = Duration.ofDays(3); private static final LocaleList DEFAULT_LOCALES = LocaleList.forLanguageTags(DEFAULT_LOCALE_TAGS); private static final Map DEFAULT_PACKAGE_LOCALES_MAP = Map.of( DEFAULT_PACKAGE_NAME, DEFAULT_LOCALE_TAGS); + private static final File STAGED_LOCALES_DIR = new File( + Environment.getExternalStorageDirectory(), "lmsUnitTests"); + private LocaleManagerBackupHelper mBackupHelper; private long mCurrentTimeMillis; @@ -80,7 +107,11 @@ public class LocaleManagerBackupRestoreTest { @Mock private PackageManagerInternal mMockPackageManagerInternal; @Mock + private PackageManager mMockPackageManager; + @Mock private LocaleManagerService mMockLocaleManagerService; + BroadcastReceiver mUserMonitor; + PackageMonitor mPackageMonitor; private final Clock mClock = new SimpleClock(ZoneOffset.UTC) { @Override @@ -101,13 +132,20 @@ public class LocaleManagerBackupRestoreTest { public void setUp() throws Exception { mMockContext = mock(Context.class); mMockPackageManagerInternal = mock(PackageManagerInternal.class); - + mMockPackageManager = mock(PackageManager.class); mMockLocaleManagerService = mock(LocaleManagerService.class); - mBackupHelper = new LocaleManagerBackupHelper(mMockContext, mMockLocaleManagerService, - mMockPackageManagerInternal, - new File(Environment.getExternalStorageDirectory(), "lmsUnitTests"), - mClock); + doReturn(mMockPackageManager).when(mMockContext).getPackageManager(); + + mBackupHelper = spy(new ShadowLocaleManagerBackupHelper(mMockContext, + mMockLocaleManagerService, mMockPackageManagerInternal, + new File(Environment.getExternalStorageDirectory(), "lmsUnitTests"), mClock)); + doNothing().when(mBackupHelper).notifyBackupManager(); + + mUserMonitor = mBackupHelper.getUserMonitor(); + mPackageMonitor = mBackupHelper.getPackageMonitor(); + setCurrentTimeMillis(DEFAULT_CREATION_TIME_MILLIS); + cleanStagedFiles(); } @Test @@ -164,6 +202,414 @@ public class LocaleManagerBackupRestoreTest { verifyPayloadForAppLocales(DEFAULT_PACKAGE_LOCALES_MAP, payload); } + @Test + public void testRestore_nullPayload_nothingRestoredAndNoStageFile() throws Exception { + mBackupHelper.stageAndApplyRestoredPayload(/* payload= */ null, DEFAULT_USER_ID); + + verifyNothingRestored(); + checkStageFileDoesNotExist(DEFAULT_USER_ID); + } + + @Test + public void testRestore_zeroLengthPayload_nothingRestoredAndNoStageFile() throws Exception { + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + mBackupHelper.stageAndApplyRestoredPayload(/* payload= */ out.toByteArray(), + DEFAULT_USER_ID); + + verifyNothingRestored(); + checkStageFileDoesNotExist(DEFAULT_USER_ID); + } + + @Test + public void testRestore_allAppsInstalled_noStageFileCreated() throws Exception { + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + writeTestPayload(out, DEFAULT_PACKAGE_LOCALES_MAP); + + setUpPackageInstalled(DEFAULT_PACKAGE_NAME); + setUpLocalesForPackage(DEFAULT_PACKAGE_NAME, LocaleList.getEmptyLocaleList()); + + mBackupHelper.stageAndApplyRestoredPayload(out.toByteArray(), DEFAULT_USER_ID); + + // Locales were restored + verify(mMockLocaleManagerService, times(1)).setApplicationLocales(DEFAULT_PACKAGE_NAME, + DEFAULT_USER_ID, DEFAULT_LOCALES); + + // Stage file wasn't created. + checkStageFileDoesNotExist(DEFAULT_USER_ID); + } + + @Test + public void testRestore_noAppsInstalled_everythingStaged() throws Exception { + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + writeTestPayload(out, DEFAULT_PACKAGE_LOCALES_MAP); + + setUpPackageNotInstalled(DEFAULT_PACKAGE_NAME); + + mBackupHelper.stageAndApplyRestoredPayload(out.toByteArray(), DEFAULT_USER_ID); + + verifyNothingRestored(); + verifyStageFileContent(DEFAULT_PACKAGE_LOCALES_MAP, + getStageFileIfExists(DEFAULT_USER_ID), DEFAULT_CREATION_TIME_MILLIS); + } + + @Test + public void testRestore_someAppsInstalled_partiallyStaged() throws Exception { + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + HashMap pkgLocalesMap = new HashMap<>(); + + String pkgNameA = "com.android.myAppA", pkgNameB = "com.android.myAppB"; + String langTagsA = "ru", langTagsB = "hi,fr"; + pkgLocalesMap.put(pkgNameA, langTagsA); + pkgLocalesMap.put(pkgNameB, langTagsB); + writeTestPayload(out, pkgLocalesMap); + + setUpPackageInstalled(pkgNameA); + setUpPackageNotInstalled(pkgNameB); + setUpLocalesForPackage(pkgNameA, LocaleList.getEmptyLocaleList()); + + mBackupHelper.stageAndApplyRestoredPayload(out.toByteArray(), DEFAULT_USER_ID); + + verify(mMockLocaleManagerService, times(1)).setApplicationLocales(pkgNameA, DEFAULT_USER_ID, + LocaleList.forLanguageTags(langTagsA)); + + pkgLocalesMap.remove(pkgNameA); + verifyStageFileContent(pkgLocalesMap, getStageFileIfExists(DEFAULT_USER_ID), + DEFAULT_CREATION_TIME_MILLIS); + } + + @Test + public void testRestore_appLocalesAlreadySet_nothingRestoredAndNoStageFile() throws Exception { + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + writeTestPayload(out, DEFAULT_PACKAGE_LOCALES_MAP); + + setUpPackageInstalled(DEFAULT_PACKAGE_NAME); + setUpLocalesForPackage(DEFAULT_PACKAGE_NAME, LocaleList.forLanguageTags("hi,mr")); + + mBackupHelper.stageAndApplyRestoredPayload(out.toByteArray(), DEFAULT_USER_ID); + + // Since locales are already set, we should not restore anything for it. + verifyNothingRestored(); + // Stage file wasn't created + checkStageFileDoesNotExist(DEFAULT_USER_ID); + } + + @Test + public void testRestore_appLocalesSetForSomeApps_restoresOnlyForAppsHavingNoLocalesSet() + throws Exception { + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + HashMap pkgLocalesMap = new HashMap<>(); + + String pkgNameA = "com.android.myAppA", pkgNameB = "com.android.myAppB", pkgNameC = + "com.android.myAppC"; + String langTagsA = "ru", langTagsB = "hi,fr", langTagsC = "zh,es"; + pkgLocalesMap.put(pkgNameA, langTagsA); + pkgLocalesMap.put(pkgNameB, langTagsB); + pkgLocalesMap.put(pkgNameC, langTagsC); + writeTestPayload(out, pkgLocalesMap); + + // Both app A & B are installed on the device but A has locales already set. + setUpPackageInstalled(pkgNameA); + setUpPackageInstalled(pkgNameB); + setUpPackageNotInstalled(pkgNameC); + setUpLocalesForPackage(pkgNameA, LocaleList.forLanguageTags("mr,fr")); + setUpLocalesForPackage(pkgNameB, LocaleList.getEmptyLocaleList()); + setUpLocalesForPackage(pkgNameC, LocaleList.getEmptyLocaleList()); + + mBackupHelper.stageAndApplyRestoredPayload(out.toByteArray(), DEFAULT_USER_ID); + + // Restore locales only for myAppB. + verify(mMockLocaleManagerService, times(0)).setApplicationLocales(eq(pkgNameA), anyInt(), + any()); + verify(mMockLocaleManagerService, times(1)).setApplicationLocales(pkgNameB, DEFAULT_USER_ID, + LocaleList.forLanguageTags(langTagsB)); + verify(mMockLocaleManagerService, times(0)).setApplicationLocales(eq(pkgNameC), anyInt(), + any()); + + // App C is staged. + pkgLocalesMap.remove(pkgNameA); + pkgLocalesMap.remove(pkgNameB); + verifyStageFileContent(pkgLocalesMap, getStageFileIfExists(DEFAULT_USER_ID), + DEFAULT_CREATION_TIME_MILLIS); + } + + @Test + public void testRestore_restoreInvokedAgain_creationTimeChanged() throws Exception { + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + writeTestPayload(out, DEFAULT_PACKAGE_LOCALES_MAP); + + setUpPackageNotInstalled(DEFAULT_PACKAGE_NAME); + + mBackupHelper.stageAndApplyRestoredPayload(out.toByteArray(), DEFAULT_USER_ID); + + verifyStageFileContent(DEFAULT_PACKAGE_LOCALES_MAP, getStageFileIfExists(DEFAULT_USER_ID), + DEFAULT_CREATION_TIME_MILLIS); + + final long newCreationTime = DEFAULT_CREATION_TIME_MILLIS + 100; + setCurrentTimeMillis(newCreationTime); + mBackupHelper.stageAndApplyRestoredPayload(out.toByteArray(), DEFAULT_USER_ID); + + verifyStageFileContent(DEFAULT_PACKAGE_LOCALES_MAP, getStageFileIfExists(DEFAULT_USER_ID), + newCreationTime); + } + + @Test + public void testRestore_appInstalledAfterSUW_restoresFromStage() throws Exception { + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + HashMap pkgLocalesMap = new HashMap<>(); + + String pkgNameA = "com.android.myAppA", pkgNameB = "com.android.myAppB"; + String langTagsA = "ru", langTagsB = "hi,fr"; + pkgLocalesMap.put(pkgNameA, langTagsA); + pkgLocalesMap.put(pkgNameB, langTagsB); + writeTestPayload(out, pkgLocalesMap); + + setUpPackageNotInstalled(pkgNameA); + setUpPackageNotInstalled(pkgNameB); + setUpLocalesForPackage(pkgNameA, LocaleList.getEmptyLocaleList()); + setUpLocalesForPackage(pkgNameB, LocaleList.getEmptyLocaleList()); + + mBackupHelper.stageAndApplyRestoredPayload(out.toByteArray(), DEFAULT_USER_ID); + + verifyNothingRestored(); + + setUpPackageInstalled(pkgNameA); + + mPackageMonitor.onPackageAdded(pkgNameA, DEFAULT_UID); + + verify(mMockLocaleManagerService, times(1)).setApplicationLocales(pkgNameA, DEFAULT_USER_ID, + LocaleList.forLanguageTags(langTagsA)); + + pkgLocalesMap.remove(pkgNameA); + verifyStageFileContent(pkgLocalesMap, getStageFileIfExists(DEFAULT_USER_ID), + DEFAULT_CREATION_TIME_MILLIS); + + setUpPackageInstalled(pkgNameB); + + mPackageMonitor.onPackageAdded(pkgNameB, DEFAULT_UID); + + verify(mMockLocaleManagerService, times(1)).setApplicationLocales(pkgNameB, DEFAULT_USER_ID, + LocaleList.forLanguageTags(langTagsB)); + checkStageFileDoesNotExist(DEFAULT_USER_ID); + } + + @Test + public void testRestore_appInstalledAfterSUWAndLocalesAlreadySet_restoresNothing() + throws Exception { + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + writeTestPayload(out, DEFAULT_PACKAGE_LOCALES_MAP); + + // Package is not present on the device when the SUW restore is going on. + setUpPackageNotInstalled(DEFAULT_PACKAGE_NAME); + + mBackupHelper.stageAndApplyRestoredPayload(out.toByteArray(), DEFAULT_USER_ID); + + verifyNothingRestored(); + verifyStageFileContent(DEFAULT_PACKAGE_LOCALES_MAP, getStageFileIfExists(DEFAULT_USER_ID), + DEFAULT_CREATION_TIME_MILLIS); + + // App is installed later (post SUW). + setUpPackageInstalled(DEFAULT_PACKAGE_NAME); + setUpLocalesForPackage(DEFAULT_PACKAGE_NAME, LocaleList.forLanguageTags("hi,mr")); + + mPackageMonitor.onPackageAdded(DEFAULT_PACKAGE_NAME, DEFAULT_UID); + + // Since locales are already set, we should not restore anything for it. + verifyNothingRestored(); + checkStageFileDoesNotExist(DEFAULT_USER_ID); + } + + @Test + public void testStageFileDeletion_backupPassRunAfterRetentionPeriod_stageFileDeleted() + throws Exception { + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + writeTestPayload(out, DEFAULT_PACKAGE_LOCALES_MAP); + + setUpPackageNotInstalled(DEFAULT_PACKAGE_NAME); + + mBackupHelper.stageAndApplyRestoredPayload(out.toByteArray(), DEFAULT_USER_ID); + + verifyNothingRestored(); + verifyStageFileContent(DEFAULT_PACKAGE_LOCALES_MAP, getStageFileIfExists(DEFAULT_USER_ID), + DEFAULT_CREATION_TIME_MILLIS); + + // Retention period has not elapsed. + setCurrentTimeMillis( + DEFAULT_CREATION_TIME_MILLIS + RETENTION_PERIOD.minusHours(1).toMillis()); + doReturn(List.of()).when(mMockPackageManagerInternal) + .getInstalledApplications(anyLong(), anyInt(), anyInt()); + assertNull(mBackupHelper.getBackupPayload(DEFAULT_USER_ID)); + + // Stage file should NOT be deleted. + checkStageFileExists(DEFAULT_USER_ID); + + // Exactly RETENTION_PERIOD amount of time has passed so stage file should still not be + // removed. + setCurrentTimeMillis(DEFAULT_CREATION_TIME_MILLIS + RETENTION_PERIOD.toMillis()); + doReturn(List.of()).when(mMockPackageManagerInternal) + .getInstalledApplications(anyLong(), anyInt(), anyInt()); + assertNull(mBackupHelper.getBackupPayload(DEFAULT_USER_ID)); + + // Stage file should NOT be deleted. + checkStageFileExists(DEFAULT_USER_ID); + + // Retention period has now expired, stage file should be deleted. + setCurrentTimeMillis( + DEFAULT_CREATION_TIME_MILLIS + RETENTION_PERIOD.plusSeconds(1).toMillis()); + doReturn(List.of()).when(mMockPackageManagerInternal) + .getInstalledApplications(anyLong(), anyInt(), anyInt()); + assertNull(mBackupHelper.getBackupPayload(DEFAULT_USER_ID)); + + // Stage file should be deleted. + checkStageFileDoesNotExist(DEFAULT_USER_ID); + } + + @Test + public void testUserRemoval_userRemoved_stageFileDeleted() throws Exception { + final ByteArrayOutputStream outDefault = new ByteArrayOutputStream(); + writeTestPayload(outDefault, DEFAULT_PACKAGE_LOCALES_MAP); + + final ByteArrayOutputStream outWorkProfile = new ByteArrayOutputStream(); + String anotherPackage = "com.android.anotherapp"; + String anotherLangTags = "mr,zh"; + HashMap pkgLocalesMapWorkProfile = new HashMap<>(); + pkgLocalesMapWorkProfile.put(anotherPackage, anotherLangTags); + writeTestPayload(outWorkProfile, pkgLocalesMapWorkProfile); + + // DEFAULT_PACKAGE_NAME is NOT installed on the device. + setUpPackageNotInstalled(DEFAULT_PACKAGE_NAME); + setUpPackageNotInstalled(anotherPackage); + + mBackupHelper.stageAndApplyRestoredPayload(outDefault.toByteArray(), DEFAULT_USER_ID); + mBackupHelper.stageAndApplyRestoredPayload(outWorkProfile.toByteArray(), + WORK_PROFILE_USER_ID); + + verifyNothingRestored(); + + // Verify stage file contents. + AtomicFile stageFileDefaultUser = getStageFileIfExists(DEFAULT_USER_ID); + verifyStageFileContent(DEFAULT_PACKAGE_LOCALES_MAP, stageFileDefaultUser, + DEFAULT_CREATION_TIME_MILLIS); + + AtomicFile stageFileWorkProfile = getStageFileIfExists(WORK_PROFILE_USER_ID); + verifyStageFileContent(pkgLocalesMapWorkProfile, stageFileWorkProfile, + DEFAULT_CREATION_TIME_MILLIS); + + Intent intent = new Intent(); + intent.setAction(Intent.ACTION_USER_REMOVED); + intent.putExtra(Intent.EXTRA_USER_HANDLE, DEFAULT_USER_ID); + mUserMonitor.onReceive(mMockContext, intent); + + // Stage file should be removed only for DEFAULT_USER_ID. + checkStageFileDoesNotExist(DEFAULT_USER_ID); + verifyStageFileContent(pkgLocalesMapWorkProfile, stageFileWorkProfile, + DEFAULT_CREATION_TIME_MILLIS); + } + + @Test + public void testLoadStageFiles_invalidNameFormat_stageFileDeleted() throws Exception { + // Stage file name should be : staged_locales_.xml + File stageFile = new File(STAGED_LOCALES_DIR, "xyz.xml"); + assertTrue(stageFile.createNewFile()); + assertTrue(stageFile.isFile()); + + // Putting valid xml data in file. + FileOutputStream out = new FileOutputStream(stageFile); + writeTestPayload(out, DEFAULT_PACKAGE_LOCALES_MAP, /* forStage= */ + true, /* creationTimeMillis= */ 0); + out.flush(); + out.close(); + + verifyStageFileContent(DEFAULT_PACKAGE_LOCALES_MAP, + new AtomicFile(stageFile), /* creationTimeMillis= */ 0); + + mBackupHelper = new LocaleManagerBackupHelper(mMockContext, mMockLocaleManagerService, + mMockPackageManagerInternal, STAGED_LOCALES_DIR, mClock); + assertFalse(stageFile.isFile()); + } + + @Test + public void testLoadStageFiles_userIdNotParseable_stageFileDeleted() throws Exception { + // Stage file name should be : staged_locales_.xml + File stageFile = new File(STAGED_LOCALES_DIR, "staged_locales_abc.xml"); + assertTrue(stageFile.createNewFile()); + assertTrue(stageFile.isFile()); + + // Putting valid xml data in file. + FileOutputStream out = new FileOutputStream(stageFile); + writeTestPayload(out, DEFAULT_PACKAGE_LOCALES_MAP, /* forStage= */ + true, /* creationTimeMillis= */ 0); + out.flush(); + out.close(); + + verifyStageFileContent(DEFAULT_PACKAGE_LOCALES_MAP, + new AtomicFile(stageFile), /* creationTimeMillis= */ 0); + + mBackupHelper = new LocaleManagerBackupHelper(mMockContext, mMockLocaleManagerService, + mMockPackageManagerInternal, STAGED_LOCALES_DIR, mClock); + assertFalse(stageFile.isFile()); + } + + @Test + public void testLoadStageFiles_invalidContent_stageFileDeleted() throws Exception { + File stageFile = new File(STAGED_LOCALES_DIR, "staged_locales_0.xml"); + assertTrue(stageFile.createNewFile()); + assertTrue(stageFile.isFile()); + + FileOutputStream out = new FileOutputStream(stageFile); + out.write("some_non_xml_string".getBytes()); + out.close(); + + mBackupHelper = new LocaleManagerBackupHelper(mMockContext, mMockLocaleManagerService, + mMockPackageManagerInternal, STAGED_LOCALES_DIR, mClock); + assertFalse(stageFile.isFile()); + } + + @Test + public void testLoadStageFiles_validContent_doesLazyRestore() throws Exception { + File stageFile = new File(STAGED_LOCALES_DIR, "staged_locales_0.xml"); + assertTrue(stageFile.createNewFile()); + assertTrue(stageFile.isFile()); + + // Putting valid xml data in file. + FileOutputStream out = new FileOutputStream(stageFile); + writeTestPayload(out, DEFAULT_PACKAGE_LOCALES_MAP, /* forStage= */ + true, DEFAULT_CREATION_TIME_MILLIS); + out.flush(); + out.close(); + + verifyStageFileContent(DEFAULT_PACKAGE_LOCALES_MAP, + new AtomicFile(stageFile), DEFAULT_CREATION_TIME_MILLIS); + + mBackupHelper = new LocaleManagerBackupHelper(mMockContext, mMockLocaleManagerService, + mMockPackageManagerInternal, STAGED_LOCALES_DIR, mClock); + mPackageMonitor = mBackupHelper.getPackageMonitor(); + + // Stage file still exists. + assertTrue(stageFile.isFile()); + + // App is installed later. + setUpPackageInstalled(DEFAULT_PACKAGE_NAME); + setUpLocalesForPackage(DEFAULT_PACKAGE_NAME, LocaleList.getEmptyLocaleList()); + + mPackageMonitor.onPackageAdded(DEFAULT_PACKAGE_NAME, DEFAULT_UID); + + verify(mMockLocaleManagerService, times(1)).setApplicationLocales(DEFAULT_PACKAGE_NAME, + DEFAULT_USER_ID, DEFAULT_LOCALES); + + // Stage file gets deleted here because all staged locales have been applied. + assertFalse(stageFile.isFile()); + } + + private void setUpPackageInstalled(String packageName) throws Exception { + doReturn(new PackageInfo()).when(mMockPackageManager).getPackageInfoAsUser( + eq(packageName), anyInt(), anyInt()); + } + + private void setUpPackageNotInstalled(String packageName) throws Exception { + doReturn(null).when(mMockPackageManager).getPackageInfoAsUser(eq(packageName), + anyInt(), anyInt()); + } + private void setUpLocalesForPackage(String packageName, LocaleList locales) throws Exception { doReturn(locales).when(mMockLocaleManagerService).getApplicationLocales( eq(packageName), anyInt()); @@ -176,15 +622,38 @@ public class LocaleManagerBackupRestoreTest { .getInstalledApplications(anyLong(), anyInt(), anyInt()); } - private void verifyPayloadForAppLocales(Map expectedPkgLocalesMap, + /** + * Verifies that nothing was restored for any package. + * + *

If {@link LocaleManagerService#setApplicationLocales} is not invoked, we can conclude + * that nothing was restored. + */ + private void verifyNothingRestored() throws Exception { + verify(mMockLocaleManagerService, times(0)).setApplicationLocales(anyString(), anyInt(), + any()); + } + + + private static void verifyPayloadForAppLocales(Map expectedPkgLocalesMap, byte[] payload) throws IOException, XmlPullParserException { + verifyPayloadForAppLocales(expectedPkgLocalesMap, payload, /* forStage= */ false, -1); + } + + private static void verifyPayloadForAppLocales(Map expectedPkgLocalesMap, + byte[] payload, boolean forStage, long expectedCreationTime) + throws IOException, XmlPullParserException { final ByteArrayInputStream stream = new ByteArrayInputStream(payload); final TypedXmlPullParser parser = Xml.newFastPullParser(); parser.setInput(stream, StandardCharsets.UTF_8.name()); Map backupDataMap = new HashMap<>(); - XmlUtils.beginDocument(parser, STAGE_LOCALES_XML_TAG); + XmlUtils.beginDocument(parser, TEST_LOCALES_XML_TAG); + if (forStage) { + long actualCreationTime = parser.getAttributeLong(/* namespace= */ null, + "creationTimeMillis"); + assertEquals(expectedCreationTime, actualCreationTime); + } int depth = parser.getDepth(); while (XmlUtils.nextElementWithin(parser, depth)) { if (parser.getName().equals("package")) { @@ -196,4 +665,76 @@ public class LocaleManagerBackupRestoreTest { assertEquals(expectedPkgLocalesMap, backupDataMap); } + + private static void writeTestPayload(OutputStream stream, Map pkgLocalesMap) + throws IOException { + writeTestPayload(stream, pkgLocalesMap, /* forStage= */ false, /* creationTimeMillis= */ + -1); + } + + private static void writeTestPayload(OutputStream stream, Map pkgLocalesMap, + boolean forStage, long creationTimeMillis) + throws IOException { + if (pkgLocalesMap.isEmpty()) { + return; + } + + TypedXmlSerializer out = Xml.newFastSerializer(); + out.setOutput(stream, StandardCharsets.UTF_8.name()); + out.startDocument(/* encoding= */ null, /* standalone= */ true); + out.startTag(/* namespace= */ null, TEST_LOCALES_XML_TAG); + + if (forStage) { + out.attribute(/* namespace= */ null, "creationTimeMillis", + Long.toString(creationTimeMillis)); + } + + for (String pkg : pkgLocalesMap.keySet()) { + out.startTag(/* namespace= */ null, "package"); + out.attribute(/* namespace= */ null, "name", pkg); + out.attribute(/* namespace= */ null, "locales", pkgLocalesMap.get(pkg)); + out.endTag(/*namespace= */ null, "package"); + } + + out.endTag(/* namespace= */ null, TEST_LOCALES_XML_TAG); + out.endDocument(); + } + + private static void verifyStageFileContent(Map expectedPkgLocalesMap, + AtomicFile stageFile, + long creationTimeMillis) + throws Exception { + assertNotNull(stageFile); + try (InputStream stagedDataInputStream = stageFile.openRead()) { + verifyPayloadForAppLocales(expectedPkgLocalesMap, stagedDataInputStream.readAllBytes(), + /* forStage= */ true, creationTimeMillis); + } catch (IOException | XmlPullParserException e) { + throw e; + } + } + + private static void checkStageFileDoesNotExist(int userId) { + assertNull(getStageFileIfExists(userId)); + } + + private static void checkStageFileExists(int userId) { + assertNotNull(getStageFileIfExists(userId)); + } + + private static AtomicFile getStageFileIfExists(int userId) { + File file = new File(STAGED_LOCALES_DIR, String.format("staged_locales_%d.xml", userId)); + if (file.isFile()) { + return new AtomicFile(file); + } + return null; + } + + private static void cleanStagedFiles() { + File[] files = STAGED_LOCALES_DIR.listFiles(); + if (files != null) { + for (File f : files) { + f.delete(); + } + } + } }