refactor: improve locale handling fixing issues with yoruba, hausa and guarani languages
What changed, and why it matters
This commit fixes app crashes and broken number formatting that could happen when the wallet was set to certain less-common languages such as Yoruba, Hausa, or Guarani. It makes the app fall back to English formatting when the device's locale has no number-formatting data, and it prevents invalid locale codes from being restored from saved settings. There is no sign this was a security flaw that could steal funds; it is best described as a reliability/UX fix.
Treat as a routine reliability/UX fix. No urgent security response is indicated. If triaging, verify that the fallback behavior does not mask more serious locale-dependent parsing bugs in payment amount fields, and consider adding tests for all supported locales.
Security signals we found
Locale fallback prevents unhandled NumberFormat exceptions
Saved language code is now validated against the supported list before restoration
No cryptographic, authentication, or authorization changes observed
No input parsing of attacker-controlled data beyond existing locale strings
Evidence from the diff
The patch refactors locale handling in Cake Wallet. Key changes: (1) crypto_amount_format.dart now uses Intl.verifiedLocale with a fallback to en when constructing NumberFormat, preventing exceptions for locales lacking ICU number data (yo, ha, gn). (2) money_local.dart reuses the same withLocalSeperator helper instead of its own NumberFormat call. (3) language_service.dart simplifies localeFromLanguageCode. (4) settings_store.dart validates saved language codes against the offered list before restoring them, writing back a default if the saved code is unsupported. (5) main.dart preloads GlobalMaterialLocalizations for English. (6) A UI selector widget is made more flexible to avoid overflow. (7) The localization generator now uses a SplayTreeMap for deterministic ordering. The changes reduce unhandled exceptions and bad locale states, but they do not introduce or fix a cryptographic or authorization vulnerability.
Changed components
cw_core/lib/amount/money_local.dartcw_core/lib/crypto_amount_format.dartlib/entities/language_service.dartlib/main.dartlib/store/settings_store.darttool/generate_localization.dartInspect captured patch +68 / −44
diff --git a/cw_core/lib/amount/money_local.dart b/cw_core/lib/amount/money_local.dart
index c5a3c0ea..8f88985c 100644
--- a/cw_core/lib/amount/money_local.dart
+++ b/cw_core/lib/amount/money_local.dart
@@ -1,5 +1,5 @@
import "package:cw_core/amount/money.dart";
-import "package:intl/intl.dart";
+import "package:cw_core/crypto_amount_format.dart";
extension WithLocalSeparator on Money {
String toLocalStringWithSymbol({
@@ -37,10 +37,7 @@ extension WithLocalSeparator on Money {
String _withLocalSeparator(String amount, {String? locale}) {
final isNegative = amount.startsWith("-");
- final formater = NumberFormat("#,###", locale);
- final parts = (isNegative ? amount.substring(1) : amount).split(".");
- final formatted = [formater.format(int.tryParse(parts.first) ?? 0), ...parts.sublist(1)]
- .join(formater.symbols.DECIMAL_SEP);
+ final formatted = (isNegative ? amount.substring(1) : amount).withLocalSeperator(locale);
return isNegative ? "-$formatted" : formatted;
}
diff --git a/cw_core/lib/crypto_amount_format.dart b/cw_core/lib/crypto_amount_format.dart
index 2c1d96f4..ddefd9ee 100644
--- a/cw_core/lib/crypto_amount_format.dart
+++ b/cw_core/lib/crypto_amount_format.dart
@@ -67,7 +67,10 @@ extension MaxDecimals on String {
return [parts.first.withLocalSeperator(locale), ...parts.sublist(1)].join(" ");
}
- final formater = NumberFormat("#,###", locale);
+ final formater = NumberFormat(
+ "#,###",
+ Intl.verifiedLocale(locale, NumberFormat.localeExists, onFailure: (_) => "en"),
+ );
final parts = replaceAll(",", "").split(".");
if (parts.first.contains("< 0")) parts.first = "0";
diff --git a/cw_core/test/amount/money_local_test.dart b/cw_core/test/amount/money_local_test.dart
index 24a4fffa..15aeaea0 100644
--- a/cw_core/test/amount/money_local_test.dart
+++ b/cw_core/test/amount/money_local_test.dart
@@ -23,6 +23,10 @@ void main() {
expect(_btcGrouping.toLocalStringWithPrecision(locale: "de_DE"), "1.234,5");
});
+ test("formats as English when intl has no number data for the locale (yo)", () {
+ expect(_btcGrouping.toLocalStringWithPrecision(locale: "yo"), "1,234.5");
+ });
+
test("base unit renders integer sats with grouping", () {
expect(
_btc012.toLocalStringWithPrecision(useBaseUnit: true, locale: "en_US"),
diff --git a/cw_core/test/crypto_amount_format.dart b/cw_core/test/crypto_amount_format.dart
index c611ad7d..fa0458bd 100644
--- a/cw_core/test/crypto_amount_format.dart
+++ b/cw_core/test/crypto_amount_format.dart
@@ -108,6 +108,15 @@ void main() {
});
});
+ group("Locale without intl number data", () {
+ test("should format as English instead of throwing (ha)", () {
+ const input = "1123.4567";
+ final result = input.withLocalSeperator("ha");
+
+ expect(result, equals("1,123.4567"));
+ });
+ });
+
group('With XMR Suffix', () {
const locale = 'de_CH';
diff --git a/lib/entities/language_service.dart b/lib/entities/language_service.dart
index 5331b1e8..95200fbc 100644
--- a/lib/entities/language_service.dart
+++ b/lib/entities/language_service.dart
@@ -3,16 +3,11 @@ import 'package:devicelocale/devicelocale.dart';
import "package:flutter/widgets.dart";
import 'package:intl/intl.dart';
-// This will turn stored language code into Flutter Locale.
-// Some of our language codes uses an underscore (like "pt_BR") and this must be split into language
-// and country subtags, if we pass the whole string as the languageCode, GlobalMaterialLocalizations
-// would reject it as an invalid ISO 639-1 code and crash the app on locale switch
+// Flutter's Material delegates match on the language subtag, so "pt_BR" must be split into two
Locale localeFromLanguageCode(String code) {
final parts = code.split("_");
- if (parts.length == 2 && parts[0].isNotEmpty && parts[1].isNotEmpty) {
- return Locale(parts[0], parts[1]);
- }
- return Locale(code);
+
+ return parts.length == 2 ? Locale(parts[0], parts[1]) : Locale(code);
}
class LanguageService {
diff --git a/lib/main.dart b/lib/main.dart
index eeb9e039..300fb19d 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -65,6 +65,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_daemon/flutter_daemon.dart';
+import "package:flutter_localizations/flutter_localizations.dart";
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:hive/hive.dart';
import 'package:quick_actions/quick_actions.dart';
@@ -329,6 +330,7 @@ Future<void> initialSetup({
required int initialMigrationVersion,
}) async {
LanguageService.loadLocaleList();
+ await GlobalMaterialLocalizations.delegate.load(const Locale("en"));
await defaultSettingsMigration(
secureStorage: secureStorage,
version: initialMigrationVersion,
diff --git a/lib/src/widgets/new_list_row/list_item_selector_widget.dart b/lib/src/widgets/new_list_row/list_item_selector_widget.dart
index fc707144..7989fdbd 100644
--- a/lib/src/widgets/new_list_row/list_item_selector_widget.dart
+++ b/lib/src/widgets/new_list_row/list_item_selector_widget.dart
@@ -32,30 +32,35 @@ class ListItemSelectorWidget extends StatelessWidget {
onTap: onTap,
isFirstInSection: isFirstInSection,
isLastInSection: isLastInSection,
- builder: (context, textStyle, labelStyle) {
- return Row(
+ builder: (context, textStyle, labelStyle) => Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Flexible(child: Text(label, style: textStyle)),
- Padding(
- padding: const EdgeInsets.symmetric(vertical: 4.0),
- child: Row(
- spacing: 8,
- children: [
- Text(
- options[selectedIndex],
- style: labelStyle,
- ),
- CakeImageWidget(
- imageUrl: "assets/new-ui/chooser.svg",
- colorFilter:
- ColorFilter.mode(theme.colorScheme.onSurfaceVariant, BlendMode.srcIn),
- ),
- ],
+ Flexible(
+ child: Padding(
+ padding: const EdgeInsets.symmetric(vertical: 4.0),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ spacing: 8,
+ children: [
+ Flexible(
+ child: Text(
+ options[selectedIndex],
+ style: labelStyle,
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ ),
+ ),
+ CakeImageWidget(
+ imageUrl: "assets/new-ui/chooser.svg",
+ colorFilter:
+ ColorFilter.mode(theme.colorScheme.onSurfaceVariant, BlendMode.srcIn),
+ ),
+ ],
+ ),
),
),
],
- );
- });
+ ));
}
}
diff --git a/lib/store/settings_store.dart b/lib/store/settings_store.dart
index f845cf86..bbbffa0c 100644
--- a/lib/store/settings_store.dart
+++ b/lib/store/settings_store.dart
@@ -1378,8 +1378,8 @@ abstract class SettingsStoreBase with Store {
pinLength = defaultPinLength;
}
- final savedLanguageCode = sharedPreferences.getString(PreferencesKey.currentLanguageCode) ??
- await LanguageService.localeDetection();
+ final savedLanguageCode =
+ _offeredLanguageCode(sharedPreferences) ?? await LanguageService.localeDetection();
final nodeId = sharedPreferences.getInt(PreferencesKey.currentNodeIdKey);
final bitcoinElectrumServerId =
sharedPreferences.getInt(PreferencesKey.currentBitcoinElectrumSererIdKey);
@@ -1938,7 +1938,12 @@ abstract class SettingsStoreBase with Store {
}
pinCodeLength = pinLength;
- languageCode = sharedPreferences.getString(PreferencesKey.currentLanguageCode) ?? languageCode;
+ final restoredLanguageCode = _offeredLanguageCode(sharedPreferences);
+ if (restoredLanguageCode == null) {
+ await sharedPreferences.setString(PreferencesKey.currentLanguageCode, languageCode);
+ } else {
+ languageCode = restoredLanguageCode;
+ }
shouldShowYatPopup =
sharedPreferences.getBool(PreferencesKey.shouldShowYatPopup) ?? shouldShowYatPopup;
shouldShowDEuroDisclaimer =
@@ -2315,6 +2320,12 @@ abstract class SettingsStoreBase with Store {
await _sharedPreferences.setString(key, serializedData);
}
+ static String? _offeredLanguageCode(SharedPreferences sharedPreferences) {
+ final code = sharedPreferences.getString(PreferencesKey.currentLanguageCode);
+
+ return LanguageService.list.containsKey(code) ? code : null;
+ }
+
static Future<String?> _getDeviceName() async {
String? deviceName = '';
final deviceInfoPlugin = DeviceInfoPlugin();
diff --git a/tool/generate_localization.dart b/tool/generate_localization.dart
index 5bbf016a..ed2a4679 100644
--- a/tool/generate_localization.dart
+++ b/tool/generate_localization.dart
@@ -1,3 +1,4 @@
+import "dart:collection";
import 'dart:io';
import 'dart:convert';
@@ -48,7 +49,7 @@ Future<void> main(List<String> args) async {
return;
}
- final arbFiles = <String, dynamic>{};
+ final localePath = SplayTreeMap<String, dynamic>();
await dir.list(recursive: false).forEach((element) {
// Parse the locale from the file name (e.g. strings_pt_br.arb -> pt_BR),
// normalizing the case so keys match LanguageService.supportedLocales.
@@ -62,12 +63,9 @@ Future<void> main(List<String> args) async {
final locale = parts.length > 1
? '${parts.first.toLowerCase()}_${parts.sublist(1).join('_').toUpperCase()}'
: parts.first.toLowerCase();
- arbFiles[locale] = element.path;
+ localePath[locale] = element.path;
});
- final sortedLocales = arbFiles.keys.toList()..sort();
- final localePath = <String, dynamic>{for (final key in sortedLocales) key: arbFiles[key]};
-
if (!localePath.keys.contains(defaultLocale)) {
print("Locale list doesn't contain $defaultLocale");
return;
@@ -105,13 +103,13 @@ Future<void> main(List<String> args) async {
output += classDeclaration;
- // for handling keys like pt_BR so entry becomes Locale("pt", "BR") and doesn't crash app
+ // Flutter matches on the language subtag, so pt_BR is emitted as Locale("pt", "BR")
localePath.keys.forEach((key) {
final parts = key.split("_");
if (parts.length == 2) {
- output += " Locale(\"${parts[0]}\", \"${parts[1]}\"),\n";
+ output += ' Locale("${parts[0]}", "${parts[1]}"),\n';
} else {
- output += " Locale(\"$key\", \"\"),\n";
+ output += ' Locale("$key", ""),\n';
}
});
Why this scored 22/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.