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:
committed by
Android (Google) Code Review
commit
d83ecd4c44
@@ -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"
|
||||
/>
|
||||
@@ -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)
|
||||
|
||||
+168
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+32
-4
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
+97
-1
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+87
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user