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