| /* |
| * Copyright (C) 2018 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| package com.android.quickstep.views; |
| |
| import static android.provider.Settings.ACTION_APP_USAGE_SETTINGS; |
| import static android.view.Gravity.BOTTOM; |
| import static android.view.Gravity.CENTER_HORIZONTAL; |
| import static android.view.Gravity.START; |
| |
| import static com.android.launcher3.Utilities.prefixTextWithIcon; |
| import static com.android.launcher3.util.Executors.THREAD_POOL_EXECUTOR; |
| |
| import android.annotation.TargetApi; |
| import android.app.ActivityOptions; |
| import android.content.ActivityNotFoundException; |
| import android.content.Intent; |
| import android.content.pm.LauncherApps; |
| import android.content.pm.LauncherApps.AppUsageLimit; |
| import android.graphics.Outline; |
| import android.graphics.Paint; |
| import android.icu.text.MeasureFormat; |
| import android.icu.text.MeasureFormat.FormatWidth; |
| import android.icu.util.Measure; |
| import android.icu.util.MeasureUnit; |
| import android.os.Build; |
| import android.os.UserHandle; |
| import android.util.Log; |
| import android.util.Pair; |
| import android.view.View; |
| import android.view.ViewGroup; |
| import android.view.ViewOutlineProvider; |
| import android.widget.FrameLayout; |
| import android.widget.TextView; |
| |
| import androidx.annotation.IntDef; |
| import androidx.annotation.Nullable; |
| import androidx.annotation.StringRes; |
| |
| import com.android.launcher3.BaseActivity; |
| import com.android.launcher3.BaseDraggingActivity; |
| import com.android.launcher3.DeviceProfile; |
| import com.android.launcher3.R; |
| import com.android.launcher3.Utilities; |
| import com.android.launcher3.touch.PagedOrientationHandler; |
| import com.android.launcher3.util.SplitConfigurationOptions.StagedSplitBounds; |
| import com.android.systemui.shared.recents.model.Task; |
| |
| import java.lang.annotation.Retention; |
| import java.lang.annotation.RetentionPolicy; |
| import java.time.Duration; |
| import java.util.Locale; |
| |
| @TargetApi(Build.VERSION_CODES.Q) |
| public final class DigitalWellBeingToast { |
| |
| private static final float THRESHOLD_LEFT_ICON_ONLY = 0.4f; |
| private static final float THRESHOLD_RIGHT_ICON_ONLY = 0.6f; |
| |
| /** Will span entire width of taskView with full text */ |
| private static final int SPLIT_BANNER_FULLSCREEN = 0; |
| /** Used for grid task view, only showing icon and time */ |
| private static final int SPLIT_GRID_BANNER_LARGE = 1; |
| /** Used for grid task view, only showing icon */ |
| private static final int SPLIT_GRID_BANNER_SMALL = 2; |
| @IntDef(value = { |
| SPLIT_BANNER_FULLSCREEN, |
| SPLIT_GRID_BANNER_LARGE, |
| SPLIT_GRID_BANNER_SMALL, |
| }) |
| @Retention(RetentionPolicy.SOURCE) |
| @interface SPLIT_BANNER_CONFIG{} |
| |
| static final Intent OPEN_APP_USAGE_SETTINGS_TEMPLATE = new Intent(ACTION_APP_USAGE_SETTINGS); |
| static final int MINUTE_MS = 60000; |
| |
| private static final String TAG = DigitalWellBeingToast.class.getSimpleName(); |
| |
| private final BaseDraggingActivity mActivity; |
| private final TaskView mTaskView; |
| private final LauncherApps mLauncherApps; |
| |
| private Task mTask; |
| private boolean mHasLimit; |
| private long mAppRemainingTimeMs; |
| @Nullable |
| private View mBanner; |
| private ViewOutlineProvider mOldBannerOutlineProvider; |
| private float mBannerOffsetPercentage; |
| /** |
| * Clips rect provided by {@link #mOldBannerOutlineProvider} when in the model state to |
| * hide this banner as the taskView scales up and down |
| */ |
| private float mModalOffset = 0f; |
| @Nullable |
| private StagedSplitBounds mStagedSplitBounds; |
| private int mSplitBannerConfig = SPLIT_BANNER_FULLSCREEN; |
| private float mSplitOffsetTranslationY; |
| private float mSplitOffsetTranslationX; |
| |
| public DigitalWellBeingToast(BaseDraggingActivity activity, TaskView taskView) { |
| mActivity = activity; |
| mTaskView = taskView; |
| mLauncherApps = activity.getSystemService(LauncherApps.class); |
| } |
| |
| private void setNoLimit() { |
| mHasLimit = false; |
| mTaskView.setContentDescription(mTask.titleDescription); |
| replaceBanner(null); |
| mAppRemainingTimeMs = 0; |
| } |
| |
| private void setLimit(long appUsageLimitTimeMs, long appRemainingTimeMs) { |
| mAppRemainingTimeMs = appRemainingTimeMs; |
| mHasLimit = true; |
| TextView toast = mActivity.getViewCache().getView(R.layout.digital_wellbeing_toast, |
| mActivity, mTaskView); |
| toast.setText(prefixTextWithIcon(mActivity, R.drawable.ic_hourglass_top, getText())); |
| toast.setOnClickListener(this::openAppUsageSettings); |
| replaceBanner(toast); |
| |
| mTaskView.setContentDescription( |
| getContentDescriptionForTask(mTask, appUsageLimitTimeMs, appRemainingTimeMs)); |
| } |
| |
| public String getText() { |
| return getText(mAppRemainingTimeMs, false /* forContentDesc */); |
| } |
| |
| public boolean hasLimit() { |
| return mHasLimit; |
| } |
| |
| public void initialize(Task task) { |
| mTask = task; |
| |
| if (task.key.userId != UserHandle.myUserId()) { |
| setNoLimit(); |
| return; |
| } |
| |
| THREAD_POOL_EXECUTOR.execute(() -> { |
| final AppUsageLimit usageLimit = mLauncherApps.getAppUsageLimit( |
| task.getTopComponent().getPackageName(), |
| UserHandle.of(task.key.userId)); |
| |
| final long appUsageLimitTimeMs = |
| usageLimit != null ? usageLimit.getTotalUsageLimit() : -1; |
| final long appRemainingTimeMs = |
| usageLimit != null ? usageLimit.getUsageRemaining() : -1; |
| |
| mTaskView.post(() -> { |
| if (appUsageLimitTimeMs < 0 || appRemainingTimeMs < 0) { |
| setNoLimit(); |
| } else { |
| setLimit(appUsageLimitTimeMs, appRemainingTimeMs); |
| } |
| }); |
| }); |
| } |
| |
| public void setSplitConfiguration(StagedSplitBounds stagedSplitBounds) { |
| mStagedSplitBounds = stagedSplitBounds; |
| if (mStagedSplitBounds == null || |
| !mActivity.getDeviceProfile().overviewShowAsGrid || |
| mTaskView.isFocusedTask()) { |
| mSplitBannerConfig = SPLIT_BANNER_FULLSCREEN; |
| return; |
| } |
| |
| // For portrait grid only height of task changes, not width. So we keep the text the same |
| if (!mActivity.getDeviceProfile().isLandscape) { |
| mSplitBannerConfig = SPLIT_GRID_BANNER_LARGE; |
| return; |
| } |
| |
| // For landscape grid, for 30% width we only show icon, otherwise show icon and time |
| if (mTask.key.id == mStagedSplitBounds.leftTopTaskId) { |
| mSplitBannerConfig = mStagedSplitBounds.leftTaskPercent < THRESHOLD_LEFT_ICON_ONLY ? |
| SPLIT_GRID_BANNER_SMALL : SPLIT_GRID_BANNER_LARGE; |
| } else { |
| mSplitBannerConfig = mStagedSplitBounds.leftTaskPercent > THRESHOLD_RIGHT_ICON_ONLY ? |
| SPLIT_GRID_BANNER_SMALL : SPLIT_GRID_BANNER_LARGE; |
| } |
| } |
| |
| private String getReadableDuration( |
| Duration duration, |
| FormatWidth formatWidthHourAndMinute, |
| @StringRes int durationLessThanOneMinuteStringId, |
| boolean forceFormatWidth) { |
| int hours = Math.toIntExact(duration.toHours()); |
| int minutes = Math.toIntExact(duration.minusHours(hours).toMinutes()); |
| |
| // Apply formatWidthHourAndMinute if both the hour part and the minute part are non-zero. |
| if (hours > 0 && minutes > 0) { |
| return MeasureFormat.getInstance(Locale.getDefault(), formatWidthHourAndMinute) |
| .formatMeasures( |
| new Measure(hours, MeasureUnit.HOUR), |
| new Measure(minutes, MeasureUnit.MINUTE)); |
| } |
| |
| // Apply formatWidthHourOrMinute if only the hour part is non-zero (unless forced). |
| if (hours > 0) { |
| return MeasureFormat.getInstance( |
| Locale.getDefault(), |
| forceFormatWidth ? formatWidthHourAndMinute : FormatWidth.WIDE) |
| .formatMeasures(new Measure(hours, MeasureUnit.HOUR)); |
| } |
| |
| // Apply formatWidthHourOrMinute if only the minute part is non-zero (unless forced). |
| if (minutes > 0) { |
| return MeasureFormat.getInstance( |
| Locale.getDefault() |
| , forceFormatWidth ? formatWidthHourAndMinute : FormatWidth.WIDE) |
| .formatMeasures(new Measure(minutes, MeasureUnit.MINUTE)); |
| } |
| |
| // Use a specific string for usage less than one minute but non-zero. |
| if (duration.compareTo(Duration.ZERO) > 0) { |
| return mActivity.getString(durationLessThanOneMinuteStringId); |
| } |
| |
| // Otherwise, return 0-minute string. |
| return MeasureFormat.getInstance( |
| Locale.getDefault(), forceFormatWidth ? formatWidthHourAndMinute : FormatWidth.WIDE) |
| .formatMeasures(new Measure(0, MeasureUnit.MINUTE)); |
| } |
| |
| /** |
| * Returns text to show for the banner depending on {@link #mSplitBannerConfig} |
| * If {@param forContentDesc} is {@code true}, this will always return the full |
| * string corresponding to {@link #SPLIT_BANNER_FULLSCREEN} |
| */ |
| private String getText(long remainingTime, boolean forContentDesc) { |
| final Duration duration = Duration.ofMillis( |
| remainingTime > MINUTE_MS ? |
| (remainingTime + MINUTE_MS - 1) / MINUTE_MS * MINUTE_MS : |
| remainingTime); |
| String readableDuration = getReadableDuration(duration, |
| FormatWidth.NARROW, |
| R.string.shorter_duration_less_than_one_minute, |
| false /* forceFormatWidth */); |
| if (forContentDesc || mSplitBannerConfig == SPLIT_BANNER_FULLSCREEN) { |
| return mActivity.getString( |
| R.string.time_left_for_app, |
| readableDuration); |
| } |
| |
| if (mSplitBannerConfig == SPLIT_GRID_BANNER_SMALL) { |
| // show no text |
| return ""; |
| } else { // SPLIT_GRID_BANNER_LARGE |
| // only show time |
| return readableDuration; |
| } |
| } |
| |
| public void openAppUsageSettings(View view) { |
| final Intent intent = new Intent(OPEN_APP_USAGE_SETTINGS_TEMPLATE) |
| .putExtra(Intent.EXTRA_PACKAGE_NAME, |
| mTask.getTopComponent().getPackageName()).addFlags( |
| Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); |
| try { |
| final BaseActivity activity = BaseActivity.fromContext(view.getContext()); |
| final ActivityOptions options = ActivityOptions.makeScaleUpAnimation( |
| view, 0, 0, |
| view.getWidth(), view.getHeight()); |
| activity.startActivity(intent, options.toBundle()); |
| |
| // TODO: add WW logging on the app usage settings click. |
| } catch (ActivityNotFoundException e) { |
| Log.e(TAG, "Failed to open app usage settings for task " |
| + mTask.getTopComponent().getPackageName(), e); |
| } |
| } |
| |
| private String getContentDescriptionForTask( |
| Task task, long appUsageLimitTimeMs, long appRemainingTimeMs) { |
| return appUsageLimitTimeMs >= 0 && appRemainingTimeMs >= 0 ? |
| mActivity.getString( |
| R.string.task_contents_description_with_remaining_time, |
| task.titleDescription, |
| getText(appRemainingTimeMs, true /* forContentDesc */)) : |
| task.titleDescription; |
| } |
| |
| private void replaceBanner(@Nullable View view) { |
| resetOldBanner(); |
| setBanner(view); |
| } |
| |
| private void resetOldBanner() { |
| if (mBanner != null) { |
| mBanner.setOutlineProvider(mOldBannerOutlineProvider); |
| mTaskView.removeView(mBanner); |
| mBanner.setOnClickListener(null); |
| mActivity.getViewCache().recycleView(R.layout.digital_wellbeing_toast, mBanner); |
| } |
| } |
| |
| private void setBanner(@Nullable View view) { |
| mBanner = view; |
| if (view != null) { |
| setupAndAddBanner(); |
| setBannerOutline(); |
| } |
| } |
| |
| private void setupAndAddBanner() { |
| FrameLayout.LayoutParams layoutParams = |
| (FrameLayout.LayoutParams) mBanner.getLayoutParams(); |
| DeviceProfile deviceProfile = mActivity.getDeviceProfile(); |
| layoutParams.bottomMargin = ((ViewGroup.MarginLayoutParams) |
| mTaskView.getThumbnail().getLayoutParams()).bottomMargin; |
| PagedOrientationHandler orientationHandler = mTaskView.getPagedOrientationHandler(); |
| Pair<Float, Float> translations = orientationHandler |
| .setDwbLayoutParamsAndGetTranslations(mTaskView.getMeasuredWidth(), |
| mTaskView.getMeasuredHeight(), mStagedSplitBounds, deviceProfile, |
| mTaskView.getThumbnails(), mTask.key.id, mBanner); |
| mSplitOffsetTranslationX = translations.first; |
| mSplitOffsetTranslationY = translations.second; |
| updateTranslationY(); |
| updateTranslationX(); |
| mTaskView.addView(mBanner); |
| } |
| |
| private void setBannerOutline() { |
| mOldBannerOutlineProvider = mBanner.getOutlineProvider(); |
| mBanner.setOutlineProvider(new ViewOutlineProvider() { |
| @Override |
| public void getOutline(View view, Outline outline) { |
| mOldBannerOutlineProvider.getOutline(view, outline); |
| float verticalTranslation = -view.getTranslationY() + mModalOffset |
| + mSplitOffsetTranslationY; |
| outline.offset(0, Math.round(verticalTranslation)); |
| } |
| }); |
| mBanner.setClipToOutline(true); |
| } |
| |
| void updateBannerOffset(float offsetPercentage, float verticalOffset) { |
| if (mBanner != null && mBannerOffsetPercentage != offsetPercentage) { |
| mModalOffset = verticalOffset; |
| mBannerOffsetPercentage = offsetPercentage; |
| updateTranslationY(); |
| mBanner.invalidateOutline(); |
| } |
| } |
| |
| private void updateTranslationY() { |
| if (mBanner == null) { |
| return; |
| } |
| |
| mBanner.setTranslationY( |
| (mBannerOffsetPercentage * mBanner.getHeight()) + |
| mModalOffset + |
| mSplitOffsetTranslationY |
| ); |
| } |
| |
| private void updateTranslationX() { |
| if (mBanner == null) { |
| return; |
| } |
| |
| mBanner.setTranslationX(mSplitOffsetTranslationX); |
| } |
| |
| void setBannerColorTint(int color, float amount) { |
| if (mBanner == null) { |
| return; |
| } |
| if (amount == 0) { |
| mBanner.setLayerType(View.LAYER_TYPE_NONE, null); |
| } |
| Paint layerPaint = new Paint(); |
| layerPaint.setColorFilter(Utilities.makeColorTintingColorFilter(color, amount)); |
| mBanner.setLayerType(View.LAYER_TYPE_HARDWARE, layerPaint); |
| mBanner.setLayerPaint(layerPaint); |
| } |
| } |