• 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 
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