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