Define Utils.formatRelativeTime() and use it

Previously, relative times were formatted using formatElapsedTime()
(appending translations of "ago" to them), sometimes resulting in
grammatically hard-to-understand or unnatural localizations. Now we
use ICU's RelativeDateTimeFormatter, which uses grammatically correct
and natural localizations from CLDR data.

Bug: 64507689
Bug: 64605781
Bug: 64556849
Bug: 64550172
Test: make -j RunSettingsRoboTests
Change-Id: Ia2d098b190ab99e7748ef6f03b919f5c6174ba7d
diff --git a/res/values/strings.xml b/res/values/strings.xml
index c58f6bb..51305b5 100644
--- a/res/values/strings.xml
+++ b/res/values/strings.xml
@@ -3633,8 +3633,6 @@
     <string name="recent_app_category_title">Recently opened apps</string>
     <!-- Preference title for showing all apps on device [CHAR_LIMIT=50]-->
     <string name="see_all_apps_title">See all <xliff:g id="count" example="3">%1$d</xliff:g> apps</string>
-    <!-- Preference summary for each recently used app, which is the time since last used, i.e. "7 h 20 min ago". Note: ^1 should be used in all translations [CHAR_LIMIT=60] -->
-    <string name="recent_app_summary"><xliff:g id="time">^1</xliff:g> ago</string>
 
     <!-- Warning that appears below the unknown sources switch in settings -->
     <string name="install_all_warning" product="tablet">
@@ -4644,12 +4642,10 @@
     <!-- Title for the cellular network in power use UI(i.e. Mobile network scanning: 30% of battery usage) [CHAR_LIMIT=40] -->
     <string name="device_cellular_network">Mobile network scanning</string>
 
-    <!-- Label for time since last full charge in power use UI, i.e. "7 h 20 min ago". Note: ^1 should be used in all translations [CHAR_LIMIT=60] -->
-    <string name="power_last_full_charge_summary"><xliff:g id="time">^1</xliff:g> ago</string>
     <!-- Label for list of apps using battery in power use UI. Note: ^1 should be used in all translations[CHAR_LIMIT=120] -->
-    <string name="power_usage_list_summary">App usage since full charge (<xliff:g id="time">^1</xliff:g> ago)</string>
+    <string name="power_usage_list_summary">App usage since full charge (<xliff:g id="relative_time">^1</xliff:g>)</string>
     <!-- Label for device components using battery in power use UI. Note: ^1 should be used in all translations[CHAR_LIMIT=120] -->
-    <string name="power_usage_list_summary_device">Device usage since full charge (<xliff:g id="time">^1</xliff:g> ago)</string>
+    <string name="power_usage_list_summary_device">Device usage since full charge (<xliff:g id="relative_time">^1</xliff:g>)</string>
     <!-- Description for the screen usage item [CHAR_LIMIT=120] -->
     <string name="screen_usage_summary">Amount of time screen has been on since full charge</string>
     <!-- Label for list of different types using battery in power use UI [CHAR_LIMIT=60] -->
diff --git a/src/com/android/settings/Utils.java b/src/com/android/settings/Utils.java
index 417ac0f..fa61cec 100644
--- a/src/com/android/settings/Utils.java
+++ b/src/com/android/settings/Utils.java
@@ -52,8 +52,11 @@
 import android.graphics.BitmapFactory;
 import android.hardware.fingerprint.FingerprintManager;
 import android.icu.text.MeasureFormat;
+import android.icu.text.RelativeDateTimeFormatter;
+import android.icu.text.RelativeDateTimeFormatter.RelativeUnit;
 import android.icu.util.Measure;
 import android.icu.util.MeasureUnit;
+import android.icu.util.ULocale;
 import android.net.ConnectivityManager;
 import android.net.LinkProperties;
 import android.net.Network;
@@ -861,6 +864,48 @@
     }
 
     /**
+     * Returns relative time for the given millis in the past, in a short format such as "2 days
+     * ago", "5 hr. ago", "40 min. ago", or "29 sec. ago".
+     *
+     * <p>The unit is chosen to have good information value while only using one unit. So 27 hours
+     * and 50 minutes would be formatted as "28 hr. ago", while 50 hours would be formatted as
+     * "2 days ago".
+     *
+     * @param context the application context
+     * @param millis the elapsed time in milli seconds
+     * @param withSeconds include seconds?
+     * @return the formatted elapsed time
+     */
+    public static CharSequence formatRelativeTime(Context context, double millis,
+            boolean withSeconds) {
+        final int seconds = (int) Math.floor(millis / 1000);
+        final RelativeUnit unit;
+        final int value;
+        if (withSeconds && seconds < 2 * SECONDS_PER_MINUTE) {
+            unit = RelativeUnit.SECONDS;
+            value = seconds;
+        } else if (seconds < 2 * SECONDS_PER_HOUR) {
+            unit = RelativeUnit.MINUTES;
+            value = (seconds + SECONDS_PER_MINUTE / 2) / SECONDS_PER_MINUTE;
+        } else if (seconds < 2 * SECONDS_PER_DAY) {
+            unit = RelativeUnit.HOURS;
+            value = (seconds + SECONDS_PER_HOUR / 2) / SECONDS_PER_HOUR;
+        } else {
+            unit = RelativeUnit.DAYS;
+            value = (seconds + SECONDS_PER_DAY / 2) / SECONDS_PER_DAY;
+        }
+
+        final Locale locale = context.getResources().getConfiguration().locale;
+        final RelativeDateTimeFormatter formatter = RelativeDateTimeFormatter.getInstance(
+                ULocale.forLocale(locale),
+                null /* default NumberFormat */,
+                RelativeDateTimeFormatter.Style.SHORT,
+                android.icu.text.DisplayContext.CAPITALIZATION_FOR_MIDDLE_OF_SENTENCE);
+
+        return formatter.format(value, RelativeDateTimeFormatter.Direction.LAST, unit);
+    }
+
+    /**
      * Queries for the UserInfo of a user. Returns null if the user doesn't exist (was removed).
      * @param userManager Instance of UserManager
      * @param checkUser The user to check the existence of.
diff --git a/src/com/android/settings/applications/RecentAppsPreferenceController.java b/src/com/android/settings/applications/RecentAppsPreferenceController.java
index d0f7584..69a36f67 100644
--- a/src/com/android/settings/applications/RecentAppsPreferenceController.java
+++ b/src/com/android/settings/applications/RecentAppsPreferenceController.java
@@ -235,10 +235,8 @@
             pref.setKey(pkgName);
             pref.setTitle(appEntry.label);
             pref.setIcon(mIconDrawableFactory.getBadgedIcon(appEntry.info));
-            pref.setSummary(TextUtils.expandTemplate(
-                mContext.getResources().getText(R.string.recent_app_summary),
-                Utils.formatElapsedTime(mContext,
-                    System.currentTimeMillis() - stat.getLastTimeUsed(), false)));
+            pref.setSummary(Utils.formatRelativeTime(mContext,
+                    System.currentTimeMillis() - stat.getLastTimeUsed(), false));
             pref.setOrder(i);
             pref.setOnPreferenceClickListener(preference -> {
                 AppInfoBase.startAppInfoFragment(InstalledAppDetails.class,
diff --git a/src/com/android/settings/fuelgauge/PowerUsageSummary.java b/src/com/android/settings/fuelgauge/PowerUsageSummary.java
index 1596aca..9798749 100644
--- a/src/com/android/settings/fuelgauge/PowerUsageSummary.java
+++ b/src/com/android/settings/fuelgauge/PowerUsageSummary.java
@@ -533,7 +533,7 @@
         updateScreenPreference();
         updateLastFullChargePreference(lastFullChargeTime);
 
-        final CharSequence timeSequence = Utils.formatElapsedTime(context, lastFullChargeTime,
+        final CharSequence timeSequence = Utils.formatRelativeTime(context, lastFullChargeTime,
                 false);
         final int resId = mShowAllApps ? R.string.power_usage_list_summary_device
                 : R.string.power_usage_list_summary;
@@ -682,10 +682,8 @@
 
     @VisibleForTesting
     void updateLastFullChargePreference(long timeMs) {
-        final CharSequence timeSequence = Utils.formatElapsedTime(getContext(), timeMs, false);
-        mLastFullChargePref.setSubtitle(
-                TextUtils.expandTemplate(getText(R.string.power_last_full_charge_summary),
-                        timeSequence));
+        final CharSequence timeSequence = Utils.formatRelativeTime(getContext(), timeMs, false);
+        mLastFullChargePref.setSubtitle(timeSequence);
     }
 
     @VisibleForTesting
diff --git a/tests/robotests/src/com/android/settings/UtilsTest.java b/tests/robotests/src/com/android/settings/UtilsTest.java
index 33ead1f..19b87a1 100644
--- a/tests/robotests/src/com/android/settings/UtilsTest.java
+++ b/tests/robotests/src/com/android/settings/UtilsTest.java
@@ -174,6 +174,105 @@
     }
 
     @Test
+    public void testFormatRelativeTime_WithSeconds_ShowSeconds() {
+        final double testMillis = 40 * DateUtils.SECOND_IN_MILLIS;
+        final String expectedTime = "40 sec. ago";
+
+        assertThat(Utils.formatRelativeTime(mContext, testMillis, true).toString()).isEqualTo(
+                expectedTime);
+    }
+
+    @Test
+    public void testFormatRelativeTime_NoSeconds_DoNotShowSeconds() {
+        final double testMillis = 40 * DateUtils.SECOND_IN_MILLIS;
+        final String expectedTime = "1 min. ago";
+
+        assertThat(Utils.formatRelativeTime(mContext, testMillis, false).toString()).isEqualTo(
+                expectedTime);
+    }
+
+    @Test
+    public void testFormatRelativeTime_LessThanTwoMinutes_withSeconds() {
+        final double testMillis = 119 * DateUtils.SECOND_IN_MILLIS;
+        final String expectedTime = "119 sec. ago";
+
+        assertThat(Utils.formatRelativeTime(mContext, testMillis, true).toString()).isEqualTo(
+                expectedTime);
+    }
+
+    @Test
+    public void testFormatRelativeTime_LessThanTwoMinutes_NoSeconds() {
+        final double testMillis = 119 * DateUtils.SECOND_IN_MILLIS;
+        final String expectedTime = "2 min. ago";
+
+        assertThat(Utils.formatRelativeTime(mContext, testMillis, false).toString()).isEqualTo(
+                expectedTime);
+    }
+
+    @Test
+    public void testFormatRelativeTime_TwoMinutes_withSeconds() {
+        final double testMillis = 2 * DateUtils.MINUTE_IN_MILLIS;
+        final String expectedTime = "2 min. ago";
+
+        assertThat(Utils.formatRelativeTime(mContext, testMillis, true).toString()).isEqualTo(
+                expectedTime);
+    }
+
+    @Test
+    public void testFormatRelativeTime_LessThanTwoHours_withSeconds() {
+        final double testMillis = 119 * DateUtils.MINUTE_IN_MILLIS;
+        final String expectedTime = "119 min. ago";
+
+        assertThat(Utils.formatRelativeTime(mContext, testMillis, true).toString()).isEqualTo(
+                expectedTime);
+    }
+
+    @Test
+    public void testFormatRelativeTime_TwoHours_withSeconds() {
+        final double testMillis = 2 * DateUtils.HOUR_IN_MILLIS;
+        final String expectedTime = "2 hr. ago";
+
+        assertThat(Utils.formatRelativeTime(mContext, testMillis, true).toString()).isEqualTo(
+                expectedTime);
+    }
+
+    @Test
+    public void testFormatRelativeTime_LessThanTwoDays_withSeconds() {
+        final double testMillis = 47 * DateUtils.HOUR_IN_MILLIS;
+        final String expectedTime = "47 hr. ago";
+
+        assertThat(Utils.formatRelativeTime(mContext, testMillis, true).toString()).isEqualTo(
+                expectedTime);
+    }
+
+    @Test
+    public void testFormatRelativeTime_TwoDays_withSeconds() {
+        final double testMillis = 2 * DateUtils.DAY_IN_MILLIS;
+        final String expectedTime = "2 days ago";
+
+        assertThat(Utils.formatRelativeTime(mContext, testMillis, true).toString()).isEqualTo(
+                expectedTime);
+    }
+
+    @Test
+    public void testFormatRelativeTime_FormatZero_WithSeconds() {
+        final double testMillis = 0;
+        final String expectedTime = "0 sec. ago";
+
+        assertThat(Utils.formatRelativeTime(mContext, testMillis, true).toString()).isEqualTo(
+                expectedTime);
+    }
+
+    @Test
+    public void testFormatRelativeTime_FormatZero_NoSeconds() {
+        final double testMillis = 0;
+        final String expectedTime = "0 min. ago";
+
+        assertThat(Utils.formatRelativeTime(mContext, testMillis, false).toString()).isEqualTo(
+                expectedTime);
+    }
+
+    @Test
     public void testInitializeVolumeDoesntBreakOnNullVolume() {
         VolumeInfo info = new VolumeInfo("id", 0, new DiskInfo("id", 0), "");
         StorageManager storageManager = mock(StorageManager.class, RETURNS_DEEP_STUBS);
diff --git a/tests/robotests/src/com/android/settings/applications/RecentAppsPreferenceControllerTest.java b/tests/robotests/src/com/android/settings/applications/RecentAppsPreferenceControllerTest.java
index 2510f20..5e85f9b 100644
--- a/tests/robotests/src/com/android/settings/applications/RecentAppsPreferenceControllerTest.java
+++ b/tests/robotests/src/com/android/settings/applications/RecentAppsPreferenceControllerTest.java
@@ -249,8 +249,6 @@
         when(mUsageStatsManager.queryUsageStats(anyInt(), anyLong(), anyLong()))
             .thenReturn(stats);
 
-        when(mMockContext.getResources().getText(eq(R.string.recent_app_summary)))
-            .thenReturn(mContext.getResources().getText(R.string.recent_app_summary));
         final Configuration configuration = new Configuration();
         configuration.locale = Locale.US;
         when(mMockContext.getResources().getConfiguration()).thenReturn(configuration);
@@ -258,7 +256,7 @@
         mController = new RecentAppsPreferenceController(mMockContext, mAppState, null);
         mController.displayPreference(mScreen);
 
-        verify(mCategory).addPreference(argThat(summaryMatches("0m ago")));
+        verify(mCategory).addPreference(argThat(summaryMatches("0 min. ago")));
     }
 
     private static ArgumentMatcher<Preference> summaryMatches(String expected) {
diff --git a/tests/robotests/src/com/android/settings/fuelgauge/PowerUsageSummaryTest.java b/tests/robotests/src/com/android/settings/fuelgauge/PowerUsageSummaryTest.java
index db4fb6d..89a4208 100644
--- a/tests/robotests/src/com/android/settings/fuelgauge/PowerUsageSummaryTest.java
+++ b/tests/robotests/src/com/android/settings/fuelgauge/PowerUsageSummaryTest.java
@@ -411,18 +411,11 @@
 
     @Test
     public void testUpdateLastFullChargePreference_showCorrectSummary() {
-        final CharSequence formattedString = mRealContext.getText(
-                R.string.power_last_full_charge_summary);
-        final CharSequence timeSequence = Utils.formatElapsedTime(mRealContext,
-                TIME_SINCE_LAST_FULL_CHARGE_MS, false);
-        final CharSequence expectedSummary = TextUtils.expandTemplate(
-                formattedString, timeSequence);
-        doReturn(formattedString).when(mFragment).getText(R.string.power_last_full_charge_summary);
         doReturn(mRealContext).when(mFragment).getContext();
 
         mFragment.updateLastFullChargePreference(TIME_SINCE_LAST_FULL_CHARGE_MS);
 
-        assertThat(mLastFullChargePref.getSubtitle()).isEqualTo(expectedSummary);
+        assertThat(mLastFullChargePref.getSubtitle()).isEqualTo("2 hr. ago");
     }
 
     @Test