From 6ee109dc80cca02c60c1918059d8cc38766afeea Mon Sep 17 00:00:00 2001 From: Kyle Spearrin Date: Thu, 11 Apr 2019 15:33:10 -0400 Subject: [PATCH] i18n service --- src/Android/Android.csproj | 1 + src/Android/MainApplication.cs | 4 + src/Android/Services/LocalizeService.cs | 97 ++++++++++++++++++++++ src/App/Abstractions/ILocalizeService.cs | 9 ++ src/App/App.xaml.cs | 25 +++++- src/App/Models/PlatformCulture.cs | 39 +++++++++ src/App/Services/MobileI18nService.cs | 67 +++++++++++++++ src/App/Utilities/TranslateExtension.cs | 29 +++++++ src/Core/Abstractions/II18nService.cs | 11 +++ src/iOS.Core/Services/LocalizeService.cs | 101 +++++++++++++++++++++++ src/iOS.Core/iOS.Core.csproj | 5 ++ 11 files changed, 384 insertions(+), 4 deletions(-) create mode 100644 src/Android/Services/LocalizeService.cs create mode 100644 src/App/Abstractions/ILocalizeService.cs create mode 100644 src/App/Models/PlatformCulture.cs create mode 100644 src/App/Services/MobileI18nService.cs create mode 100644 src/App/Utilities/TranslateExtension.cs create mode 100644 src/Core/Abstractions/II18nService.cs create mode 100644 src/iOS.Core/Services/LocalizeService.cs diff --git a/src/Android/Android.csproj b/src/Android/Android.csproj index 99a728ad2..bdb15c440 100644 --- a/src/Android/Android.csproj +++ b/src/Android/Android.csproj @@ -80,6 +80,7 @@ + diff --git a/src/Android/MainApplication.cs b/src/Android/MainApplication.cs index 5fee117ff..638550c8f 100644 --- a/src/Android/MainApplication.cs +++ b/src/Android/MainApplication.cs @@ -41,7 +41,11 @@ namespace Bit.Droid var documentsPath = Environment.GetFolderPath(Environment.SpecialFolder.Personal); var liteDbStorage = new LiteDbStorageService(Path.Combine(documentsPath, "bitwarden.db")); var deviceActionService = new DeviceActionService(); + var localizeService = new LocalizeService(); + ServiceContainer.Register("localizeService", localizeService); + ServiceContainer.Register("i18nService", + new MobileI18nService(localizeService.GetCurrentCultureInfo())); ServiceContainer.Register("cryptoPrimitiveService", new CryptoPrimitiveService()); ServiceContainer.Register("storageService", new MobileStorageService(preferencesStorage, liteDbStorage)); diff --git a/src/Android/Services/LocalizeService.cs b/src/Android/Services/LocalizeService.cs new file mode 100644 index 000000000..1521b629c --- /dev/null +++ b/src/Android/Services/LocalizeService.cs @@ -0,0 +1,97 @@ +using System; +using System.Globalization; +using Bit.App.Abstractions; +using Bit.App.Models; + +namespace Bit.Droid.Services +{ + public class LocalizeService : ILocalizeService + { + public CultureInfo GetCurrentCultureInfo() + { + var netLanguage = "en"; + var androidLocale = Java.Util.Locale.Default; + netLanguage = AndroidToDotnetLanguage(androidLocale.ToString().Replace("_", "-")); + // This gets called a lot - try/catch can be expensive so consider caching or something + CultureInfo ci = null; + try + { + ci = new CultureInfo(netLanguage); + } + catch(CultureNotFoundException e1) + { + // iOS locale not valid .NET culture (eg. "en-ES" : English in Spain) + // fallback to first characters, in this case "en" + try + { + var fallback = ToDotnetFallbackLanguage(new PlatformCulture(netLanguage)); + Console.WriteLine(netLanguage + " failed, trying " + fallback + " (" + e1.Message + ")"); + ci = new CultureInfo(fallback); + } + catch(CultureNotFoundException e2) + { + // iOS language not valid .NET culture, falling back to English + Console.WriteLine(netLanguage + " couldn't be set, using 'en' (" + e2.Message + ")"); + ci = new CultureInfo("en"); + } + } + return ci; + } + + private string AndroidToDotnetLanguage(string androidLanguage) + { + Console.WriteLine("Android Language:" + androidLanguage); + var netLanguage = androidLanguage; + if(androidLanguage.StartsWith("zh")) + { + if(androidLanguage.Contains("Hant") || androidLanguage.Contains("TW") || + androidLanguage.Contains("HK") || androidLanguage.Contains("MO")) + { + netLanguage = "zh-Hant"; + } + else + { + netLanguage = "zh-Hans"; + } + } + else + { + // Certain languages need to be converted to CultureInfo equivalent + switch(androidLanguage) + { + case "ms-BN": // "Malaysian (Brunei)" not supported .NET culture + case "ms-MY": // "Malaysian (Malaysia)" not supported .NET culture + case "ms-SG": // "Malaysian (Singapore)" not supported .NET culture + netLanguage = "ms"; // closest supported + break; + case "in-ID": // "Indonesian (Indonesia)" has different code in .NET + netLanguage = "id-ID"; // correct code for .NET + break; + case "gsw-CH": // "Schwiizertüütsch (Swiss German)" not supported .NET culture + netLanguage = "de-CH"; // closest supported + break; + // add more application-specific cases here (if required) + // ONLY use cultures that have been tested and known to work + } + } + Console.WriteLine(".NET Language/Locale:" + netLanguage); + return netLanguage; + } + + private string ToDotnetFallbackLanguage(PlatformCulture platCulture) + { + Console.WriteLine(".NET Fallback Language:" + platCulture.LanguageCode); + var netLanguage = platCulture.LanguageCode; // use the first part of the identifier (two chars, usually); + switch(platCulture.LanguageCode) + { + case "gsw": + netLanguage = "de-CH"; // equivalent to German (Switzerland) for this app + break; + // add more application-specific cases here (if required) + // ONLY use cultures that have been tested and known to work + } + Console.WriteLine(".NET Fallback Language/Locale:" + netLanguage + " (application-specific)"); + return netLanguage; + } + } +} \ No newline at end of file diff --git a/src/App/Abstractions/ILocalizeService.cs b/src/App/Abstractions/ILocalizeService.cs new file mode 100644 index 000000000..e5d009c4b --- /dev/null +++ b/src/App/Abstractions/ILocalizeService.cs @@ -0,0 +1,9 @@ +using System.Globalization; + +namespace Bit.App.Abstractions +{ + public interface ILocalizeService + { + CultureInfo GetCurrentCultureInfo(); + } +} diff --git a/src/App/App.xaml.cs b/src/App/App.xaml.cs index 186155371..18af7b7b8 100644 --- a/src/App/App.xaml.cs +++ b/src/App/App.xaml.cs @@ -1,8 +1,11 @@ using Bit.App.Models; using Bit.App.Pages; +using Bit.App.Resources; +using Bit.App.Services; using Bit.App.Utilities; +using Bit.Core.Abstractions; +using Bit.Core.Utilities; using System; -using System.Reflection; using Xamarin.Forms; using Xamarin.Forms.StyleSheets; using Xamarin.Forms.Xaml; @@ -12,18 +15,23 @@ namespace Bit.App { public partial class App : Application { + private readonly MobileI18nService _i18nService; + public App() { - InitializeComponent(); + _i18nService = ServiceContainer.Resolve("i18nService") as MobileI18nService; + InitializeComponent(); + SetCulture(); ThemeManager.SetTheme("light"); MainPage = new TabsPage(); + ServiceContainer.Resolve("platformUtilsService").Init(); MessagingCenter.Subscribe(Current, "ShowDialog", async (sender, details) => { var confirmed = true; - // TODO: ok text - var confirmText = string.IsNullOrWhiteSpace(details.ConfirmText) ? "Ok" : details.ConfirmText; + var confirmText = string.IsNullOrWhiteSpace(details.ConfirmText) ? + AppResources.Ok : details.ConfirmText; if(!string.IsNullOrWhiteSpace(details.CancelText)) { confirmed = await MainPage.DisplayAlert(details.Title, details.Text, confirmText, @@ -51,5 +59,14 @@ namespace Bit.App { // Handle when your app resumes } + + private void SetCulture() + { + _i18nService.Init(); + // Calendars are removed by linker. ref https://bugzilla.xamarin.com/show_bug.cgi?id=59077 + new System.Globalization.ThaiBuddhistCalendar(); + new System.Globalization.HijriCalendar(); + new System.Globalization.UmAlQuraCalendar(); + } } } diff --git a/src/App/Models/PlatformCulture.cs b/src/App/Models/PlatformCulture.cs new file mode 100644 index 000000000..2b1dc671d --- /dev/null +++ b/src/App/Models/PlatformCulture.cs @@ -0,0 +1,39 @@ +using System; + +namespace Bit.App.Models +{ + public class PlatformCulture + { + public PlatformCulture(string platformCultureString) + { + if(string.IsNullOrWhiteSpace(platformCultureString)) + { + throw new ArgumentException("Expected culture identifier.", nameof(platformCultureString)); + } + + // .NET expects dash, not underscore + PlatformString = platformCultureString.Replace("_", "-"); + var dashIndex = PlatformString.IndexOf("-", StringComparison.Ordinal); + if(dashIndex > 0) + { + var parts = PlatformString.Split('-'); + LanguageCode = parts[0]; + LocaleCode = parts[1]; + } + else + { + LanguageCode = PlatformString; + LocaleCode = string.Empty; + } + } + + public string PlatformString { get; private set; } + public string LanguageCode { get; private set; } + public string LocaleCode { get; private set; } + + public override string ToString() + { + return PlatformString; + } + } +} diff --git a/src/App/Services/MobileI18nService.cs b/src/App/Services/MobileI18nService.cs new file mode 100644 index 000000000..5bb9990b1 --- /dev/null +++ b/src/App/Services/MobileI18nService.cs @@ -0,0 +1,67 @@ +using Bit.App.Resources; +using Bit.Core.Abstractions; +using System; +using System.Globalization; +using System.Reflection; +using System.Resources; +using System.Threading; + +namespace Bit.App.Services +{ + public class MobileI18nService : II18nService + { + private const string ResourceId = "UsingResxLocalization.Resx.AppResources"; + + private static readonly Lazy _resourceManager = new Lazy(() => + new ResourceManager(ResourceId, IntrospectionExtensions.GetTypeInfo(typeof(MobileI18nService)).Assembly)); + + private readonly CultureInfo _defaultCulture = new CultureInfo("en-US"); + private bool _inited; + + public MobileI18nService(CultureInfo systemCulture) + { + Culture = systemCulture; + } + + public CultureInfo Culture { get; set; } + + public void Init(CultureInfo culture = null) + { + if(_inited) + { + throw new Exception("I18n already inited."); + } + _inited = true; + if(culture != null) + { + Culture = culture; + } + AppResources.Culture = Culture; + Thread.CurrentThread.CurrentCulture = Culture; + Thread.CurrentThread.CurrentUICulture = Culture; + } + + public string T(string id, params string[] p) + { + return Translate(id, p); + } + + public string Translate(string id, params string[] p) + { + if(string.IsNullOrWhiteSpace(id)) + { + return string.Empty; + } + var result = _resourceManager.Value.GetString(id, Culture); + if(result == null) + { + result = _resourceManager.Value.GetString(id, _defaultCulture); + if(result == null) + { + result = $"{{{id}}}"; + } + } + return string.Format(result, p); + } + } +} diff --git a/src/App/Utilities/TranslateExtension.cs b/src/App/Utilities/TranslateExtension.cs new file mode 100644 index 000000000..a8d8a940a --- /dev/null +++ b/src/App/Utilities/TranslateExtension.cs @@ -0,0 +1,29 @@ +using Bit.Core.Abstractions; +using Bit.Core.Utilities; +using System; +using Xamarin.Forms; +using Xamarin.Forms.Xaml; + +namespace Bit.App.Utilities +{ + [ContentProperty("Text")] + public class TranslateExtension : IMarkupExtension + { + private II18nService _i18nService; + + public TranslateExtension() + { + _i18nService = ServiceContainer.Resolve("i18nService"); + } + + public string Id { get; set; } + public string P1 { get; set; } + public string P2 { get; set; } + public string P3 { get; set; } + + public object ProvideValue(IServiceProvider serviceProvider) + { + return _i18nService.T(Id, P1, P2, P3); + } + } +} diff --git a/src/Core/Abstractions/II18nService.cs b/src/Core/Abstractions/II18nService.cs new file mode 100644 index 000000000..8eabf1f13 --- /dev/null +++ b/src/Core/Abstractions/II18nService.cs @@ -0,0 +1,11 @@ +using System.Globalization; + +namespace Bit.Core.Abstractions +{ + public interface II18nService + { + CultureInfo Culture { get; set; } + string T(string id, params string[] p); + string Translate(string id, params string[] p); + } +} \ No newline at end of file diff --git a/src/iOS.Core/Services/LocalizeService.cs b/src/iOS.Core/Services/LocalizeService.cs new file mode 100644 index 000000000..0c0bef664 --- /dev/null +++ b/src/iOS.Core/Services/LocalizeService.cs @@ -0,0 +1,101 @@ +using System; +using System.Globalization; +using Bit.App.Abstractions; +using Bit.App.Models; +using Foundation; + +namespace Bit.iOS.Core.Services +{ + public class LocalizeService : ILocalizeService + { + public CultureInfo GetCurrentCultureInfo() + { + var netLanguage = "en"; + if(NSLocale.PreferredLanguages.Length > 0) + { + var pref = NSLocale.PreferredLanguages[0]; + + netLanguage = iOSToDotnetLanguage(pref); + } + + // This gets called a lot - try/catch can be expensive so consider caching or something + CultureInfo ci = null; + try + { + ci = new CultureInfo(netLanguage); + } + catch(CultureNotFoundException e1) + { + // iOS locale not valid .NET culture (eg. "en-ES" : English in Spain) + // fallback to first characters, in this case "en" + try + { + var fallback = ToDotnetFallbackLanguage(new PlatformCulture(netLanguage)); + Console.WriteLine(netLanguage + " failed, trying " + fallback + " (" + e1.Message + ")"); + ci = new CultureInfo(fallback); + } + catch(CultureNotFoundException e2) + { + // iOS language not valid .NET culture, falling back to English + Console.WriteLine(netLanguage + " couldn't be set, using 'en' (" + e2.Message + ")"); + ci = new CultureInfo("en"); + } + } + + return ci; + } + + private string iOSToDotnetLanguage(string iOSLanguage) + { + Console.WriteLine("iOS Language:" + iOSLanguage); + var netLanguage = iOSLanguage; + if(iOSLanguage.StartsWith("zh-Hant") || iOSLanguage.StartsWith("zh-HK")) + { + netLanguage = "zh-Hant"; + } + else if(iOSLanguage.StartsWith("zh")) + { + netLanguage = "zh-Hans"; + } + else + { + // Certain languages need to be converted to CultureInfo equivalent + switch(iOSLanguage) + { + case "ms-MY": // "Malaysian (Malaysia)" not supported .NET culture + case "ms-SG": // "Malaysian (Singapore)" not supported .NET culture + netLanguage = "ms"; // closest supported + break; + case "gsw-CH": // "Schwiizertüütsch (Swiss German)" not supported .NET culture + netLanguage = "de-CH"; // closest supported + break; + // add more application-specific cases here (if required) + // ONLY use cultures that have been tested and known to work + } + } + + Console.WriteLine(".NET Language/Locale:" + netLanguage); + return netLanguage; + } + + private string ToDotnetFallbackLanguage(PlatformCulture platCulture) + { + Console.WriteLine(".NET Fallback Language:" + platCulture.LanguageCode); + // Use the first part of the identifier (two chars, usually); + var netLanguage = platCulture.LanguageCode; + switch(platCulture.LanguageCode) + { + case "pt": + netLanguage = "pt-PT"; // fallback to Portuguese (Portugal) + break; + case "gsw": + netLanguage = "de-CH"; // equivalent to German (Switzerland) for this app + break; + // add more application-specific cases here (if required) + // ONLY use cultures that have been tested and known to work + } + Console.WriteLine(".NET Fallback Language/Locale:" + netLanguage + " (application-specific)"); + return netLanguage; + } + } +} \ No newline at end of file diff --git a/src/iOS.Core/iOS.Core.csproj b/src/iOS.Core/iOS.Core.csproj index 89667d36b..dafb4a06d 100644 --- a/src/iOS.Core/iOS.Core.csproj +++ b/src/iOS.Core/iOS.Core.csproj @@ -49,9 +49,14 @@ + + + {ee44c6a1-2a85-45fe-8d9b-bf1d5f88809c} + App + {4b8a8c41-9820-4341-974c-41e65b7f4366} Core