Integrate system language filtering functionality

Filter system languages based on the configuration provided via
the corresponding system property.

Bug: 154133013
Test: manual; atest LocalizationTest
Change-Id: I7e4694f4b381ab81d0e8ceab97bad68bb266aaee
diff --git a/Android.bp b/Android.bp
index a93a155..8e2f0ec 100644
--- a/Android.bp
+++ b/Android.bp
@@ -456,6 +456,7 @@
 
         "com.android.sysprop.apex",
         "com.android.sysprop.init",
+        "com.android.sysprop.localization",
         "PlatformProperties",
     ],
     sdk_version: "core_platform",
diff --git a/core/java/com/android/internal/app/LocalePicker.java b/core/java/com/android/internal/app/LocalePicker.java
index 3343593f..0c43578 100644
--- a/core/java/com/android/internal/app/LocalePicker.java
+++ b/core/java/com/android/internal/app/LocalePicker.java
@@ -16,6 +16,8 @@
 
 package com.android.internal.app;
 
+import android.annotation.NonNull;
+import android.annotation.Nullable;
 import android.app.ActivityManager;
 import android.app.IActivityManager;
 import android.app.ListFragment;
@@ -28,6 +30,7 @@
 import android.os.LocaleList;
 import android.os.RemoteException;
 import android.provider.Settings;
+import android.sysprop.LocalizationProperties;
 import android.util.Log;
 import android.view.LayoutInflater;
 import android.view.View;
@@ -43,6 +46,9 @@
 import java.util.Collections;
 import java.util.List;
 import java.util.Locale;
+import java.util.function.Predicate;
+import java.util.regex.Pattern;
+import java.util.regex.PatternSyntaxException;
 
 public class LocalePicker extends ListFragment {
     private static final String TAG = "LocalePicker";
@@ -92,7 +98,38 @@
     }
 
     public static String[] getSupportedLocales(Context context) {
-        return context.getResources().getStringArray(R.array.supported_locales);
+        String[] allLocales = context.getResources().getStringArray(R.array.supported_locales);
+
+        Predicate<String> localeFilter = getLocaleFilter();
+        if (localeFilter == null) {
+            return allLocales;
+        }
+
+        List<String> result = new ArrayList<>(allLocales.length);
+        for (String locale : allLocales) {
+            if (localeFilter.test(locale)) {
+                result.add(locale);
+            }
+        }
+
+        int localeCount = result.size();
+        return (localeCount == allLocales.length) ? allLocales
+                : result.toArray(new String[localeCount]);
+    }
+
+    @Nullable
+    private static Predicate<String> getLocaleFilter() {
+        try {
+            return LocalizationProperties.locale_filter()
+                    .map(filter -> Pattern.compile(filter).asPredicate())
+                    .orElse(null);
+        } catch (SecurityException e) {
+            Log.e(TAG, "Failed to read locale filter.", e);
+        } catch (PatternSyntaxException e) {
+            Log.e(TAG, "Bad locale filter format (\"" + e.getPattern() + "\"), skipping.");
+        }
+
+        return null;
     }
 
     public static List<LocaleInfo> getAllAssetLocales(Context context, boolean isInDeveloperMode) {
@@ -265,6 +302,11 @@
      */
     @UnsupportedAppUsage
     public static void updateLocales(LocaleList locales) {
+        if (locales != null) {
+            locales = removeExcludedLocales(locales);
+        }
+        // Note: the empty list case is covered by Configuration.setLocales().
+
         try {
             final IActivityManager am = ActivityManager.getService();
             final Configuration config = am.getConfiguration();
@@ -280,6 +322,26 @@
         }
     }
 
+    @NonNull
+    private static LocaleList removeExcludedLocales(@NonNull LocaleList locales) {
+        Predicate<String> localeFilter = getLocaleFilter();
+        if (localeFilter == null) {
+            return locales;
+        }
+
+        int localeCount = locales.size();
+        ArrayList<Locale> filteredLocales = new ArrayList<>(localeCount);
+        for (int i = 0; i < localeCount; ++i) {
+            Locale locale = locales.get(i);
+            if (localeFilter.test(locale.toString())) {
+                filteredLocales.add(locale);
+            }
+        }
+
+        return (localeCount == filteredLocales.size()) ? locales
+                : new LocaleList(filteredLocales.toArray(new Locale[0]));
+    }
+
     /**
      * Get the locale list.
      *
diff --git a/core/sysprop/Android.bp b/core/sysprop/Android.bp
new file mode 100644
index 0000000..7f20a0b
--- /dev/null
+++ b/core/sysprop/Android.bp
@@ -0,0 +1,21 @@
+// Copyright (C) 2020 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.
+
+sysprop_library {
+    name: "com.android.sysprop.localization",
+    srcs: ["LocalizationProperties.sysprop"],
+    property_owner: "Platform",
+    api_packages: ["android.sysprop"],
+    vendor_available: false,
+}
diff --git a/core/sysprop/LocalizationProperties.sysprop b/core/sysprop/LocalizationProperties.sysprop
new file mode 100644
index 0000000..65f544f
--- /dev/null
+++ b/core/sysprop/LocalizationProperties.sysprop
@@ -0,0 +1,24 @@
+# Copyright (C) 2020 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.
+
+module: "android.sysprop.LocalizationProperties"
+owner: Platform
+
+prop {
+    api_name: "locale_filter"
+    type: String
+    prop_name: "ro.localization.locale_filter"
+    scope: Internal
+    access: Readonly
+}
diff --git a/core/sysprop/api/com.android.sysprop.localization-current.txt b/core/sysprop/api/com.android.sysprop.localization-current.txt
new file mode 100644
index 0000000..fe4f457
--- /dev/null
+++ b/core/sysprop/api/com.android.sysprop.localization-current.txt
@@ -0,0 +1,9 @@
+props {
+  module: "android.sysprop.LocalizationProperties"
+  prop {
+    api_name: "locale_filter"
+    type: String
+    scope: Internal
+    prop_name: "ro.localization.locale_filter"
+  }
+}
diff --git a/core/sysprop/api/com.android.sysprop.localization-latest.txt b/core/sysprop/api/com.android.sysprop.localization-latest.txt
new file mode 100644
index 0000000..fe4f457
--- /dev/null
+++ b/core/sysprop/api/com.android.sysprop.localization-latest.txt
@@ -0,0 +1,9 @@
+props {
+  module: "android.sysprop.LocalizationProperties"
+  prop {
+    api_name: "locale_filter"
+    type: String
+    scope: Internal
+    prop_name: "ro.localization.locale_filter"
+  }
+}
diff --git a/tests/LocalizationTest/Android.bp b/tests/LocalizationTest/Android.bp
new file mode 100644
index 0000000..c4bfcb1
--- /dev/null
+++ b/tests/LocalizationTest/Android.bp
@@ -0,0 +1,41 @@
+// Copyright (C) 2020 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.
+
+android_test {
+    name: "LocalizationTest",
+    srcs: ["java/**/*.kt"],
+    libs: [
+        "android.test.runner",
+        "android.test.base",
+        "android.test.mock",
+    ],
+    static_libs: [
+        "androidx.test.core",
+        "androidx.test.ext.junit",
+        "androidx.test.rules",
+        "mockito-target-extended-minus-junit4",
+        "truth-prebuilt",
+    ],
+    jni_libs: [
+        // For mockito extended
+        "libdexmakerjvmtiagent",
+        "libstaticjvmtiagent",
+    ],
+    certificate: "platform",
+    platform_apis: true,
+    test_suites: ["device-tests"],
+    optimize: {
+        enabled: false,
+    },
+}
diff --git a/tests/LocalizationTest/AndroidManifest.xml b/tests/LocalizationTest/AndroidManifest.xml
new file mode 100644
index 0000000..b135443
--- /dev/null
+++ b/tests/LocalizationTest/AndroidManifest.xml
@@ -0,0 +1,29 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  ~ Copyright (C) 2020 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.
+  -->
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    package="com.android.android.internal.app">
+
+    <application android:debuggable="true"  android:testOnly="true">
+        <uses-library android:name="android.test.runner" />
+    </application>
+
+    <instrumentation
+        android:name="androidx.test.runner.AndroidJUnitRunner"
+        android:targetPackage="com.android.android.internal.app"
+        android:label="Localization Tests" />
+
+</manifest>
diff --git a/tests/LocalizationTest/AndroidTest.xml b/tests/LocalizationTest/AndroidTest.xml
new file mode 100644
index 0000000..8309b4f
--- /dev/null
+++ b/tests/LocalizationTest/AndroidTest.xml
@@ -0,0 +1,34 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  ~ Copyright (C) 2020 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.
+  -->
+<configuration description="Localization Tests.">
+    <option name="test-suite-tag" value="apct" />
+    <option name="test-suite-tag" value="apct-instrumentation" />
+
+    <target_preparer class="com.android.tradefed.targetprep.suite.SuiteApkInstaller">
+        <option name="cleanup-apks" value="true" />
+        <option name="install-arg" value="-t" />
+        <option name="test-file-name" value="LocalizationTest.apk" />
+    </target_preparer>
+
+    <option name="test-tag" value="LocalizationTest" />
+
+    <test class="com.android.tradefed.testtype.AndroidJUnitTest" >
+        <option name="package" value="com.android.android.internal.app" />
+        <option name="runner" value="androidx.test.runner.AndroidJUnitRunner" />
+        <option name="hidden-api-checks" value="false"/>
+    </test>
+</configuration>
\ No newline at end of file
diff --git a/tests/LocalizationTest/java/com/android/internal/app/LocalizationTest.kt b/tests/LocalizationTest/java/com/android/internal/app/LocalizationTest.kt
new file mode 100644
index 0000000..22ea971
--- /dev/null
+++ b/tests/LocalizationTest/java/com/android/internal/app/LocalizationTest.kt
@@ -0,0 +1,118 @@
+/*
+ * Copyright (C) 2020 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.android.internal.app
+
+import android.os.SystemProperties
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import com.android.dx.mockito.inline.extended.ExtendedMockito.doReturn
+import com.android.dx.mockito.inline.extended.ExtendedMockito.mockitoSession
+import com.android.internal.R
+import com.android.internal.app.LocalePicker
+import com.google.common.truth.Truth.assertThat
+import org.junit.Before
+import org.junit.After
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.ArgumentMatchers.eq
+import org.mockito.MockitoSession
+
+@RunWith(AndroidJUnit4::class)
+class LocalizationTest {
+    private val mContext = InstrumentationRegistry.getInstrumentation().context
+    private val mUnfilteredLocales =
+            mContext.getResources().getStringArray(R.array.supported_locales)
+
+    private lateinit var mMockitoSession: MockitoSession
+
+    @Before
+    fun setUp() {
+        mMockitoSession = mockitoSession()
+                .initMocks(this)
+                .spyStatic(SystemProperties::class.java)
+                .startMocking()
+    }
+
+    @After
+    fun tearDown() {
+        mMockitoSession.finishMocking()
+    }
+
+    @Test
+    fun testGetSupportedLocales_noFilter() {
+        // Filter not set.
+        setTestLocaleFilter(null)
+
+        val locales1 = LocalePicker.getSupportedLocales(mContext)
+
+        assertThat(locales1).isEqualTo(mUnfilteredLocales)
+
+        // Empty filter.
+        setTestLocaleFilter("")
+
+        val locales2 = LocalePicker.getSupportedLocales(mContext)
+
+        assertThat(locales2).isEqualTo(mUnfilteredLocales)
+    }
+
+    @Test
+    fun testGetSupportedLocales_invalidFilter() {
+        setTestLocaleFilter("**")
+
+        val locales = LocalePicker.getSupportedLocales(mContext)
+
+        assertThat(locales).isEqualTo(mUnfilteredLocales)
+    }
+
+    @Test
+    fun testGetSupportedLocales_inclusiveFilter() {
+        setTestLocaleFilter("^(de-AT|de-DE|en|ru).*")
+
+        val locales = LocalePicker.getSupportedLocales(mContext)
+
+        assertThat(locales).isEqualTo(
+                mUnfilteredLocales
+                        .filter { it.startsWithAnyOf("de-AT", "de-DE", "en", "ru") }
+                        .toTypedArray()
+        )
+    }
+
+    @Test
+    fun testGetSupportedLocales_exclusiveFilter() {
+        setTestLocaleFilter("^(?!de-IT|es|fr).*")
+
+        val locales = LocalePicker.getSupportedLocales(mContext)
+
+        assertThat(locales).isEqualTo(
+                mUnfilteredLocales
+                        .filter { !it.startsWithAnyOf("de-IT", "es", "fr") }
+                        .toTypedArray()
+        )
+    }
+
+    private fun setTestLocaleFilter(localeFilter: String?) {
+        doReturn(localeFilter).`when` { SystemProperties.get(eq("ro.localization.locale_filter")) }
+    }
+
+    private fun String.startsWithAnyOf(vararg prefixes: String): Boolean {
+        prefixes.forEach {
+            if (startsWith(it)) return true
+        }
+
+        return false
+    }
+}