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