Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add method to obtain native string resource in localization plugin. #24575

Merged
merged 4 commits into from Feb 25, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Expand Up @@ -6,23 +6,72 @@

import android.os.Build;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import io.flutter.Log;
import io.flutter.embedding.engine.dart.DartExecutor;
import io.flutter.plugin.common.JSONMethodCodec;
import io.flutter.plugin.common.MethodCall;
import io.flutter.plugin.common.MethodChannel;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import org.json.JSONException;
import org.json.JSONObject;

/** Sends the platform's locales to Dart. */
public class LocalizationChannel {
private static final String TAG = "LocalizationChannel";

@NonNull public final MethodChannel channel;
@Nullable private LocalizationMessageHandler localizationMessageHandler;

@NonNull @VisibleForTesting
public final MethodChannel.MethodCallHandler handler =
new MethodChannel.MethodCallHandler() {
@Override
public void onMethodCall(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
if (localizationMessageHandler == null) {
// If no explicit LocalizationMessageHandler has been registered then we don't
// need to forward this call to an API. Return.
return;
}

String method = call.method;
switch (method) {
case "Localization.getStringResource":
JSONObject arguments = call.<JSONObject>arguments();
try {
String key = arguments.getString("key");
String localeString = null;
if (arguments.has("locale")) {
localeString = arguments.getString("locale");
}
result.success(localizationMessageHandler.getStringResource(key, localeString));
} catch (JSONException exception) {
result.error("error", exception.getMessage(), null);
}
break;
default:
result.notImplemented();
break;
}
}
};

public LocalizationChannel(@NonNull DartExecutor dartExecutor) {
this.channel =
new MethodChannel(dartExecutor, "flutter/localization", JSONMethodCodec.INSTANCE);
channel.setMethodCallHandler(handler);
}

/**
* Sets the {@link LocalizationMessageHandler} which receives all events and requests that are
* parsed from the underlying platform channel.
*/
public void setLocalizationMessageHandler(
@Nullable LocalizationMessageHandler localizationMessageHandler) {
this.localizationMessageHandler = localizationMessageHandler;
}

/** Send the given {@code locales} to Dart. */
Expand All @@ -48,4 +97,19 @@ public void sendLocales(@NonNull List<Locale> locales) {
}
channel.invokeMethod("setLocale", data);
}

/**
* Handler that receives platform messages sent from Flutter to Android through a given {@link
* PlatformChannel}.
*
* <p>To register a {@code LocalizationMessageHandler} with a {@link PlatformChannel}, see {@link
* LocalizationChannel#setLocalizationMessageHandler(LocalizationMessageHandler)}.
*/
public interface LocalizationMessageHandler {
/**
* The Flutter application would like to obtain the string resource of given {@code key} in
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rephrase to be less passive/third person.

perhaps Obtains the string resource of the provided {@code key} ...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I copied the tone from PlatformChannel.java.

The way I understand it is this comment is meant for the implementor of this interface.

What do you think?

* {@code locale}.
*/
String getStringResource(@NonNull String key, String locale);
}
}
Expand Up @@ -6,9 +6,12 @@

import android.content.Context;
import android.content.res.Configuration;
import android.content.res.Resources;
import android.os.Build;
import android.os.LocaleList;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import io.flutter.embedding.engine.systemchannels.LocalizationChannel;
import java.util.ArrayList;
import java.util.List;
Expand All @@ -19,11 +22,58 @@ public class LocalizationPlugin {
@NonNull private final LocalizationChannel localizationChannel;
@NonNull private final Context context;

@VisibleForTesting
final LocalizationChannel.LocalizationMessageHandler localizationMessageHandler =
new LocalizationChannel.LocalizationMessageHandler() {
@Override
public String getStringResource(@NonNull String key, @Nullable String localeString) {
Context localContext = context;
String stringToReturn = null;
Locale savedLocale = null;

if (localeString != null) {
Locale locale = localeFromString(localeString);

// setLocale and createConfigurationContext is only available on API >= 17
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
Copy link
Contributor

@GaryQian GaryQian Feb 24, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit, comment the API number for Build.VERSION_CODES.JELLY_BEAN_MR1, same as below.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

Configuration config = new Configuration(context.getResources().getConfiguration());
config.setLocale(locale);
localContext = context.createConfigurationContext(config);
} else {
// In API < 17, we have to update the locale in Configuration.
Resources resources = context.getResources();
Configuration config = resources.getConfiguration();
savedLocale = config.locale;
config.locale = locale;
resources.updateConfiguration(config, null);
}
}

String packageName = context.getPackageName();
int resId = localContext.getResources().getIdentifier(key, "string", packageName);
if (resId != 0) {
// 0 means the resource is not found.
stringToReturn = localContext.getResources().getString(resId);
}

// In API < 17, we had to restore the original locale after using.
if (localeString != null && Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR1) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit, comment the API number for Build.VERSION_CODES.JELLY_BEAN_MR1

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

Resources resources = context.getResources();
Configuration config = resources.getConfiguration();
config.locale = savedLocale;
resources.updateConfiguration(config, null);
}

return stringToReturn;
}
};

public LocalizationPlugin(
@NonNull Context context, @NonNull LocalizationChannel localizationChannel) {

this.context = context;
this.localizationChannel = localizationChannel;
this.localizationChannel.setLocalizationMessageHandler(localizationMessageHandler);
}

/**
Expand Down Expand Up @@ -136,4 +186,39 @@ public void sendLocalesToFlutter(@NonNull Configuration config) {

localizationChannel.sendLocales(locales);
}

@VisibleForTesting
public static Locale localeFromString(String localeString) {
GaryQian marked this conversation as resolved.
Show resolved Hide resolved
// Use Locale.forLanguageTag if available (API 21+).
if (false && Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.LOLLIPOP) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any chance you remember why this if block exist?

Copy link
Contributor Author

@chingjun chingjun Feb 28, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Most likely a mistake. The false && was probably added to temporarily test the else branch and I forgot to remove that.

Do you want me to send a PR to undo this?

return Locale.forLanguageTag(localeString);
} else {
// Normalize the locale string, replace all underscores with hyphens.
localeString = localeString.replace('_', '-');

// Pre-API 21, we fall back to manually parsing the locale tag.
String parts[] = localeString.split("-", -1);

// The format is:
// language[-script][-region][-...]
// where script is an alphabet string of length 4, and region is either an alphabet string of
// length 2 or a digit string of length 3.

// Assume the first part is always the language code.
String languageCode = parts[0];
String scriptCode = "";
String countryCode = "";
int index = 1;
if (parts.length > index && parts[index].length() == 4) {
scriptCode = parts[index];
index++;
}
if (parts.length > index && parts[index].length() >= 2 && parts[index].length() <= 3) {
countryCode = parts[index];
index++;
}
// Ignore the rest of the locale for this purpose.
return new Locale(languageCode, countryCode, scriptCode);
}
}
}
Expand Up @@ -3,7 +3,9 @@

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import android.annotation.TargetApi;
Expand All @@ -14,10 +16,14 @@
import android.os.LocaleList;
import io.flutter.embedding.engine.dart.DartExecutor;
import io.flutter.embedding.engine.systemchannels.LocalizationChannel;
import io.flutter.plugin.common.MethodCall;
import io.flutter.plugin.common.MethodChannel;
import io.flutter.plugin.localization.LocalizationPlugin;
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.util.Locale;
import org.json.JSONException;
import org.json.JSONObject;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
Expand Down Expand Up @@ -321,6 +327,132 @@ public void computePlatformResolvedLocaleAPI16() {
assertEquals(result[2], "");
}

// Tests the legacy pre API 21 algorithm.
@Config(sdk = 16)
@Test
public void localeFromString_languageOnly() {
Locale locale = LocalizationPlugin.localeFromString("en");
assertEquals(locale, new Locale("en"));
}

@Config(sdk = 16)
@Test
public void localeFromString_languageAndCountry() {
Locale locale = LocalizationPlugin.localeFromString("en-US");
assertEquals(locale, new Locale("en", "US"));
}

@Config(sdk = 16)
@Test
public void localeFromString_languageCountryAndVariant() {
Locale locale = LocalizationPlugin.localeFromString("zh-Hans-CN");
assertEquals(locale, new Locale("zh", "CN", "Hans"));
}

@Config(sdk = 16)
@Test
public void localeFromString_underscore() {
Locale locale = LocalizationPlugin.localeFromString("zh_Hans_CN");
assertEquals(locale, new Locale("zh", "CN", "Hans"));
}

@Config(sdk = 16)
@Test
public void localeFromString_additionalVariantsAreIgnored() {
Locale locale = LocalizationPlugin.localeFromString("de-DE-u-co-phonebk");
assertEquals(locale, new Locale("de", "DE"));
}

@Test
public void getStringResource_withoutLocale() throws JSONException {
Context context = mock(Context.class);
Resources resources = mock(Resources.class);
DartExecutor dartExecutor = mock(DartExecutor.class);
LocalizationChannel localizationChannel = new LocalizationChannel(dartExecutor);
LocalizationPlugin plugin = new LocalizationPlugin(context, localizationChannel);

MethodChannel.Result mockResult = mock(MethodChannel.Result.class);

String fakePackageName = "package_name";
String fakeKey = "test_key";
int fakeId = 123;

when(context.getPackageName()).thenReturn(fakePackageName);
when(context.getResources()).thenReturn(resources);
when(resources.getIdentifier(fakeKey, "string", fakePackageName)).thenReturn(fakeId);
when(resources.getString(fakeId)).thenReturn("test_value");

JSONObject param = new JSONObject();
param.put("key", fakeKey);

localizationChannel.handler.onMethodCall(
new MethodCall("Localization.getStringResource", param), mockResult);

verify(mockResult).success("test_value");
}

@Test
public void getStringResource_withLocale() throws JSONException {
Context context = mock(Context.class);
Context localContext = mock(Context.class);
Resources resources = mock(Resources.class);
Resources localResources = mock(Resources.class);
Configuration configuration = new Configuration();
DartExecutor dartExecutor = mock(DartExecutor.class);
LocalizationChannel localizationChannel = new LocalizationChannel(dartExecutor);
LocalizationPlugin plugin = new LocalizationPlugin(context, localizationChannel);

MethodChannel.Result mockResult = mock(MethodChannel.Result.class);

String fakePackageName = "package_name";
String fakeKey = "test_key";
int fakeId = 123;

when(context.getPackageName()).thenReturn(fakePackageName);
when(context.createConfigurationContext(any())).thenReturn(localContext);
when(context.getResources()).thenReturn(resources);
when(localContext.getResources()).thenReturn(localResources);
when(resources.getConfiguration()).thenReturn(configuration);
when(localResources.getIdentifier(fakeKey, "string", fakePackageName)).thenReturn(fakeId);
when(localResources.getString(fakeId)).thenReturn("test_value");

JSONObject param = new JSONObject();
param.put("key", fakeKey);
param.put("locale", "en-US");

localizationChannel.handler.onMethodCall(
new MethodCall("Localization.getStringResource", param), mockResult);

verify(mockResult).success("test_value");
}

@Test
public void getStringResource_nonExistentKey() throws JSONException {
Context context = mock(Context.class);
Resources resources = mock(Resources.class);
DartExecutor dartExecutor = mock(DartExecutor.class);
LocalizationChannel localizationChannel = new LocalizationChannel(dartExecutor);
LocalizationPlugin plugin = new LocalizationPlugin(context, localizationChannel);

MethodChannel.Result mockResult = mock(MethodChannel.Result.class);

String fakePackageName = "package_name";
String fakeKey = "test_key";

when(context.getPackageName()).thenReturn(fakePackageName);
when(context.getResources()).thenReturn(resources);
when(resources.getIdentifier(fakeKey, "string", fakePackageName))
.thenReturn(0); // 0 means not exist

JSONObject param = new JSONObject();
param.put("key", fakeKey);

localizationChannel.handler.onMethodCall(
new MethodCall("Localization.getStringResource", param), mockResult);

verify(mockResult).success(null);
}

private static void setApiVersion(int apiVersion) {
try {
Field field = Build.VERSION.class.getField("SDK_INT");
Expand Down