diff --git a/shell/platform/android/BUILD.gn b/shell/platform/android/BUILD.gn
index f83390662a6..6a1b25f906d 100644
--- a/shell/platform/android/BUILD.gn
+++ b/shell/platform/android/BUILD.gn
@@ -450,6 +450,7 @@ action("robolectric_tests") {
"test/io/flutter/plugin/common/StandardMethodCodecTest.java",
"test/io/flutter/plugin/editing/InputConnectionAdaptorTest.java",
"test/io/flutter/plugin/editing/TextInputPluginTest.java",
+ "test/io/flutter/plugin/localization/LocalizationPluginTest.java",
"test/io/flutter/plugin/mouse/MouseCursorPluginTest.java",
"test/io/flutter/plugin/platform/PlatformPluginTest.java",
"test/io/flutter/plugin/platform/SingleViewPresentationTest.java",
diff --git a/shell/platform/android/io/flutter/plugin/localization/LocalizationPlugin.java b/shell/platform/android/io/flutter/plugin/localization/LocalizationPlugin.java
index ed75dbb2541..929711f3544 100644
--- a/shell/platform/android/io/flutter/plugin/localization/LocalizationPlugin.java
+++ b/shell/platform/android/io/flutter/plugin/localization/LocalizationPlugin.java
@@ -26,9 +26,25 @@ public class LocalizationPlugin {
this.localizationChannel = localizationChannel;
}
+ /**
+ * Computes the {@link Locale} in supportedLocales that best matches the user's preferred locales.
+ *
+ *
FlutterEngine must be non-null when this method is invoked.
+ */
+ @SuppressWarnings("deprecation")
public Locale resolveNativeLocale(List supportedLocales) {
- Locale platformResolvedLocale = null;
+ if (supportedLocales == null || supportedLocales.isEmpty()) {
+ return null;
+ }
+
+ // Android improved the localization resolution algorithms after API 24 (7.0, Nougat).
+ // See https://developer.android.com/guide/topics/resources/multilingual-support
+ //
+ // LanguageRange and Locale.lookup was added in API 26 and is the preferred way to
+ // select a locale. Pre-API 26, we implement a manual locale resolution.
if (Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
+ // Modern locale resolution using LanguageRange
+ // https://developer.android.com/guide/topics/resources/multilingual-support#postN
List languageRanges = new ArrayList<>();
LocaleList localeList = context.getResources().getConfiguration().getLocales();
int localeCount = localeList.size();
@@ -37,14 +53,60 @@ public class LocalizationPlugin {
String localeString = locale.toString();
// This string replacement converts the locale string into the ranges format.
languageRanges.add(new Locale.LanguageRange(localeString.replace("_", "-")));
+ languageRanges.add(new Locale.LanguageRange(locale.getLanguage()));
+ languageRanges.add(new Locale.LanguageRange(locale.getLanguage() + "-*"));
}
-
- // TODO(garyq): This should be modified to achieve Android's full
- // locale resolution:
- // https://developer.android.com/guide/topics/resources/multilingual-support
- platformResolvedLocale = Locale.lookup(languageRanges, supportedLocales);
+ Locale platformResolvedLocale = Locale.lookup(languageRanges, supportedLocales);
+ if (platformResolvedLocale != null) {
+ return platformResolvedLocale;
+ }
+ return supportedLocales.get(0);
+ } else if (Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) {
+ // Modern locale resolution without languageRange
+ // https://developer.android.com/guide/topics/resources/multilingual-support#postN
+ LocaleList localeList = context.getResources().getConfiguration().getLocales();
+ for (int index = 0; index < localeList.size(); ++index) {
+ Locale preferredLocale = localeList.get(index);
+ // Look for exact match.
+ for (Locale locale : supportedLocales) {
+ if (preferredLocale.equals(locale)) {
+ return locale;
+ }
+ }
+ // Look for exact language only match.
+ for (Locale locale : supportedLocales) {
+ if (preferredLocale.getLanguage().equals(locale.toLanguageTag())) {
+ return locale;
+ }
+ }
+ // Look for any locale with matching language.
+ for (Locale locale : supportedLocales) {
+ if (preferredLocale.getLanguage().equals(locale.getLanguage())) {
+ return locale;
+ }
+ }
+ }
+ return supportedLocales.get(0);
}
- return platformResolvedLocale;
+
+ // Legacy locale resolution
+ // https://developer.android.com/guide/topics/resources/multilingual-support#preN
+ Locale preferredLocale = context.getResources().getConfiguration().locale;
+ if (preferredLocale != null) {
+ // Look for exact match.
+ for (Locale locale : supportedLocales) {
+ if (preferredLocale.equals(locale)) {
+ return locale;
+ }
+ }
+ // Look for exact language only match.
+ for (Locale locale : supportedLocales) {
+ if (preferredLocale.getLanguage().equals(locale.toString())) {
+ return locale;
+ }
+ }
+ }
+ return supportedLocales.get(0);
}
/**
diff --git a/shell/platform/android/test/io/flutter/FlutterTestSuite.java b/shell/platform/android/test/io/flutter/FlutterTestSuite.java
index 7df51ee9dba..3775bbaccc5 100644
--- a/shell/platform/android/test/io/flutter/FlutterTestSuite.java
+++ b/shell/platform/android/test/io/flutter/FlutterTestSuite.java
@@ -13,6 +13,7 @@ import io.flutter.embedding.android.FlutterViewTest;
import io.flutter.embedding.engine.FlutterEngineCacheTest;
import io.flutter.embedding.engine.FlutterEnginePluginRegistryTest;
import io.flutter.embedding.engine.FlutterJNITest;
+import io.flutter.embedding.engine.LocalizationPluginTest;
import io.flutter.embedding.engine.RenderingComponentTest;
import io.flutter.embedding.engine.plugins.shim.ShimPluginRegistryTest;
import io.flutter.embedding.engine.renderer.FlutterRendererTest;
@@ -52,6 +53,7 @@ import test.io.flutter.embedding.engine.dart.DartExecutorTest;
FlutterRendererTest.class,
FlutterViewTest.class,
InputConnectionAdaptorTest.class,
+ LocalizationPluginTest.class,
PlatformPluginTest.class,
PluginComponentTest.class,
PreconditionsTest.class,
diff --git a/shell/platform/android/test/io/flutter/embedding/engine/FlutterJNITest.java b/shell/platform/android/test/io/flutter/embedding/engine/FlutterJNITest.java
index 43c52675db8..a748d078da3 100644
--- a/shell/platform/android/test/io/flutter/embedding/engine/FlutterJNITest.java
+++ b/shell/platform/android/test/io/flutter/embedding/engine/FlutterJNITest.java
@@ -96,7 +96,10 @@ public class FlutterJNITest {
"en", "CA", ""
};
result = flutterJNI.computePlatformResolvedLocale(supportedLocales);
- assertEquals(result.length, 0); // This should change when full algo is implemented.
+ assertEquals(result.length, 3);
+ assertEquals(result[0], "en");
+ assertEquals(result[1], "CA");
+ assertEquals(result[2], "");
supportedLocales =
new String[] {
@@ -137,7 +140,11 @@ public class FlutterJNITest {
localeList = new LocaleList();
when(config.getLocales()).thenReturn(localeList);
result = flutterJNI.computePlatformResolvedLocale(supportedLocales);
- assertEquals(result.length, 0);
+ // The first locale is default.
+ assertEquals(result.length, 3);
+ assertEquals(result[0], "fr");
+ assertEquals(result[1], "FR");
+ assertEquals(result[2], "");
}
public void onDisplayPlatformView__callsPlatformViewsController() {
diff --git a/shell/platform/android/test/io/flutter/plugin/localization/LocalizationPluginTest.java b/shell/platform/android/test/io/flutter/plugin/localization/LocalizationPluginTest.java
new file mode 100644
index 00000000000..28ece52e6f4
--- /dev/null
+++ b/shell/platform/android/test/io/flutter/plugin/localization/LocalizationPluginTest.java
@@ -0,0 +1,339 @@
+// Part of the embedding.engine package to allow access to FlutterJNI methods.
+package io.flutter.embedding.engine;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import android.annotation.TargetApi;
+import android.content.Context;
+import android.content.res.Configuration;
+import android.content.res.Resources;
+import android.os.Build;
+import android.os.LocaleList;
+import io.flutter.embedding.engine.dart.DartExecutor;
+import io.flutter.embedding.engine.systemchannels.LocalizationChannel;
+import io.flutter.plugin.localization.LocalizationPlugin;
+import java.lang.reflect.Field;
+import java.lang.reflect.Modifier;
+import java.util.Locale;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+
+@Config(manifest = Config.NONE)
+@RunWith(RobolectricTestRunner.class)
+@TargetApi(24) // LocaleList and scriptCode are API 24+.
+public class LocalizationPluginTest {
+ // This test should be synced with the version for API 24.
+ @Test
+ public void computePlatformResolvedLocaleAPI26() {
+ // --- Test Setup ---
+ setApiVersion(26);
+ FlutterJNI flutterJNI = new FlutterJNI();
+
+ Context context = mock(Context.class);
+ Resources resources = mock(Resources.class);
+ Configuration config = mock(Configuration.class);
+ DartExecutor dartExecutor = mock(DartExecutor.class);
+ LocaleList localeList =
+ new LocaleList(new Locale("es", "MX"), new Locale("zh", "CN"), new Locale("en", "US"));
+ when(context.getResources()).thenReturn(resources);
+ when(resources.getConfiguration()).thenReturn(config);
+ when(config.getLocales()).thenReturn(localeList);
+
+ flutterJNI.setLocalizationPlugin(
+ new LocalizationPlugin(context, new LocalizationChannel(dartExecutor)));
+
+ // Empty supportedLocales.
+ String[] supportedLocales = new String[] {};
+ String[] result = flutterJNI.computePlatformResolvedLocale(supportedLocales);
+ assertEquals(result.length, 0);
+
+ // Empty preferredLocales.
+ supportedLocales =
+ new String[] {
+ "fr", "FR", "",
+ "zh", "", "",
+ "en", "CA", ""
+ };
+ localeList = new LocaleList();
+ when(config.getLocales()).thenReturn(localeList);
+ result = flutterJNI.computePlatformResolvedLocale(supportedLocales);
+ // The first locale is default.
+ assertEquals(result.length, 3);
+ assertEquals(result[0], "fr");
+ assertEquals(result[1], "FR");
+ assertEquals(result[2], "");
+
+ // Example from https://developer.android.com/guide/topics/resources/multilingual-support#postN
+ supportedLocales =
+ new String[] {
+ "en", "", "",
+ "de", "DE", "",
+ "es", "ES", "",
+ "fr", "FR", "",
+ "it", "IT", ""
+ };
+ localeList = new LocaleList(new Locale("fr", "CH"));
+ when(config.getLocales()).thenReturn(localeList);
+ result = flutterJNI.computePlatformResolvedLocale(supportedLocales);
+ // The call will use the new (> API 24) algorithm.
+ assertEquals(result.length, 3);
+ assertEquals(result[0], "fr");
+ assertEquals(result[1], "FR");
+ assertEquals(result[2], "");
+
+ supportedLocales =
+ new String[] {
+ "en", "", "",
+ "de", "DE", "",
+ "es", "ES", "",
+ "fr", "FR", "",
+ "fr", "", "",
+ "it", "IT", ""
+ };
+ localeList = new LocaleList(new Locale("fr", "CH"));
+ when(config.getLocales()).thenReturn(localeList);
+ result = flutterJNI.computePlatformResolvedLocale(supportedLocales);
+ // The call will use the new (> API 24) algorithm.
+ assertEquals(result.length, 3);
+ assertEquals(result[0], "fr");
+ assertEquals(result[1], "");
+ assertEquals(result[2], "");
+
+ // Example from https://developer.android.com/guide/topics/resources/multilingual-support#postN
+ supportedLocales =
+ new String[] {
+ "en", "", "",
+ "de", "DE", "",
+ "es", "ES", "",
+ "it", "IT", ""
+ };
+ localeList = new LocaleList(new Locale("fr", "CH"), new Locale("it", "CH"));
+ when(config.getLocales()).thenReturn(localeList);
+ result = flutterJNI.computePlatformResolvedLocale(supportedLocales);
+ // The call will use the new (> API 24) algorithm.
+ assertEquals(result.length, 3);
+ assertEquals(result[0], "it");
+ assertEquals(result[1], "IT");
+ assertEquals(result[2], "");
+ }
+
+ // This test should be synced with the version for API 26.
+ @Test
+ public void computePlatformResolvedLocaleAPI24() {
+ // --- Test Setup ---
+ setApiVersion(24);
+ FlutterJNI flutterJNI = new FlutterJNI();
+
+ Context context = mock(Context.class);
+ Resources resources = mock(Resources.class);
+ Configuration config = mock(Configuration.class);
+ DartExecutor dartExecutor = mock(DartExecutor.class);
+ LocaleList localeList =
+ new LocaleList(new Locale("es", "MX"), new Locale("zh", "CN"), new Locale("en", "US"));
+ when(context.getResources()).thenReturn(resources);
+ when(resources.getConfiguration()).thenReturn(config);
+ when(config.getLocales()).thenReturn(localeList);
+
+ flutterJNI.setLocalizationPlugin(
+ new LocalizationPlugin(context, new LocalizationChannel(dartExecutor)));
+
+ // Empty supportedLocales.
+ String[] supportedLocales = new String[] {};
+ String[] result = flutterJNI.computePlatformResolvedLocale(supportedLocales);
+ assertEquals(result.length, 0);
+
+ // Empty preferredLocales.
+ supportedLocales =
+ new String[] {
+ "fr", "FR", "",
+ "zh", "", "",
+ "en", "CA", ""
+ };
+ localeList = new LocaleList();
+ when(config.getLocales()).thenReturn(localeList);
+ result = flutterJNI.computePlatformResolvedLocale(supportedLocales);
+ // The first locale is default.
+ assertEquals(result.length, 3);
+ assertEquals(result[0], "fr");
+ assertEquals(result[1], "FR");
+ assertEquals(result[2], "");
+
+ // Example from https://developer.android.com/guide/topics/resources/multilingual-support#postN
+ supportedLocales =
+ new String[] {
+ "en", "", "",
+ "de", "DE", "",
+ "es", "ES", "",
+ "fr", "FR", "",
+ "it", "IT", ""
+ };
+ localeList = new LocaleList(new Locale("fr", "CH"));
+ when(config.getLocales()).thenReturn(localeList);
+ result = flutterJNI.computePlatformResolvedLocale(supportedLocales);
+ // The call will use the new (> API 24) algorithm.
+ assertEquals(result.length, 3);
+ assertEquals(result[0], "fr");
+ assertEquals(result[1], "FR");
+ assertEquals(result[2], "");
+
+ supportedLocales =
+ new String[] {
+ "en", "", "",
+ "de", "DE", "",
+ "es", "ES", "",
+ "fr", "FR", "",
+ "fr", "", "",
+ "it", "IT", ""
+ };
+ localeList = new LocaleList(new Locale("fr", "CH"));
+ when(config.getLocales()).thenReturn(localeList);
+ result = flutterJNI.computePlatformResolvedLocale(supportedLocales);
+ // The call will use the new (> API 24) algorithm.
+ assertEquals(result.length, 3);
+ assertEquals(result[0], "fr");
+ assertEquals(result[1], "");
+ assertEquals(result[2], "");
+
+ // Example from https://developer.android.com/guide/topics/resources/multilingual-support#postN
+ supportedLocales =
+ new String[] {
+ "en", "", "",
+ "de", "DE", "",
+ "es", "ES", "",
+ "it", "IT", ""
+ };
+ localeList = new LocaleList(new Locale("fr", "CH"), new Locale("it", "CH"));
+ when(config.getLocales()).thenReturn(localeList);
+ result = flutterJNI.computePlatformResolvedLocale(supportedLocales);
+ // The call will use the new (> API 24) algorithm.
+ assertEquals(result.length, 3);
+ assertEquals(result[0], "it");
+ assertEquals(result[1], "IT");
+ assertEquals(result[2], "");
+ }
+
+ // Tests the legacy pre API 24 algorithm.
+ @Test
+ public void computePlatformResolvedLocaleAPI16() {
+ // --- Test Setup ---
+ setApiVersion(16);
+ FlutterJNI flutterJNI = new FlutterJNI();
+
+ Context context = mock(Context.class);
+ Resources resources = mock(Resources.class);
+ Configuration config = mock(Configuration.class);
+ DartExecutor dartExecutor = mock(DartExecutor.class);
+ Locale userLocale = new Locale("es", "MX");
+ when(context.getResources()).thenReturn(resources);
+ when(resources.getConfiguration()).thenReturn(config);
+ setLegacyLocale(config, userLocale);
+
+ flutterJNI.setLocalizationPlugin(
+ new LocalizationPlugin(context, new LocalizationChannel(dartExecutor)));
+
+ // Empty supportedLocales.
+ String[] supportedLocales = new String[] {};
+ String[] result = flutterJNI.computePlatformResolvedLocale(supportedLocales);
+ assertEquals(result.length, 0);
+
+ // Empty null preferred locale.
+ supportedLocales =
+ new String[] {
+ "fr", "FR", "",
+ "zh", "", "",
+ "en", "CA", ""
+ };
+ userLocale = null;
+ setLegacyLocale(config, userLocale);
+ result = flutterJNI.computePlatformResolvedLocale(supportedLocales);
+ // The first locale is default.
+ assertEquals(result.length, 3);
+ assertEquals(result[0], "fr");
+ assertEquals(result[1], "FR");
+ assertEquals(result[2], "");
+
+ // Example from https://developer.android.com/guide/topics/resources/multilingual-support#postN
+ supportedLocales =
+ new String[] {
+ "en", "", "",
+ "de", "DE", "",
+ "es", "ES", "",
+ "fr", "FR", "",
+ "it", "IT", ""
+ };
+ userLocale = new Locale("fr", "CH");
+ setLegacyLocale(config, userLocale);
+ result = flutterJNI.computePlatformResolvedLocale(supportedLocales);
+ assertEquals(result.length, 3);
+ assertEquals(result[0], "en");
+ assertEquals(result[1], "");
+ assertEquals(result[2], "");
+
+ supportedLocales =
+ new String[] {
+ "en", "", "",
+ "de", "DE", "",
+ "es", "ES", "",
+ "fr", "FR", "",
+ "it", "IT", ""
+ };
+ userLocale = new Locale("it", "IT");
+ setLegacyLocale(config, userLocale);
+ result = flutterJNI.computePlatformResolvedLocale(supportedLocales);
+ assertEquals(result.length, 3);
+ assertEquals(result[0], "it");
+ assertEquals(result[1], "IT");
+ assertEquals(result[2], "");
+
+ supportedLocales =
+ new String[] {
+ "en", "", "",
+ "de", "DE", "",
+ "es", "ES", "",
+ "fr", "FR", "",
+ "fr", "", "",
+ "it", "IT", ""
+ };
+ userLocale = new Locale("fr", "CH");
+ setLegacyLocale(config, userLocale);
+ result = flutterJNI.computePlatformResolvedLocale(supportedLocales);
+ assertEquals(result.length, 3);
+ assertEquals(result[0], "fr");
+ assertEquals(result[1], "");
+ assertEquals(result[2], "");
+ }
+
+ private static void setApiVersion(int apiVersion) {
+ try {
+ Field field = Build.VERSION.class.getField("SDK_INT");
+
+ field.setAccessible(true);
+ Field modifiersField = Field.class.getDeclaredField("modifiers");
+ modifiersField.setAccessible(true);
+ modifiersField.setInt(field, field.getModifiers() & ~Modifier.FINAL);
+
+ field.set(null, apiVersion);
+ } catch (Exception e) {
+ assertTrue(false);
+ }
+ }
+
+ private static void setLegacyLocale(Configuration config, Locale locale) {
+ try {
+ Field field = config.getClass().getField("locale");
+ field.setAccessible(true);
+ Field modifiersField = Field.class.getDeclaredField("modifiers");
+ modifiersField.setAccessible(true);
+ modifiersField.setInt(field, field.getModifiers() & ~Modifier.FINAL);
+
+ field.set(config, locale);
+ } catch (Exception e) {
+ assertTrue(false);
+ }
+ }
+}