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 17 package com.android.quickstep.views; 18 19 import static android.provider.Settings.ACTION_APP_USAGE_SETTINGS; 20 21 import static com.android.launcher3.Utilities.prefixTextWithIcon; 22 import static com.android.launcher3.util.Executors.ORDERED_BG_EXECUTOR; 23 24 import android.app.ActivityOptions; 25 import android.content.ActivityNotFoundException; 26 import android.content.Context; 27 import android.content.Intent; 28 import android.content.pm.LauncherApps; 29 import android.content.pm.LauncherApps.AppUsageLimit; 30 import android.graphics.Outline; 31 import android.graphics.Paint; 32 import android.icu.text.MeasureFormat; 33 import android.icu.text.MeasureFormat.FormatWidth; 34 import android.icu.util.Measure; 35 import android.icu.util.MeasureUnit; 36 import android.os.UserHandle; 37 import android.util.Log; 38 import android.util.Pair; 39 import android.view.View; 40 import android.view.ViewGroup; 41 import android.view.ViewOutlineProvider; 42 import android.view.accessibility.AccessibilityNodeInfo; 43 import android.widget.FrameLayout; 44 import android.widget.TextView; 45 46 import androidx.annotation.IntDef; 47 import androidx.annotation.Nullable; 48 import androidx.annotation.StringRes; 49 50 import com.android.launcher3.DeviceProfile; 51 import com.android.launcher3.R; 52 import com.android.launcher3.Utilities; 53 import com.android.launcher3.util.SplitConfigurationOptions.SplitBounds; 54 import com.android.quickstep.TaskUtils; 55 import com.android.quickstep.orientation.RecentsPagedOrientationHandler; 56 import com.android.systemui.shared.recents.model.Task; 57 58 import java.lang.annotation.Retention; 59 import java.lang.annotation.RetentionPolicy; 60 import java.time.Duration; 61 import java.util.Locale; 62 63 public final class DigitalWellBeingToast { 64 65 private static final float THRESHOLD_LEFT_ICON_ONLY = 0.4f; 66 private static final float THRESHOLD_RIGHT_ICON_ONLY = 0.6f; 67 68 /** Will span entire width of taskView with full text */ 69 private static final int SPLIT_BANNER_FULLSCREEN = 0; 70 /** Used for grid task view, only showing icon and time */ 71 private static final int SPLIT_GRID_BANNER_LARGE = 1; 72 /** Used for grid task view, only showing icon */ 73 private static final int SPLIT_GRID_BANNER_SMALL = 2; 74 75 @IntDef(value = { 76 SPLIT_BANNER_FULLSCREEN, 77 SPLIT_GRID_BANNER_LARGE, 78 SPLIT_GRID_BANNER_SMALL, 79 }) 80 @Retention(RetentionPolicy.SOURCE) 81 @interface SplitBannerConfig { 82 } 83 84 static final Intent OPEN_APP_USAGE_SETTINGS_TEMPLATE = new Intent(ACTION_APP_USAGE_SETTINGS); 85 static final int MINUTE_MS = 60000; 86 87 private static final String TAG = "DigitalWellBeingToast"; 88 89 private final RecentsViewContainer mContainer; 90 private final TaskView mTaskView; 91 private final LauncherApps mLauncherApps; 92 93 private final int mBannerHeight; 94 95 private Task mTask; 96 private boolean mHasLimit; 97 98 private long mAppRemainingTimeMs; 99 @Nullable 100 private View mBanner; 101 private ViewOutlineProvider mOldBannerOutlineProvider; 102 private float mBannerOffsetPercentage; 103 @Nullable 104 private SplitBounds mSplitBounds; 105 private float mSplitOffsetTranslationY; 106 private float mSplitOffsetTranslationX; 107 108 private boolean mIsDestroyed = false; 109 DigitalWellBeingToast(RecentsViewContainer container, TaskView taskView)110 public DigitalWellBeingToast(RecentsViewContainer container, TaskView taskView) { 111 mContainer = container; 112 mTaskView = taskView; 113 mLauncherApps = container.asContext().getSystemService(LauncherApps.class); 114 mBannerHeight = container.asContext().getResources().getDimensionPixelSize( 115 R.dimen.digital_wellbeing_toast_height); 116 } 117 setNoLimit()118 private void setNoLimit() { 119 mHasLimit = false; 120 mTaskView.setContentDescription(mTask.titleDescription); 121 replaceBanner(null); 122 mAppRemainingTimeMs = -1; 123 } 124 setLimit(long appUsageLimitTimeMs, long appRemainingTimeMs)125 private void setLimit(long appUsageLimitTimeMs, long appRemainingTimeMs) { 126 mAppRemainingTimeMs = appRemainingTimeMs; 127 mHasLimit = true; 128 TextView toast = mContainer.getViewCache().getView(R.layout.digital_wellbeing_toast, 129 mContainer.asContext(), mTaskView); 130 toast.setText(prefixTextWithIcon(mContainer.asContext(), R.drawable.ic_hourglass_top, 131 getText())); 132 toast.setOnClickListener(this::openAppUsageSettings); 133 replaceBanner(toast); 134 135 mTaskView.setContentDescription( 136 getContentDescriptionForTask(mTask, appUsageLimitTimeMs, appRemainingTimeMs)); 137 } 138 getText()139 public String getText() { 140 return getText(mAppRemainingTimeMs, false /* forContentDesc */); 141 } 142 hasLimit()143 public boolean hasLimit() { 144 return mHasLimit; 145 } 146 initialize(Task task)147 public void initialize(Task task) { 148 if (mIsDestroyed) { 149 throw new IllegalStateException("Cannot re-initialize a destroyed toast"); 150 } 151 mTask = task; 152 ORDERED_BG_EXECUTOR.execute(() -> { 153 AppUsageLimit usageLimit = null; 154 try { 155 usageLimit = mLauncherApps.getAppUsageLimit( 156 mTask.getTopComponent().getPackageName(), 157 UserHandle.of(mTask.key.userId)); 158 } catch (Exception e) { 159 Log.e(TAG, "Error initializing digital well being toast", e); 160 } 161 final long appUsageLimitTimeMs = 162 usageLimit != null ? usageLimit.getTotalUsageLimit() : -1; 163 final long appRemainingTimeMs = 164 usageLimit != null ? usageLimit.getUsageRemaining() : -1; 165 166 mTaskView.post(() -> { 167 if (mIsDestroyed) { 168 return; 169 } 170 if (appUsageLimitTimeMs < 0 || appRemainingTimeMs < 0) { 171 setNoLimit(); 172 } else { 173 setLimit(appUsageLimitTimeMs, appRemainingTimeMs); 174 } 175 }); 176 }); 177 } 178 179 /** 180 * Mark the DWB toast as destroyed and remove banner from TaskView. 181 */ destroy()182 public void destroy() { 183 mIsDestroyed = true; 184 mTaskView.post(() -> replaceBanner(null)); 185 } 186 setSplitBounds(@ullable SplitBounds splitBounds)187 public void setSplitBounds(@Nullable SplitBounds splitBounds) { 188 mSplitBounds = splitBounds; 189 } 190 getSplitBannerConfig()191 private @SplitBannerConfig int getSplitBannerConfig() { 192 if (mSplitBounds == null 193 || !mContainer.getDeviceProfile().isTablet 194 || mTaskView.isFocusedTask()) { 195 return SPLIT_BANNER_FULLSCREEN; 196 } 197 198 // For portrait grid only height of task changes, not width. So we keep the text the same 199 if (!mContainer.getDeviceProfile().isLeftRightSplit) { 200 return SPLIT_GRID_BANNER_LARGE; 201 } 202 203 // For landscape grid, for 30% width we only show icon, otherwise show icon and time 204 if (mTask.key.id == mSplitBounds.leftTopTaskId) { 205 return mSplitBounds.leftTaskPercent < THRESHOLD_LEFT_ICON_ONLY 206 ? SPLIT_GRID_BANNER_SMALL : SPLIT_GRID_BANNER_LARGE; 207 } else { 208 return mSplitBounds.leftTaskPercent > THRESHOLD_RIGHT_ICON_ONLY 209 ? SPLIT_GRID_BANNER_SMALL : SPLIT_GRID_BANNER_LARGE; 210 } 211 } 212 getReadableDuration( Duration duration, @StringRes int durationLessThanOneMinuteStringId)213 private String getReadableDuration( 214 Duration duration, 215 @StringRes int durationLessThanOneMinuteStringId) { 216 int hours = Math.toIntExact(duration.toHours()); 217 int minutes = Math.toIntExact(duration.minusHours(hours).toMinutes()); 218 219 // Apply FormatWidth.WIDE if both the hour part and the minute part are non-zero. 220 if (hours > 0 && minutes > 0) { 221 return MeasureFormat.getInstance(Locale.getDefault(), FormatWidth.NARROW) 222 .formatMeasures( 223 new Measure(hours, MeasureUnit.HOUR), 224 new Measure(minutes, MeasureUnit.MINUTE)); 225 } 226 227 // Apply FormatWidth.WIDE if only the hour part is non-zero (unless forced). 228 if (hours > 0) { 229 return MeasureFormat.getInstance(Locale.getDefault(), FormatWidth.WIDE).formatMeasures( 230 new Measure(hours, MeasureUnit.HOUR)); 231 } 232 233 // Apply FormatWidth.WIDE if only the minute part is non-zero (unless forced). 234 if (minutes > 0) { 235 return MeasureFormat.getInstance(Locale.getDefault(), FormatWidth.WIDE).formatMeasures( 236 new Measure(minutes, MeasureUnit.MINUTE)); 237 } 238 239 // Use a specific string for usage less than one minute but non-zero. 240 if (duration.compareTo(Duration.ZERO) > 0) { 241 return mContainer.asContext().getString(durationLessThanOneMinuteStringId); 242 } 243 244 // Otherwise, return 0-minute string. 245 return MeasureFormat.getInstance(Locale.getDefault(), FormatWidth.WIDE).formatMeasures( 246 new Measure(0, MeasureUnit.MINUTE)); 247 } 248 249 /** 250 * Returns text to show for the banner depending on {@link #getSplitBannerConfig()} 251 * If {@param forContentDesc} is {@code true}, this will always return the full 252 * string corresponding to {@link #SPLIT_BANNER_FULLSCREEN} 253 */ getText(long remainingTime, boolean forContentDesc)254 private String getText(long remainingTime, boolean forContentDesc) { 255 final Duration duration = Duration.ofMillis( 256 remainingTime > MINUTE_MS ? 257 (remainingTime + MINUTE_MS - 1) / MINUTE_MS * MINUTE_MS : 258 remainingTime); 259 String readableDuration = getReadableDuration(duration, 260 R.string.shorter_duration_less_than_one_minute 261 /* forceFormatWidth */); 262 @SplitBannerConfig int splitBannerConfig = getSplitBannerConfig(); 263 if (forContentDesc || splitBannerConfig == SPLIT_BANNER_FULLSCREEN) { 264 return mContainer.asContext().getString( 265 R.string.time_left_for_app, 266 readableDuration); 267 } 268 269 if (splitBannerConfig == SPLIT_GRID_BANNER_SMALL) { 270 // show no text 271 return ""; 272 } else { // SPLIT_GRID_BANNER_LARGE 273 // only show time 274 return readableDuration; 275 } 276 } 277 openAppUsageSettings(View view)278 public void openAppUsageSettings(View view) { 279 final Intent intent = new Intent(OPEN_APP_USAGE_SETTINGS_TEMPLATE) 280 .putExtra(Intent.EXTRA_PACKAGE_NAME, 281 mTask.getTopComponent().getPackageName()).addFlags( 282 Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); 283 try { 284 final RecentsViewContainer container = 285 RecentsViewContainer.containerFromContext(view.getContext()); 286 final ActivityOptions options = ActivityOptions.makeScaleUpAnimation( 287 view, 0, 0, 288 view.getWidth(), view.getHeight()); 289 container.asContext().startActivity(intent, options.toBundle()); 290 291 // TODO: add WW logging on the app usage settings click. 292 } catch (ActivityNotFoundException e) { 293 Log.e(TAG, "Failed to open app usage settings for task " 294 + mTask.getTopComponent().getPackageName(), e); 295 } 296 } 297 getContentDescriptionForTask( Task task, long appUsageLimitTimeMs, long appRemainingTimeMs)298 private String getContentDescriptionForTask( 299 Task task, long appUsageLimitTimeMs, long appRemainingTimeMs) { 300 return appUsageLimitTimeMs >= 0 && appRemainingTimeMs >= 0 ? 301 mContainer.asContext().getString( 302 R.string.task_contents_description_with_remaining_time, 303 task.titleDescription, 304 getText(appRemainingTimeMs, true /* forContentDesc */)) : 305 task.titleDescription; 306 } 307 replaceBanner(@ullable View view)308 private void replaceBanner(@Nullable View view) { 309 resetOldBanner(); 310 setBanner(view); 311 } 312 resetOldBanner()313 private void resetOldBanner() { 314 if (mBanner != null) { 315 mBanner.setOutlineProvider(mOldBannerOutlineProvider); 316 mTaskView.removeView(mBanner); 317 mBanner.setOnClickListener(null); 318 mContainer.getViewCache().recycleView(R.layout.digital_wellbeing_toast, mBanner); 319 } 320 } 321 setBanner(@ullable View view)322 private void setBanner(@Nullable View view) { 323 mBanner = view; 324 if (mBanner != null && mTaskView.getRecentsView() != null) { 325 setupAndAddBanner(); 326 setBannerOutline(); 327 } 328 } 329 setupAndAddBanner()330 private void setupAndAddBanner() { 331 FrameLayout.LayoutParams layoutParams = 332 (FrameLayout.LayoutParams) mBanner.getLayoutParams(); 333 DeviceProfile deviceProfile = mContainer.getDeviceProfile(); 334 layoutParams.bottomMargin = ((ViewGroup.MarginLayoutParams) 335 mTaskView.getFirstThumbnailViewDeprecated().getLayoutParams()).bottomMargin; 336 RecentsPagedOrientationHandler orientationHandler = mTaskView.getPagedOrientationHandler(); 337 Pair<Float, Float> translations = orientationHandler 338 .getDwbLayoutTranslations(mTaskView.getMeasuredWidth(), 339 mTaskView.getMeasuredHeight(), mSplitBounds, deviceProfile, 340 mTaskView.getThumbnailViews(), mTask.key.id, mBanner); 341 mSplitOffsetTranslationX = translations.first; 342 mSplitOffsetTranslationY = translations.second; 343 updateTranslationY(); 344 updateTranslationX(); 345 mTaskView.addView(mBanner); 346 } 347 setBannerOutline()348 private void setBannerOutline() { 349 // TODO(b\273367585) to investigate why mBanner.getOutlineProvider() can be null 350 mOldBannerOutlineProvider = mBanner.getOutlineProvider() != null 351 ? mBanner.getOutlineProvider() 352 : ViewOutlineProvider.BACKGROUND; 353 354 mBanner.setOutlineProvider(new ViewOutlineProvider() { 355 @Override 356 public void getOutline(View view, Outline outline) { 357 mOldBannerOutlineProvider.getOutline(view, outline); 358 float verticalTranslation = -view.getTranslationY() + mSplitOffsetTranslationY; 359 outline.offset(0, Math.round(verticalTranslation)); 360 } 361 }); 362 mBanner.setClipToOutline(true); 363 } 364 updateBannerOffset(float offsetPercentage)365 void updateBannerOffset(float offsetPercentage) { 366 if (mBannerOffsetPercentage != offsetPercentage) { 367 mBannerOffsetPercentage = offsetPercentage; 368 if (mBanner != null) { 369 updateTranslationY(); 370 mBanner.invalidateOutline(); 371 } 372 } 373 } 374 updateTranslationY()375 private void updateTranslationY() { 376 if (mBanner == null) { 377 return; 378 } 379 380 mBanner.setTranslationY( 381 (mBannerOffsetPercentage * mBannerHeight) + mSplitOffsetTranslationY); 382 } 383 updateTranslationX()384 private void updateTranslationX() { 385 if (mBanner == null) { 386 return; 387 } 388 389 mBanner.setTranslationX(mSplitOffsetTranslationX); 390 } 391 setBannerColorTint(int color, float amount)392 void setBannerColorTint(int color, float amount) { 393 if (mBanner == null) { 394 return; 395 } 396 if (amount == 0) { 397 mBanner.setLayerType(View.LAYER_TYPE_NONE, null); 398 } 399 Paint layerPaint = new Paint(); 400 layerPaint.setColorFilter(Utilities.makeColorTintingColorFilter(color, amount)); 401 mBanner.setLayerType(View.LAYER_TYPE_HARDWARE, layerPaint); 402 mBanner.setLayerPaint(layerPaint); 403 } 404 setBannerVisibility(int visibility)405 void setBannerVisibility(int visibility) { 406 if (mBanner == null) { 407 return; 408 } 409 410 mBanner.setVisibility(visibility); 411 } 412 getAccessibilityActionId()413 private int getAccessibilityActionId() { 414 return (mSplitBounds != null 415 && mSplitBounds.rightBottomTaskId == mTask.key.id) 416 ? R.id.action_digital_wellbeing_bottom_right 417 : R.id.action_digital_wellbeing_top_left; 418 } 419 420 @Nullable getDWBAccessibilityAction()421 public AccessibilityNodeInfo.AccessibilityAction getDWBAccessibilityAction() { 422 if (!hasLimit()) { 423 return null; 424 } 425 426 Context context = mContainer.asContext(); 427 String label = 428 (mTaskView.containsMultipleTasks()) 429 ? context.getString( 430 R.string.split_app_usage_settings, 431 TaskUtils.getTitle(context, mTask) 432 ) : context.getString(R.string.accessibility_app_usage_settings); 433 return new AccessibilityNodeInfo.AccessibilityAction(getAccessibilityActionId(), label); 434 } 435 handleAccessibilityAction(int action)436 public boolean handleAccessibilityAction(int action) { 437 if (getAccessibilityActionId() == action) { 438 openAppUsageSettings(mTaskView); 439 return true; 440 } else { 441 return false; 442 } 443 } 444 } 445