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)
+ }
+}