• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (C) 2018 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 package com.android.quickstep.views
17 
18 import android.annotation.SuppressLint
19 import android.app.ActivityOptions
20 import android.content.ActivityNotFoundException
21 import android.content.Context
22 import android.content.Intent
23 import android.content.pm.LauncherApps
24 import android.content.pm.LauncherApps.AppUsageLimit
25 import android.graphics.Outline
26 import android.graphics.Paint
27 import android.icu.text.MeasureFormat
28 import android.icu.util.Measure
29 import android.icu.util.MeasureUnit
30 import android.os.UserHandle
31 import android.provider.Settings
32 import android.util.AttributeSet
33 import android.util.Log
34 import android.view.View
35 import android.view.ViewOutlineProvider
36 import android.view.accessibility.AccessibilityNodeInfo
37 import android.widget.TextView
38 import androidx.annotation.StringRes
39 import androidx.annotation.VisibleForTesting
40 import androidx.core.util.component1
41 import androidx.core.util.component2
42 import androidx.core.view.isVisible
43 import com.android.launcher3.R
44 import com.android.launcher3.Utilities
45 import com.android.launcher3.util.Executors
46 import com.android.launcher3.util.SplitConfigurationOptions
47 import com.android.launcher3.util.SplitConfigurationOptions.STAGE_POSITION_TOP_OR_LEFT
48 import com.android.launcher3.util.SplitConfigurationOptions.STAGE_POSITION_UNDEFINED
49 import com.android.launcher3.util.SplitConfigurationOptions.StagePosition
50 import com.android.quickstep.TaskUtils
51 import com.android.systemui.shared.recents.model.Task
52 import java.time.Duration
53 import java.util.Locale
54 
55 @SuppressLint("AppCompatCustomView")
56 class DigitalWellBeingToast
57 @JvmOverloads
58 constructor(
59     context: Context,
60     attrs: AttributeSet? = null,
61     defStyleAttr: Int = 0,
62     defStyleRes: Int = 0,
63 ) : TextView(context, attrs, defStyleAttr, defStyleRes) {
64     private val recentsViewContainer: RecentsViewContainer =
65         RecentsViewContainer.containerFromContext(context)
66 
67     private val launcherApps: LauncherApps? = context.getSystemService(LauncherApps::class.java)
68 
69     private val bannerHeight =
70         context.resources.getDimensionPixelSize(R.dimen.digital_wellbeing_toast_height)
71 
72     private lateinit var task: Task
73     private lateinit var taskView: TaskView
74     private lateinit var snapshotView: View
75     @StagePosition private var stagePosition = STAGE_POSITION_UNDEFINED
76 
77     private var appRemainingTimeMs: Long = 0
78     private var splitOffsetTranslationY = 0f
79         set(value) {
80             if (field != value) {
81                 field = value
82                 updateTranslationY()
83             }
84         }
85 
86     private var isDestroyed = false
87 
88     var hasLimit = false
89     var splitBounds: SplitConfigurationOptions.SplitBounds? = null
90     var bannerOffsetPercentage = 0f
91         set(value) {
92             if (field != value) {
93                 field = value
94                 updateTranslationY()
95             }
96         }
97 
98     init {
99         setOnClickListener(::openAppUsageSettings)
100         outlineProvider =
101             object : ViewOutlineProvider() {
getOutlinenull102                 override fun getOutline(view: View, outline: Outline) {
103                     BACKGROUND.getOutline(view, outline)
104                     val verticalTranslation = splitOffsetTranslationY - translationY
105                     outline.offset(0, Math.round(verticalTranslation))
106                 }
107             }
108         clipToOutline = true
109     }
110 
setNoLimitnull111     private fun setNoLimit() {
112         isVisible = false
113         hasLimit = false
114         appRemainingTimeMs = -1
115         setContentDescription(appUsageLimitTimeMs = -1, appRemainingTimeMs = -1)
116     }
117 
setLimitnull118     private fun setLimit(appUsageLimitTimeMs: Long, appRemainingTimeMs: Long) {
119         isVisible = true
120         hasLimit = true
121         this.appRemainingTimeMs = appRemainingTimeMs
122         setContentDescription(appUsageLimitTimeMs, appRemainingTimeMs)
123         text = Utilities.prefixTextWithIcon(context, R.drawable.ic_hourglass_top, getBannerText())
124     }
125 
setContentDescriptionnull126     private fun setContentDescription(appUsageLimitTimeMs: Long, appRemainingTimeMs: Long) {
127         val contentDescription =
128             getContentDescriptionForTask(task, appUsageLimitTimeMs, appRemainingTimeMs)
129         snapshotView.contentDescription = contentDescription
130     }
131 
initializenull132     fun initialize() {
133         check(!isDestroyed) { "Cannot re-initialize a destroyed toast" }
134         setupTranslations()
135         Executors.ORDERED_BG_EXECUTOR.execute {
136             var usageLimit: AppUsageLimit? = null
137             try {
138                 usageLimit =
139                     launcherApps?.getAppUsageLimit(
140                         task.topComponent.packageName,
141                         UserHandle.of(task.key.userId),
142                     )
143             } catch (e: Exception) {
144                 Log.e(TAG, "Error initializing digital well being toast", e)
145             }
146             val appUsageLimitTimeMs = usageLimit?.totalUsageLimit ?: -1
147             val appRemainingTimeMs = usageLimit?.usageRemaining ?: -1
148 
149             taskView.post {
150                 if (isDestroyed) return@post
151                 if (appUsageLimitTimeMs < 0 || appRemainingTimeMs < 0) {
152                     setNoLimit()
153                 } else {
154                     setLimit(appUsageLimitTimeMs, appRemainingTimeMs)
155                 }
156             }
157         }
158     }
159 
160     /** Bind the DWB toast to its dependencies. */
bindnull161     fun bind(
162         task: Task,
163         taskView: TaskView,
164         snapshotView: View,
165         @StagePosition stagePosition: Int,
166     ) {
167         this.task = task
168         this.taskView = taskView
169         this.snapshotView = snapshotView
170         this.stagePosition = stagePosition
171         isDestroyed = false
172     }
173 
174     /** Mark the DWB toast as destroyed and hide it. */
destroynull175     fun destroy() {
176         isVisible = false
177         isDestroyed = true
178     }
179 
getSplitBannerConfignull180     private fun getSplitBannerConfig(): SplitBannerConfig {
181         val splitBounds = splitBounds
182         return when {
183             splitBounds == null ||
184                 !recentsViewContainer.deviceProfile.isTablet ||
185                 taskView.isLargeTile -> SplitBannerConfig.SPLIT_BANNER_FULLSCREEN
186             // For portrait grid only height of task changes, not width. So we keep the text the
187             // same
188             !recentsViewContainer.deviceProfile.isLeftRightSplit ->
189                 SplitBannerConfig.SPLIT_GRID_BANNER_LARGE
190             // For landscape grid, for 30% width we only show icon, otherwise show icon and time
191             task.key.id == splitBounds.leftTopTaskId ->
192                 if (splitBounds.leftTopTaskPercent < THRESHOLD_LEFT_ICON_ONLY)
193                     SplitBannerConfig.SPLIT_GRID_BANNER_SMALL
194                 else SplitBannerConfig.SPLIT_GRID_BANNER_LARGE
195             else ->
196                 if (splitBounds.leftTopTaskPercent > THRESHOLD_RIGHT_ICON_ONLY)
197                     SplitBannerConfig.SPLIT_GRID_BANNER_SMALL
198                 else SplitBannerConfig.SPLIT_GRID_BANNER_LARGE
199         }
200     }
201 
getReadableDurationnull202     private fun getReadableDuration(
203         duration: Duration,
204         @StringRes durationLessThanOneMinuteStringId: Int,
205     ): String {
206         val hours = Math.toIntExact(duration.toHours())
207         val minutes = Math.toIntExact(duration.minusHours(hours.toLong()).toMinutes())
208         return when {
209             // Apply FormatWidth.WIDE if both the hour part and the minute part are non-zero.
210             hours > 0 && minutes > 0 ->
211                 MeasureFormat.getInstance(Locale.getDefault(), MeasureFormat.FormatWidth.NARROW)
212                     .formatMeasures(
213                         Measure(hours, MeasureUnit.HOUR),
214                         Measure(minutes, MeasureUnit.MINUTE),
215                     )
216             // Apply FormatWidth.WIDE if only the hour part is non-zero (unless forced).
217             hours > 0 ->
218                 MeasureFormat.getInstance(Locale.getDefault(), MeasureFormat.FormatWidth.WIDE)
219                     .formatMeasures(Measure(hours, MeasureUnit.HOUR))
220             // Apply FormatWidth.WIDE if only the minute part is non-zero (unless forced).
221             minutes > 0 ->
222                 MeasureFormat.getInstance(Locale.getDefault(), MeasureFormat.FormatWidth.WIDE)
223                     .formatMeasures(Measure(minutes, MeasureUnit.MINUTE))
224             // Use a specific string for usage less than one minute but non-zero.
225             duration > Duration.ZERO -> context.getString(durationLessThanOneMinuteStringId)
226             // Otherwise, return 0-minute string.
227             else ->
228                 MeasureFormat.getInstance(Locale.getDefault(), MeasureFormat.FormatWidth.WIDE)
229                     .formatMeasures(Measure(0, MeasureUnit.MINUTE))
230         }
231     }
232 
233     /**
234      * Returns text to show for the banner depending on [.getSplitBannerConfig] If {@param
235      * forContentDesc} is `true`, this will always return the full string corresponding to
236      * [.SPLIT_BANNER_FULLSCREEN]
237      */
238     @JvmOverloads
239     @VisibleForTesting
getBannerTextnull240     fun getBannerText(
241         remainingTime: Long = appRemainingTimeMs,
242         forContentDesc: Boolean = false,
243     ): String {
244         val duration =
245             Duration.ofMillis(
246                 if (remainingTime > MINUTE_MS)
247                     (remainingTime + MINUTE_MS - 1) / MINUTE_MS * MINUTE_MS
248                 else remainingTime
249             )
250         val readableDuration =
251             getReadableDuration(
252                 duration,
253                 R.string.shorter_duration_less_than_one_minute, /* forceFormatWidth */
254             )
255         val splitBannerConfig = getSplitBannerConfig()
256         return when {
257             forContentDesc || splitBannerConfig == SplitBannerConfig.SPLIT_BANNER_FULLSCREEN ->
258                 context.getString(R.string.time_left_for_app, readableDuration)
259             // show no text
260             splitBannerConfig == SplitBannerConfig.SPLIT_GRID_BANNER_SMALL -> ""
261             // SPLIT_GRID_BANNER_LARGE only show time
262             else -> readableDuration
263         }
264     }
265 
openAppUsageSettingsnull266     private fun openAppUsageSettings(view: View) {
267         val intent =
268             Intent(OPEN_APP_USAGE_SETTINGS_TEMPLATE)
269                 .putExtra(Intent.EXTRA_PACKAGE_NAME, task.topComponent.packageName)
270                 .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
271         try {
272             val options = ActivityOptions.makeScaleUpAnimation(view, 0, 0, view.width, view.height)
273             context.startActivity(intent, options.toBundle())
274 
275             // TODO: add WW logging on the app usage settings click.
276         } catch (e: ActivityNotFoundException) {
277             Log.e(
278                 TAG,
279                 "Failed to open app usage settings for task " + task.topComponent.packageName,
280                 e,
281             )
282         }
283     }
284 
getContentDescriptionForTasknull285     private fun getContentDescriptionForTask(
286         task: Task,
287         appUsageLimitTimeMs: Long,
288         appRemainingTimeMs: Long,
289     ): String? =
290         if (appUsageLimitTimeMs >= 0 && appRemainingTimeMs >= 0)
291             context.getString(
292                 R.string.task_contents_description_with_remaining_time,
293                 task.titleDescription,
294                 getBannerText(appRemainingTimeMs, true /* forContentDesc */),
295             )
296         else task.titleDescription
297 
298     fun setupLayout() {
299         val snapshotWidth: Int
300         val snapshotHeight: Int
301         val splitBounds = splitBounds
302         if (splitBounds == null) {
303             snapshotWidth = taskView.layoutParams.width
304             snapshotHeight =
305                 taskView.layoutParams.height -
306                     recentsViewContainer.deviceProfile.overviewTaskThumbnailTopMarginPx
307         } else {
308             val groupedTaskSize =
309                 taskView.pagedOrientationHandler.getGroupedTaskViewSizes(
310                     recentsViewContainer.deviceProfile,
311                     splitBounds,
312                     taskView.layoutParams.width,
313                     taskView.layoutParams.height,
314                 )
315             if (stagePosition == STAGE_POSITION_TOP_OR_LEFT) {
316                 snapshotWidth = groupedTaskSize.first.x
317                 snapshotHeight = groupedTaskSize.first.y
318             } else {
319                 snapshotWidth = groupedTaskSize.second.x
320                 snapshotHeight = groupedTaskSize.second.y
321             }
322         }
323         taskView.pagedOrientationHandler.updateDwbBannerLayout(
324             taskView.layoutParams.width,
325             taskView.layoutParams.height,
326             taskView is GroupedTaskView,
327             recentsViewContainer.deviceProfile,
328             snapshotWidth,
329             snapshotHeight,
330             this,
331         )
332     }
333 
setupTranslationsnull334     private fun setupTranslations() {
335         val (translationX, translationY) =
336             taskView.pagedOrientationHandler.getDwbBannerTranslations(
337                 taskView.layoutParams.width,
338                 taskView.layoutParams.height,
339                 splitBounds,
340                 recentsViewContainer.deviceProfile,
341                 taskView.snapshotViews,
342                 task.key.id,
343                 this,
344             )
345         this.translationX = translationX
346         this.splitOffsetTranslationY = translationY
347     }
348 
updateTranslationYnull349     private fun updateTranslationY() {
350         translationY = bannerOffsetPercentage * bannerHeight + splitOffsetTranslationY
351         invalidateOutline()
352     }
353 
setColorTintnull354     fun setColorTint(color: Int, amount: Float) {
355         if (amount == 0f) {
356             setLayerType(View.LAYER_TYPE_NONE, null)
357         }
358         val layerPaint = Paint()
359         layerPaint.setColorFilter(Utilities.makeColorTintingColorFilter(color, amount))
360         setLayerType(View.LAYER_TYPE_HARDWARE, layerPaint)
361         setLayerPaint(layerPaint)
362     }
363 
getAccessibilityActionIdnull364     private fun getAccessibilityActionId(): Int =
365         if (splitBounds?.rightBottomTaskId == task.key.id)
366             R.id.action_digital_wellbeing_bottom_right
367         else R.id.action_digital_wellbeing_top_left
368 
369     fun getDWBAccessibilityAction(): AccessibilityNodeInfo.AccessibilityAction? {
370         if (!hasLimit) return null
371         val label =
372             if (taskView.containsMultipleTasks())
373                 context.getString(
374                     R.string.split_app_usage_settings,
375                     TaskUtils.getTitle(context, task),
376                 )
377             else context.getString(R.string.accessibility_app_usage_settings)
378         return AccessibilityNodeInfo.AccessibilityAction(getAccessibilityActionId(), label)
379     }
380 
handleAccessibilityActionnull381     fun handleAccessibilityAction(action: Int): Boolean {
382         if (getAccessibilityActionId() != action) return false
383         openAppUsageSettings(taskView)
384         return true
385     }
386 
387     companion object {
388         private const val THRESHOLD_LEFT_ICON_ONLY = 0.4f
389         private const val THRESHOLD_RIGHT_ICON_ONLY = 0.6f
390 
391         enum class SplitBannerConfig {
392             /** Will span entire width of taskView with full text */
393             SPLIT_BANNER_FULLSCREEN,
394             /** Used for grid task view, only showing icon and time */
395             SPLIT_GRID_BANNER_LARGE,
396             /** Used for grid task view, only showing icon */
397             SPLIT_GRID_BANNER_SMALL,
398         }
399 
400         val OPEN_APP_USAGE_SETTINGS_TEMPLATE: Intent = Intent(Settings.ACTION_APP_USAGE_SETTINGS)
401         const val MINUTE_MS: Int = 60000
402 
403         private const val TAG = "DigitalWellBeingToast"
404     }
405 }
406