Add new LocaleManagerService and its shell commands

Ignore-AOSP-First: permission present in internal branch only

Test: tested manually via adb

Bug: 194094788
Bug: 194484378

Change-Id: I413a74fc1b15d164de7f2098e975e0b792685394
diff --git a/core/java/android/app/ILocaleManager.aidl b/core/java/android/app/ILocaleManager.aidl
new file mode 100644
index 0000000..348cb2d
--- /dev/null
+++ b/core/java/android/app/ILocaleManager.aidl
@@ -0,0 +1,43 @@
+
+/*
+ * 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 android.app;
+
+import android.os.LocaleList;
+
+/**
+ * Internal interface used to control app-specific locales.
+ *
+ * <p>Use the {@link android.app.LocaleManager} class rather than going through
+ * this Binder interface directly. See {@link android.app.LocaleManager} for
+ * more complete documentation.
+ *
+ * @hide
+ */
+ interface ILocaleManager {
+
+     /**
+      * Sets a specified app’s app-specific UI locales.
+      */
+     void setApplicationLocales(String packageName, int userId, in LocaleList locales);
+
+     /**
+      * Returns the specified app's app-specific locales.
+      */
+     LocaleList getApplicationLocales(String packageName, int userId);
+
+ }
\ No newline at end of file
diff --git a/core/java/android/content/Context.java b/core/java/android/content/Context.java
index 3ab070f..20ee695 100644
--- a/core/java/android/content/Context.java
+++ b/core/java/android/content/Context.java
@@ -3801,6 +3801,7 @@
             //@hide: TIME_ZONE_DETECTOR_SERVICE,
             PERMISSION_SERVICE,
             LIGHTS_SERVICE,
+            LOCALE_SERVICE,
             //@hide: PEOPLE_SERVICE,
             //@hide: DEVICE_STATE_SERVICE,
             //@hide: SPEECH_RECOGNITION_SERVICE,
@@ -5784,6 +5785,15 @@
     public static final String DISPLAY_HASH_SERVICE = "display_hash";
 
     /**
+     * Use with {@link #getSystemService(String)} to retrieve a
+     * {@link android.app.LocaleManager}.
+     *
+     * @see #getSystemService(String)
+     * @hide
+     */
+    public static final String LOCALE_SERVICE = "locale";
+
+    /**
      * Determine whether the given permission is allowed for a particular
      * process and user ID running in the system.
      *
diff --git a/packages/Shell/AndroidManifest.xml b/packages/Shell/AndroidManifest.xml
index b9eec6e..7f0c5d4 100644
--- a/packages/Shell/AndroidManifest.xml
+++ b/packages/Shell/AndroidManifest.xml
@@ -464,6 +464,10 @@
     <uses-permission android:name="android.permission.MANAGE_TIME_AND_ZONE_DETECTION" />
     <uses-permission android:name="android.permission.SUGGEST_EXTERNAL_TIME" />
 
+    <!-- Permissions needed for testing locale manager service -->
+    <!-- todo(b/201957547): Add CTS test name when available-->
+    <uses-permission android:name="android.permission.READ_APP_SPECIFIC_LOCALES" />
+
     <!-- Permission required for CTS test - android.server.biometrics -->
     <uses-permission android:name="android.permission.USE_BIOMETRIC" />
 
diff --git a/services/core/java/com/android/server/locales/LocaleManagerService.java b/services/core/java/com/android/server/locales/LocaleManagerService.java
new file mode 100644
index 0000000..0045499
--- /dev/null
+++ b/services/core/java/com/android/server/locales/LocaleManagerService.java
@@ -0,0 +1,246 @@
+/*
+ * 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 java.util.Objects.requireNonNull;
+
+import android.annotation.NonNull;
+import android.annotation.UserIdInt;
+import android.app.ActivityManagerInternal;
+import android.app.ILocaleManager;
+import android.content.Context;
+import android.content.pm.PackageManagerInternal;
+import android.os.Binder;
+import android.os.LocaleList;
+import android.os.RemoteException;
+import android.os.ResultReceiver;
+import android.os.ShellCallback;
+import android.os.UserHandle;
+import android.util.Slog;
+
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.internal.util.DumpUtils;
+import com.android.server.LocalServices;
+import com.android.server.SystemService;
+import com.android.server.wm.ActivityTaskManagerInternal;
+
+import java.io.FileDescriptor;
+import java.io.PrintWriter;
+
+/**
+ * The implementation of ILocaleManager.aidl.
+ *
+ * <p>This service is API entry point for storing app-specific UI locales
+ */
+public class LocaleManagerService extends SystemService {
+    private static final String TAG = "LocaleManagerService";
+    private final Context mContext;
+    private final LocaleManagerService.LocaleManagerBinderService mBinderService;
+    private ActivityTaskManagerInternal mActivityTaskManagerInternal;
+    private ActivityManagerInternal mActivityManagerInternal;
+    private PackageManagerInternal mPackageManagerInternal;
+    public static final boolean DEBUG = false;
+
+    public LocaleManagerService(Context context) {
+        super(context);
+        mContext = context;
+        mBinderService = new LocaleManagerBinderService();
+        mActivityTaskManagerInternal = LocalServices.getService(ActivityTaskManagerInternal.class);
+        mActivityManagerInternal = LocalServices.getService(ActivityManagerInternal.class);
+        mPackageManagerInternal = LocalServices.getService(PackageManagerInternal.class);
+    }
+
+    @VisibleForTesting
+    LocaleManagerService(Context context, ActivityTaskManagerInternal activityTaskManagerInternal,
+            ActivityManagerInternal activityManagerInternal,
+            PackageManagerInternal packageManagerInternal) {
+        super(context);
+        mContext = context;
+        mBinderService = new LocaleManagerBinderService();
+        mActivityTaskManagerInternal = activityTaskManagerInternal;
+        mActivityManagerInternal = activityManagerInternal;
+        mPackageManagerInternal = packageManagerInternal;
+    }
+
+    @Override
+    public void onStart() {
+        publishBinderService(Context.LOCALE_SERVICE, mBinderService);
+    }
+
+    private final class LocaleManagerBinderService extends ILocaleManager.Stub {
+        @Override
+        public void setApplicationLocales(@NonNull String appPackageName, @UserIdInt int userId,
+                @NonNull LocaleList locales) throws RemoteException {
+            LocaleManagerService.this.setApplicationLocales(appPackageName, userId, locales);
+        }
+
+        @Override
+        @NonNull
+        public LocaleList getApplicationLocales(@NonNull String appPackageName,
+                @UserIdInt int userId) throws RemoteException {
+            return LocaleManagerService.this.getApplicationLocales(appPackageName, userId);
+        }
+
+        @Override
+        public void dump(FileDescriptor fd, PrintWriter pw, String[] args) {
+            LocaleManagerService.this.dump(fd, pw, args);
+        }
+
+        @Override
+        public void onShellCommand(FileDescriptor in, FileDescriptor out,
+                FileDescriptor err, String[] args, ShellCallback callback,
+                ResultReceiver resultReceiver) {
+            (new LocaleManagerShellCommand(mBinderService))
+                    .exec(this, in, out, err, args, callback, resultReceiver);
+        }
+    }
+
+    /**
+     * Sets the current UI locales for a specified app.
+     */
+    public void setApplicationLocales(@NonNull String appPackageName, @UserIdInt int userId,
+            @NonNull LocaleList locales) throws RemoteException, IllegalArgumentException {
+        requireNonNull(appPackageName);
+        requireNonNull(locales);
+
+        //Allow apps with INTERACT_ACROSS_USERS permission to set locales for different user.
+        userId = mActivityManagerInternal.handleIncomingUser(
+                Binder.getCallingPid(), Binder.getCallingUid(), userId,
+                false /* allowAll */, ActivityManagerInternal.ALLOW_NON_FULL,
+                "setApplicationLocales", appPackageName);
+
+        // This function handles two types of set operations:
+        // 1.) A normal, non-privileged app setting its own locale.
+        // 2.) A privileged system service setting locales of another package.
+        // The least privileged case is a normal app performing a set, so check that first and
+        // set locales if the package name is owned by the app. Next, check if the caller has the
+        // necessary permission and set locales.
+        boolean isCallerOwner = isPackageOwnedByCaller(appPackageName, userId);
+        if (!isCallerOwner) {
+            enforceChangeConfigurationPermission();
+        }
+
+        final long token = Binder.clearCallingIdentity();
+        try {
+            setApplicationLocalesUnchecked(appPackageName, userId, locales);
+        } finally {
+            Binder.restoreCallingIdentity(token);
+        }
+    }
+
+
+    private void setApplicationLocalesUnchecked(@NonNull String appPackageName,
+            @UserIdInt int userId, @NonNull LocaleList locales) {
+        if (DEBUG) {
+            Slog.d(TAG, "setApplicationLocales: setting locales for package " + appPackageName
+                    + " and user " + userId);
+        }
+        final ActivityTaskManagerInternal.PackageConfigurationUpdater updater =
+                mActivityTaskManagerInternal.createPackageConfigurationUpdater(appPackageName,
+                        userId);
+        updater.setLocales(locales).commit();
+    }
+
+
+    /**
+     * Checks if the package is owned by the calling app or not for the given user id.
+     *
+     * @throws IllegalArgumentException if package not found for given userid
+     */
+    private boolean isPackageOwnedByCaller(String appPackageName, int userId) {
+        final int uid = mPackageManagerInternal
+                .getPackageUid(appPackageName, /* flags */ 0, userId);
+        if (uid < 0) {
+            Slog.w(TAG, "Unknown package " + appPackageName + " for user " + userId);
+            throw new IllegalArgumentException("Unknown package: " + appPackageName
+                    + " for user " + userId);
+        }
+        //Once valid package found, ignore the userId part for validating package ownership
+        //as apps with INTERACT_ACROSS_USERS permission could be changing locale for different user.
+        return UserHandle.isSameApp(Binder.getCallingUid(), uid);
+    }
+
+    private void enforceChangeConfigurationPermission() {
+        mContext.enforceCallingPermission(
+                android.Manifest.permission.CHANGE_CONFIGURATION, "setApplicationLocales");
+    }
+
+    /**
+     * Returns the current UI locales for the specified app.
+     */
+    @NonNull
+    public LocaleList getApplicationLocales(@NonNull String appPackageName, @UserIdInt int userId)
+            throws RemoteException, IllegalArgumentException {
+        requireNonNull(appPackageName);
+
+        //Allow apps with INTERACT_ACROSS_USERS permission to query locales for different user.
+        userId = mActivityManagerInternal.handleIncomingUser(
+                Binder.getCallingPid(), Binder.getCallingUid(), userId,
+                false /* allowAll */, ActivityManagerInternal.ALLOW_NON_FULL,
+                "getApplicationLocales", appPackageName);
+
+        // This function handles two types of query operations:
+        // 1.) A normal, non-privileged app querying its own locale.
+        // 2.) A privileged system service querying locales of another package.
+        // The least privileged case is a normal app performing a query, so check that first and
+        // get locales if the package name is owned by the app. Next, check if the caller has the
+        // necessary permission and get locales.
+        if (!isPackageOwnedByCaller(appPackageName, userId)) {
+            enforceReadAppSpecificLocalesPermission();
+        }
+        final long token = Binder.clearCallingIdentity();
+        try {
+            return getApplicationLocalesUnchecked(appPackageName, userId);
+        } finally {
+            Binder.restoreCallingIdentity(token);
+        }
+    }
+
+    private LocaleList getApplicationLocalesUnchecked(@NonNull String appPackageName,
+            @UserIdInt int userId) {
+        if (DEBUG) {
+            Slog.d(TAG, "getApplicationLocales: fetching locales for package " + appPackageName
+                    + " and user " + userId);
+        }
+
+        final ActivityTaskManagerInternal.PackageConfig appConfig =
+                mActivityTaskManagerInternal.getApplicationConfig(appPackageName, userId);
+        if (appConfig == null) {
+            if (DEBUG) {
+                Slog.d(TAG, "getApplicationLocales: application config not found for "
+                        + appPackageName + " and user id " + userId);
+            }
+            return LocaleList.getEmptyLocaleList();
+        }
+        LocaleList locales = appConfig.mLocales;
+        return locales != null ? locales : LocaleList.getEmptyLocaleList();
+    }
+
+    private void enforceReadAppSpecificLocalesPermission() {
+        mContext.enforceCallingPermission(
+                android.Manifest.permission.READ_APP_SPECIFIC_LOCALES,
+                "getApplicationLocales");
+    }
+
+    /**
+     * Dumps useful info related to service.
+     */
+    public void dump(FileDescriptor fd, PrintWriter pw, String[] args) {
+        if (!DumpUtils.checkDumpPermission(mContext, TAG, pw)) return;
+        // TODO(b/201766221): Implement when there is state.
+    }
+}
diff --git a/services/core/java/com/android/server/locales/LocaleManagerShellCommand.java b/services/core/java/com/android/server/locales/LocaleManagerShellCommand.java
new file mode 100644
index 0000000..769ea17
--- /dev/null
+++ b/services/core/java/com/android/server/locales/LocaleManagerShellCommand.java
@@ -0,0 +1,159 @@
+/*
+ * 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.app.ActivityManager;
+import android.app.ILocaleManager;
+import android.os.LocaleList;
+import android.os.RemoteException;
+import android.os.ShellCommand;
+import android.os.UserHandle;
+
+import java.io.PrintWriter;
+
+/**
+ * Shell commands for {@link LocaleManagerService}
+ */
+public class LocaleManagerShellCommand extends ShellCommand {
+    private final ILocaleManager mBinderService;
+
+    LocaleManagerShellCommand(ILocaleManager localeManager) {
+        mBinderService = localeManager;
+    }
+    @Override
+    public int onCommand(String cmd) {
+        if (cmd == null) {
+            return handleDefaultCommands(cmd);
+        }
+        switch (cmd) {
+            case "set-app-locales":
+                return runSetAppLocales();
+            case "get-app-locales":
+                return runGetAppLocales();
+            default: {
+                return handleDefaultCommands(cmd);
+            }
+        }
+    }
+
+    @Override
+    public void onHelp() {
+        PrintWriter pw = getOutPrintWriter();
+        pw.println("Locale manager (locale) shell commands:");
+        pw.println("  help");
+        pw.println("      Print this help text.");
+        pw.println("  set-app-locales <PACKAGE_NAME> [--user <USER_ID>] [--locales <LOCALE_INFO>]");
+        pw.println("      Set the locales for the specified app.");
+        pw.println("      --user <USER_ID>: apply for the given user, "
+                + "the current user is used when unspecified.");
+        pw.println("      --locales <LOCALE_INFO>: The language tags of locale to be included "
+                + "as a single String separated by commas");
+        pw.println("                 Empty locale list is used when unspecified.");
+        pw.println("                 eg. en,en-US,hi ");
+        pw.println("  get-app-locales <PACKAGE_NAME> [--user <USER_ID>]");
+        pw.println("      Get the locales for the specified app.");
+        pw.println("      --user <USER_ID>: get for the given user, "
+                + "the current user is used when unspecified.");
+    }
+
+    private int runSetAppLocales() {
+        final PrintWriter err = getErrPrintWriter();
+        String packageName = getNextArg();
+
+        if (packageName != null) {
+            int userId = ActivityManager.getCurrentUser();
+            LocaleList locales = LocaleList.getEmptyLocaleList();
+            do {
+                String option = getNextOption();
+                if (option == null) {
+                    break;
+                }
+                switch (option) {
+                    case "--user": {
+                        userId = UserHandle.parseUserArg(getNextArgRequired());
+                        break;
+                    }
+                    case "--locales": {
+                        locales = parseLocales();
+                        break;
+                    }
+                    default: {
+                        throw new IllegalArgumentException("Unknown option: " + option);
+                    }
+                }
+            } while (true);
+
+            try {
+                mBinderService.setApplicationLocales(packageName, userId, locales);
+            } catch (RemoteException e) {
+                getOutPrintWriter().println("Remote Exception: " + e);
+            } catch (IllegalArgumentException e) {
+                getOutPrintWriter().println("Unknown package " + packageName
+                        + " for userId " + userId);
+            }
+        } else {
+            err.println("Error: no package specified");
+            return -1;
+        }
+        return 0;
+    }
+
+    private int runGetAppLocales() {
+        final PrintWriter err = getErrPrintWriter();
+        String packageName = getNextArg();
+
+        if (packageName != null) {
+            int userId = ActivityManager.getCurrentUser();
+            do {
+                String option = getNextOption();
+                if (option == null) {
+                    break;
+                }
+                if ("--user".equals(option)) {
+                    userId = UserHandle.parseUserArg(getNextArgRequired());
+                    break;
+                } else {
+                    throw new IllegalArgumentException("Unknown option: " + option);
+                }
+            } while (true);
+            try {
+                LocaleList locales = mBinderService.getApplicationLocales(packageName, userId);
+                getOutPrintWriter().println("Locales for " + packageName
+                        + " for user " + userId + " are " + locales);
+            } catch (RemoteException e) {
+                getOutPrintWriter().println("Remote Exception: " + e);
+            } catch (IllegalArgumentException e) {
+                getOutPrintWriter().println("Unknown package " + packageName
+                        + " for userId " + userId);
+            }
+        } else {
+            err.println("Error: no package specified");
+            return -1;
+        }
+        return 0;
+    }
+
+    private LocaleList parseLocales() {
+        if (getRemainingArgsCount() <= 0) {
+            return LocaleList.getEmptyLocaleList();
+        }
+        String[] args = peekRemainingArgs();
+        String inputLocales = args[0];
+        LocaleList locales = LocaleList.forLanguageTags(inputLocales);
+        return locales;
+    }
+}
diff --git a/services/core/java/com/android/server/locales/OWNERS b/services/core/java/com/android/server/locales/OWNERS
new file mode 100644
index 0000000..be284a7
--- /dev/null
+++ b/services/core/java/com/android/server/locales/OWNERS
@@ -0,0 +1,3 @@
+roosa@google.com
+pratyushmore@google.com
+goldmanj@google.com
diff --git a/services/java/com/android/server/SystemServer.java b/services/java/com/android/server/SystemServer.java
index dae1275..fc60bab 100644
--- a/services/java/com/android/server/SystemServer.java
+++ b/services/java/com/android/server/SystemServer.java
@@ -133,6 +133,7 @@
 import com.android.server.inputmethod.InputMethodManagerService;
 import com.android.server.integrity.AppIntegrityManagerService;
 import com.android.server.lights.LightsService;
+import com.android.server.locales.LocaleManagerService;
 import com.android.server.location.LocationManagerService;
 import com.android.server.media.MediaRouterService;
 import com.android.server.media.metrics.MediaMetricsManagerService;
@@ -1684,6 +1685,15 @@
         mSystemServiceManager.startService(UiModeManagerService.class);
         t.traceEnd();
 
+        t.traceBegin("StartLocaleManagerService");
+        try {
+            mSystemServiceManager.startService(LocaleManagerService.class);
+        } catch (Throwable e) {
+            reportWtf("starting LocaleManagerService service", e);
+        }
+        t.traceEnd();
+
+
         if (!mOnlyCore) {
             t.traceBegin("UpdatePackagesIfNeeded");
             try {
diff --git a/services/tests/servicestests/src/com/android/server/locales/OWNERS b/services/tests/servicestests/src/com/android/server/locales/OWNERS
new file mode 100644
index 0000000..be284a7
--- /dev/null
+++ b/services/tests/servicestests/src/com/android/server/locales/OWNERS
@@ -0,0 +1,3 @@
+roosa@google.com
+pratyushmore@google.com
+goldmanj@google.com