From 988cb5a32d7012043108263a3297d423ee7a1f52 Mon Sep 17 00:00:00 2001 From: Roozbeh Pournader Date: Wed, 21 Jun 2017 14:58:02 -0700 Subject: [PATCH] Re-land "Switch file size formatters to use ICU's MeasureFormat" Re-land http://ag/2443141 because the over-translation issue should be resolved now. Use ICU's MeasureFormat to the degree possible for formatting file sizes. Bug: 36994779 Bug: 71580745 Bug: 217592956 Test: atest FrameworksCoreTests:android.text Test: atest CtsTextTestCases Change-Id: If3416ec38cf18c0441576643bfab850148e18c8e --- core/java/android/text/format/Formatter.java | 253 ++++++++++++------ .../android/text/format/FormatterTest.java | 53 ++++ 2 files changed, 224 insertions(+), 82 deletions(-) diff --git a/core/java/android/text/format/Formatter.java b/core/java/android/text/format/Formatter.java index 471f2c2aecaed..b9de93cc8ae08 100644 --- a/core/java/android/text/format/Formatter.java +++ b/core/java/android/text/format/Formatter.java @@ -21,7 +21,11 @@ import android.annotation.Nullable; import android.compat.annotation.UnsupportedAppUsage; import android.content.Context; import android.content.res.Resources; +import android.icu.text.DecimalFormat; import android.icu.text.MeasureFormat; +import android.icu.text.NumberFormat; +import android.icu.text.UnicodeSet; +import android.icu.text.UnicodeSetSpanner; import android.icu.util.Measure; import android.icu.util.MeasureUnit; import android.text.BidiFormatter; @@ -30,6 +34,7 @@ import android.view.View; import com.android.net.module.util.Inet4AddressUtils; +import java.math.BigDecimal; import java.util.Locale; /** @@ -64,7 +69,9 @@ public final class Formatter { return context.getResources().getConfiguration().getLocales().get(0); } - /* Wraps the source string in bidi formatting characters in RTL locales */ + /** + * Wraps the source string in bidi formatting characters in RTL locales. + */ private static String bidiWrap(@NonNull Context context, String source) { final Locale locale = localeFromContext(context); if (TextUtils.getLayoutDirectionFromLocale(locale) == View.LAYOUT_DIRECTION_RTL) { @@ -101,9 +108,8 @@ public final class Formatter { if (context == null) { return ""; } - final BytesResult res = formatBytes(context.getResources(), sizeBytes, flags); - return bidiWrap(context, context.getString(com.android.internal.R.string.fileSizeSuffix, - res.value, res.units)); + final RoundedBytesResult res = RoundedBytesResult.roundBytes(sizeBytes, flags); + return bidiWrap(context, formatRoundedBytesResult(context, res)); } /** @@ -111,91 +117,174 @@ public final class Formatter { * (showing fewer digits of precision). */ public static String formatShortFileSize(@Nullable Context context, long sizeBytes) { - if (context == null) { - return ""; + return formatFileSize(context, sizeBytes, FLAG_SI_UNITS | FLAG_SHORTER); + } + + private static String getByteSuffixOverride(@NonNull Resources res) { + return res.getString(com.android.internal.R.string.byteShort); + } + + private static NumberFormat getNumberFormatter(Locale locale, int fractionDigits) { + final NumberFormat numberFormatter = NumberFormat.getInstance(locale); + numberFormatter.setMinimumFractionDigits(fractionDigits); + numberFormatter.setMaximumFractionDigits(fractionDigits); + numberFormatter.setGroupingUsed(false); + if (numberFormatter instanceof DecimalFormat) { + // We do this only for DecimalFormat, since in the general NumberFormat case, calling + // setRoundingMode may throw an exception. + numberFormatter.setRoundingMode(BigDecimal.ROUND_HALF_UP); + } + return numberFormatter; + } + + private static String deleteFirstFromString(String source, String toDelete) { + final int location = source.indexOf(toDelete); + if (location == -1) { + return source; + } else { + return source.substring(0, location) + + source.substring(location + toDelete.length(), source.length()); + } + } + + private static String formatMeasureShort(Locale locale, NumberFormat numberFormatter, + float value, MeasureUnit units) { + final MeasureFormat measureFormatter = MeasureFormat.getInstance( + locale, MeasureFormat.FormatWidth.SHORT, numberFormatter); + return measureFormatter.format(new Measure(value, units)); + } + + private static final UnicodeSetSpanner SPACES_AND_CONTROLS = + new UnicodeSetSpanner(new UnicodeSet("[[:Zs:][:Cf:]]").freeze()); + + private static String formatRoundedBytesResult( + @NonNull Context context, @NonNull RoundedBytesResult input) { + final Locale locale = localeFromContext(context); + final NumberFormat numberFormatter = getNumberFormatter(locale, input.fractionDigits); + if (input.units == MeasureUnit.BYTE) { + // ICU spells out "byte" instead of "B". + final String formattedNumber = numberFormatter.format(input.value); + return context.getString(com.android.internal.R.string.fileSizeSuffix, + formattedNumber, getByteSuffixOverride(context.getResources())); + } else { + return formatMeasureShort(locale, numberFormatter, input.value, input.units); + } + } + + /** {@hide} */ + public static class RoundedBytesResult { + public final float value; + public final MeasureUnit units; + public final int fractionDigits; + public final long roundedBytes; + + private RoundedBytesResult( + float value, MeasureUnit units, int fractionDigits, long roundedBytes) { + this.value = value; + this.units = units; + this.fractionDigits = fractionDigits; + this.roundedBytes = roundedBytes; + } + + /** + * Returns a RoundedBytesResult object based on the input size in bytes and the rounding + * flags. The result can be used for formatting. + */ + public static RoundedBytesResult roundBytes(long sizeBytes, int flags) { + final int unit = ((flags & FLAG_IEC_UNITS) != 0) ? 1024 : 1000; + final boolean isNegative = (sizeBytes < 0); + float result = isNegative ? -sizeBytes : sizeBytes; + MeasureUnit units = MeasureUnit.BYTE; + long mult = 1; + if (result > 900) { + units = MeasureUnit.KILOBYTE; + mult = unit; + result = result / unit; + } + if (result > 900) { + units = MeasureUnit.MEGABYTE; + mult *= unit; + result = result / unit; + } + if (result > 900) { + units = MeasureUnit.GIGABYTE; + mult *= unit; + result = result / unit; + } + if (result > 900) { + units = MeasureUnit.TERABYTE; + mult *= unit; + result = result / unit; + } + if (result > 900) { + units = MeasureUnit.PETABYTE; + mult *= unit; + result = result / unit; + } + // Note we calculate the rounded long by ourselves, but still let NumberFormat compute + // the rounded value. NumberFormat.format(0.1) might not return "0.1" due to floating + // point errors. + final int roundFactor; + final int roundDigits; + if (mult == 1 || result >= 100) { + roundFactor = 1; + roundDigits = 0; + } else if (result < 1) { + roundFactor = 100; + roundDigits = 2; + } else if (result < 10) { + if ((flags & FLAG_SHORTER) != 0) { + roundFactor = 10; + roundDigits = 1; + } else { + roundFactor = 100; + roundDigits = 2; + } + } else { // 10 <= result < 100 + if ((flags & FLAG_SHORTER) != 0) { + roundFactor = 1; + roundDigits = 0; + } else { + roundFactor = 100; + roundDigits = 2; + } + } + + if (isNegative) { + result = -result; + } + + // Note this might overflow if abs(result) >= Long.MAX_VALUE / 100, but that's like + // 80PB so it's okay (for now)... + final long roundedBytes = + (flags & FLAG_CALCULATE_ROUNDED) == 0 ? 0 + : (((long) Math.round(result * roundFactor)) * mult / roundFactor); + + return new RoundedBytesResult(result, units, roundDigits, roundedBytes); } - final BytesResult res = formatBytes(context.getResources(), sizeBytes, - FLAG_SI_UNITS | FLAG_SHORTER); - return bidiWrap(context, context.getString(com.android.internal.R.string.fileSizeSuffix, - res.value, res.units)); } /** {@hide} */ @UnsupportedAppUsage public static BytesResult formatBytes(Resources res, long sizeBytes, int flags) { - final int unit = ((flags & FLAG_IEC_UNITS) != 0) ? 1024 : 1000; - final boolean isNegative = (sizeBytes < 0); - float result = isNegative ? -sizeBytes : sizeBytes; - int suffix = com.android.internal.R.string.byteShort; - long mult = 1; - if (result > 900) { - suffix = com.android.internal.R.string.kilobyteShort; - mult = unit; - result = result / unit; + final RoundedBytesResult rounded = RoundedBytesResult.roundBytes(sizeBytes, flags); + final Locale locale = res.getConfiguration().getLocales().get(0); + final NumberFormat numberFormatter = getNumberFormatter(locale, rounded.fractionDigits); + final String formattedNumber = numberFormatter.format(rounded.value); + final String units; + if (rounded.units == MeasureUnit.BYTE) { + // ICU spells out "byte" instead of "B". + units = getByteSuffixOverride(res); + } else { + // Since ICU does not give us access to the pattern, we need to extract the unit string + // from ICU, which we do by taking out the formatted number out of the formatted string + // and trimming the result of spaces and controls. + final String formattedMeasure = formatMeasureShort( + locale, numberFormatter, rounded.value, rounded.units); + final String numberRemoved = deleteFirstFromString(formattedMeasure, formattedNumber); + units = SPACES_AND_CONTROLS.trim(numberRemoved).toString(); } - if (result > 900) { - suffix = com.android.internal.R.string.megabyteShort; - mult *= unit; - result = result / unit; - } - if (result > 900) { - suffix = com.android.internal.R.string.gigabyteShort; - mult *= unit; - result = result / unit; - } - if (result > 900) { - suffix = com.android.internal.R.string.terabyteShort; - mult *= unit; - result = result / unit; - } - if (result > 900) { - suffix = com.android.internal.R.string.petabyteShort; - mult *= unit; - result = result / unit; - } - // Note we calculate the rounded long by ourselves, but still let String.format() - // compute the rounded value. String.format("%f", 0.1) might not return "0.1" due to - // floating point errors. - final int roundFactor; - final String roundFormat; - if (mult == 1 || result >= 100) { - roundFactor = 1; - roundFormat = "%.0f"; - } else if (result < 1) { - roundFactor = 100; - roundFormat = "%.2f"; - } else if (result < 10) { - if ((flags & FLAG_SHORTER) != 0) { - roundFactor = 10; - roundFormat = "%.1f"; - } else { - roundFactor = 100; - roundFormat = "%.2f"; - } - } else { // 10 <= result < 100 - if ((flags & FLAG_SHORTER) != 0) { - roundFactor = 1; - roundFormat = "%.0f"; - } else { - roundFactor = 100; - roundFormat = "%.2f"; - } - } - - if (isNegative) { - result = -result; - } - final String roundedString = String.format(roundFormat, result); - - // Note this might overflow if abs(result) >= Long.MAX_VALUE / 100, but that's like 80PB so - // it's okay (for now)... - final long roundedBytes = - (flags & FLAG_CALCULATE_ROUNDED) == 0 ? 0 - : (((long) Math.round(result * roundFactor)) * mult / roundFactor); - - final String units = res.getString(suffix); - - return new BytesResult(roundedString, units, roundedBytes); + return new BytesResult(formattedNumber, units, rounded.roundedBytes); } /** diff --git a/core/tests/coretests/src/android/text/format/FormatterTest.java b/core/tests/coretests/src/android/text/format/FormatterTest.java index 5612833e5ddde..986cee55a1088 100644 --- a/core/tests/coretests/src/android/text/format/FormatterTest.java +++ b/core/tests/coretests/src/android/text/format/FormatterTest.java @@ -36,6 +36,8 @@ import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; +import java.math.BigDecimal; +import java.math.MathContext; import java.util.Locale; @Presubmit @@ -216,6 +218,57 @@ public class FormatterTest { mContext, 1 * SECOND)); } + /** + * Regression test for http://b/71580745 and https://unicode-org.atlassian.net/browse/CLDR-10831 + */ + @Test + public void testFormatFileSize_zhCN() { + setLocale(Locale.forLanguageTag("zh-CN")); + + assertFormatFileSize_englishOutput(); + } + + @Test + public void testFormatFileSize_enUS() { + setLocale(Locale.US); + + assertFormatFileSize_englishOutput(); + } + + private void assertFormatFileSize_englishOutput() { + final MathContext mc = MathContext.DECIMAL64; + final BigDecimal bd = new BigDecimal((long) 1000, mc); + // test null Context + assertEquals("", Formatter.formatFileSize(null, 0)); + // test different long values with various length + assertEquals("0 B", Formatter.formatFileSize(mContext, 0)); + assertEquals("1 B", Formatter.formatFileSize(mContext, 1)); + assertEquals("9 B", Formatter.formatFileSize(mContext, 9)); + assertEquals("10 B", Formatter.formatFileSize(mContext, 10)); + assertEquals("99 B", Formatter.formatFileSize(mContext, 99)); + assertEquals("100 B", Formatter.formatFileSize(mContext, 100)); + assertEquals("900 B", Formatter.formatFileSize(mContext, 900)); + assertEquals("0.90 kB", Formatter.formatFileSize(mContext, 901)); + + assertEquals("1.00 kB", Formatter.formatFileSize(mContext, bd.pow(1).longValue())); + assertEquals("1.50 kB", Formatter.formatFileSize(mContext, bd.pow(1).longValue() * 3 / 2)); + assertEquals("12.50 kB", Formatter.formatFileSize(mContext, + bd.pow(1).longValue() * 25 / 2)); + + assertEquals("1.00 MB", Formatter.formatFileSize(mContext, bd.pow(2).longValue())); + + assertEquals("1.00 GB", Formatter.formatFileSize(mContext, bd.pow(3).longValue())); + + assertEquals("1.00 TB", Formatter.formatFileSize(mContext, bd.pow(4).longValue())); + + assertEquals("1.00 PB", Formatter.formatFileSize(mContext, bd.pow(5).longValue())); + + assertEquals("1000 PB", Formatter.formatFileSize(mContext, bd.pow(6).longValue())); + + // test Negative value + assertEquals("-1 B", Formatter.formatFileSize(mContext, -1)); + } + private void checkFormatBytes(long bytes, boolean useShort, String expectedString, long expectedRounded) { checkFormatBytes(bytes, (useShort ? Formatter.FLAG_SHORTER : 0),