package org.multibit.hd.ui.languages; import com.google.common.base.Optional; import com.google.common.base.Preconditions; import com.google.common.base.Strings; import org.bitcoinj.core.Address; import org.bitcoinj.core.Coin; import org.bitcoinj.uri.BitcoinURI; import org.multibit.hd.core.config.BitcoinConfiguration; import org.multibit.hd.core.config.Configurations; import org.multibit.hd.core.config.LanguageConfiguration; import org.multibit.hd.core.dto.PaymentSessionSummary; import org.multibit.hd.core.events.TransactionSeenEvent; import org.multibit.hd.core.utils.BitcoinSymbol; import org.multibit.hd.core.utils.Coins; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.math.BigDecimal; import java.text.DecimalFormat; import java.text.DecimalFormatSymbols; import java.util.Locale; /** * <p>Utility to provide the following to controllers:</p> * <ul> * <li>Access to international formats for date/time and decimal data</li> * <li>Access to alert layouts in different languages</li> * </ul> * * @since 0.0.1 * */ public class Formats { private static final Logger log = LoggerFactory.getLogger(Formats.class); /** * The number of decimal places for showing the exchange rate depends on the bitcoin symbol used, with this offset */ public static final int EXCHANGE_RATE_DECIMAL_PLACES_OFFSET = 2; /** * <p>Provide a split representation for the Bitcoin balance display.</p> * <p>For example, 12345.6789 becomes "12,345.67", "89" </p> * <p>The amount will be adjusted by the symbolic multiplier from the current configuration</p> * * @param coin The amount in coins * @param languageConfiguration The language configuration to use as the basis for presentation * @param bitcoinConfiguration The Bitcoin configuration to use as the basis for the symbol * * @return The left [0] and right [1] components suitable for presentation as a balance with no symbolic decoration */ public static String[] formatCoinAsSymbolic( Coin coin, LanguageConfiguration languageConfiguration, BitcoinConfiguration bitcoinConfiguration ) { return formatCoinAsSymbolic(coin, languageConfiguration, bitcoinConfiguration, true); } /** * <p>Provide a split representation for the Bitcoin balance display.</p> * <p>For example, 123456789 in uBTC becomes "1,234,567.", "89" </p> * <p>The amount will be adjusted by the symbolic multiplier from the current configuration</p> * * @param coin The amount in coins * @param languageConfiguration The language configuration to use as the basis for presentation * @param bitcoinConfiguration The Bitcoin configuration to use as the basis for the symbol * @param showNegative If true, show '-' for negative numbers * * @return The left [0] and right [1] components suitable for presentation as a balance with no symbolic decoration */ public static String[] formatCoinAsSymbolic( Coin coin, LanguageConfiguration languageConfiguration, BitcoinConfiguration bitcoinConfiguration, boolean showNegative ) { Preconditions.checkNotNull(coin, "'coin' must be present"); Preconditions.checkNotNull(languageConfiguration, "'languageConfiguration' must be present"); Preconditions.checkNotNull(bitcoinConfiguration, "'bitcoinConfiguration' must be present"); Locale currentLocale = languageConfiguration.getLocale(); BitcoinSymbol bitcoinSymbol = BitcoinSymbol.of(bitcoinConfiguration.getBitcoinSymbol()); DecimalFormatSymbols dfs = configureDecimalFormatSymbols(bitcoinConfiguration, currentLocale); DecimalFormat localFormat = configureBitcoinDecimalFormat(dfs, bitcoinSymbol, showNegative); // Apply formatting to the symbolic amount String formattedAmount = localFormat.format(Coins.toSymbolicAmount(coin, bitcoinSymbol)); // The Satoshi symbol does not have decimals if (BitcoinSymbol.SATOSHI.equals(bitcoinSymbol)) { return new String[]{ formattedAmount, "" }; } // All other representations require a decimal int decimalIndex = formattedAmount.lastIndexOf(dfs.getDecimalSeparator()); if (decimalIndex == -1) { formattedAmount += dfs.getDecimalSeparator() + "00"; decimalIndex = formattedAmount.lastIndexOf(dfs.getDecimalSeparator()); } return new String[]{ formattedAmount.substring(0, decimalIndex + 3), // 12,345.67 (significant figures) formattedAmount.substring(decimalIndex + 3) // 89 (lesser figures truncated ) }; } /** * <p>Provide a single text representation for the Bitcoin balance display.</p> * <p>For example, 123456789 becomes "1,234,567.89 uBTC" or "uXBT 12,345.6789" </p> * <p>The amount will be adjusted by the symbolic multiplier from the current configuration</p> * * @param coin The amount in coins * @param languageConfiguration The language configuration to use as the basis for presentation * @param bitcoinConfiguration The Bitcoin configuration to use as the basis for the symbol * * @return The string suitable for presentation as a balance with symbol in a UTF-8 string */ public static String formatCoinAsSymbolicText( Coin coin, LanguageConfiguration languageConfiguration, BitcoinConfiguration bitcoinConfiguration ) { String[] formattedAmount = formatCoinAsSymbolic(coin, languageConfiguration, bitcoinConfiguration); String lineSymbol = BitcoinSymbol.of(bitcoinConfiguration.getBitcoinSymbol()).getTextSymbol(); // Convert to single text line with leading or trailing symbol if (bitcoinConfiguration.isCurrencySymbolLeading()) { return lineSymbol + "\u00a0" + formattedAmount[0] + formattedAmount[1]; } else { return formattedAmount[0] + formattedAmount[1] + "\u00a0" + lineSymbol; } } /** * <p>Provide a simple representation for a coin amount respecting decimal and grouping separators.</p> * <p>For example, 123456789 becomes "1,234,567.89" or "1.234.567,89" depending on configuration</p> * <p>The amount will be adjusted by the symbolic multiplier from the current configuration</p> * * @param coin The amount in coins * @param languageConfiguration The language configuration to use as the basis for presentation * @param bitcoinConfiguration The Bitcoin configuration to use as the basis for the symbol * * @return The string suitable for presentation as a balance without symbol in a UTF-8 string */ public static String formatCoinAmount(Coin coin, LanguageConfiguration languageConfiguration, BitcoinConfiguration bitcoinConfiguration) { String[] formattedAmount = formatCoinAsSymbolic(coin, languageConfiguration, bitcoinConfiguration); // Convert to single text line return formattedAmount[0] + formattedAmount[1]; } /** * <p>Provide a simple representation for a local currency amount.</p> * * @param amount The amount as a plain number (no multipliers) * @param locale The locale to use * @param bitcoinConfiguration The Bitcoin configuration to use as the basis for the symbol * @param showNegative True if the negative prefix is allowed * * @return The local currency representation with no symbolic decoration */ public static String formatLocalAmount(BigDecimal amount, Locale locale, BitcoinConfiguration bitcoinConfiguration, boolean showNegative) { if (amount == null) { return ""; } DecimalFormatSymbols dfs = configureDecimalFormatSymbols(bitcoinConfiguration, locale); DecimalFormat localFormat = configureLocalDecimalFormat(dfs, bitcoinConfiguration, showNegative); return localFormat.format(amount); } /** * <p>Convert the bitcoin exchange rate to use the unit of bitcoin being displayed</p> * <p>For example, 589.00 will become "0,589" if the unit of bitcoin is mB and the decimal separator is ","</p> * <p>The value passed into formatExchangeRate must be in "fiat currency per bitcoin" and NOT localised</p> * * @param exchangeRate The exchange rate in fiat per bitcoin * @param languageConfiguration The language configuration to use as the basis for presentation * @param bitcoinConfiguration The Bitcoin configuration to use as the basis for the symbol * * @return The localised string representing the bitcoin exchange rate in the display bitcoin unit */ public static String formatExchangeRate( Optional<String> exchangeRate, LanguageConfiguration languageConfiguration, BitcoinConfiguration bitcoinConfiguration ) { Preconditions.checkNotNull(exchangeRate, "'exchangeRate' must be non null"); Preconditions.checkState(exchangeRate.isPresent(), "'exchangeRate' must be present"); Preconditions.checkNotNull(languageConfiguration, "'languageConfiguration' must be present"); Preconditions.checkNotNull(bitcoinConfiguration, "'bitcoinConfiguration' must be present"); BigDecimal exchangeRateBigDecimal = new BigDecimal(exchangeRate.get()); // Correct for non unitary bitcoin display units e.g 567 USD per BTCis identical to 0.567 USD per mBTC BigDecimal correctedExchangeRateBigDecimal = exchangeRateBigDecimal.divide(BitcoinSymbol.current().multiplier()); Locale currentLocale = languageConfiguration.getLocale(); DecimalFormatSymbols dfs = configureDecimalFormatSymbols(bitcoinConfiguration, currentLocale); DecimalFormat localFormat = configureLocalDecimalFormat(dfs, bitcoinConfiguration, false); localFormat.setMinimumFractionDigits(Formats.EXCHANGE_RATE_DECIMAL_PLACES_OFFSET + (int)Math.log10(BitcoinSymbol.current().multiplier().doubleValue())); return localFormat.format(correctedExchangeRateBigDecimal); } /** * @param dfs The decimal format symbols * * @return A decimal format suitable for Bitcoin balance representation */ private static DecimalFormat configureBitcoinDecimalFormat(DecimalFormatSymbols dfs, BitcoinSymbol bitcoinSymbol, boolean showNegative) { DecimalFormat format = new DecimalFormat(); format.setDecimalFormatSymbols(dfs); format.setMaximumIntegerDigits(16); format.setMinimumIntegerDigits(1); format.setMaximumFractionDigits(bitcoinSymbol.decimalPlaces()); format.setMinimumFractionDigits(bitcoinSymbol.decimalPlaces()); format.setDecimalSeparatorAlwaysShown(false); if (showNegative) { format.setNegativePrefix("-"); } else { format.setNegativePrefix(""); } return format; } /** * @param bitcoinConfiguration The Bitcoin configuration * @param currentLocale The current locale * * @return The decimal format symbols to use based on the configuration and locale */ private static DecimalFormatSymbols configureDecimalFormatSymbols(BitcoinConfiguration bitcoinConfiguration, Locale currentLocale) { DecimalFormatSymbols dfs = new DecimalFormatSymbols(currentLocale); dfs.setDecimalSeparator(bitcoinConfiguration.getDecimalSeparator().charAt(0)); dfs.setGroupingSeparator(bitcoinConfiguration.getGroupingSeparator().charAt(0)); return dfs; } /** * @param dfs The decimal format symbols * @param bitcoinConfiguration The Bitcoin configuration to use * @param showNegative True if the negative prefix is allowed * * @return A decimal format suitable for local currency balance representation */ private static DecimalFormat configureLocalDecimalFormat( DecimalFormatSymbols dfs, BitcoinConfiguration bitcoinConfiguration, boolean showNegative ) { DecimalFormat format = new DecimalFormat(); format.setDecimalFormatSymbols(dfs); format.setMinimumIntegerDigits(1); format.setMaximumFractionDigits(bitcoinConfiguration.getLocalDecimalPlaces()); format.setMinimumFractionDigits(bitcoinConfiguration.getLocalDecimalPlaces()); format.setDecimalSeparatorAlwaysShown(true); if (showNegative) { format.setNegativePrefix("-"); } else { format.setNegativePrefix(""); } return format; } /** * @param event The "transaction seen" event * * @return A String suitably formatted for presentation as an alert message */ public static String formatAlertMessage(TransactionSeenEvent event) { // Decode the "transaction seen" event final Coin amount = event.getAmount(); final Coin modulusAmount; if (amount.compareTo(Coin.ZERO) >= 0) { modulusAmount = amount; } else { modulusAmount = amount.negate(); } // Create a suitable representation for inline text (no icon) final String messageAmount = Formats.formatCoinAsSymbolicText( modulusAmount, Configurations.currentConfiguration.getLanguage(), Configurations.currentConfiguration.getBitcoin() ); // Construct a suitable alert message if (amount.compareTo(Coin.ZERO) >= 0) { // Positive or zero amount, this is a receive return Languages.safeText(MessageKey.PAYMENT_RECEIVED_ALERT, messageAmount); } else { // Negative amount, this is a send (probably from a wallet clone elsewhere) return Languages.safeText(MessageKey.PAYMENT_SENT_ALERT, messageAmount); } } /** * @param bitcoinURI The Bitcoin URI * * @return A String suitably formatted for presentation as an alert message */ public static Optional<String> formatAlertMessage(BitcoinURI bitcoinURI) { Optional<String> alertMessage = Optional.absent(); // Decode the Bitcoin URI Optional<Address> address = Optional.fromNullable(bitcoinURI.getAddress()); Optional<Coin> amount = Optional.fromNullable(bitcoinURI.getAmount()); // Do not truncate the label here leave it to the MiG layout String label = bitcoinURI.getLabel(); if (Strings.isNullOrEmpty(label)) { label = Languages.safeText(MessageKey.NOT_AVAILABLE); } // Only proceed if we have an address if (address.isPresent()) { final String messageAmount; if (amount.isPresent()) { // Create a suitable representation for inline text (no icon) messageAmount = Formats.formatCoinAsSymbolicText( amount.get(), Configurations.currentConfiguration.getLanguage(), Configurations.currentConfiguration.getBitcoin() ); } else { messageAmount = Languages.safeText(MessageKey.NOT_AVAILABLE); } // Construct a suitable alert message alertMessage = Optional.of(Languages.safeText( MessageKey.BITCOIN_URI_ALERT, label, address.get().toString(), messageAmount )); } return alertMessage; } /** * @param paymentSessionSummary The payment session summary * * @return A String suitably formatted for presentation as an alert message */ public static Optional<String> formatAlertMessage(PaymentSessionSummary paymentSessionSummary) { if (!paymentSessionSummary.hasPaymentSession()) { // Construct a suitable alert message return Optional.of(Languages.safeText( paymentSessionSummary.getMessageKey(), paymentSessionSummary.getMessageData() )); } final boolean isTrusted; // Decode the payment session summary switch (paymentSessionSummary.getStatus()) { case TRUSTED: isTrusted = true; break; case UNTRUSTED: isTrusted = false; break; case DOWN: // Fall through to error case ERROR: // Construct a suitable alert message return Optional.of(Languages.safeText( MessageKey.PAYMENT_PROTOCOL_ERROR_ALERT, paymentSessionSummary.getMessageData() )); default: log.error("Unknown payment session status: {}", paymentSessionSummary.getStatus()); return Optional.absent(); } // Extract merchant information (payment session must be present) Optional<Coin> amount = paymentSessionSummary.getPaymentSessionValue(); // The UI will handle truncation String label = paymentSessionSummary.getPaymentSessionMemo().orNull(); if (Strings.isNullOrEmpty(label)) { label = Languages.safeText(MessageKey.NOT_AVAILABLE); } Optional<String> alertMessage = Optional.absent(); // Only proceed if we have outputs if (paymentSessionSummary.hasPaymentSessionOutputs().get()) { final String messageAmount; if (amount.isPresent()) { // Create a suitable representation for inline text (no icon) messageAmount = Formats.formatCoinAsSymbolicText( amount.get(), Configurations.currentConfiguration.getLanguage(), Configurations.currentConfiguration.getBitcoin() ); } else { messageAmount = Languages.safeText(MessageKey.NOT_AVAILABLE); } // Construct a suitable alert message MessageKey messageKey = isTrusted? MessageKey.PAYMENT_PROTOCOL_TRUSTED_ALERT : MessageKey.PAYMENT_PROTOCOL_UNTRUSTED_ALERT; alertMessage = Optional.of(Languages.safeText( messageKey, label, messageAmount )); } return alertMessage; } }