Merge changes from topic "dwb-refactor-domain" into main

* changes:
  Move app timer in the taskContentView behind a refactor flag.
  Change task content view to be a constraint layout.
  Extract out timer text specific utility from the dwb view
  Add a ui state for the app timer toast and add a mapper function for it
This commit is contained in:
Shamali Patwa
2025-04-23 19:49:38 -07:00
committed by Android (Google) Code Review
16 changed files with 917 additions and 63 deletions
@@ -0,0 +1,32 @@
<!--
~ 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.
-->
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/task_app_timer_toast"
style="@style/TextTitle"
android:layout_width="match_parent"
android:layout_height="@dimen/digital_wellbeing_toast_height"
android:background="@drawable/bg_wellbeing_toast"
android:forceHasOverlappingRendering="false"
android:gravity="center"
android:layout_gravity="bottom"
android:importantForAccessibility="no"
android:textColor="@color/materialColorOnSecondaryFixed"
android:textSize="14sp"
android:autoSizeTextType="uniform"
android:autoSizeMaxTextSize="14sp"
android:clipToOutline="true"
android:visibility="gone"
/>
+25 -3
View File
@@ -14,21 +14,43 @@
~ limitations under the License.
-->
<com.android.quickstep.task.thumbnail.TaskContentView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/task_content_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:importantForAccessibility="no">
<ViewStub
android:id="@+id/task_header_view"
android:inflatedId="@+id/task_header_view"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
android:layout_height="wrap_content"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
/>
<ViewStub
android:id="@+id/snapshot"
android:inflatedId="@+id/snapshot"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
/>
<ViewStub
android:id="@+id/task_app_timer_toast"
android:inflatedId="@+id/task_app_timer_toast"
android:importantForAccessibility="noHideDescendants"
android:contentDescription="@null"
android:layout_width="match_parent"
android:layout_height="@dimen/digital_wellbeing_toast_height"
android:layout_gravity="bottom"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintBottom_toBottomOf="@id/snapshot"
/>
</com.android.quickstep.task.thumbnail.TaskContentView>
@@ -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
},
)
}
}
@@ -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))
}
}
}
@@ -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)
}
@@ -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
}
}
@@ -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<AccessibilityAction> {
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<ViewStub>(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<ViewStub>(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),
)
}
}
@@ -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)
}
}
}
@@ -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 */
)
@@ -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<ViewStub>(R.id.bottomRight_digital_wellbeing_toast)
?.apply { layoutResource = R.layout.digital_wellbeing_toast }
?.inflate()
if (!enableRefactorDigitalWellbeingToast()) {
findViewById<ViewStub>(R.id.bottomRight_digital_wellbeing_toast)
?.apply { layoutResource = R.layout.digital_wellbeing_toast }
?.inflate()
}
}
override fun onRecycle() {
@@ -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 {
@@ -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<ViewStub>(R.id.digital_wellbeing_toast)
?.apply { layoutResource = R.layout.digital_wellbeing_toast }
?.inflate()
if (!enableRefactorDigitalWellbeingToast()) {
findViewById<ViewStub>(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)
@@ -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,
)
}
}
@@ -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")
}
}
@@ -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,
)
}
}
@@ -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)
}
}