diff --git a/quickstep/res/layout/task_app_timer_toast.xml b/quickstep/res/layout/task_app_timer_toast.xml new file mode 100644 index 0000000000..694d1ce497 --- /dev/null +++ b/quickstep/res/layout/task_app_timer_toast.xml @@ -0,0 +1,32 @@ + + diff --git a/quickstep/res/layout/task_content_view.xml b/quickstep/res/layout/task_content_view.xml index 478ee55c06..28744b6ab4 100644 --- a/quickstep/res/layout/task_content_view.xml +++ b/quickstep/res/layout/task_content_view.xml @@ -14,21 +14,43 @@ ~ limitations under the License. --> + android:layout_height="wrap_content" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintTop_toTopOf="parent" + /> + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + /> + \ No newline at end of file diff --git a/quickstep/src/com/android/quickstep/recents/ui/mapper/TaskUiStateMapper.kt b/quickstep/src/com/android/quickstep/recents/ui/mapper/TaskUiStateMapper.kt index 7425d361ee..e035015070 100644 --- a/quickstep/src/com/android/quickstep/recents/ui/mapper/TaskUiStateMapper.kt +++ b/quickstep/src/com/android/quickstep/recents/ui/mapper/TaskUiStateMapper.kt @@ -18,7 +18,10 @@ package com.android.quickstep.recents.ui.mapper import android.view.View.OnClickListener import com.android.launcher3.Flags.enableDesktopExplodedView +import com.android.launcher3.R +import com.android.launcher3.util.SplitConfigurationOptions.STAGE_POSITION_BOTTOM_OR_RIGHT import com.android.quickstep.recents.ui.viewmodel.TaskData +import com.android.quickstep.task.apptimer.TaskAppTimerUiState import com.android.quickstep.task.thumbnail.TaskHeaderUiState import com.android.quickstep.task.thumbnail.TaskThumbnailUiState import com.android.quickstep.task.thumbnail.TaskThumbnailUiState.BackgroundOnly @@ -105,4 +108,37 @@ object TaskUiStateMapper { taskData.icon != null && taskData.titleDescription != null && clickCloseListener != null + + /** + * Converts a [TaskData] object into a [TaskAppTimerUiState] for displaying an app timer toast + * + * @property taskData The [TaskData] to convert. Can be null or a specific sub-class. + * @property stagePosition the position of this task when shown as a group + * @return a [TaskAppTimerUiState] representing state for the information displayed in the app + * timer toast. + */ + fun toTaskAppTimerUiState( + canShowAppTimer: Boolean, + stagePosition: Int, + taskData: TaskData?, + ): TaskAppTimerUiState = + when { + taskData !is TaskData.Data -> TaskAppTimerUiState.Uninitialized + + !canShowAppTimer || taskData.remainingAppTimerDuration == null -> + TaskAppTimerUiState.NoTimer(taskDescription = taskData.titleDescription) + + else -> + TaskAppTimerUiState.Timer( + taskDescription = taskData.titleDescription, + timeRemaining = taskData.remainingAppTimerDuration, + taskPackageName = taskData.packageName, + accessibilityActionId = + if (stagePosition == STAGE_POSITION_BOTTOM_OR_RIGHT) { + R.id.action_digital_wellbeing_bottom_right + } else { + R.id.action_digital_wellbeing_top_left + }, + ) + } } 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/task/apptimer/TaskAppTimerUiState.kt b/quickstep/src/com/android/quickstep/task/apptimer/TaskAppTimerUiState.kt new file mode 100644 index 0000000000..08c92db044 --- /dev/null +++ b/quickstep/src/com/android/quickstep/task/apptimer/TaskAppTimerUiState.kt @@ -0,0 +1,50 @@ +/* + * 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.annotation.IdRes +import java.time.Duration + +/** + * UI state of the digital wellbeing app timer toast + * + * @property taskDescription description of the task for which the timer is being displayed. + */ +sealed class TaskAppTimerUiState(open val taskDescription: String?) { + /** Timer information not available in UI. */ + data object Uninitialized : TaskAppTimerUiState(null) + + /** No timer information to display */ + data class NoTimer(override val taskDescription: String?) : + TaskAppTimerUiState(taskDescription) + + /** + * Represents the UI state necessary to show an app timer on a task + * + * @property timeRemaining time remaining on the app timer for the application. + * @property taskDescription description of the task for which the timer is being displayed. + * @property taskPackageName package name for of the top component for the task's app. + * @property accessibilityActionId action id to use for tap like accessibility actions on this + * timer. + */ + data class Timer( + val timeRemaining: Duration, + override val taskDescription: String?, + val taskPackageName: String, + @IdRes val accessibilityActionId: Int, + ) : TaskAppTimerUiState(taskDescription) +} diff --git a/quickstep/src/com/android/quickstep/task/apptimer/TimerTextHelper.kt b/quickstep/src/com/android/quickstep/task/apptimer/TimerTextHelper.kt new file mode 100644 index 0000000000..0d0ca841f4 --- /dev/null +++ b/quickstep/src/com/android/quickstep/task/apptimer/TimerTextHelper.kt @@ -0,0 +1,73 @@ +/* + * 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 com.android.launcher3.R +import com.android.launcher3.Utilities +import java.time.Duration + +/** A helper class that is responsible for building the digital wellbeing timer text. */ +class TimerTextHelper(private val context: Context, timeLeft: Duration) { + val formattedDuration = + DurationFormatter.format(context, timeLeft, R.string.shorter_duration_less_than_one_minute) + + /** Provides the time left as a user friendly text that fits in the [availableWidth]. */ + fun getTextThatFits(availableWidth: Int, textPaint: android.text.TextPaint): CharSequence { + val iconOnlyText = Utilities.prefixTextWithIcon(context, R.drawable.ic_hourglass_top, "") + + if (availableWidth == 0) { + return iconOnlyText + } + + // "$icon $formattedDuration left today" + val fullText = + Utilities.prefixTextWithIcon( + context, + R.drawable.ic_hourglass_top, + context.getString(R.string.time_left_for_app, formattedDuration), + ) + + val textWidth = textPaint.measureText(fullText, /* start= */ 0, /* end= */ fullText.length) + val textToWidthRatio = textWidth / availableWidth + return when { + textToWidthRatio > ICON_ONLY_WIDTH_RATIO_THRESHOLD -> + // "$icon" + iconOnlyText + + textToWidthRatio > ICON_SHORT_TEXT_WIDTH_RATIO_THRESHOLD -> + // "$icon $formattedDuration" + Utilities.prefixTextWithIcon( + context, + R.drawable.ic_hourglass_top, + formattedDuration, + ) + + else -> fullText + } + } + + companion object { + // If the full text ("$icon $formattedDuration left today") takes a lot more space than is + // available, it is likely short text won't fit either. So, we fallback to just an icon. + private const val ICON_ONLY_WIDTH_RATIO_THRESHOLD = 1.2f + + // If the full text fits but leaves very little space, use short text instead for + // comfortable viewing. + private const val ICON_SHORT_TEXT_WIDTH_RATIO_THRESHOLD = 0.8f + } +} diff --git a/quickstep/src/com/android/quickstep/task/thumbnail/TaskContentView.kt b/quickstep/src/com/android/quickstep/task/thumbnail/TaskContentView.kt index a010f81c1f..425448e89d 100644 --- a/quickstep/src/com/android/quickstep/task/thumbnail/TaskContentView.kt +++ b/quickstep/src/com/android/quickstep/task/thumbnail/TaskContentView.kt @@ -17,28 +17,50 @@ package com.android.quickstep.task.thumbnail import android.content.Context +import android.content.Intent import android.graphics.Outline import android.graphics.Path import android.graphics.Rect +import android.provider.Settings import android.util.AttributeSet import android.view.View import android.view.ViewOutlineProvider import android.view.ViewStub -import android.widget.LinearLayout +import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction +import android.widget.TextView +import androidx.annotation.IdRes +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.constraintlayout.widget.ConstraintSet import androidx.core.view.isInvisible +import androidx.core.view.isVisible +import com.android.launcher3.Flags.enableRefactorDigitalWellbeingToast import com.android.launcher3.R import com.android.launcher3.util.ViewPool +import com.android.quickstep.task.apptimer.TaskAppTimerUiState +import com.android.quickstep.task.apptimer.TimerTextHelper +import com.android.quickstep.util.setActivityStarterClickListener import com.android.quickstep.views.TaskHeaderView /** - * TaskContentView is a wrapper around the TaskHeaderView and TaskThumbnailView. It is a sibling to - * DWB, AiAi (TaskOverlay). + * TaskContentView is a wrapper around the TaskHeaderView, TaskThumbnailView and Digital wellbeing + * app timer toast. It is a sibling to AiAi (TaskOverlay). + * + * When enableRefactorDigitalWellbeingToast is off, it is sibling to digital wellbeing toast unlike + * when the flag is on. */ class TaskContentView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : - LinearLayout(context, attrs), ViewPool.Reusable { + ConstraintLayout(context, attrs), ViewPool.Reusable { private var taskHeaderView: TaskHeaderView? = null private var taskThumbnailView: TaskThumbnailView? = null + private var taskAppTimerToast: TextView? = null + + private var timerTextHelper: TimerTextHelper? = null + private var timerUiState: TaskAppTimerUiState = TaskAppTimerUiState.Uninitialized + private var timerUsageAccessibilityAction: AccessibilityAction? = null + private val timerToastHeight = + context.resources.getDimensionPixelSize(R.dimen.digital_wellbeing_toast_height) + private var onSizeChanged: ((width: Int, height: Int) -> Unit)? = null private val outlinePath = Path() @@ -104,6 +126,10 @@ class TaskContentView @JvmOverloads constructor(context: Context, attrs: Attribu outlineBounds = null alpha = 1.0f taskThumbnailView?.onRecycle() + taskAppTimerToast?.isInvisible = true + timerUiState = TaskAppTimerUiState.Uninitialized + timerTextHelper = null + timerUsageAccessibilityAction = null } fun doOnSizeChange(action: (width: Int, height: Int) -> Unit) { @@ -114,15 +140,45 @@ class TaskContentView @JvmOverloads constructor(context: Context, attrs: Attribu super.onSizeChanged(w, h, oldw, oldh) onSizeChanged?.invoke(width, height) bounds.set(0, 0, w, h) + updateTimerText(w) invalidateOutline() } + fun onParentAnimationProgress(progress: Float) { + taskAppTimerToast?.apply { translationY = timerToastHeight * (1f - progress) } + } + + /** Returns accessibility actions supported by items in the task content view. */ + fun getSupportedAccessibilityActions(): List { + return listOfNotNull(timerUsageAccessibilityAction) + } + + fun handleAccessibilityAction(action: Int): Boolean { + timerUsageAccessibilityAction?.let { + if (action == it.id) { + return taskAppTimerToast?.callOnClick() ?: false + } + } + + return false + } + private fun createHeaderView(taskHeaderState: TaskHeaderUiState) { if (taskHeaderView == null && taskHeaderState is TaskHeaderUiState.ShowHeader) { taskHeaderView = findViewById(R.id.task_header_view) .apply { layoutResource = R.layout.task_header_view } .inflate() as TaskHeaderView + + // TODO: Move to layout xml when moving away from view stubs. + val constraintSet = ConstraintSet().apply { clone(this@TaskContentView) } + constraintSet.connect( + R.id.snapshot, + ConstraintSet.TOP, + R.id.task_header_view, + ConstraintSet.BOTTOM, + ) + constraintSet.applyTo(this@TaskContentView) } } @@ -135,13 +191,106 @@ class TaskContentView @JvmOverloads constructor(context: Context, attrs: Attribu } } + private fun createAppTimerToastView(taskAppTimerUiState: TaskAppTimerUiState) { + if ( + enableRefactorDigitalWellbeingToast() && + taskAppTimerToast == null && + taskAppTimerUiState is TaskAppTimerUiState.Timer + ) { + taskAppTimerToast = + findViewById(R.id.task_app_timer_toast) + .apply { layoutResource = R.layout.task_app_timer_toast } + .inflate() as TextView + } + } + fun setState( taskHeaderState: TaskHeaderUiState, taskThumbnailUiState: TaskThumbnailUiState, + taskAppTimerUiState: TaskAppTimerUiState, taskId: Int?, ) { createHeaderView(taskHeaderState) taskHeaderView?.setState(taskHeaderState) taskThumbnailView?.setState(taskThumbnailUiState, taskId) + createAppTimerToastView(taskAppTimerUiState) + if (enableRefactorDigitalWellbeingToast() && timerUiState != taskAppTimerUiState) { + setAppTimerToastState(taskAppTimerUiState) + updateContentDescriptionWithTimer(taskAppTimerUiState) + } + } + + private fun updateContentDescriptionWithTimer(state: TaskAppTimerUiState) { + taskThumbnailView?.contentDescription = + when (state) { + is TaskAppTimerUiState.Uninitialized -> return + is TaskAppTimerUiState.NoTimer -> state.taskDescription + is TaskAppTimerUiState.Timer -> + timerTextHelper?.let { + context.getString( + R.string.task_contents_description_with_remaining_time, + state.taskDescription, + context.getString(R.string.time_left_for_app, it.formattedDuration), + ) + } + } + } + + private fun setAppTimerToastState(state: TaskAppTimerUiState) { + timerUiState = state + + taskAppTimerToast?.apply { + when (state) { + is TaskAppTimerUiState.Uninitialized -> isInvisible = true + is TaskAppTimerUiState.NoTimer -> isInvisible = true + is TaskAppTimerUiState.Timer -> { + timerTextHelper = TimerTextHelper(context, state.timeRemaining) + isInvisible = false + updateTimerText(width) + + // TODO: add WW logging on the app usage settings click. + setActivityStarterClickListener( + appUsageSettingsIntent(state.taskPackageName), + "app usage settings for task ${state.taskDescription}", + ) + + timerUsageAccessibilityAction = + appUsageSettingsAccessibilityAction( + context, + state.accessibilityActionId, + state.taskDescription, + ) + } + } + } + } + + private fun updateTimerText(width: Int) { + taskAppTimerToast?.apply { + val helper = timerTextHelper + + if (isVisible && helper != null) { + text = helper.getTextThatFits(width, paint) + } + } + } + + companion object { + const val TAG = "TaskContentView" + + private fun appUsageSettingsIntent(packageName: String) = + Intent(Intent(Settings.ACTION_APP_USAGE_SETTINGS)) + .putExtra(Intent.EXTRA_PACKAGE_NAME, packageName) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) + + private fun appUsageSettingsAccessibilityAction( + context: Context, + @IdRes actionId: Int, + taskDescription: String?, + ) = + AccessibilityAction( + actionId, + context.getString(R.string.split_app_usage_settings, taskDescription), + ) } } diff --git a/quickstep/src/com/android/quickstep/util/ClickListeners.kt b/quickstep/src/com/android/quickstep/util/ClickListeners.kt new file mode 100644 index 0000000000..e827e9fe9b --- /dev/null +++ b/quickstep/src/com/android/quickstep/util/ClickListeners.kt @@ -0,0 +1,44 @@ +/* + * 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.util + +import android.app.ActivityOptions +import android.content.ActivityNotFoundException +import android.content.Intent +import android.util.Log +import android.view.View +import com.android.quickstep.task.thumbnail.TaskContentView + +/** + * Sets a click listener on the view that launches an activity using the provided [intent] + * + * Performs scale up animation. + * + * @property intent the intent to use for launching the activity + * @property targetDescription a short text describing the target activity beings opened that can be + * used for logging (e.g. usage settings for task A). + */ +fun View.setActivityStarterClickListener(intent: Intent, targetDescription: String) { + setOnClickListener { view -> + try { + val options = ActivityOptions.makeScaleUpAnimation(view, 0, 0, view.width, view.height) + context.startActivity(intent, options.toBundle()) + } catch (e: ActivityNotFoundException) { + Log.e(TaskContentView.TAG, "Failed to open $targetDescription ", e) + } + } +} 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/src/com/android/quickstep/views/GroupedTaskView.kt b/quickstep/src/com/android/quickstep/views/GroupedTaskView.kt index d23c4b726d..65810b6f9c 100644 --- a/quickstep/src/com/android/quickstep/views/GroupedTaskView.kt +++ b/quickstep/src/com/android/quickstep/views/GroupedTaskView.kt @@ -23,13 +23,13 @@ import android.util.Log import android.view.View import android.view.ViewStub import com.android.internal.jank.Cuj +import com.android.launcher3.Flags.enableRefactorDigitalWellbeingToast import com.android.launcher3.Flags.enableRefactorTaskContentView import com.android.launcher3.Flags.enableRefactorTaskThumbnail import com.android.launcher3.R import com.android.launcher3.Utilities import com.android.launcher3.util.OverviewReleaseFlags.enableOverviewIconMenu import com.android.launcher3.util.RunnableList -import com.android.launcher3.util.SplitConfigurationOptions import com.android.launcher3.util.SplitConfigurationOptions.STAGE_POSITION_BOTTOM_OR_RIGHT import com.android.launcher3.util.SplitConfigurationOptions.STAGE_POSITION_TOP_OR_LEFT import com.android.launcher3.util.SplitConfigurationOptions.STAGE_POSITION_UNDEFINED @@ -117,9 +117,11 @@ class GroupedTaskView @JvmOverloads constructor(context: Context, attrs: Attribu else R.layout.icon_view } ?.inflate() - findViewById(R.id.bottomRight_digital_wellbeing_toast) - ?.apply { layoutResource = R.layout.digital_wellbeing_toast } - ?.inflate() + if (!enableRefactorDigitalWellbeingToast()) { + findViewById(R.id.bottomRight_digital_wellbeing_toast) + ?.apply { layoutResource = R.layout.digital_wellbeing_toast } + ?.inflate() + } } override fun onRecycle() { diff --git a/quickstep/src/com/android/quickstep/views/TaskContainer.kt b/quickstep/src/com/android/quickstep/views/TaskContainer.kt index b187df1e99..1fa3f61821 100644 --- a/quickstep/src/com/android/quickstep/views/TaskContainer.kt +++ b/quickstep/src/com/android/quickstep/views/TaskContainer.kt @@ -181,12 +181,18 @@ class TaskContainer( overlay.addChildForAccessibility(outChildren) } - fun setState(state: TaskData?, hasHeader: Boolean, clickCloseListener: OnClickListener?) = + fun setState( + state: TaskData?, + hasHeader: Boolean, + canShowAppTimer: Boolean, + clickCloseListener: OnClickListener?, + ) = traceSection("TaskContainer.setState") { if (enableRefactorTaskContentView()) { (taskContentView as TaskContentView).setState( TaskUiStateMapper.toTaskHeaderState(state, hasHeader, clickCloseListener), TaskUiStateMapper.toTaskThumbnailUiState(state), + TaskUiStateMapper.toTaskAppTimerUiState(canShowAppTimer, stagePosition, state), state?.taskId, ) } else { diff --git a/quickstep/src/com/android/quickstep/views/TaskView.kt b/quickstep/src/com/android/quickstep/views/TaskView.kt index df93fc488e..4583a63df2 100644 --- a/quickstep/src/com/android/quickstep/views/TaskView.kt +++ b/quickstep/src/com/android/quickstep/views/TaskView.kt @@ -33,6 +33,7 @@ import android.util.Log import android.view.Display import android.view.MotionEvent import android.view.View +import android.view.View.OnClickListener import android.view.ViewGroup import android.view.ViewStub import android.view.accessibility.AccessibilityNodeInfo @@ -47,6 +48,7 @@ import com.android.launcher3.AbstractFloatingView import com.android.launcher3.Flags.enableCursorHoverStates import com.android.launcher3.Flags.enableDesktopExplodedView import com.android.launcher3.Flags.enableLargeDesktopWindowingTile +import com.android.launcher3.Flags.enableRefactorDigitalWellbeingToast import com.android.launcher3.Flags.enableRefactorTaskContentView import com.android.launcher3.Flags.enableRefactorTaskThumbnail import com.android.launcher3.R @@ -735,7 +737,14 @@ constructor( // Add DWB accessibility action at the end of the list taskContainers.forEach { - it.digitalWellBeingToast?.getDWBAccessibilityAction()?.let(::addAction) + if ( + enableRefactorDigitalWellbeingToast() && + it.taskContentView is TaskContentView + ) { + it.taskContentView.getSupportedAccessibilityActions().forEach(::addAction) + } else { + it.digitalWellBeingToast?.getDWBAccessibilityAction()?.let(::addAction) + } } } @@ -755,8 +764,14 @@ constructor( override fun performAccessibilityAction(action: Int, arguments: Bundle?): Boolean { // TODO(b/343708271): Add support for multiple tasks per action. taskContainers.forEach { - if (it.digitalWellBeingToast?.handleAccessibilityAction(action) == true) { - return true + if (enableRefactorDigitalWellbeingToast() && it.taskContentView is TaskContentView) { + if (it.taskContentView.handleAccessibilityAction(action)) { + return true + } + } else { + if (it.digitalWellBeingToast?.handleAccessibilityAction(action) == true) { + return true + } } TaskOverlayFactory.getEnabledShortcuts(this, it).forEach { shortcut -> @@ -797,9 +812,11 @@ constructor( } ?.inflate() - findViewById(R.id.digital_wellbeing_toast) - ?.apply { layoutResource = R.layout.digital_wellbeing_toast } - ?.inflate() + if (!enableRefactorDigitalWellbeingToast()) { + findViewById(R.id.digital_wellbeing_toast) + ?.apply { layoutResource = R.layout.digital_wellbeing_toast } + ?.inflate() + } } override fun onAttachedToWindow() = @@ -826,9 +843,12 @@ constructor( val taskId = container.task.key.id val containerState = mapOfTasks[taskId] val shouldHaveHeader = (type == TaskViewType.DESKTOP) && enableDesktopExplodedView() + val shouldShowAppTimer = + (type == TaskViewType.SINGLE || type == TaskViewType.GROUPED) container.setState( state = containerState, hasHeader = shouldHaveHeader, + canShowAppTimer = shouldShowAppTimer, clickCloseListener = if (shouldHaveHeader) { { @@ -1024,6 +1044,13 @@ constructor( val snapshotView = if (enableRefactorTaskContentView()) taskContentView.findViewById(thumbnailViewId) else taskContentView + + val digitalWellBeingToast: DigitalWellBeingToast? = + if (enableRefactorDigitalWellbeingToast()) { + null + } else { + findViewById(digitalWellbeingBannerId)!! + } return TaskContainer( this, task, @@ -1032,7 +1059,7 @@ constructor( iconView, TransformingTouchDelegate(iconView.asView()), stagePosition, - findViewById(digitalWellbeingBannerId)!!, + digitalWellBeingToast, findViewById(showWindowViewId)!!, taskOverlayFactory, ) @@ -1718,7 +1745,11 @@ constructor( private fun onSettledProgressUpdated(settledProgress: Float) { taskContainers.forEach { it.iconView.setContentAlpha(settledProgress) - it.digitalWellBeingToast?.bannerOffsetPercentage = 1f - settledProgress + if (enableRefactorDigitalWellbeingToast() && it.taskContentView is TaskContentView) { + it.taskContentView.onParentAnimationProgress(settledProgress) + } else { + it.digitalWellBeingToast?.bannerOffsetPercentage = 1f - settledProgress + } } } @@ -1864,7 +1895,11 @@ constructor( isClickable = modalness == 0f taskContainers.forEach { it.iconView.setModalAlpha(1f - modalness) - it.digitalWellBeingToast?.bannerOffsetPercentage = modalness + if (enableRefactorDigitalWellbeingToast() && it.taskContentView is TaskContentView) { + it.taskContentView.onParentAnimationProgress(1f - modalness) + } else { + it.digitalWellBeingToast?.bannerOffsetPercentage = modalness + } } if (enableGridOnlyOverview()) { modalAlpha = if (isSelectedTask) 1f else (1f - modalness) diff --git a/quickstep/tests/multivalentScreenshotTests/src/com/android/quickstep/task/apptimer/TaskAppTimerScreenshotTest.kt b/quickstep/tests/multivalentScreenshotTests/src/com/android/quickstep/task/apptimer/TaskAppTimerScreenshotTest.kt new file mode 100644 index 0000000000..bef8709769 --- /dev/null +++ b/quickstep/tests/multivalentScreenshotTests/src/com/android/quickstep/task/apptimer/TaskAppTimerScreenshotTest.kt @@ -0,0 +1,168 @@ +/* + * 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.graphics.Color +import android.platform.test.flag.junit.SetFlagsRule +import android.view.Gravity +import android.view.LayoutInflater +import android.widget.FrameLayout +import com.android.launcher3.Flags +import com.android.launcher3.R +import com.android.launcher3.util.Themes +import com.android.launcher3.util.rule.setFlags +import com.android.quickstep.task.thumbnail.TaskContentView +import com.android.quickstep.task.thumbnail.TaskHeaderUiState +import com.android.quickstep.task.thumbnail.TaskThumbnailUiState.BackgroundOnly +import com.google.android.apps.nexuslauncher.imagecomparison.goldenpathmanager.ViewScreenshotGoldenPathManager +import java.time.Duration +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import platform.test.runner.parameterized.ParameterizedAndroidJunit4 +import platform.test.runner.parameterized.Parameters +import platform.test.screenshot.DeviceEmulationSpec +import platform.test.screenshot.Displays +import platform.test.screenshot.ViewScreenshotTestRule +import platform.test.screenshot.getEmulatedDevicePathConfig + +/** Screenshot tests for the digital wellbeing timer shown in the task content view */ +@RunWith(ParameterizedAndroidJunit4::class) +class TaskAppTimerScreenshotTest(emulationSpec: DeviceEmulationSpec) { + @get:Rule(order = 0) val setFlagsRule = SetFlagsRule() + + @get:Rule(order = 1) + val screenshotRule = + ViewScreenshotTestRule( + emulationSpec, + ViewScreenshotGoldenPathManager(getEmulatedDevicePathConfig(emulationSpec)), + ) + + @Before + fun setUp() { + setFlagsRule.setFlags( + true, + Flags.FLAG_ENABLE_REFACTOR_TASK_THUMBNAIL, + Flags.FLAG_ENABLE_REFACTOR_TASK_CONTENT_VIEW, + Flags.FLAG_ENABLE_REFACTOR_DIGITAL_WELLBEING_TOAST, + ) + } + + @Test + fun taskAppTimer_iconAndFullText() { + screenshotRule.screenshotTest("taskAppTimer_iconAndFullText") { activity -> + activity.actionBar?.hide() + val container = createContainer(activity) + val taskContentView = createTaskContentView(activity) + container.addView(taskContentView, CONTAINER_WIDTH_WIDE, CONTAINER_HEIGHT) + + taskContentView.setState( + taskHeaderState = TaskHeaderUiState.HideHeader, + taskThumbnailUiState = BackgroundOnly(Color.YELLOW), + taskAppTimerUiState = TIMER_UI_STATE, + taskId = null, + ) + + container + } + } + + @Test + fun taskAppTimer_iconAndShortText() { + screenshotRule.screenshotTest("taskAppTimer_iconAndShortText") { activity -> + activity.actionBar?.hide() + val container = createContainer(activity) + val taskContentView = createTaskContentView(activity) + container.addView(taskContentView, CONTAINER_WIDTH_MEDIUM, CONTAINER_HEIGHT) + + taskContentView.setState( + taskHeaderState = TaskHeaderUiState.HideHeader, + taskThumbnailUiState = BackgroundOnly(Color.YELLOW), + taskAppTimerUiState = TIMER_UI_STATE, + taskId = null, + ) + + container + } + } + + @Test + fun taskAppTimer_iconOnly() { + screenshotRule.screenshotTest("taskAppTimer_iconOnly") { activity -> + activity.actionBar?.hide() + val container = createContainer(activity) + val taskContentView = createTaskContentView(activity) + container.addView(taskContentView, CONTAINER_WIDTH_NARROW, CONTAINER_HEIGHT) + + taskContentView.setState( + taskHeaderState = TaskHeaderUiState.HideHeader, + taskThumbnailUiState = BackgroundOnly(Color.YELLOW), + taskAppTimerUiState = TIMER_UI_STATE, + taskId = null, + ) + + container + } + } + + private fun createTaskContentView(context: Context): TaskContentView { + val taskContentView = + LayoutInflater.from(context).inflate(R.layout.task_content_view, null, false) + as TaskContentView + taskContentView.cornerRadius = Themes.getDialogCornerRadius(context) + return taskContentView + } + + private fun createContainer(context: Context): FrameLayout { + val container = FrameLayout(context) + val lp = + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.WRAP_CONTENT, + FrameLayout.LayoutParams.WRAP_CONTENT, + Gravity.CENTER, + ) + container.layoutParams = lp + + return container + } + + companion object { + private const val CONTAINER_HEIGHT = 700 + private const val CONTAINER_WIDTH_WIDE = 800 + private const val CONTAINER_WIDTH_MEDIUM = 400 + private const val CONTAINER_WIDTH_NARROW = 150 + + private val TIMER_UI_STATE = + TaskAppTimerUiState.Timer( + timeRemaining = Duration.ofHours(23).plusMinutes(2), + taskDescription = "test", + taskPackageName = "com.test", + accessibilityActionId = R.id.action_digital_wellbeing_top_left, + ) + + @Parameters(name = "{0}") + @JvmStatic + fun getTestSpecs() = + DeviceEmulationSpec.forDisplays( + Displays.Phone, + isDarkTheme = false, + isLandscape = false, + ) + } +} diff --git a/quickstep/tests/multivalentScreenshotTests/src/com/android/quickstep/task/thumbnail/TaskContentViewScreenshotTest.kt b/quickstep/tests/multivalentScreenshotTests/src/com/android/quickstep/task/thumbnail/TaskContentViewScreenshotTest.kt index 5e546f7f1a..41d793f8f5 100644 --- a/quickstep/tests/multivalentScreenshotTests/src/com/android/quickstep/task/thumbnail/TaskContentViewScreenshotTest.kt +++ b/quickstep/tests/multivalentScreenshotTests/src/com/android/quickstep/task/thumbnail/TaskContentViewScreenshotTest.kt @@ -23,9 +23,11 @@ import android.view.LayoutInflater import com.android.launcher3.Flags import com.android.launcher3.R import com.android.launcher3.util.rule.setFlags +import com.android.quickstep.task.apptimer.TaskAppTimerUiState import com.android.quickstep.task.thumbnail.SplashHelper.createSplash import com.android.quickstep.task.thumbnail.TaskThumbnailUiState.BackgroundOnly import com.google.android.apps.nexuslauncher.imagecomparison.goldenpathmanager.ViewScreenshotGoldenPathManager +import java.time.Duration import org.junit.Before import org.junit.Rule import org.junit.Test @@ -56,17 +58,22 @@ class TaskContentViewScreenshotTest(emulationSpec: DeviceEmulationSpec) { true, Flags.FLAG_ENABLE_REFACTOR_TASK_THUMBNAIL, Flags.FLAG_ENABLE_REFACTOR_TASK_CONTENT_VIEW, + Flags.FLAG_ENABLE_REFACTOR_DIGITAL_WELLBEING_TOAST, ) } @Test fun taskContentView_recyclesToUninitialized() { - screenshotRule.screenshotTest("taskContentView_uninitialized") { activity -> + screenshotRule.screenshotTest( + "taskContentView_uninitialized", + ViewScreenshotTestRule.Mode.MatchSize, + ) { activity -> activity.actionBar?.hide() val taskContentView = createTaskContentView(activity) taskContentView.setState( TaskHeaderUiState.HideHeader, BackgroundOnly(Color.YELLOW), + TIMER_UI_STATE, null, ) taskContentView.onRecycle() @@ -76,7 +83,10 @@ class TaskContentViewScreenshotTest(emulationSpec: DeviceEmulationSpec) { @Test fun taskContentView_shows_thumbnail_and_header() { - screenshotRule.screenshotTest("taskContentView_shows_thumbnail_and_header") { activity -> + screenshotRule.screenshotTest( + "taskContentView_shows_thumbnail_and_header", + ViewScreenshotTestRule.Mode.MatchSize, + ) { activity -> activity.actionBar?.hide() createTaskContentView(activity).apply { setState( @@ -87,6 +97,7 @@ class TaskContentViewScreenshotTest(emulationSpec: DeviceEmulationSpec) { ) {} ), BackgroundOnly(Color.YELLOW), + NO_TIMER_UI_STATE, null, ) } @@ -95,12 +106,20 @@ class TaskContentViewScreenshotTest(emulationSpec: DeviceEmulationSpec) { @Test fun taskContentView_scaled_roundRoundedCorners() { - screenshotRule.screenshotTest("taskContentView_scaledRoundedCorners") { activity -> + screenshotRule.screenshotTest( + "taskContentView_scaledRoundedCorners", + ViewScreenshotTestRule.Mode.MatchSize, + ) { activity -> activity.actionBar?.hide() createTaskContentView(activity).apply { scaleX = 0.75f scaleY = 0.3f - setState(TaskHeaderUiState.HideHeader, BackgroundOnly(Color.YELLOW), null) + setState( + TaskHeaderUiState.HideHeader, + BackgroundOnly(Color.YELLOW), + NO_TIMER_UI_STATE, + null, + ) } } } @@ -124,5 +143,14 @@ class TaskContentViewScreenshotTest(emulationSpec: DeviceEmulationSpec) { ) const val CORNER_RADIUS = 56f + + private val TIMER_UI_STATE = + TaskAppTimerUiState.Timer( + timeRemaining = Duration.ofHours(2).plusMinutes(20L), + taskDescription = "test", + taskPackageName = "com.test", + accessibilityActionId = R.id.action_digital_wellbeing_top_left, + ) + private val NO_TIMER_UI_STATE = TaskAppTimerUiState.NoTimer(taskDescription = "test") } } diff --git a/quickstep/tests/multivalentTests/src/com/android/quickstep/recents/ui/mapper/TaskUiStateMapperTest.kt b/quickstep/tests/multivalentTests/src/com/android/quickstep/recents/ui/mapper/TaskUiStateMapperTest.kt index e55d8e53f2..db78449b2f 100644 --- a/quickstep/tests/multivalentTests/src/com/android/quickstep/recents/ui/mapper/TaskUiStateMapperTest.kt +++ b/quickstep/tests/multivalentTests/src/com/android/quickstep/recents/ui/mapper/TaskUiStateMapperTest.kt @@ -26,7 +26,11 @@ import android.view.Surface import android.view.View import androidx.test.ext.junit.runners.AndroidJUnit4 import com.android.launcher3.Flags +import com.android.launcher3.R +import com.android.launcher3.util.SplitConfigurationOptions.STAGE_POSITION_BOTTOM_OR_RIGHT +import com.android.launcher3.util.SplitConfigurationOptions.STAGE_POSITION_TOP_OR_LEFT import com.android.quickstep.recents.ui.viewmodel.TaskData +import com.android.quickstep.task.apptimer.TaskAppTimerUiState import com.android.quickstep.task.thumbnail.TaskHeaderUiState import com.android.quickstep.task.thumbnail.TaskThumbnailUiState import com.android.quickstep.task.thumbnail.TaskThumbnailUiState.LiveTile @@ -189,6 +193,96 @@ class TaskUiStateMapperTest { assertThat(result).isEqualTo(expected) } + @Test + fun toTaskAppTimer_nullTaskData_returnsUninitialized() { + val result = + TaskUiStateMapper.toTaskAppTimerUiState( + canShowAppTimer = true, + stagePosition = STAGE_POSITION_DEFAULT, + taskData = null, + ) + + val expected = TaskAppTimerUiState.Uninitialized + assertThat(result).isEqualTo(expected) + } + + @Test + fun toTaskAppTimer_noTaskData_returnsUninitialized() { + val result = + TaskUiStateMapper.toTaskAppTimerUiState( + canShowAppTimer = true, + stagePosition = STAGE_POSITION_DEFAULT, + taskData = TaskData.NoData(TASK_ID), + ) + + val expected = TaskAppTimerUiState.Uninitialized + assertThat(result).isEqualTo(expected) + } + + @Test + fun toTaskAppTimer_canShowAppTimerFalse_returnsNoTimer() { + val result = + TaskUiStateMapper.toTaskAppTimerUiState( + canShowAppTimer = false, + stagePosition = STAGE_POSITION_DEFAULT, + taskData = TASK_DATA, + ) + + val expected = TaskAppTimerUiState.NoTimer(taskDescription = TASK_TITLE_DESCRIPTION) + assertThat(result).isEqualTo(expected) + } + + @Test + fun toTaskAppTimer_timerNullAndCanShow_returnsNoTimer() { + val result = + TaskUiStateMapper.toTaskAppTimerUiState( + canShowAppTimer = false, + stagePosition = STAGE_POSITION_DEFAULT, + taskData = TASK_DATA.copy(remainingAppTimerDuration = null), + ) + + val expected = TaskAppTimerUiState.NoTimer(taskDescription = TASK_TITLE_DESCRIPTION) + assertThat(result).isEqualTo(expected) + } + + @Test + fun toTaskAppTimer_timerPresentAndCanShow_returnsTimer() { + val result = + TaskUiStateMapper.toTaskAppTimerUiState( + canShowAppTimer = true, + stagePosition = STAGE_POSITION_DEFAULT, + taskData = TASK_DATA.copy(remainingAppTimerDuration = TASK_APP_TIMER_DURATION), + ) + + val expected = + TaskAppTimerUiState.Timer( + timeRemaining = TASK_APP_TIMER_DURATION, + taskDescription = TASK_DATA.titleDescription, + taskPackageName = TASK_DATA.packageName, + accessibilityActionId = R.id.action_digital_wellbeing_top_left, + ) + assertThat(result).isEqualTo(expected) + } + + @Test + fun toTaskAppTimer_stagePositionBottomOrRight_returnsTimerWithCorrectActionId() { + val result = + TaskUiStateMapper.toTaskAppTimerUiState( + canShowAppTimer = true, + stagePosition = STAGE_POSITION_BOTTOM_OR_RIGHT, + taskData = TASK_DATA.copy(remainingAppTimerDuration = TASK_APP_TIMER_DURATION), + ) + + val expected = + TaskAppTimerUiState.Timer( + timeRemaining = TASK_APP_TIMER_DURATION, + taskDescription = TASK_DATA.titleDescription, + taskPackageName = TASK_DATA.packageName, + accessibilityActionId = R.id.action_digital_wellbeing_bottom_right, + ) + assertThat(result).isEqualTo(expected) + } + private companion object { const val TASK_TITLE_DESCRIPTION = "Title Description 1" var TASK_ID = 1 @@ -198,6 +292,8 @@ class TaskUiStateMapperTest { val TASK_THUMBNAIL_DATA = ThumbnailData(thumbnail = TASK_THUMBNAIL, rotation = Surface.ROTATION_0) val TASK_BACKGROUND_COLOR = Color.rgb(1, 2, 3) + val TASK_APP_TIMER_DURATION: Duration = Duration.ofMillis(30) + val STAGE_POSITION_DEFAULT = STAGE_POSITION_TOP_OR_LEFT val TASK_DATA = TaskData.Data( TASK_ID, @@ -209,7 +305,7 @@ class TaskUiStateMapperTest { backgroundColor = TASK_BACKGROUND_COLOR, isLocked = false, isLiveTile = false, - remainingAppTimerDuration = Duration.ofMillis(30), + remainingAppTimerDuration = TASK_APP_TIMER_DURATION, ) } } 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) + } +}