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.ORDERED_BG_EXECUTOR;
23 
24 import android.app.ActivityOptions;
25 import android.content.ActivityNotFoundException;
26 import android.content.Context;
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.UserHandle;
37 import android.util.Log;
38 import android.util.Pair;
39 import android.view.View;
40 import android.view.ViewGroup;
41 import android.view.ViewOutlineProvider;
42 import android.view.accessibility.AccessibilityNodeInfo;
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.DeviceProfile;
51 import com.android.launcher3.R;
52 import com.android.launcher3.Utilities;
53 import com.android.launcher3.util.SplitConfigurationOptions.SplitBounds;
54 import com.android.quickstep.TaskUtils;
55 import com.android.quickstep.orientation.RecentsPagedOrientationHandler;
56 import com.android.systemui.shared.recents.model.Task;
57 
58 import java.lang.annotation.Retention;
59 import java.lang.annotation.RetentionPolicy;
60 import java.time.Duration;
61 import java.util.Locale;
62 
63 public final class DigitalWellBeingToast {
64 
65     private static final float THRESHOLD_LEFT_ICON_ONLY = 0.4f;
66     private static final float THRESHOLD_RIGHT_ICON_ONLY = 0.6f;
67 
68     /** Will span entire width of taskView with full text */
69     private static final int SPLIT_BANNER_FULLSCREEN = 0;
70     /** Used for grid task view, only showing icon and time */
71     private static final int SPLIT_GRID_BANNER_LARGE = 1;
72     /** Used for grid task view, only showing icon */
73     private static final int SPLIT_GRID_BANNER_SMALL = 2;
74 
75     @IntDef(value = {
76             SPLIT_BANNER_FULLSCREEN,
77             SPLIT_GRID_BANNER_LARGE,
78             SPLIT_GRID_BANNER_SMALL,
79     })
80     @Retention(RetentionPolicy.SOURCE)
81     @interface SplitBannerConfig {
82     }
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";
88 
89     private final RecentsViewContainer mContainer;
90     private final TaskView mTaskView;
91     private final LauncherApps mLauncherApps;
92 
93     private final int mBannerHeight;
94 
95     private Task mTask;
96     private boolean mHasLimit;
97 
98     private long mAppRemainingTimeMs;
99     @Nullable
100     private View mBanner;
101     private ViewOutlineProvider mOldBannerOutlineProvider;
102     private float mBannerOffsetPercentage;
103     @Nullable
104     private SplitBounds mSplitBounds;
105     private float mSplitOffsetTranslationY;
106     private float mSplitOffsetTranslationX;
107 
108     private boolean mIsDestroyed = false;
109 
DigitalWellBeingToast(RecentsViewContainer container, TaskView taskView)110     public DigitalWellBeingToast(RecentsViewContainer container, TaskView taskView) {
111         mContainer = container;
112         mTaskView = taskView;
113         mLauncherApps = container.asContext().getSystemService(LauncherApps.class);
114         mBannerHeight = container.asContext().getResources().getDimensionPixelSize(
115                 R.dimen.digital_wellbeing_toast_height);
116     }
117 
setNoLimit()118     private void setNoLimit() {
119         mHasLimit = false;
120         mTaskView.setContentDescription(mTask.titleDescription);
121         replaceBanner(null);
122         mAppRemainingTimeMs = -1;
123     }
124 
setLimit(long appUsageLimitTimeMs, long appRemainingTimeMs)125     private void setLimit(long appUsageLimitTimeMs, long appRemainingTimeMs) {
126         mAppRemainingTimeMs = appRemainingTimeMs;
127         mHasLimit = true;
128         TextView toast = mContainer.getViewCache().getView(R.layout.digital_wellbeing_toast,
129                 mContainer.asContext(), mTaskView);
130         toast.setText(prefixTextWithIcon(mContainer.asContext(), R.drawable.ic_hourglass_top,
131                 getText()));
132         toast.setOnClickListener(this::openAppUsageSettings);
133         replaceBanner(toast);
134 
135         mTaskView.setContentDescription(
136                 getContentDescriptionForTask(mTask, appUsageLimitTimeMs, appRemainingTimeMs));
137     }
138 
getText()139     public String getText() {
140         return getText(mAppRemainingTimeMs, false /* forContentDesc */);
141     }
142 
hasLimit()143     public boolean hasLimit() {
144         return mHasLimit;
145     }
146 
initialize(Task task)147     public void initialize(Task task) {
148         if (mIsDestroyed) {
149             throw new IllegalStateException("Cannot re-initialize a destroyed toast");
150         }
151         mTask = task;
152         ORDERED_BG_EXECUTOR.execute(() -> {
153             AppUsageLimit usageLimit = null;
154             try {
155                 usageLimit = mLauncherApps.getAppUsageLimit(
156                         mTask.getTopComponent().getPackageName(),
157                         UserHandle.of(mTask.key.userId));
158             } catch (Exception e) {
159                 Log.e(TAG, "Error initializing digital well being toast", e);
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 (mIsDestroyed) {
168                     return;
169                 }
170                 if (appUsageLimitTimeMs < 0 || appRemainingTimeMs < 0) {
171                     setNoLimit();
172                 } else {
173                     setLimit(appUsageLimitTimeMs, appRemainingTimeMs);
174                 }
175             });
176         });
177     }
178 
179     /**
180      * Mark the DWB toast as destroyed and remove banner from TaskView.
181      */
destroy()182     public void destroy() {
183         mIsDestroyed = true;
184         mTaskView.post(() -> replaceBanner(null));
185     }
186 
setSplitBounds(@ullable SplitBounds splitBounds)187     public void setSplitBounds(@Nullable SplitBounds splitBounds) {
188         mSplitBounds = splitBounds;
189     }
190 
getSplitBannerConfig()191     private @SplitBannerConfig int getSplitBannerConfig() {
192         if (mSplitBounds == null
193                 || !mContainer.getDeviceProfile().isTablet
194                 || mTaskView.isFocusedTask()) {
195             return SPLIT_BANNER_FULLSCREEN;
196         }
197 
198         // For portrait grid only height of task changes, not width. So we keep the text the same
199         if (!mContainer.getDeviceProfile().isLeftRightSplit) {
200             return SPLIT_GRID_BANNER_LARGE;
201         }
202 
203         // For landscape grid, for 30% width we only show icon, otherwise show icon and time
204         if (mTask.key.id == mSplitBounds.leftTopTaskId) {
205             return mSplitBounds.leftTaskPercent < THRESHOLD_LEFT_ICON_ONLY
206                     ? SPLIT_GRID_BANNER_SMALL : SPLIT_GRID_BANNER_LARGE;
207         } else {
208             return mSplitBounds.leftTaskPercent > THRESHOLD_RIGHT_ICON_ONLY
209                     ? SPLIT_GRID_BANNER_SMALL : SPLIT_GRID_BANNER_LARGE;
210         }
211     }
212 
getReadableDuration( Duration duration, @StringRes int durationLessThanOneMinuteStringId)213     private String getReadableDuration(
214             Duration duration,
215             @StringRes int durationLessThanOneMinuteStringId) {
216         int hours = Math.toIntExact(duration.toHours());
217         int minutes = Math.toIntExact(duration.minusHours(hours).toMinutes());
218 
219         // Apply FormatWidth.WIDE if both the hour part and the minute part are non-zero.
220         if (hours > 0 && minutes > 0) {
221             return MeasureFormat.getInstance(Locale.getDefault(), FormatWidth.NARROW)
222                     .formatMeasures(
223                             new Measure(hours, MeasureUnit.HOUR),
224                             new Measure(minutes, MeasureUnit.MINUTE));
225         }
226 
227         // Apply FormatWidth.WIDE if only the hour part is non-zero (unless forced).
228         if (hours > 0) {
229             return MeasureFormat.getInstance(Locale.getDefault(), FormatWidth.WIDE).formatMeasures(
230                     new Measure(hours, MeasureUnit.HOUR));
231         }
232 
233         // Apply FormatWidth.WIDE if only the minute part is non-zero (unless forced).
234         if (minutes > 0) {
235             return MeasureFormat.getInstance(Locale.getDefault(), FormatWidth.WIDE).formatMeasures(
236                     new Measure(minutes, MeasureUnit.MINUTE));
237         }
238 
239         // Use a specific string for usage less than one minute but non-zero.
240         if (duration.compareTo(Duration.ZERO) > 0) {
241             return mContainer.asContext().getString(durationLessThanOneMinuteStringId);
242         }
243 
244         // Otherwise, return 0-minute string.
245         return MeasureFormat.getInstance(Locale.getDefault(), FormatWidth.WIDE).formatMeasures(
246                 new Measure(0, MeasureUnit.MINUTE));
247     }
248 
249     /**
250      * Returns text to show for the banner depending on {@link #getSplitBannerConfig()}
251      * If {@param forContentDesc} is {@code true}, this will always return the full
252      * string corresponding to {@link #SPLIT_BANNER_FULLSCREEN}
253      */
getText(long remainingTime, boolean forContentDesc)254     private String getText(long remainingTime, boolean forContentDesc) {
255         final Duration duration = Duration.ofMillis(
256                 remainingTime > MINUTE_MS ?
257                         (remainingTime + MINUTE_MS - 1) / MINUTE_MS * MINUTE_MS :
258                         remainingTime);
259         String readableDuration = getReadableDuration(duration,
260                 R.string.shorter_duration_less_than_one_minute
261                 /* forceFormatWidth */);
262         @SplitBannerConfig int splitBannerConfig = getSplitBannerConfig();
263         if (forContentDesc || splitBannerConfig == SPLIT_BANNER_FULLSCREEN) {
264             return mContainer.asContext().getString(
265                     R.string.time_left_for_app,
266                     readableDuration);
267         }
268 
269         if (splitBannerConfig == SPLIT_GRID_BANNER_SMALL) {
270             // show no text
271             return "";
272         } else { // SPLIT_GRID_BANNER_LARGE
273             // only show time
274             return readableDuration;
275         }
276     }
277 
openAppUsageSettings(View view)278     public void openAppUsageSettings(View view) {
279         final Intent intent = new Intent(OPEN_APP_USAGE_SETTINGS_TEMPLATE)
280                 .putExtra(Intent.EXTRA_PACKAGE_NAME,
281                         mTask.getTopComponent().getPackageName()).addFlags(
282                         Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
283         try {
284             final RecentsViewContainer container =
285                     RecentsViewContainer.containerFromContext(view.getContext());
286             final ActivityOptions options = ActivityOptions.makeScaleUpAnimation(
287                     view, 0, 0,
288                     view.getWidth(), view.getHeight());
289             container.asContext().startActivity(intent, options.toBundle());
290 
291             // TODO: add WW logging on the app usage settings click.
292         } catch (ActivityNotFoundException e) {
293             Log.e(TAG, "Failed to open app usage settings for task "
294                     + mTask.getTopComponent().getPackageName(), e);
295         }
296     }
297 
getContentDescriptionForTask( Task task, long appUsageLimitTimeMs, long appRemainingTimeMs)298     private String getContentDescriptionForTask(
299             Task task, long appUsageLimitTimeMs, long appRemainingTimeMs) {
300         return appUsageLimitTimeMs >= 0 && appRemainingTimeMs >= 0 ?
301                 mContainer.asContext().getString(
302                         R.string.task_contents_description_with_remaining_time,
303                         task.titleDescription,
304                         getText(appRemainingTimeMs, true /* forContentDesc */)) :
305                 task.titleDescription;
306     }
307 
replaceBanner(@ullable View view)308     private void replaceBanner(@Nullable View view) {
309         resetOldBanner();
310         setBanner(view);
311     }
312 
resetOldBanner()313     private void resetOldBanner() {
314         if (mBanner != null) {
315             mBanner.setOutlineProvider(mOldBannerOutlineProvider);
316             mTaskView.removeView(mBanner);
317             mBanner.setOnClickListener(null);
318             mContainer.getViewCache().recycleView(R.layout.digital_wellbeing_toast, mBanner);
319         }
320     }
321 
setBanner(@ullable View view)322     private void setBanner(@Nullable View view) {
323         mBanner = view;
324         if (mBanner != null && mTaskView.getRecentsView() != null) {
325             setupAndAddBanner();
326             setBannerOutline();
327         }
328     }
329 
setupAndAddBanner()330     private void setupAndAddBanner() {
331         FrameLayout.LayoutParams layoutParams =
332                 (FrameLayout.LayoutParams) mBanner.getLayoutParams();
333         DeviceProfile deviceProfile = mContainer.getDeviceProfile();
334         layoutParams.bottomMargin = ((ViewGroup.MarginLayoutParams)
335                 mTaskView.getFirstThumbnailViewDeprecated().getLayoutParams()).bottomMargin;
336         RecentsPagedOrientationHandler orientationHandler = mTaskView.getPagedOrientationHandler();
337         Pair<Float, Float> translations = orientationHandler
338                 .getDwbLayoutTranslations(mTaskView.getMeasuredWidth(),
339                         mTaskView.getMeasuredHeight(), mSplitBounds, deviceProfile,
340                         mTaskView.getThumbnailViews(), mTask.key.id, mBanner);
341         mSplitOffsetTranslationX = translations.first;
342         mSplitOffsetTranslationY = translations.second;
343         updateTranslationY();
344         updateTranslationX();
345         mTaskView.addView(mBanner);
346     }
347 
setBannerOutline()348     private void setBannerOutline() {
349         // TODO(b\273367585) to investigate why mBanner.getOutlineProvider() can be null
350         mOldBannerOutlineProvider = mBanner.getOutlineProvider() != null
351                 ? mBanner.getOutlineProvider()
352                 : ViewOutlineProvider.BACKGROUND;
353 
354         mBanner.setOutlineProvider(new ViewOutlineProvider() {
355             @Override
356             public void getOutline(View view, Outline outline) {
357                 mOldBannerOutlineProvider.getOutline(view, outline);
358                 float verticalTranslation = -view.getTranslationY() + mSplitOffsetTranslationY;
359                 outline.offset(0, Math.round(verticalTranslation));
360             }
361         });
362         mBanner.setClipToOutline(true);
363     }
364 
updateBannerOffset(float offsetPercentage)365     void updateBannerOffset(float offsetPercentage) {
366         if (mBannerOffsetPercentage != offsetPercentage) {
367             mBannerOffsetPercentage = offsetPercentage;
368             if (mBanner != null) {
369                 updateTranslationY();
370                 mBanner.invalidateOutline();
371             }
372         }
373     }
374 
updateTranslationY()375     private void updateTranslationY() {
376         if (mBanner == null) {
377             return;
378         }
379 
380         mBanner.setTranslationY(
381                 (mBannerOffsetPercentage * mBannerHeight) + mSplitOffsetTranslationY);
382     }
383 
updateTranslationX()384     private void updateTranslationX() {
385         if (mBanner == null) {
386             return;
387         }
388 
389         mBanner.setTranslationX(mSplitOffsetTranslationX);
390     }
391 
setBannerColorTint(int color, float amount)392     void setBannerColorTint(int color, float amount) {
393         if (mBanner == null) {
394             return;
395         }
396         if (amount == 0) {
397             mBanner.setLayerType(View.LAYER_TYPE_NONE, null);
398         }
399         Paint layerPaint = new Paint();
400         layerPaint.setColorFilter(Utilities.makeColorTintingColorFilter(color, amount));
401         mBanner.setLayerType(View.LAYER_TYPE_HARDWARE, layerPaint);
402         mBanner.setLayerPaint(layerPaint);
403     }
404 
setBannerVisibility(int visibility)405     void setBannerVisibility(int visibility) {
406         if (mBanner == null) {
407             return;
408         }
409 
410         mBanner.setVisibility(visibility);
411     }
412 
getAccessibilityActionId()413     private int getAccessibilityActionId() {
414         return (mSplitBounds != null
415                 && mSplitBounds.rightBottomTaskId == mTask.key.id)
416                 ? R.id.action_digital_wellbeing_bottom_right
417                 : R.id.action_digital_wellbeing_top_left;
418     }
419 
420     @Nullable
getDWBAccessibilityAction()421     public AccessibilityNodeInfo.AccessibilityAction getDWBAccessibilityAction() {
422         if (!hasLimit()) {
423             return null;
424         }
425 
426         Context context = mContainer.asContext();
427         String label =
428                 (mTaskView.containsMultipleTasks())
429                         ? context.getString(
430                         R.string.split_app_usage_settings,
431                         TaskUtils.getTitle(context, mTask)
432                 ) : context.getString(R.string.accessibility_app_usage_settings);
433         return new AccessibilityNodeInfo.AccessibilityAction(getAccessibilityActionId(), label);
434     }
435 
handleAccessibilityAction(int action)436     public boolean handleAccessibilityAction(int action) {
437         if (getAccessibilityActionId() == action) {
438             openAppUsageSettings(mTaskView);
439             return true;
440         } else {
441             return false;
442         }
443     }
444 }
445