From 0dd7f4c9ca538a90b88ad2ec2a7681900aa4bae0 Mon Sep 17 00:00:00 2001 From: Vineet1101 Date: Thu, 23 Apr 2026 23:07:39 +0530 Subject: [PATCH 1/3] feat: add shared localization infrastructure (xtask generate-strings) Add a centralized localization system that generates platform-specific string files from a single JSON source of truth. New files: - localization/strings.json: shared source with 162 strings organized by feature area (common, wallet, send, transaction, etc.) - rust/xtask/src/localization.rs: generator that produces iOS Localizable.strings and Android generated_strings.xml - ios/Cove/Resources/en.lproj/Localizable.strings: generated iOS strings - android/app/src/main/res/values/generated_strings.xml: generated Android strings Modified: - rust/xtask/src/main.rs: wire GenerateStrings command - rust/xtask/Cargo.toml: add serde_json dependency - justfile: add 'generate-strings' recipe (alias: gs) Includes 7 unit tests covering iOS/Android flattening, XML/quote escaping, nested keys, and $schema key filtering. Closes #538 (Phase 1) --- .../src/main/res/values/generated_strings.xml | 166 +++++++++++ .../Resources/en.lproj/Localizable.strings | 164 +++++++++++ justfile | 8 + localization/strings.json | 206 ++++++++++++++ rust/Cargo.lock | 1 + rust/xtask/Cargo.toml | 1 + rust/xtask/src/localization.rs | 263 ++++++++++++++++++ rust/xtask/src/main.rs | 7 + 8 files changed, 816 insertions(+) create mode 100644 android/app/src/main/res/values/generated_strings.xml create mode 100644 ios/Cove/Resources/en.lproj/Localizable.strings create mode 100644 localization/strings.json create mode 100644 rust/xtask/src/localization.rs diff --git a/android/app/src/main/res/values/generated_strings.xml b/android/app/src/main/res/values/generated_strings.xml new file mode 100644 index 000000000..c080cbba4 --- /dev/null +++ b/android/app/src/main/res/values/generated_strings.xml @@ -0,0 +1,166 @@ + + + + Account + Main + Duplicate Wallet + This wallet has already been imported! + Import Error + Wallet imported successfully + Save Failed + You will have to write down a new set of words. + ⚠️ Wallet Not Saved ⚠️ + Words not valid + The words you entered do not create a valid wallet. Please check the words and try again. + Yes, Go Back + Are you sure? + Back + Cancel + Continue + Copied + Copy + Delete + Done + Go Back + loading… + Next + OK + Save + View current transaction breakdown + Advanced Details + Fee + Sent To Self + UTXOs Used + Failed to load wallet settings + Node Connection Failed + Custom + Customize Fee + Fast + Medium + Set Custom Network Fee + Slow + Network Fee + Import wallet + Enter your recovery words below to restore your wallet. Make sure you\'re in a private location. + NFC + Scan QR + Select Number of Words + Import Wallet + 12 Words + 24 Words + Add label + Delete + Edit + Change PIN + Download Backup + Export Labels + Export Transactions + Import Labels + Manage UTXOs + Scan NFC + More Options + Wallet Settings + Would you like to select a different node? + Custom Electrum + Custom Esplora + Error + Name + Node Name (optional) + Save Custom Node + Success + URL + Enter URL + Yes, Change Node + Address unavailable + Receive + Your secret recovery words are the only way to recover your wallet if you lose your phone or switch to a different wallet. Whoever has your recovery words, controls your Bitcoin. + Please save these words in a secure location. + Show Words + Skip Verification + Recovery Words + To confirm that you\'ve securely saved your recovery phrase, please select the correct word + Verify Recovery Words + The amount they will receive + Change speed + Enter address + Enter amount + How much would you like to send? + Can\'t send a transaction when you have no funds. + Send + Swipe to Send + They\'ll receive + Total Spending + Where do you want to send to? + You\'re sending + You\'ll pay + Appearance + Currency + General + Network + Node + Settings + Amount + Change + Date + Name + Block Number + Confirmations + Fiat Price + Price at time of transaction + Go buy some bitcoin! + Hide Details + Network Fee + No transactions yet + Pending + Pending Signature + Received + Received At + Received from + Receiving + Recipient Receives + Sending + Sent + Sent to + Show Details + Total Spent + Transaction Pending + Transaction Received + Transaction Sent + Transactions + Unconfirmed + View in Explorer + When received + When sent + Change Address + Denotes UTXO change + Select UTXOs to manage or send. Unspent outputs will remain in your wallet for future use. + Deselect All + LIST OF UTXOS + Manage UTXOs + Receive Address + Search UTXOs + Select All + Add New Wallet + Do you already have a wallet? + Backup your wallet + Create new wallet + Hardware Wallet + How do you want to secure your Bitcoin? + Import existing wallet + On This Device + Save Wallet + Change Name + Danger zone + Delete wallet + Fingerprint + Network + Show transaction labels + Settings + View secret words + Wallet color + Wallet information + Name + Wallet type + This action cannot be undone. + This wallet is not backed up. Make sure you have your secret words saved before deleting. + diff --git a/ios/Cove/Resources/en.lproj/Localizable.strings b/ios/Cove/Resources/en.lproj/Localizable.strings new file mode 100644 index 000000000..8b17dc238 --- /dev/null +++ b/ios/Cove/Resources/en.lproj/Localizable.strings @@ -0,0 +1,164 @@ +/* Generated from localization/strings.json — DO NOT EDIT */ + +"account.account" = "Account"; +"account.main" = "Main"; +"alert.duplicateWallet" = "Duplicate Wallet"; +"alert.duplicateWalletMessage" = "This wallet has already been imported!"; +"alert.importError" = "Import Error"; +"alert.importedSuccessfully" = "Wallet imported successfully"; +"alert.saveFailed" = "Save Failed"; +"alert.walletNotSavedMessage" = "You will have to write down a new set of words."; +"alert.walletNotSavedTitle" = "⚠️ Wallet Not Saved ⚠️"; +"alert.wordsNotValid" = "Words not valid"; +"alert.wordsNotValidMessage" = "The words you entered do not create a valid wallet. Please check the words and try again."; +"alert.yesGoBack" = "Yes, Go Back"; +"common.areYouSure" = "Are you sure?"; +"common.back" = "Back"; +"common.cancel" = "Cancel"; +"common.continue" = "Continue"; +"common.copied" = "Copied"; +"common.copy" = "Copy"; +"common.delete" = "Delete"; +"common.done" = "Done"; +"common.goBack" = "Go Back"; +"common.loading" = "loading…"; +"common.next" = "Next"; +"common.ok" = "OK"; +"common.save" = "Save"; +"details.advancedSubtitle" = "View current transaction breakdown"; +"details.advancedTitle" = "Advanced Details"; +"details.fee" = "Fee"; +"details.sentToSelf" = "Sent To Self"; +"details.utxosUsed" = "UTXOs Used"; +"errors.loadFailed" = "Failed to load wallet settings"; +"errors.nodeConnectionFailed" = "Node Connection Failed"; +"fee.custom" = "Custom"; +"fee.customizeFee" = "Customize Fee"; +"fee.fast" = "Fast"; +"fee.medium" = "Medium"; +"fee.setCustomFee" = "Set Custom Network Fee"; +"fee.slow" = "Slow"; +"fee.title" = "Network Fee"; +"import.action" = "Import wallet"; +"import.instructions" = "Enter your recovery words below to restore your wallet. Make sure you're in a private location."; +"import.nfc" = "NFC"; +"import.scanQr" = "Scan QR"; +"import.selectNumberOfWords" = "Select Number of Words"; +"import.title" = "Import Wallet"; +"import.twelveWords" = "12 Words"; +"import.twentyFourWords" = "24 Words"; +"label.addLabel" = "Add label"; +"label.deleteLabel" = "Delete"; +"label.editLabel" = "Edit"; +"menu.changePin" = "Change PIN"; +"menu.downloadBackup" = "Download Backup"; +"menu.exportLabels" = "Export Labels"; +"menu.exportTransactions" = "Export Transactions"; +"menu.importLabels" = "Import Labels"; +"menu.manageUtxos" = "Manage UTXOs"; +"menu.scanNfc" = "Scan NFC"; +"menu.title" = "More Options"; +"menu.walletSettings" = "Wallet Settings"; +"node.changePrompt" = "Would you like to select a different node?"; +"node.customElectrum" = "Custom Electrum"; +"node.customEsplora" = "Custom Esplora"; +"node.errorTitle" = "Error"; +"node.nameLabel" = "Name"; +"node.namePlaceholder" = "Node Name (optional)"; +"node.saveButton" = "Save Custom Node"; +"node.successTitle" = "Success"; +"node.urlLabel" = "URL"; +"node.urlPlaceholder" = "Enter URL"; +"node.yesChange" = "Yes, Change Node"; +"receive.addressUnavailable" = "Address unavailable"; +"receive.receive" = "Receive"; +"recoveryWords.body" = "Your secret recovery words are the only way to recover your wallet if you lose your phone or switch to a different wallet. Whoever has your recovery words, controls your Bitcoin."; +"recoveryWords.secureNote" = "Please save these words in a secure location."; +"recoveryWords.showWords" = "Show Words"; +"recoveryWords.skipVerification" = "Skip Verification"; +"recoveryWords.title" = "Recovery Words"; +"recoveryWords.verifyBody" = "To confirm that you've securely saved your recovery phrase, please select the correct word"; +"recoveryWords.verifyTitle" = "Verify Recovery Words"; +"send.amountTheyReceive" = "The amount they will receive"; +"send.changeSpeed" = "Change speed"; +"send.enterAddress" = "Enter address"; +"send.enterAmount" = "Enter amount"; +"send.howMuchToSend" = "How much would you like to send?"; +"send.noBalance" = "Can't send a transaction when you have no funds."; +"send.send" = "Send"; +"send.swipeToSend" = "Swipe to Send"; +"send.theyWillReceive" = "They'll receive"; +"send.totalSpending" = "Total Spending"; +"send.whereSendTo" = "Where do you want to send to?"; +"send.youAreSending" = "You're sending"; +"send.youWillPay" = "You'll pay"; +"settings.appearance" = "Appearance"; +"settings.currency" = "Currency"; +"settings.general" = "General"; +"settings.network" = "Network"; +"settings.node" = "Node"; +"settings.title" = "Settings"; +"sort.amount" = "Amount"; +"sort.change" = "Change"; +"sort.date" = "Date"; +"sort.name" = "Name"; +"transaction.blockNumber" = "Block Number"; +"transaction.confirmations" = "Confirmations"; +"transaction.fiatPrice" = "Fiat Price"; +"transaction.fiatPriceTooltip" = "Price at time of transaction"; +"transaction.goBuySomeBitcoin" = "Go buy some bitcoin!"; +"transaction.hideDetails" = "Hide Details"; +"transaction.networkFee" = "Network Fee"; +"transaction.noTransactionsYet" = "No transactions yet"; +"transaction.pending" = "Pending"; +"transaction.pendingSignature" = "Pending Signature"; +"transaction.received" = "Received"; +"transaction.receivedAt" = "Received At"; +"transaction.receivedFrom" = "Received from"; +"transaction.receiving" = "Receiving"; +"transaction.recipientReceives" = "Recipient Receives"; +"transaction.sending" = "Sending"; +"transaction.sent" = "Sent"; +"transaction.sentTo" = "Sent to"; +"transaction.showDetails" = "Show Details"; +"transaction.totalSpent" = "Total Spent"; +"transaction.transactionPending" = "Transaction Pending"; +"transaction.transactionReceived" = "Transaction Received"; +"transaction.transactionSent" = "Transaction Sent"; +"transaction.transactions" = "Transactions"; +"transaction.unconfirmed" = "Unconfirmed"; +"transaction.viewInExplorer" = "View in Explorer"; +"transaction.whenReceived" = "When received"; +"transaction.whenSent" = "When sent"; +"utxo.changeAddress" = "Change Address"; +"utxo.denotesChange" = "Denotes UTXO change"; +"utxo.description" = "Select UTXOs to manage or send. Unspent outputs will remain in your wallet for future use."; +"utxo.deselectAll" = "Deselect All"; +"utxo.listOfUtxos" = "LIST OF UTXOS"; +"utxo.manageUtxos" = "Manage UTXOs"; +"utxo.receiveAddress" = "Receive Address"; +"utxo.searchUtxos" = "Search UTXOs"; +"utxo.selectAll" = "Select All"; +"wallet.addNewWallet" = "Add New Wallet"; +"wallet.alreadyHaveWallet" = "Do you already have a wallet?"; +"wallet.backup.title" = "Backup your wallet"; +"wallet.createNew" = "Create new wallet"; +"wallet.hardwareWallet" = "Hardware Wallet"; +"wallet.howToSecure" = "How do you want to secure your Bitcoin?"; +"wallet.importExisting" = "Import existing wallet"; +"wallet.onThisDevice" = "On This Device"; +"wallet.saveWallet" = "Save Wallet"; +"wallet.settings.changeName" = "Change Name"; +"wallet.settings.dangerZone" = "Danger zone"; +"wallet.settings.deleteWallet" = "Delete wallet"; +"wallet.settings.fingerprint" = "Fingerprint"; +"wallet.settings.network" = "Network"; +"wallet.settings.showTransactionLabels" = "Show transaction labels"; +"wallet.settings.title" = "Settings"; +"wallet.settings.viewSecretWords" = "View secret words"; +"wallet.settings.walletColor" = "Wallet color"; +"wallet.settings.walletInformation" = "Wallet information"; +"wallet.settings.walletName" = "Name"; +"wallet.settings.walletType" = "Wallet type"; +"wallet.warnings.cannotUndo" = "This action cannot be undone."; +"wallet.warnings.notBackedUp" = "This wallet is not backed up. Make sure you have your secret words saved before deleting."; diff --git a/justfile b/justfile index 8c2b4c840..88528e5f6 100644 --- a/justfile +++ b/justfile @@ -39,6 +39,14 @@ sign-psbt psbt: [private] alias sp := sign-psbt +# Generate localization files from shared JSON +[group('build')] +generate-strings: + just xtask generate-strings + +[private] +alias gs := generate-strings + # ------------------------------------------------------------------------------ # ci # ------------------------------------------------------------------------------ diff --git a/localization/strings.json b/localization/strings.json new file mode 100644 index 000000000..79275d3d8 --- /dev/null +++ b/localization/strings.json @@ -0,0 +1,206 @@ +{ + "common": { + "ok": "OK", + "cancel": "Cancel", + "delete": "Delete", + "save": "Save", + "done": "Done", + "next": "Next", + "goBack": "Go Back", + "copy": "Copy", + "copied": "Copied", + "areYouSure": "Are you sure?", + "loading": "loading…", + "continue": "Continue", + "back": "Back" + }, + "wallet": { + "addNewWallet": "Add New Wallet", + "settings": { + "title": "Settings", + "walletInformation": "Wallet information", + "network": "Network", + "fingerprint": "Fingerprint", + "walletType": "Wallet type", + "walletName": "Name", + "changeName": "Change Name", + "walletColor": "Wallet color", + "showTransactionLabels": "Show transaction labels", + "dangerZone": "Danger zone", + "viewSecretWords": "View secret words", + "deleteWallet": "Delete wallet" + }, + "warnings": { + "notBackedUp": "This wallet is not backed up. Make sure you have your secret words saved before deleting.", + "cannotUndo": "This action cannot be undone." + }, + "backup": { + "title": "Backup your wallet" + }, + "howToSecure": "How do you want to secure your Bitcoin?", + "onThisDevice": "On This Device", + "hardwareWallet": "Hardware Wallet", + "createNew": "Create new wallet", + "importExisting": "Import existing wallet", + "alreadyHaveWallet": "Do you already have a wallet?", + "saveWallet": "Save Wallet" + }, + "send": { + "send": "Send", + "noBalance": "Can't send a transaction when you have no funds.", + "enterAmount": "Enter amount", + "howMuchToSend": "How much would you like to send?", + "enterAddress": "Enter address", + "whereSendTo": "Where do you want to send to?", + "swipeToSend": "Swipe to Send", + "totalSpending": "Total Spending", + "youAreSending": "You're sending", + "amountTheyReceive": "The amount they will receive", + "theyWillReceive": "They'll receive", + "youWillPay": "You'll pay", + "changeSpeed": "Change speed" + }, + "receive": { + "receive": "Receive", + "addressUnavailable": "Address unavailable" + }, + "transaction": { + "sent": "Sent", + "received": "Received", + "sending": "Sending", + "receiving": "Receiving", + "transactions": "Transactions", + "noTransactionsYet": "No transactions yet", + "goBuySomeBitcoin": "Go buy some bitcoin!", + "pending": "Pending", + "pendingSignature": "Pending Signature", + "unconfirmed": "Unconfirmed", + "transactionSent": "Transaction Sent", + "transactionReceived": "Transaction Received", + "transactionPending": "Transaction Pending", + "sentTo": "Sent to", + "receivedFrom": "Received from", + "networkFee": "Network Fee", + "recipientReceives": "Recipient Receives", + "totalSpent": "Total Spent", + "confirmations": "Confirmations", + "blockNumber": "Block Number", + "receivedAt": "Received At", + "fiatPrice": "Fiat Price", + "whenSent": "When sent", + "whenReceived": "When received", + "fiatPriceTooltip": "Price at time of transaction", + "viewInExplorer": "View in Explorer", + "showDetails": "Show Details", + "hideDetails": "Hide Details" + }, + "fee": { + "title": "Network Fee", + "fast": "Fast", + "medium": "Medium", + "slow": "Slow", + "custom": "Custom", + "customizeFee": "Customize Fee", + "setCustomFee": "Set Custom Network Fee" + }, + "utxo": { + "manageUtxos": "Manage UTXOs", + "searchUtxos": "Search UTXOs", + "listOfUtxos": "LIST OF UTXOS", + "selectAll": "Select All", + "deselectAll": "Deselect All", + "changeAddress": "Change Address", + "receiveAddress": "Receive Address", + "description": "Select UTXOs to manage or send. Unspent outputs will remain in your wallet for future use.", + "denotesChange": "Denotes UTXO change" + }, + "label": { + "addLabel": "Add label", + "editLabel": "Edit", + "deleteLabel": "Delete" + }, + "sort": { + "date": "Date", + "name": "Name", + "amount": "Amount", + "change": "Change" + }, + "recoveryWords": { + "title": "Recovery Words", + "secureNote": "Please save these words in a secure location.", + "body": "Your secret recovery words are the only way to recover your wallet if you lose your phone or switch to a different wallet. Whoever has your recovery words, controls your Bitcoin.", + "showWords": "Show Words", + "skipVerification": "Skip Verification", + "verifyTitle": "Verify Recovery Words", + "verifyBody": "To confirm that you've securely saved your recovery phrase, please select the correct word" + }, + "import": { + "title": "Import Wallet", + "action": "Import wallet", + "instructions": "Enter your recovery words below to restore your wallet. Make sure you're in a private location.", + "selectNumberOfWords": "Select Number of Words", + "scanQr": "Scan QR", + "nfc": "NFC", + "twelveWords": "12 Words", + "twentyFourWords": "24 Words" + }, + "node": { + "changePrompt": "Would you like to select a different node?", + "yesChange": "Yes, Change Node", + "customElectrum": "Custom Electrum", + "customEsplora": "Custom Esplora", + "urlLabel": "URL", + "urlPlaceholder": "Enter URL", + "nameLabel": "Name", + "namePlaceholder": "Node Name (optional)", + "saveButton": "Save Custom Node", + "successTitle": "Success", + "errorTitle": "Error" + }, + "settings": { + "title": "Settings", + "general": "General", + "network": "Network", + "appearance": "Appearance", + "node": "Node", + "currency": "Currency" + }, + "errors": { + "loadFailed": "Failed to load wallet settings", + "nodeConnectionFailed": "Node Connection Failed" + }, + "menu": { + "title": "More Options", + "scanNfc": "Scan NFC", + "importLabels": "Import Labels", + "exportLabels": "Export Labels", + "exportTransactions": "Export Transactions", + "changePin": "Change PIN", + "downloadBackup": "Download Backup", + "manageUtxos": "Manage UTXOs", + "walletSettings": "Wallet Settings" + }, + "alert": { + "walletNotSavedTitle": "⚠️ Wallet Not Saved ⚠️", + "walletNotSavedMessage": "You will have to write down a new set of words.", + "yesGoBack": "Yes, Go Back", + "saveFailed": "Save Failed", + "wordsNotValid": "Words not valid", + "wordsNotValidMessage": "The words you entered do not create a valid wallet. Please check the words and try again.", + "importError": "Import Error", + "duplicateWallet": "Duplicate Wallet", + "duplicateWalletMessage": "This wallet has already been imported!", + "importedSuccessfully": "Wallet imported successfully" + }, + "account": { + "account": "Account", + "main": "Main" + }, + "details": { + "advancedTitle": "Advanced Details", + "advancedSubtitle": "View current transaction breakdown", + "utxosUsed": "UTXOs Used", + "sentToSelf": "Sent To Self", + "fee": "Fee" + } +} diff --git a/rust/Cargo.lock b/rust/Cargo.lock index eafec44cd..675368b98 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -5358,6 +5358,7 @@ dependencies = [ "image", "minicbor 2.2.1", "qrcode", + "serde_json", "xshell", ] diff --git a/rust/xtask/Cargo.toml b/rust/xtask/Cargo.toml index f6f5b2a0d..023d8f8f2 100644 --- a/rust/xtask/Cargo.toml +++ b/rust/xtask/Cargo.toml @@ -9,6 +9,7 @@ clap = { version = "4.5", features = ["derive", "env"] } color-eyre = "0.6" xshell = "0.2" colored = "2.0" +serde_json = "1" # for util sign-psbt bdk_wallet = { version = "2.0", features = ["keys-bip39"] } diff --git a/rust/xtask/src/localization.rs b/rust/xtask/src/localization.rs new file mode 100644 index 000000000..bbdea9548 --- /dev/null +++ b/rust/xtask/src/localization.rs @@ -0,0 +1,263 @@ +use color_eyre::{eyre::Context, Result}; +use serde_json::Value; +use std::fs; +use std::path::Path; + +use crate::common::{print_info, print_success}; + +/// Compile-time path to the xtask crate's `Cargo.toml` directory (`rust/xtask/`). +/// Baked into the binary by `env!`, so it works regardless of the working directory. +fn manifest_dir() -> &'static str { + env!("CARGO_MANIFEST_DIR") +} + +/// Resolve the repository root from the xtask manifest directory. +fn repo_root() -> &'static Path { + // rust/xtask/ -> rust/ -> repo root + Path::new(manifest_dir()) + .parent() // rust/ + .and_then(Path::parent) // repo root + .expect("CARGO_MANIFEST_DIR should be at least two levels deep") +} + +/// Generate platform-specific localization files from the shared JSON source. +/// +/// Reads `localization/strings.json` and produces: +/// - `ios/Cove/Resources/en.lproj/Localizable.strings` +/// - `android/app/src/main/res/values/generated_strings.xml` +pub fn generate_strings(verbose: bool) -> Result<()> { + let root = repo_root(); + let json_path = root.join("localization/strings.json"); + + if !json_path.exists() { + color_eyre::eyre::bail!( + "localization/strings.json not found (looked at {:?}). Run from the repository root.", + json_path + ); + } + + let content = + fs::read_to_string(json_path).context("failed to read localization/strings.json")?; + let strings: Value = + serde_json::from_str(&content).context("failed to parse localization/strings.json")?; + + let ios_count = generate_ios_strings(&strings, verbose)?; + let android_count = generate_android_strings(&strings, verbose)?; + + print_success(&format!( + "Generated {ios_count} iOS strings and {android_count} Android strings" + )); + + Ok(()) +} + +// --------------------------------------------------------------------------- +// iOS: Localizable.strings +// --------------------------------------------------------------------------- + +fn generate_ios_strings(strings: &Value, verbose: bool) -> Result { + let root = repo_root(); + let output_path = root.join("ios/Cove/Resources/en.lproj/Localizable.strings"); + + // ensure directory exists + if let Some(parent) = output_path.parent() { + fs::create_dir_all(parent).context("failed to create iOS localization directory")?; + } + + let mut output = String::new(); + output.push_str("/* Generated from localization/strings.json \u{2014} DO NOT EDIT */\n\n"); + + let mut count = 0; + flatten_to_ios(&mut output, strings, "", &mut count); + + fs::write(&output_path, &output).context("failed to write Localizable.strings")?; + + if verbose { + print_info(&format!("Wrote {}", output_path.display())); + } + + Ok(count) +} + +/// Recursively flatten the JSON tree into Apple `.strings` key-value pairs. +/// +/// Keys are dot-separated paths, e.g. `common.ok`, `wallet.settings.title`. +fn flatten_to_ios(output: &mut String, value: &Value, prefix: &str, count: &mut usize) { + match value { + Value::Object(map) => { + for (key, val) in map { + // skip JSON Schema metadata keys + if key.starts_with('$') { + continue; + } + + let new_prefix = + if prefix.is_empty() { key.clone() } else { format!("{prefix}.{key}") }; + + flatten_to_ios(output, val, &new_prefix, count); + } + } + Value::String(s) => { + // escape for .strings format + let escaped = s.replace('\\', "\\\\").replace('"', "\\\"").replace('\n', "\\n"); + + output.push_str(&format!("\"{prefix}\" = \"{escaped}\";\n")); + *count += 1; + } + _ => {} + } +} + +// --------------------------------------------------------------------------- +// Android: generated_strings.xml +// --------------------------------------------------------------------------- + +fn generate_android_strings(strings: &Value, verbose: bool) -> Result { + let root = repo_root(); + let output_path = root.join("android/app/src/main/res/values/generated_strings.xml"); + + // ensure directory exists + if let Some(parent) = output_path.parent() { + fs::create_dir_all(parent).context("failed to create Android localization directory")?; + } + + let mut output = String::new(); + output.push_str("\n"); + output.push_str("\n"); + output.push_str("\n"); + + let mut count = 0; + flatten_to_android(&mut output, strings, "", &mut count); + + output.push_str("\n"); + + fs::write(&output_path, &output).context("failed to write generated_strings.xml")?; + + if verbose { + print_info(&format!("Wrote {}", output_path.display())); + } + + Ok(count) +} + +/// Recursively flatten the JSON tree into Android `` resources. +/// +/// Keys are underscore-separated paths, e.g. `common_ok`, `wallet_settings_title`. +fn flatten_to_android(output: &mut String, value: &Value, prefix: &str, count: &mut usize) { + match value { + Value::Object(map) => { + for (key, val) in map { + if key.starts_with('$') { + continue; + } + + let new_prefix = + if prefix.is_empty() { key.clone() } else { format!("{prefix}_{key}") }; + + flatten_to_android(output, val, &new_prefix, count); + } + } + Value::String(s) => { + // escape XML special characters and Android format specifiers + let escaped = s + .replace('&', "&") + .replace('<', "<") + .replace('"', """) + .replace('\'', "\\'") + .replace('%', "%%") + .replace('\n', "\\n"); + + output.push_str(&format!(" {escaped}\n")); + *count += 1; + } + _ => {} + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_flatten_ios_simple() { + let json: Value = + serde_json::from_str(r#"{"common": {"ok": "OK", "cancel": "Cancel"}}"#).unwrap(); + let mut output = String::new(); + let mut count = 0; + flatten_to_ios(&mut output, &json, "", &mut count); + assert!(output.contains("\"common.ok\" = \"OK\";")); + assert!(output.contains("\"common.cancel\" = \"Cancel\";")); + assert_eq!(count, 2); + } + + #[test] + fn test_flatten_ios_nested() { + let json: Value = + serde_json::from_str(r#"{"wallet": {"settings": {"title": "Settings"}}}"#).unwrap(); + let mut output = String::new(); + let mut count = 0; + flatten_to_ios(&mut output, &json, "", &mut count); + assert!(output.contains("\"wallet.settings.title\" = \"Settings\";")); + assert_eq!(count, 1); + } + + #[test] + fn test_flatten_ios_escapes_quotes() { + let json: Value = serde_json::from_str(r#"{"test": {"msg": "Say \"hello\""}}"#).unwrap(); + let mut output = String::new(); + let mut count = 0; + flatten_to_ios(&mut output, &json, "", &mut count); + assert!(output.contains(r#"\"hello\""#)); + } + + #[test] + fn test_flatten_android_simple() { + let json: Value = serde_json::from_str(r#"{"common": {"ok": "OK"}}"#).unwrap(); + let mut output = String::new(); + let mut count = 0; + flatten_to_android(&mut output, &json, "", &mut count); + assert!(output.contains(r#"OK"#)); + assert_eq!(count, 1); + } + + #[test] + fn test_flatten_android_escapes_xml() { + let json: Value = serde_json::from_str(r#"{"test": {"msg": "A & B < C"}}"#).unwrap(); + let mut output = String::new(); + let mut count = 0; + flatten_to_android(&mut output, &json, "", &mut count); + assert!(output.contains("A & B < C")); + } + + #[test] + fn test_flatten_android_escapes_percent() { + let json: Value = serde_json::from_str(r#"{"test": {"msg": "50% complete"}}"#).unwrap(); + let mut output = String::new(); + let mut count = 0; + flatten_to_android(&mut output, &json, "", &mut count); + assert!(output.contains("50%% complete")); + } + + #[test] + fn test_flatten_android_escapes_apostrophe() { + let json: Value = + serde_json::from_str(r#"{"send": {"youAreSending": "You're sending"}}"#).unwrap(); + let mut output = String::new(); + let mut count = 0; + flatten_to_android(&mut output, &json, "", &mut count); + assert!(output.contains(r"You\'re sending")); + } + + #[test] + fn test_skips_schema_key() { + let json: Value = + serde_json::from_str(r#"{"$schema": "./strings.schema.json", "common": {"ok": "OK"}}"#) + .unwrap(); + let mut output = String::new(); + let mut count = 0; + flatten_to_ios(&mut output, &json, "", &mut count); + assert!(!output.contains("$schema")); + assert!(output.contains("common.ok")); + assert_eq!(count, 1); + } +} diff --git a/rust/xtask/src/main.rs b/rust/xtask/src/main.rs index 152ea652e..da92d31f8 100644 --- a/rust/xtask/src/main.rs +++ b/rust/xtask/src/main.rs @@ -4,6 +4,7 @@ use color_eyre::Result; mod android; mod common; mod ios; +mod localization; mod util; mod version; @@ -88,6 +89,10 @@ enum Commands { #[command(name = "install-deps")] InstallDeps, + /// Generate localization files from shared JSON + #[command(name = "generate-strings")] + GenerateStrings, + /// Utility commands for development and testing #[command(subcommand)] Util(UtilCommands), @@ -180,6 +185,8 @@ fn main() -> Result<()> { Commands::InstallDeps => install_deps(cli.verbose), + Commands::GenerateStrings => localization::generate_strings(cli.verbose), + Commands::Util(util_cmd) => match util_cmd { UtilCommands::SignPsbt { mnemonic, psbt, network, format, output } => { util::sign_psbt(&mnemonic, &psbt, &network, format.into(), output.as_deref()) From 5bcb20fba7064530ae4b66a9a0986fe390edec52 Mon Sep 17 00:00:00 2001 From: Vineet1101 Date: Fri, 24 Apr 2026 06:00:48 +0530 Subject: [PATCH 2/3] fix: remove duplicate Android string resources Move sort_date, sort_name, sort_amount, sort_change, and utxo_description from the hand-written strings.xml into the generated file. These are now managed by localization/strings.json as the single source of truth. --- android/app/src/main/res/values/strings.xml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index ec77961ea..cf3b658fe 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -82,16 +82,11 @@ Price at time of transaction Manage UTXOs Search UTXOs - Date - Name - Amount - Change LIST OF UTXOS Select All Deselect All Change Address Receive Address - Select UTXOs to manage or send. Unspent outputs will remain in your wallet for future use. Denotes UTXO change Continue Continue (%1$d) From 004a7eb90742c5ad83c67fa78f85f2e13dff32fc Mon Sep 17 00:00:00 2001 From: Vineet1101 Date: Fri, 24 Apr 2026 06:13:41 +0530 Subject: [PATCH 3/3] fix: always recompile xtask before running The justfile recipe used 'test -f' to skip rebuilding, which ran stale binaries when source was modified. Now always runs cargo build (which is a sub-second no-op when nothing changed). --- justfile | 2 +- rust/xtask/src/localization.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/justfile b/justfile index 88528e5f6..9889324e5 100644 --- a/justfile +++ b/justfile @@ -10,7 +10,7 @@ list: # Run an xtask command [group('utils')] xtask *args: - cd rust && test -f target/debug/xtask || cargo build --package xtask -q && ./target/debug/xtask {{args}} + cd rust && cargo build --package xtask -q && ./target/debug/xtask {{args}} # Sign a PSBT and output all formats (base64, hex, binary, bbqr-gif, ur-gif) # Requires MNEMONIC env var (set in .envrc or pass directly) diff --git a/rust/xtask/src/localization.rs b/rust/xtask/src/localization.rs index bbdea9548..0c712befb 100644 --- a/rust/xtask/src/localization.rs +++ b/rust/xtask/src/localization.rs @@ -123,7 +123,7 @@ fn generate_android_strings(strings: &Value, verbose: bool) -> Result { let mut output = String::new(); output.push_str("\n"); - output.push_str("\n"); + output.push_str("\n"); output.push_str("\n"); let mut count = 0;