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