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