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