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 23 import static com.android.launcher3.Utilities.prefixTextWithIcon; 24 import static com.android.launcher3.util.Executors.THREAD_POOL_EXECUTOR; 25 26 import android.annotation.TargetApi; 27 import android.app.ActivityOptions; 28 import android.content.ActivityNotFoundException; 29 import android.content.Intent; 30 import android.content.pm.LauncherApps; 31 import android.content.pm.LauncherApps.AppUsageLimit; 32 import android.graphics.Outline; 33 import android.graphics.Paint; 34 import android.icu.text.MeasureFormat; 35 import android.icu.text.MeasureFormat.FormatWidth; 36 import android.icu.util.Measure; 37 import android.icu.util.MeasureUnit; 38 import android.os.Build; 39 import android.os.UserHandle; 40 import android.util.Log; 41 import android.view.View; 42 import android.view.ViewGroup; 43 import android.view.ViewOutlineProvider; 44 import android.widget.FrameLayout; 45 import android.widget.TextView; 46 47 import androidx.annotation.StringRes; 48 49 import com.android.launcher3.BaseActivity; 50 import com.android.launcher3.BaseDraggingActivity; 51 import com.android.launcher3.R; 52 import com.android.launcher3.Utilities; 53 import com.android.systemui.shared.recents.model.Task; 54 55 import java.time.Duration; 56 import java.util.Locale; 57 58 @TargetApi(Build.VERSION_CODES.Q) 59 public final class DigitalWellBeingToast { 60 static final Intent OPEN_APP_USAGE_SETTINGS_TEMPLATE = new Intent(ACTION_APP_USAGE_SETTINGS); 61 static final int MINUTE_MS = 60000; 62 63 private static final String TAG = DigitalWellBeingToast.class.getSimpleName(); 64 65 private final BaseDraggingActivity mActivity; 66 private final TaskView mTaskView; 67 private final LauncherApps mLauncherApps; 68 69 private Task mTask; 70 private boolean mHasLimit; 71 private long mAppRemainingTimeMs; 72 private View mBanner; 73 private ViewOutlineProvider mOldBannerOutlineProvider; 74 private float mBannerOffsetPercentage; 75 private float mVerticalOffset = 0f; 76 DigitalWellBeingToast(BaseDraggingActivity activity, TaskView taskView)77 public DigitalWellBeingToast(BaseDraggingActivity activity, TaskView taskView) { 78 mActivity = activity; 79 mTaskView = taskView; 80 mLauncherApps = activity.getSystemService(LauncherApps.class); 81 } 82 setNoLimit()83 private void setNoLimit() { 84 mHasLimit = false; 85 mTaskView.setContentDescription(mTask.titleDescription); 86 replaceBanner(null); 87 mAppRemainingTimeMs = 0; 88 } 89 setLimit(long appUsageLimitTimeMs, long appRemainingTimeMs)90 private void setLimit(long appUsageLimitTimeMs, long appRemainingTimeMs) { 91 mAppRemainingTimeMs = appRemainingTimeMs; 92 mHasLimit = true; 93 TextView toast = mActivity.getViewCache().getView(R.layout.digital_wellbeing_toast, 94 mActivity, mTaskView); 95 toast.setText(prefixTextWithIcon(mActivity, R.drawable.ic_hourglass_top, getText())); 96 toast.setOnClickListener(this::openAppUsageSettings); 97 replaceBanner(toast); 98 99 mTaskView.setContentDescription( 100 getContentDescriptionForTask(mTask, appUsageLimitTimeMs, appRemainingTimeMs)); 101 } 102 getText()103 public String getText() { 104 return getText(mAppRemainingTimeMs); 105 } 106 hasLimit()107 public boolean hasLimit() { 108 return mHasLimit; 109 } 110 initialize(Task task)111 public void initialize(Task task) { 112 mTask = task; 113 114 if (task.key.userId != UserHandle.myUserId()) { 115 setNoLimit(); 116 return; 117 } 118 119 THREAD_POOL_EXECUTOR.execute(() -> { 120 final AppUsageLimit usageLimit = mLauncherApps.getAppUsageLimit( 121 task.getTopComponent().getPackageName(), 122 UserHandle.of(task.key.userId)); 123 124 final long appUsageLimitTimeMs = 125 usageLimit != null ? usageLimit.getTotalUsageLimit() : -1; 126 final long appRemainingTimeMs = 127 usageLimit != null ? usageLimit.getUsageRemaining() : -1; 128 129 mTaskView.post(() -> { 130 if (appUsageLimitTimeMs < 0 || appRemainingTimeMs < 0) { 131 setNoLimit(); 132 } else { 133 setLimit(appUsageLimitTimeMs, appRemainingTimeMs); 134 } 135 }); 136 }); 137 } 138 getReadableDuration( Duration duration, FormatWidth formatWidthHourAndMinute, @StringRes int durationLessThanOneMinuteStringId, boolean forceFormatWidth)139 private String getReadableDuration( 140 Duration duration, 141 FormatWidth formatWidthHourAndMinute, 142 @StringRes int durationLessThanOneMinuteStringId, 143 boolean forceFormatWidth) { 144 int hours = Math.toIntExact(duration.toHours()); 145 int minutes = Math.toIntExact(duration.minusHours(hours).toMinutes()); 146 147 // Apply formatWidthHourAndMinute if both the hour part and the minute part are non-zero. 148 if (hours > 0 && minutes > 0) { 149 return MeasureFormat.getInstance(Locale.getDefault(), formatWidthHourAndMinute) 150 .formatMeasures( 151 new Measure(hours, MeasureUnit.HOUR), 152 new Measure(minutes, MeasureUnit.MINUTE)); 153 } 154 155 // Apply formatWidthHourOrMinute if only the hour part is non-zero (unless forced). 156 if (hours > 0) { 157 return MeasureFormat.getInstance( 158 Locale.getDefault(), 159 forceFormatWidth ? formatWidthHourAndMinute : FormatWidth.WIDE) 160 .formatMeasures(new Measure(hours, MeasureUnit.HOUR)); 161 } 162 163 // Apply formatWidthHourOrMinute if only the minute part is non-zero (unless forced). 164 if (minutes > 0) { 165 return MeasureFormat.getInstance( 166 Locale.getDefault() 167 , forceFormatWidth ? formatWidthHourAndMinute : FormatWidth.WIDE) 168 .formatMeasures(new Measure(minutes, MeasureUnit.MINUTE)); 169 } 170 171 // Use a specific string for usage less than one minute but non-zero. 172 if (duration.compareTo(Duration.ZERO) > 0) { 173 return mActivity.getString(durationLessThanOneMinuteStringId); 174 } 175 176 // Otherwise, return 0-minute string. 177 return MeasureFormat.getInstance( 178 Locale.getDefault(), forceFormatWidth ? formatWidthHourAndMinute : FormatWidth.WIDE) 179 .formatMeasures(new Measure(0, MeasureUnit.MINUTE)); 180 } 181 getReadableDuration( Duration duration, FormatWidth formatWidthHourAndMinute, @StringRes int durationLessThanOneMinuteStringId)182 private String getReadableDuration( 183 Duration duration, 184 FormatWidth formatWidthHourAndMinute, 185 @StringRes int durationLessThanOneMinuteStringId) { 186 return getReadableDuration( 187 duration, 188 formatWidthHourAndMinute, 189 durationLessThanOneMinuteStringId, 190 /* forceFormatWidth= */ false); 191 } 192 getRoundedUpToMinuteReadableDuration(long remainingTime)193 private String getRoundedUpToMinuteReadableDuration(long remainingTime) { 194 final Duration duration = Duration.ofMillis( 195 remainingTime > MINUTE_MS ? 196 (remainingTime + MINUTE_MS - 1) / MINUTE_MS * MINUTE_MS : 197 remainingTime); 198 return getReadableDuration( 199 duration, FormatWidth.NARROW, R.string.shorter_duration_less_than_one_minute); 200 } 201 getText(long remainingTime)202 private String getText(long remainingTime) { 203 return mActivity.getString( 204 R.string.time_left_for_app, 205 getRoundedUpToMinuteReadableDuration(remainingTime)); 206 } 207 openAppUsageSettings(View view)208 public void openAppUsageSettings(View view) { 209 final Intent intent = new Intent(OPEN_APP_USAGE_SETTINGS_TEMPLATE) 210 .putExtra(Intent.EXTRA_PACKAGE_NAME, 211 mTask.getTopComponent().getPackageName()).addFlags( 212 Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); 213 try { 214 final BaseActivity activity = BaseActivity.fromContext(view.getContext()); 215 final ActivityOptions options = ActivityOptions.makeScaleUpAnimation( 216 view, 0, 0, 217 view.getWidth(), view.getHeight()); 218 activity.startActivity(intent, options.toBundle()); 219 220 // TODO: add WW logging on the app usage settings click. 221 } catch (ActivityNotFoundException e) { 222 Log.e(TAG, "Failed to open app usage settings for task " 223 + mTask.getTopComponent().getPackageName(), e); 224 } 225 } 226 getContentDescriptionForTask( Task task, long appUsageLimitTimeMs, long appRemainingTimeMs)227 private String getContentDescriptionForTask( 228 Task task, long appUsageLimitTimeMs, long appRemainingTimeMs) { 229 return appUsageLimitTimeMs >= 0 && appRemainingTimeMs >= 0 ? 230 mActivity.getString( 231 R.string.task_contents_description_with_remaining_time, 232 task.titleDescription, 233 getText(appRemainingTimeMs)) : 234 task.titleDescription; 235 } 236 replaceBanner(View view)237 private void replaceBanner(View view) { 238 resetOldBanner(); 239 setBanner(view); 240 } 241 resetOldBanner()242 private void resetOldBanner() { 243 if (mBanner != null) { 244 mBanner.setOutlineProvider(mOldBannerOutlineProvider); 245 mTaskView.removeView(mBanner); 246 mBanner.setOnClickListener(null); 247 mActivity.getViewCache().recycleView(R.layout.digital_wellbeing_toast, mBanner); 248 } 249 } 250 setBanner(View view)251 private void setBanner(View view) { 252 mBanner = view; 253 if (view != null) { 254 setupAndAddBanner(); 255 setBannerOutline(); 256 } 257 } 258 setupAndAddBanner()259 private void setupAndAddBanner() { 260 FrameLayout.LayoutParams layoutParams = 261 (FrameLayout.LayoutParams) mBanner.getLayoutParams(); 262 layoutParams.gravity = BOTTOM | CENTER_HORIZONTAL; 263 layoutParams.bottomMargin = ((ViewGroup.MarginLayoutParams) 264 mTaskView.getThumbnail().getLayoutParams()).bottomMargin; 265 mBanner.setTranslationY(mBannerOffsetPercentage * mBanner.getHeight()); 266 mTaskView.addView(mBanner); 267 } 268 setBannerOutline()269 private void setBannerOutline() { 270 mOldBannerOutlineProvider = mBanner.getOutlineProvider(); 271 mBanner.setOutlineProvider(new ViewOutlineProvider() { 272 @Override 273 public void getOutline(View view, Outline outline) { 274 mOldBannerOutlineProvider.getOutline(view, outline); 275 outline.offset(0, Math.round(-view.getTranslationY() + mVerticalOffset)); 276 } 277 }); 278 mBanner.setClipToOutline(true); 279 } 280 updateBannerOffset(float offsetPercentage, float verticalOffset)281 void updateBannerOffset(float offsetPercentage, float verticalOffset) { 282 if (mBanner != null && mBannerOffsetPercentage != offsetPercentage) { 283 mVerticalOffset = verticalOffset; 284 mBannerOffsetPercentage = offsetPercentage; 285 mBanner.setTranslationY(offsetPercentage * mBanner.getHeight() + mVerticalOffset); 286 mBanner.invalidateOutline(); 287 } 288 } 289 setBannerColorTint(int color, float amount)290 void setBannerColorTint(int color, float amount) { 291 if (mBanner == null) { 292 return; 293 } 294 if (amount == 0) { 295 mBanner.setLayerType(View.LAYER_TYPE_NONE, null); 296 } 297 Paint layerPaint = new Paint(); 298 layerPaint.setColorFilter(Utilities.makeColorTintingColorFilter(color, amount)); 299 mBanner.setLayerType(View.LAYER_TYPE_HARDWARE, layerPaint); 300 mBanner.setLayerPaint(layerPaint); 301 } 302 } 303