blob: e5664c6032e80c262ea74afb1bd8611d70b1aa6a [file] [log] [blame]
/*
* 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);
}
}