diff --git a/services/core/java/com/android/server/backup/AppGrammaticalGenderBackupHelper.java b/services/core/java/com/android/server/backup/AppGrammaticalGenderBackupHelper.java new file mode 100644 index 0000000000000..9e8db6e0b8ab1 --- /dev/null +++ b/services/core/java/com/android/server/backup/AppGrammaticalGenderBackupHelper.java @@ -0,0 +1,66 @@ +/* + * Copyright (C) 2022 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.backup; + +import static android.app.backup.BackupAgent.FLAG_CLIENT_SIDE_ENCRYPTION_ENABLED; + +import android.annotation.UserIdInt; +import android.app.backup.BackupDataOutput; +import android.app.backup.BlobBackupHelper; +import android.os.ParcelFileDescriptor; + +import com.android.server.LocalServices; +import com.android.server.grammaticalinflection.GrammaticalInflectionManagerInternal; + +public class AppGrammaticalGenderBackupHelper extends BlobBackupHelper { + private static final int BLOB_VERSION = 1; + private static final String KEY_APP_GENDER = "app_gender"; + + private final @UserIdInt int mUserId; + private final GrammaticalInflectionManagerInternal mGrammarInflectionManagerInternal; + + public AppGrammaticalGenderBackupHelper(int userId) { + super(BLOB_VERSION, KEY_APP_GENDER); + mUserId = userId; + mGrammarInflectionManagerInternal = LocalServices.getService( + GrammaticalInflectionManagerInternal.class); + } + + @Override + public void performBackup(ParcelFileDescriptor oldStateFd, BackupDataOutput data, + ParcelFileDescriptor newStateFd) { + // Only backup the gender data if e2e encryption is present + if ((data.getTransportFlags() & FLAG_CLIENT_SIDE_ENCRYPTION_ENABLED) == 0) { + return; + } + + super.performBackup(oldStateFd, data, newStateFd); + } + + @Override + protected byte[] getBackupPayload(String key) { + return KEY_APP_GENDER.equals(key) && mGrammarInflectionManagerInternal != null ? + mGrammarInflectionManagerInternal.getBackupPayload(mUserId) : null; + } + + @Override + protected void applyRestoredPayload(String key, byte[] payload) { + if (KEY_APP_GENDER.equals(key) && mGrammarInflectionManagerInternal != null) { + mGrammarInflectionManagerInternal.stageAndApplyRestoredPayload(payload, mUserId); + } + } +} \ No newline at end of file diff --git a/services/core/java/com/android/server/backup/SystemBackupAgent.java b/services/core/java/com/android/server/backup/SystemBackupAgent.java index 1b20e43c966cc..c5b89409d1f6f 100644 --- a/services/core/java/com/android/server/backup/SystemBackupAgent.java +++ b/services/core/java/com/android/server/backup/SystemBackupAgent.java @@ -58,6 +58,7 @@ public class SystemBackupAgent extends BackupAgentHelper { private static final String SLICES_HELPER = "slices"; private static final String PEOPLE_HELPER = "people"; private static final String APP_LOCALES_HELPER = "app_locales"; + private static final String APP_GENDER_HELPER = "app_gender"; // These paths must match what the WallpaperManagerService uses. The leaf *_FILENAME // are also used in the full-backup file format, so must not change unless steps are @@ -104,6 +105,7 @@ public class SystemBackupAgent extends BackupAgentHelper { addHelper(SLICES_HELPER, new SliceBackupHelper(this)); addHelper(PEOPLE_HELPER, new PeopleBackupHelper(mUserId)); addHelper(APP_LOCALES_HELPER, new AppSpecificLocalesBackupHelper(mUserId)); + addHelper(APP_GENDER_HELPER, new AppGrammaticalGenderBackupHelper(mUserId)); } @Override diff --git a/services/core/java/com/android/server/grammaticalinflection/GrammaticalInflectionBackupHelper.java b/services/core/java/com/android/server/grammaticalinflection/GrammaticalInflectionBackupHelper.java new file mode 100644 index 0000000000000..5be0735c23b27 --- /dev/null +++ b/services/core/java/com/android/server/grammaticalinflection/GrammaticalInflectionBackupHelper.java @@ -0,0 +1,193 @@ +/* + * Copyright (C) 2022 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.grammaticalinflection; + +import android.app.backup.BackupManager; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.content.res.Configuration; +import android.os.UserHandle; +import android.util.Log; +import android.util.SparseArray; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.time.Clock; +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; + +public class GrammaticalInflectionBackupHelper { + private static final String TAG = GrammaticalInflectionBackupHelper.class.getSimpleName(); + private static final String SYSTEM_BACKUP_PACKAGE_KEY = "android"; + // Stage data would be deleted on reboot since it's stored in memory. So it's retained until + // retention period OR next reboot, whichever happens earlier. + private static final Duration STAGE_DATA_RETENTION_PERIOD = Duration.ofDays(3); + + private final SparseArray mCache = new SparseArray<>(); + private final Object mCacheLock = new Object(); + private final PackageManager mPackageManager; + private final GrammaticalInflectionService mGrammaticalGenderService; + private final Clock mClock; + + static class StagedData { + final long mCreationTimeMillis; + final HashMap mPackageStates; + + StagedData(long creationTimeMillis) { + mCreationTimeMillis = creationTimeMillis; + mPackageStates = new HashMap<>(); + } + } + + public GrammaticalInflectionBackupHelper(GrammaticalInflectionService grammaticalGenderService, + PackageManager packageManager) { + mGrammaticalGenderService = grammaticalGenderService; + mPackageManager = packageManager; + mClock = Clock.systemUTC(); + } + + public byte[] getBackupPayload(int userId) { + synchronized (mCacheLock) { + cleanStagedDataForOldEntries(); + } + + HashMap pkgGenderInfo = new HashMap<>(); + for (ApplicationInfo appInfo : mPackageManager.getInstalledApplicationsAsUser( + PackageManager.ApplicationInfoFlags.of(0), userId)) { + int gender = mGrammaticalGenderService.getApplicationGrammaticalGender( + appInfo.packageName, userId); + if (gender != Configuration.GRAMMATICAL_GENDER_NOT_SPECIFIED) { + pkgGenderInfo.put(appInfo.packageName, gender); + } + } + + if (!pkgGenderInfo.isEmpty()) { + return convertToByteArray(pkgGenderInfo); + } else { + return null; + } + } + + public void stageAndApplyRestoredPayload(byte[] payload, int userId) { + synchronized (mCacheLock) { + cleanStagedDataForOldEntries(); + + HashMap pkgInfo = readFromByteArray(payload); + if (pkgInfo.isEmpty()) { + return; + } + + StagedData stagedData = new StagedData(mClock.millis()); + for (Map.Entry info : pkgInfo.entrySet()) { + // If app installed, restore immediately, otherwise put it in cache. + if (isPackageInstalledForUser(info.getKey(), userId)) { + if (!hasSetBeforeRestoring(info.getKey(), userId)) { + mGrammaticalGenderService.setRequestedApplicationGrammaticalGender( + info.getKey(), userId, info.getValue()); + } + } else { + if (info.getValue() != Configuration.GRAMMATICAL_GENDER_NOT_SPECIFIED) { + stagedData.mPackageStates.put(info.getKey(), info.getValue()); + } + } + } + + mCache.append(userId, stagedData); + } + } + + private boolean hasSetBeforeRestoring(String pkgName, int userId) { + return mGrammaticalGenderService.getApplicationGrammaticalGender(pkgName, userId) + != Configuration.GRAMMATICAL_GENDER_NOT_SPECIFIED; + } + + public void onPackageAdded(String packageName, int uid) { + synchronized (mCacheLock) { + int userId = UserHandle.getUserId(uid); + StagedData cache = mCache.get(userId); + if (cache != null && cache.mPackageStates.containsKey(packageName)) { + int grammaticalGender = cache.mPackageStates.get(packageName); + if (grammaticalGender != Configuration.GRAMMATICAL_GENDER_NOT_SPECIFIED) { + mGrammaticalGenderService.setRequestedApplicationGrammaticalGender( + packageName, userId, grammaticalGender); + } + } + } + } + + public void onPackageDataCleared() { + notifyBackupManager(); + } + + public void onPackageRemoved() { + notifyBackupManager(); + } + + public static void notifyBackupManager() { + BackupManager.dataChanged(SYSTEM_BACKUP_PACKAGE_KEY); + } + + private byte[] convertToByteArray(HashMap pkgGenderInfo) { + try (final ByteArrayOutputStream out = new ByteArrayOutputStream(); + final ObjectOutputStream objStream = new ObjectOutputStream(out)) { + objStream.writeObject(pkgGenderInfo); + return out.toByteArray(); + } catch (IOException e) { + Log.e(TAG, "cannot convert payload to byte array.", e); + return null; + } + } + + private HashMap readFromByteArray(byte[] payload) { + HashMap data = new HashMap<>(); + + try (ByteArrayInputStream byteIn = new ByteArrayInputStream(payload); + ObjectInputStream in = new ObjectInputStream(byteIn)) { + data = (HashMap) in.readObject(); + } catch (IOException | ClassNotFoundException e) { + Log.e(TAG, "cannot convert payload to HashMap.", e); + e.printStackTrace(); + } + return data; + } + + private void cleanStagedDataForOldEntries() { + for (int i = 0; i < mCache.size(); i++) { + int userId = mCache.keyAt(i); + StagedData stagedData = mCache.get(userId); + if (stagedData.mCreationTimeMillis + < mClock.millis() - STAGE_DATA_RETENTION_PERIOD.toMillis()) { + mCache.remove(userId); + } + } + } + + private boolean isPackageInstalledForUser(String packageName, int userId) { + PackageInfo pkgInfo = null; + try { + pkgInfo = mPackageManager.getPackageInfoAsUser(packageName, /* flags= */ 0, userId); + } catch (PackageManager.NameNotFoundException e) { + // The package is not installed + } + return pkgInfo != null; + } +} diff --git a/services/core/java/com/android/server/grammaticalinflection/GrammaticalInflectionManagerInternal.java b/services/core/java/com/android/server/grammaticalinflection/GrammaticalInflectionManagerInternal.java new file mode 100644 index 0000000000000..1f59b57d2da9b --- /dev/null +++ b/services/core/java/com/android/server/grammaticalinflection/GrammaticalInflectionManagerInternal.java @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2022 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.grammaticalinflection; + +import android.annotation.Nullable; + +/** + * System-server internal interface to the {@link android.app.GrammaticalInflectionManager}. + * + * @hide Only for use within the system server. + */ +public abstract class GrammaticalInflectionManagerInternal { + /** + * Returns the app-gender to be backed up as a data-blob. + */ + public abstract @Nullable byte[] getBackupPayload(int userId); + + /** + * Restores the app-gender that were previously backed up. + * + *

This method will parse the input data blob and restore the gender for apps which are + * present on the device. It will stage the gender 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/grammaticalinflection/GrammaticalInflectionPackageMonitor.java b/services/core/java/com/android/server/grammaticalinflection/GrammaticalInflectionPackageMonitor.java new file mode 100644 index 0000000000000..268bf6639131c --- /dev/null +++ b/services/core/java/com/android/server/grammaticalinflection/GrammaticalInflectionPackageMonitor.java @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2022 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.grammaticalinflection; + +import com.android.internal.content.PackageMonitor; + +public class GrammaticalInflectionPackageMonitor extends PackageMonitor { + private GrammaticalInflectionBackupHelper mBackupHelper; + + GrammaticalInflectionPackageMonitor(GrammaticalInflectionBackupHelper backupHelper) { + mBackupHelper = backupHelper; + } + + @Override + public void onPackageAdded(String packageName, int uid) { + mBackupHelper.onPackageAdded(packageName, uid); + } + + @Override + public void onPackageDataCleared(String packageName, int uid) { + mBackupHelper.onPackageDataCleared(); + } + + @Override + public void onPackageRemoved(String packageName, int uid) { + mBackupHelper.onPackageRemoved(); + } +} diff --git a/services/core/java/com/android/server/grammaticalinflection/GrammaticalInflectionService.java b/services/core/java/com/android/server/grammaticalinflection/GrammaticalInflectionService.java index 6cfe921f2c423..1a357eea00946 100644 --- a/services/core/java/com/android/server/grammaticalinflection/GrammaticalInflectionService.java +++ b/services/core/java/com/android/server/grammaticalinflection/GrammaticalInflectionService.java @@ -18,9 +18,12 @@ package com.android.server.grammaticalinflection; import static android.content.res.Configuration.GRAMMATICAL_GENDER_NOT_SPECIFIED; +import android.annotation.Nullable; import android.app.IGrammaticalInflectionManager; import android.content.Context; +import android.os.Binder; import android.os.IBinder; +import android.os.Process; import android.os.SystemProperties; import com.android.server.LocalServices; @@ -34,6 +37,7 @@ import com.android.server.wm.ActivityTaskManagerInternal; */ public class GrammaticalInflectionService extends SystemService { + private final GrammaticalInflectionBackupHelper mBackupHelper; private final ActivityTaskManagerInternal mActivityTaskManagerInternal; private static final String GRAMMATICAL_INFLECTION_ENABLED = "i18n.grammatical_Inflection.enabled"; @@ -46,17 +50,20 @@ public class GrammaticalInflectionService extends SystemService { *

* * @param context The system server context. - * * @hide */ public GrammaticalInflectionService(Context context) { super(context); mActivityTaskManagerInternal = LocalServices.getService(ActivityTaskManagerInternal.class); + mBackupHelper = new GrammaticalInflectionBackupHelper( + this, context.getPackageManager()); } @Override public void onStart() { publishBinderService(Context.GRAMMATICAL_INFLECTION_SERVICE, mService); + LocalServices.addService(GrammaticalInflectionManagerInternal.class, + new GrammaticalInflectionManagerInternalImpl()); } private final IBinder mService = new IGrammaticalInflectionManager.Stub() { @@ -68,7 +75,40 @@ public class GrammaticalInflectionService extends SystemService { } }; - private void setRequestedApplicationGrammaticalGender( + private final class GrammaticalInflectionManagerInternalImpl + extends GrammaticalInflectionManagerInternal { + + @Override + @Nullable + public 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."); + } + } + } + + protected int getApplicationGrammaticalGender(String appPackageName, int userId) { + final ActivityTaskManagerInternal.PackageConfig appConfig = + mActivityTaskManagerInternal.getApplicationConfig(appPackageName, userId); + + if (appConfig == null || appConfig.mGrammaticalGender == null) { + return GRAMMATICAL_GENDER_NOT_SPECIFIED; + } else { + return appConfig.mGrammaticalGender; + } + } + + protected void setRequestedApplicationGrammaticalGender( String appPackageName, int userId, int gender) { if (!SystemProperties.getBoolean(GRAMMATICAL_INFLECTION_ENABLED, true)) { return; diff --git a/services/tests/servicestests/src/com/android/server/grammaticalinflection/GrammaticalInflectionBackupTest.java b/services/tests/servicestests/src/com/android/server/grammaticalinflection/GrammaticalInflectionBackupTest.java new file mode 100644 index 0000000000000..6c5a56936e935 --- /dev/null +++ b/services/tests/servicestests/src/com/android/server/grammaticalinflection/GrammaticalInflectionBackupTest.java @@ -0,0 +1,144 @@ +/* + * Copyright (C) 2022 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.grammaticalinflection; + +import static junit.framework.Assert.assertNull; +import static junit.framework.Assert.assertTrue; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.verify; + +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageManager; +import android.content.res.Configuration; + +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import com.google.common.collect.Maps; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.util.HashMap; +import java.util.List; + +@RunWith(AndroidJUnit4.class) +public class GrammaticalInflectionBackupTest { + private static final int DEFAULT_USER_ID = 0; + private static final String DEFAULT_PACKAGE_NAME = "com.test.package.name"; + + @Rule + public final MockitoRule mockito = MockitoJUnit.rule(); + + @Mock + private PackageManager mMockPackageManager; + @Mock + private GrammaticalInflectionService mGrammaticalInflectionService; + + private GrammaticalInflectionBackupHelper mBackupHelper; + + @Before + public void setUp() throws Exception { + mBackupHelper = new GrammaticalInflectionBackupHelper( + mGrammaticalInflectionService, mMockPackageManager); + } + + @Test + public void testBackupPayload_noAppsInstalled_returnsNull() { + assertNull(mBackupHelper.getBackupPayload(DEFAULT_USER_ID)); + } + + @Test + public void testBackupPayload_AppsInstalled_returnsGender() + throws IOException, ClassNotFoundException { + mockAppInstalled(); + mockGetApplicationGrammaticalGender(Configuration.GRAMMATICAL_GENDER_MASCULINE); + + HashMap payload = + readFromByteArray(mBackupHelper.getBackupPayload(DEFAULT_USER_ID)); + + // verify the payload + HashMap expectationMap = new HashMap<>(); + expectationMap.put(DEFAULT_PACKAGE_NAME, Configuration.GRAMMATICAL_GENDER_MASCULINE); + assertTrue(Maps.difference(payload, expectationMap).areEqual()); + } + + @Test + public void testApplyPayload_onPackageAdded_setApplicationGrammaticalGender() + throws IOException { + mockAppInstalled(); + + HashMap testData = new HashMap<>(); + testData.put(DEFAULT_PACKAGE_NAME, Configuration.GRAMMATICAL_GENDER_NEUTRAL); + mBackupHelper.stageAndApplyRestoredPayload(convertToByteArray(testData), DEFAULT_USER_ID); + mBackupHelper.onPackageAdded(DEFAULT_PACKAGE_NAME, DEFAULT_USER_ID); + + verify(mGrammaticalInflectionService).setRequestedApplicationGrammaticalGender( + eq(DEFAULT_PACKAGE_NAME), + eq(DEFAULT_USER_ID), + eq(Configuration.GRAMMATICAL_GENDER_NEUTRAL)); + } + + private void mockAppInstalled() { + ApplicationInfo dummyApp = new ApplicationInfo(); + dummyApp.packageName = DEFAULT_PACKAGE_NAME; + doReturn(List.of(dummyApp)).when(mMockPackageManager) + .getInstalledApplicationsAsUser(any(), anyInt()); + } + + private void mockGetApplicationGrammaticalGender(int grammaticalGender) { + doReturn(grammaticalGender).when(mGrammaticalInflectionService) + .getApplicationGrammaticalGender( + eq(DEFAULT_PACKAGE_NAME), eq(DEFAULT_USER_ID)); + } + + private byte[] convertToByteArray(HashMap pkgGenderInfo) throws IOException{ + try (final ByteArrayOutputStream out = new ByteArrayOutputStream(); + final ObjectOutputStream objStream = new ObjectOutputStream(out)) { + objStream.writeObject(pkgGenderInfo); + return out.toByteArray(); + } catch (IOException e) { + throw e; + } + } + + private HashMap readFromByteArray(byte[] payload) + throws IOException, ClassNotFoundException { + HashMap data; + + try (ByteArrayInputStream byteIn = new ByteArrayInputStream(payload); + ObjectInputStream in = new ObjectInputStream(byteIn)) { + data = (HashMap) in.readObject(); + } catch (IOException | ClassNotFoundException e) { + throw e; + } + return data; + } +}