Merge changes from topic "panlingual-bnr"

* changes:
  Add unit tests for app-locales restore logic.
  Add unit tests for the app-locales backup logic.
  Add the restore logic for app-locales.
  Add the backup logic for app-locales.
This commit is contained in:
Nishant Singh
2021-12-09 15:30:11 +00:00
committed by Android (Google) Code Review
6 changed files with 1478 additions and 5 deletions

View File

@@ -0,0 +1,615 @@
/*
* 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 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.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.
*/
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 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<StagedData> 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.
*
* <p><b>Note:</b> 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;
}
/**
* @see LocaleManagerInternal#getBackupPayload(int userId)
*/
public byte[] getBackupPayload(int userId) {
if (DEBUG) {
Slog.d(TAG, "getBackupPayload invoked for user id " + userId);
}
synchronized (mStagedDataLock) {
cleanStagedDataForOldEntriesLocked();
}
HashMap<String, String> pkgStates = new HashMap<>();
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.put(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 {
// 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;
}
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();
}
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<String, String> 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.
*/
public void notifyBackupManager() {
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.
* <p>
* 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<String, String> readFromXml(XmlPullParser parser)
throws IOException, XmlPullParserException {
HashMap<String, String> 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 HashMap<String, String> 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, LOCALES_XML_TAG);
if (forStage) {
out.attribute(/* namespace= */ null, ATTR_CREATION_TIME_MILLIS,
Long.toString(creationTimeMillis));
}
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();
}
private static class StagedData {
final long mCreationTimeMillis;
final HashMap<String, String> mPackageStates;
StagedData(long creationTimeMillis, HashMap<String, String> pkgStates) {
mCreationTimeMillis = creationTimeMillis;
mPackageStates = pkgStates;
}
}
/**
* Broadcast listener to capture user removed event.
*
* <p>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.
*
* <p>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.
*
* <p>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;
}
}
}
}

View File

@@ -0,0 +1,40 @@
/*
* 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);
/**
* Restores the app-locales that were previously backed up.
*
* <p>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);
}

View File

@@ -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) {
@@ -65,23 +70,48 @@ 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
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 = localeManagerBackupHelper;
}
@Override
public void onStart() {
publishBinderService(Context.LOCALE_SERVICE, mBinderService);
LocalServices.addService(LocaleManagerInternal.class, new LocaleManagerInternalImpl());
}
private final class LocaleManagerInternalImpl extends LocaleManagerInternal {
@Override
public @Nullable byte[] getBackupPayload(int userId) {
checkCallerIsSystem();
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.");
}
}
}
private final class LocaleManagerBinderService extends ILocaleManager.Stub {
@@ -110,6 +140,7 @@ public class LocaleManagerService extends SystemService {
(new LocaleManagerShellCommand(mBinderService))
.exec(this, in, out, err, args, callback, resultReceiver);
}
}
/**
@@ -161,6 +192,8 @@ public class LocaleManagerService extends SystemService {
notifyAppWhoseLocaleChanged(appPackageName, userId, locales);
notifyInstallerOfAppWhoseLocaleChanged(appPackageName, userId, locales);
notifyRegisteredReceivers(appPackageName, userId, locales);
mBackupHelper.notifyBackupManager();
}
}

View File

@@ -0,0 +1,740 @@
/*
* 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.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;
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.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;
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 TEST_LOCALES_XML_TAG = "locales";
private static final int DEFAULT_USER_ID = 0;
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<String, String> 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;
@Mock
private Context mMockContext;
@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
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);
mMockPackageManager = mock(PackageManager.class);
mMockLocaleManagerService = mock(LocaleManagerService.class);
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
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);
}
@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<String, String> 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<String, String> 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<String, String> 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<String, String> 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_<user_id_int>.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_<user_id_int>.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());
}
private void setUpDummyAppForPackageManager(String packageName) {
ApplicationInfo dummyApp = new ApplicationInfo();
dummyApp.packageName = packageName;
doReturn(List.of(dummyApp)).when(mMockPackageManagerInternal)
.getInstalledApplications(anyLong(), anyInt(), anyInt());
}
/**
* Verifies that nothing was restored for any package.
*
* <p>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<String, String> expectedPkgLocalesMap,
byte[] payload)
throws IOException, XmlPullParserException {
verifyPayloadForAppLocales(expectedPkgLocalesMap, payload, /* forStage= */ false, -1);
}
private static void verifyPayloadForAppLocales(Map<String, String> 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<String, String> backupDataMap = new HashMap<>();
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")) {
String packageName = parser.getAttributeValue(null, "name");
String languageTags = parser.getAttributeValue(null, "locales");
backupDataMap.put(packageName, languageTags);
}
}
assertEquals(expectedPkgLocalesMap, backupDataMap);
}
private static void writeTestPayload(OutputStream stream, Map<String, String> pkgLocalesMap)
throws IOException {
writeTestPayload(stream, pkgLocalesMap, /* forStage= */ false, /* creationTimeMillis= */
-1);
}
private static void writeTestPayload(OutputStream stream, Map<String, String> 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<String, String> 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();
}
}
}
}

View File

@@ -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);
}

View File

@@ -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.
*
* <p>{@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);
}
}