diff --git a/quickstep/src/com/android/quickstep/task/apptimer/DurationFormatter.kt b/quickstep/src/com/android/quickstep/task/apptimer/DurationFormatter.kt new file mode 100644 index 0000000000..0c4a4d8331 --- /dev/null +++ b/quickstep/src/com/android/quickstep/task/apptimer/DurationFormatter.kt @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.quickstep.task.apptimer + +import android.content.Context +import android.icu.text.MeasureFormat +import android.icu.util.Measure +import android.icu.util.MeasureUnit +import androidx.annotation.StringRes +import java.time.Duration +import java.util.Locale + +/** Formats the given duration as a user friendly text. */ +object DurationFormatter { + fun format( + context: Context, + duration: Duration, + @StringRes durationLessThanOneMinuteStringId: Int, + ): String { + val hours = Math.toIntExact(duration.toHours()) + val minutes = Math.toIntExact(duration.minusHours(hours.toLong()).toMinutes()) + return when { + // Apply FormatWidth.NARROW if both the hour part and the minute part are non-zero. + hours > 0 && minutes > 0 -> + MeasureFormat.getInstance(Locale.getDefault(), MeasureFormat.FormatWidth.NARROW) + .formatMeasures( + Measure(hours, MeasureUnit.HOUR), + Measure(minutes, MeasureUnit.MINUTE), + ) + // Apply FormatWidth.WIDE if only the hour part is non-zero (unless forced). + hours > 0 -> + MeasureFormat.getInstance(Locale.getDefault(), MeasureFormat.FormatWidth.WIDE) + .formatMeasures(Measure(hours, MeasureUnit.HOUR)) + // Apply FormatWidth.WIDE if only the minute part is non-zero (unless forced). + minutes > 0 -> + MeasureFormat.getInstance(Locale.getDefault(), MeasureFormat.FormatWidth.WIDE) + .formatMeasures(Measure(minutes, MeasureUnit.MINUTE)) + // Use a specific string for usage less than one minute but non-zero. + duration > Duration.ZERO -> context.getString(durationLessThanOneMinuteStringId) + // Otherwise, return 0-minute string. + else -> + MeasureFormat.getInstance(Locale.getDefault(), MeasureFormat.FormatWidth.WIDE) + .formatMeasures(Measure(0, MeasureUnit.MINUTE)) + } + } +} diff --git a/quickstep/src/com/android/quickstep/views/DigitalWellBeingToast.kt b/quickstep/src/com/android/quickstep/views/DigitalWellBeingToast.kt index 51e7602960..10d4377424 100644 --- a/quickstep/src/com/android/quickstep/views/DigitalWellBeingToast.kt +++ b/quickstep/src/com/android/quickstep/views/DigitalWellBeingToast.kt @@ -24,9 +24,6 @@ import android.content.pm.LauncherApps import android.content.pm.LauncherApps.AppUsageLimit import android.graphics.Outline import android.graphics.Paint -import android.icu.text.MeasureFormat -import android.icu.util.Measure -import android.icu.util.MeasureUnit import android.os.UserHandle import android.provider.Settings import android.util.AttributeSet @@ -35,7 +32,6 @@ import android.view.View import android.view.ViewOutlineProvider import android.view.accessibility.AccessibilityNodeInfo import android.widget.TextView -import androidx.annotation.StringRes import androidx.annotation.VisibleForTesting import androidx.core.util.component1 import androidx.core.util.component2 @@ -47,10 +43,10 @@ import com.android.launcher3.util.SplitConfigurationOptions.STAGE_POSITION_TOP_O import com.android.launcher3.util.SplitConfigurationOptions.STAGE_POSITION_UNDEFINED import com.android.launcher3.util.SplitConfigurationOptions.StagePosition import com.android.quickstep.TaskUtils +import com.android.quickstep.task.apptimer.DurationFormatter import com.android.systemui.shared.recents.model.Task import com.android.wm.shell.shared.split.SplitBounds import java.time.Duration -import java.util.Locale @SuppressLint("AppCompatCustomView") class DigitalWellBeingToast @@ -200,37 +196,6 @@ constructor( } } - private fun getReadableDuration( - duration: Duration, - @StringRes durationLessThanOneMinuteStringId: Int, - ): String { - val hours = Math.toIntExact(duration.toHours()) - val minutes = Math.toIntExact(duration.minusHours(hours.toLong()).toMinutes()) - return when { - // Apply FormatWidth.WIDE if both the hour part and the minute part are non-zero. - hours > 0 && minutes > 0 -> - MeasureFormat.getInstance(Locale.getDefault(), MeasureFormat.FormatWidth.NARROW) - .formatMeasures( - Measure(hours, MeasureUnit.HOUR), - Measure(minutes, MeasureUnit.MINUTE), - ) - // Apply FormatWidth.WIDE if only the hour part is non-zero (unless forced). - hours > 0 -> - MeasureFormat.getInstance(Locale.getDefault(), MeasureFormat.FormatWidth.WIDE) - .formatMeasures(Measure(hours, MeasureUnit.HOUR)) - // Apply FormatWidth.WIDE if only the minute part is non-zero (unless forced). - minutes > 0 -> - MeasureFormat.getInstance(Locale.getDefault(), MeasureFormat.FormatWidth.WIDE) - .formatMeasures(Measure(minutes, MeasureUnit.MINUTE)) - // Use a specific string for usage less than one minute but non-zero. - duration > Duration.ZERO -> context.getString(durationLessThanOneMinuteStringId) - // Otherwise, return 0-minute string. - else -> - MeasureFormat.getInstance(Locale.getDefault(), MeasureFormat.FormatWidth.WIDE) - .formatMeasures(Measure(0, MeasureUnit.MINUTE)) - } - } - /** * Returns text to show for the banner depending on [.getSplitBannerConfig] If {@param * forContentDesc} is `true`, this will always return the full string corresponding to @@ -249,7 +214,8 @@ constructor( else remainingTime ) val readableDuration = - getReadableDuration( + DurationFormatter.format( + context, duration, R.string.shorter_duration_less_than_one_minute, /* forceFormatWidth */ ) diff --git a/quickstep/tests/multivalentTests/src/com/android/quickstep/task/apptimer/DurationFormatterTest.kt b/quickstep/tests/multivalentTests/src/com/android/quickstep/task/apptimer/DurationFormatterTest.kt new file mode 100644 index 0000000000..685927fada --- /dev/null +++ b/quickstep/tests/multivalentTests/src/com/android/quickstep/task/apptimer/DurationFormatterTest.kt @@ -0,0 +1,87 @@ +/* + * Copyright (C) 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.quickstep.task.apptimer + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.launcher3.R +import com.android.launcher3.util.SandboxApplication +import com.google.common.truth.Truth.assertThat +import java.time.Duration +import java.util.Locale +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class DurationFormatterTest { + @get:Rule val context = SandboxApplication() + + private var systemLocale: Locale? = null + + @Before + fun setup() { + systemLocale = Locale.getDefault() + val testLocale = Locale("en", "us") + Locale.setDefault(testLocale) + } + + @Test + fun getReadableDuration_hasHoursAndMinutes_returnsNarrowString() { + val result = + DurationFormatter.format( + context, + Duration.ofHours(12).plusMinutes(55), + durationLessThanOneMinuteStringId = R.string.shorter_duration_less_than_one_minute, + ) + + val expected = "12h 55m" + assertThat(result).isEqualTo(expected) + } + + @Test + fun getReadableDuration_hasFullHours_returnsWideString() { + val result = + DurationFormatter.format( + context = context, + duration = Duration.ofHours(12), + durationLessThanOneMinuteStringId = R.string.shorter_duration_less_than_one_minute, + ) + + val expected = "12 hours" + assertThat(result).isEqualTo(expected) + } + + @Test + fun getReadableDuration_hasFullMinutesNoHours_returnsWideString() { + val result = + DurationFormatter.format( + context = context, + duration = Duration.ofMinutes(50), + durationLessThanOneMinuteStringId = R.string.shorter_duration_less_than_one_minute, + ) + + val expected = "50 minutes" + assertThat(result).isEqualTo(expected) + } + + @After + fun tearDown() { + Locale.setDefault(systemLocale) + } +}