diff --git a/src/org/thoughtcrime/securesms/ApplicationContext.java b/src/org/thoughtcrime/securesms/ApplicationContext.java index d3a3cfb2cb..b5014fd36d 100644 --- a/src/org/thoughtcrime/securesms/ApplicationContext.java +++ b/src/org/thoughtcrime/securesms/ApplicationContext.java @@ -58,6 +58,7 @@ import org.thoughtcrime.securesms.service.RotateSenderCertificateListener; import org.thoughtcrime.securesms.service.RotateSignedPreKeyListener; import org.thoughtcrime.securesms.service.UpdateApkRefreshListener; import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.dynamiclanguage.DynamicLanguageContextWrapper; import org.webrtc.PeerConnectionFactory; import org.webrtc.PeerConnectionFactory.InitializationOptions; import org.webrtc.voiceengine.WebRtcAudioManager; @@ -319,4 +320,9 @@ public class ApplicationContext extends MultiDexApplication implements Dependenc BlobProvider.getInstance().onSessionStart(this); }); } + + @Override + protected void attachBaseContext(Context base) { + super.attachBaseContext(DynamicLanguageContextWrapper.updateContext(base, TextSecurePreferences.getLanguage(base))); + } } diff --git a/src/org/thoughtcrime/securesms/BaseActionBarActivity.java b/src/org/thoughtcrime/securesms/BaseActionBarActivity.java index e6b5ea5684..75d24ef5f2 100644 --- a/src/org/thoughtcrime/securesms/BaseActionBarActivity.java +++ b/src/org/thoughtcrime/securesms/BaseActionBarActivity.java @@ -1,6 +1,7 @@ package org.thoughtcrime.securesms; import android.annotation.TargetApi; +import android.content.Context; import android.content.Intent; import android.os.Build; import android.os.Build.VERSION_CODES; @@ -16,6 +17,8 @@ import android.view.WindowManager; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.dynamiclanguage.DynamicLanguageActivityHelper; +import org.thoughtcrime.securesms.util.dynamiclanguage.DynamicLanguageContextWrapper; import java.lang.reflect.Field; @@ -35,6 +38,7 @@ public abstract class BaseActionBarActivity extends AppCompatActivity { protected void onResume() { super.onResume(); initializeScreenshotSecurity(); + DynamicLanguageActivityHelper.recreateIfNotInCorrectLanguage(this, TextSecurePreferences.getLanguage(this)); } @Override @@ -90,4 +94,8 @@ public abstract class BaseActionBarActivity extends AppCompatActivity { } } + @Override + protected void attachBaseContext(Context newBase) { + super.attachBaseContext(DynamicLanguageContextWrapper.updateContext(newBase, TextSecurePreferences.getLanguage(newBase))); + } } diff --git a/src/org/thoughtcrime/securesms/BaseActivity.java b/src/org/thoughtcrime/securesms/BaseActivity.java index bcc4611b86..e1f03b1a68 100644 --- a/src/org/thoughtcrime/securesms/BaseActivity.java +++ b/src/org/thoughtcrime/securesms/BaseActivity.java @@ -1,5 +1,6 @@ package org.thoughtcrime.securesms; +import android.content.Context; import android.os.Build; import android.os.Build.VERSION; import android.os.Build.VERSION_CODES; @@ -7,6 +8,10 @@ import android.support.annotation.NonNull; import android.support.v4.app.FragmentActivity; import android.view.KeyEvent; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.dynamiclanguage.DynamicLanguageActivityHelper; +import org.thoughtcrime.securesms.util.dynamiclanguage.DynamicLanguageContextWrapper; + public abstract class BaseActivity extends FragmentActivity { @Override public boolean onKeyDown(int keyCode, KeyEvent event) { @@ -27,4 +32,15 @@ public abstract class BaseActivity extends FragmentActivity { VERSION.SDK_INT > VERSION_CODES.GINGERBREAD_MR1 && ("LGE".equalsIgnoreCase(Build.MANUFACTURER) || "E6710".equalsIgnoreCase(Build.DEVICE)); } + + @Override + protected void onResume() { + super.onResume(); + DynamicLanguageActivityHelper.recreateIfNotInCorrectLanguage(this, TextSecurePreferences.getLanguage(this)); + } + + @Override + protected void attachBaseContext(Context newBase) { + super.attachBaseContext(DynamicLanguageContextWrapper.updateContext(newBase, TextSecurePreferences.getLanguage(newBase))); + } } diff --git a/src/org/thoughtcrime/securesms/logging/Log.java b/src/org/thoughtcrime/securesms/logging/Log.java index a2a5ecb9ef..9ec2b11aea 100644 --- a/src/org/thoughtcrime/securesms/logging/Log.java +++ b/src/org/thoughtcrime/securesms/logging/Log.java @@ -119,6 +119,13 @@ public class Log { } } + public static String tag(Class clazz) { + String simpleName = clazz.getSimpleName(); + if (simpleName.length() > 23) { + return simpleName.substring(0, 23); + } + return simpleName; + } public static abstract class Logger { public abstract void v(String tag, String message, Throwable t); diff --git a/src/org/thoughtcrime/securesms/util/DynamicLanguage.java b/src/org/thoughtcrime/securesms/util/DynamicLanguage.java index e2b6b20ae1..2909e84ca6 100644 --- a/src/org/thoughtcrime/securesms/util/DynamicLanguage.java +++ b/src/org/thoughtcrime/securesms/util/DynamicLanguage.java @@ -1,50 +1,35 @@ package org.thoughtcrime.securesms.util; -import android.annotation.TargetApi; import android.app.Activity; import android.app.Service; import android.content.Context; -import android.content.Intent; import android.content.res.Configuration; -import android.os.Build.VERSION; -import android.os.Build.VERSION_CODES; -import android.support.annotation.RequiresApi; -import android.text.TextUtils; + +import org.thoughtcrime.securesms.util.dynamiclanguage.LanguageString; import java.util.Locale; +/** + * @deprecated Use a base activity that uses the {@link org.thoughtcrime.securesms.util.dynamiclanguage.DynamicLanguageContextWrapper} + */ +@Deprecated public class DynamicLanguage { - private static final String DEFAULT = "zz"; - - private Locale currentLocale; - public void onCreate(Activity activity) { - currentLocale = getSelectedLocale(activity); - setContextLocale(activity, currentLocale); } public void onResume(Activity activity) { - if (!currentLocale.equals(getSelectedLocale(activity))) { - Intent intent = activity.getIntent(); - activity.finish(); - OverridePendingTransition.invoke(activity); - activity.startActivity(intent); - OverridePendingTransition.invoke(activity); - } } public void updateServiceLocale(Service service) { - currentLocale = getSelectedLocale(service); - setContextLocale(service, currentLocale); + setContextLocale(service, getSelectedLocale(service)); } public Locale getCurrentLocale() { - return currentLocale; + return Locale.getDefault(); } - @RequiresApi(VERSION_CODES.JELLY_BEAN_MR1) - public static int getLayoutDirection(Context context) { + static int getLayoutDirection(Context context) { Configuration configuration = context.getResources().getConfiguration(); return configuration.getLayoutDirection(); } @@ -53,34 +38,18 @@ public class DynamicLanguage { Configuration configuration = context.getResources().getConfiguration(); if (!configuration.locale.equals(selectedLocale)) { - configuration.locale = selectedLocale; - if (VERSION.SDK_INT >= VERSION_CODES.JELLY_BEAN_MR1) { - configuration.setLayoutDirection(selectedLocale); - } + configuration.setLocale(selectedLocale); context.getResources().updateConfiguration(configuration, context.getResources().getDisplayMetrics()); } } - private static Locale getActivityLocale(Activity activity) { - return activity.getResources().getConfiguration().locale; - } - private static Locale getSelectedLocale(Context context) { - String language[] = TextUtils.split(TextSecurePreferences.getLanguage(context), "_"); - - if (language[0].equals(DEFAULT)) { + Locale locale = LanguageString.parseLocale(TextSecurePreferences.getLanguage(context)); + if (locale == null) { return Locale.getDefault(); - } else if (language.length == 2) { - return new Locale(language[0], language[1]); } else { - return new Locale(language[0]); - } - } - - private static final class OverridePendingTransition { - static void invoke(Activity activity) { - activity.overridePendingTransition(0, 0); + return locale; } } } diff --git a/src/org/thoughtcrime/securesms/util/dynamiclanguage/DynamicLanguageActivityHelper.java b/src/org/thoughtcrime/securesms/util/dynamiclanguage/DynamicLanguageActivityHelper.java new file mode 100644 index 0000000000..8c16f51348 --- /dev/null +++ b/src/org/thoughtcrime/securesms/util/dynamiclanguage/DynamicLanguageActivityHelper.java @@ -0,0 +1,40 @@ +package org.thoughtcrime.securesms.util.dynamiclanguage; + +import android.app.Activity; +import android.content.Intent; +import android.support.annotation.MainThread; +import android.support.v4.os.ConfigurationCompat; + +import org.thoughtcrime.securesms.logging.Log; + +import java.util.Locale; + +public final class DynamicLanguageActivityHelper { + + private static final String TAG = Log.tag(DynamicLanguageActivityHelper.class); + + private static String reentryProtection; + + /** + * If the activity isn't in the specified language, it will restart the activity. + */ + @MainThread + public static void recreateIfNotInCorrectLanguage(Activity activity, String language) { + Locale currentActivityLocale = ConfigurationCompat.getLocales(activity.getResources().getConfiguration()).get(0); + Locale selectedLocale = LocaleParser.findBestMatchingLocaleForLanguage(language); + + if (currentActivityLocale.equals(selectedLocale)) { + reentryProtection = ""; + return; + } + + String reentryKey = activity.getClass().getName() + ":" + selectedLocale; + if (!reentryKey.equals(reentryProtection)) { + reentryProtection = reentryKey; + Log.d(TAG, String.format("Activity Locale %s, Selected locale %s, restarting", currentActivityLocale, selectedLocale)); + activity.recreate(); + } else { + Log.d(TAG, String.format("Skipping recreate as looks like looping, Activity Locale %s, Selected locale %s", currentActivityLocale, selectedLocale)); + } + } +} diff --git a/src/org/thoughtcrime/securesms/util/dynamiclanguage/DynamicLanguageContextWrapper.java b/src/org/thoughtcrime/securesms/util/dynamiclanguage/DynamicLanguageContextWrapper.java new file mode 100644 index 0000000000..d4e227fb2f --- /dev/null +++ b/src/org/thoughtcrime/securesms/util/dynamiclanguage/DynamicLanguageContextWrapper.java @@ -0,0 +1,34 @@ +package org.thoughtcrime.securesms.util.dynamiclanguage; + +import android.content.Context; +import android.content.res.Configuration; +import android.content.res.Resources; + +import java.util.Locale; + +/** + * Updates a context with an alternative language. + */ +public final class DynamicLanguageContextWrapper { + + public static Context updateContext(Context context, String language) { + final Locale newLocale = LocaleParser.findBestMatchingLocaleForLanguage(language); + + Locale.setDefault(newLocale); + + final Resources resources = context.getResources(); + final Configuration config = resources.getConfiguration(); + final Configuration newConfig = copyWithNewLocale(config, newLocale); + + resources.updateConfiguration(newConfig, resources.getDisplayMetrics()); + + return context; + } + + private static Configuration copyWithNewLocale(Configuration config, Locale locale) { + final Configuration copy = new Configuration(config); + copy.setLocale(locale); + return copy; + } + +} diff --git a/src/org/thoughtcrime/securesms/util/dynamiclanguage/LanguageString.java b/src/org/thoughtcrime/securesms/util/dynamiclanguage/LanguageString.java new file mode 100644 index 0000000000..c3d9f2d870 --- /dev/null +++ b/src/org/thoughtcrime/securesms/util/dynamiclanguage/LanguageString.java @@ -0,0 +1,48 @@ +package org.thoughtcrime.securesms.util.dynamiclanguage; + +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; + +import java.util.Locale; + +public final class LanguageString { + + private LanguageString() { + } + + /** + * @param languageString String in format language_REGION, e.g. en_US + * @return Locale, or null if cannot parse + */ + @Nullable + public static Locale parseLocale(@Nullable String languageString) { + if (languageString == null || languageString.isEmpty()) { + return null; + } + + final Locale locale = createLocale(languageString); + + if (!isValid(locale)) { + return null; + } else { + return locale; + } + } + + private static Locale createLocale(@NonNull String languageString) { + final String language[] = languageString.split("_"); + if (language.length == 2) { + return new Locale(language[0], language[1]); + } else { + return new Locale(language[0]); + } + } + + private static boolean isValid(@NonNull Locale locale) { + try { + return locale.getISO3Language() != null && locale.getISO3Country() != null; + } catch (Exception ex) { + return false; + } + } +} diff --git a/src/org/thoughtcrime/securesms/util/dynamiclanguage/LocaleParser.java b/src/org/thoughtcrime/securesms/util/dynamiclanguage/LocaleParser.java new file mode 100644 index 0000000000..5146ff7f02 --- /dev/null +++ b/src/org/thoughtcrime/securesms/util/dynamiclanguage/LocaleParser.java @@ -0,0 +1,53 @@ +package org.thoughtcrime.securesms.util.dynamiclanguage; + +import android.content.res.Configuration; +import android.content.res.Resources; +import android.support.annotation.Nullable; +import android.support.v4.os.ConfigurationCompat; + +import org.thoughtcrime.securesms.BuildConfig; + +import java.util.Arrays; +import java.util.Locale; + +final class LocaleParser { + + private LocaleParser() { + } + + /** + * Given a language, gets the best choice from the apps list of supported languages and the + * Systems set of languages. + */ + static Locale findBestMatchingLocaleForLanguage(@Nullable String language) { + final Locale locale = LanguageString.parseLocale(language); + if (appSupportsTheExactLocale(locale)) { + return locale; + } else { + return findBestSystemLocale(); + } + } + + private static boolean appSupportsTheExactLocale(@Nullable Locale locale) { + if (locale == null) { + return false; + } + return Arrays.asList(BuildConfig.LANGUAGES).contains(locale.toString()); + } + + /** + * Get the first preferred language the app supports. + */ + private static Locale findBestSystemLocale() { + final Configuration config = Resources.getSystem().getConfiguration(); + + final Locale firstMatch = ConfigurationCompat.getLocales(config) + .getFirstMatch(BuildConfig.LANGUAGES); + + if (firstMatch != null) { + return firstMatch; + } + + return Locale.ENGLISH; + } +} diff --git a/test/unitTest/java/org/thoughtcrime/securesms/logging/LogTest.java b/test/unitTest/java/org/thoughtcrime/securesms/logging/LogTest.java new file mode 100644 index 0000000000..d8b6426f77 --- /dev/null +++ b/test/unitTest/java/org/thoughtcrime/securesms/logging/LogTest.java @@ -0,0 +1,37 @@ +package org.thoughtcrime.securesms.logging; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + +public final class LogTest { + + @Test + public void tag_short_class_name() { + assertEquals("MyClass", Log.tag(MyClass.class)); + } + + @Test + public void tag_23_character_class_name() { + String tag = Log.tag(TwentyThreeCharacters23.class); + assertEquals("TwentyThreeCharacters23", tag); + assertEquals(23, tag.length()); + } + + @Test + public void tag_24_character_class_name() { + assertEquals(24, TwentyFour24Characters24.class.getSimpleName().length()); + String tag = Log.tag(TwentyFour24Characters24.class); + assertEquals("TwentyFour24Characters2", tag); + assertEquals(23, Log.tag(TwentyThreeCharacters23.class).length()); + } + + private class MyClass { + } + + private class TwentyThreeCharacters23 { + } + + private class TwentyFour24Characters24 { + } +} diff --git a/test/unitTest/java/org/thoughtcrime/securesms/util/dynamiclanguage/LanguageStringTest.java b/test/unitTest/java/org/thoughtcrime/securesms/util/dynamiclanguage/LanguageStringTest.java new file mode 100644 index 0000000000..a54ff24ab4 --- /dev/null +++ b/test/unitTest/java/org/thoughtcrime/securesms/util/dynamiclanguage/LanguageStringTest.java @@ -0,0 +1,55 @@ +package org.thoughtcrime.securesms.util.dynamiclanguage; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; + +import java.util.Arrays; +import java.util.Collection; +import java.util.Locale; + +import static org.junit.Assert.assertEquals; + +@RunWith(Parameterized.class) +public final class LanguageStringTest { + + private final Locale expected; + private final String input; + + @Parameterized.Parameters + public static Collection data() { + return Arrays.asList(new Object[][]{ + + /* Language */ + { new Locale("en"), "en" }, + { new Locale("de"), "de" }, + { new Locale("fr"), "FR" }, + + /* Language and region */ + { new Locale("en", "US"), "en_US" }, + { new Locale("es", "US"), "es_US" }, + { new Locale("es", "MX"), "es_MX" }, + { new Locale("es", "MX"), "es_mx" }, + { new Locale("de", "DE"), "de_DE" }, + + /* Not parsable input */ + { null, null }, + { null, "" }, + { null, "zz" }, + { null, "zz_ZZ" }, + { null, "fr_ZZ" }, + { null, "zz_FR" }, + + }); + } + + public LanguageStringTest(Locale expected, String input) { + this.expected = expected; + this.input = input; + } + + @Test + public void parse() { + assertEquals(expected, LanguageString.parseLocale(input)); + } +} diff --git a/test/unitTest/java/org/thoughtcrime/securesms/util/dynamiclanguage/LocaleParserTest.java b/test/unitTest/java/org/thoughtcrime/securesms/util/dynamiclanguage/LocaleParserTest.java new file mode 100644 index 0000000000..f3de703cdf --- /dev/null +++ b/test/unitTest/java/org/thoughtcrime/securesms/util/dynamiclanguage/LocaleParserTest.java @@ -0,0 +1,53 @@ +package org.thoughtcrime.securesms.util.dynamiclanguage; + +import android.app.Application; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; +import org.thoughtcrime.securesms.BuildConfig; + +import java.util.Arrays; +import java.util.List; +import java.util.Locale; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; + +@RunWith(RobolectricTestRunner.class) +@Config(manifest = Config.NONE, application = Application.class) +public final class LocaleParserTest { + + @Test + public void findBestMatchingLocaleForLanguage_all_build_config_languages_can_be_resolved() { + for (String lang : buildConfigLanguages()) { + Locale locale = LocaleParser.findBestMatchingLocaleForLanguage(lang); + assertEquals(lang, locale.toString()); + } + } + + @Test + @Config(qualifiers = "fr") + public void findBestMatchingLocaleForLanguage_a_non_build_config_language_defaults_to_device_value_which_is_supported_directly() { + String unsupportedLanguage = getUnsupportedLanguage(); + assertEquals(Locale.FRENCH, LocaleParser.findBestMatchingLocaleForLanguage(unsupportedLanguage)); + } + + @Test + @Config(qualifiers = "en-rCA") + public void findBestMatchingLocaleForLanguage_a_non_build_config_language_defaults_to_device_value_which_is_not_supported_directly() { + String unsupportedLanguage = getUnsupportedLanguage(); + assertEquals(Locale.CANADA, LocaleParser.findBestMatchingLocaleForLanguage(unsupportedLanguage)); + } + + private static String getUnsupportedLanguage() { + String unsupportedLanguage = "af"; + assertFalse("Language should be an unsupported one", buildConfigLanguages().contains(unsupportedLanguage)); + return unsupportedLanguage; + } + + private static List buildConfigLanguages() { + return Arrays.asList(BuildConfig.LANGUAGES); + } +}