blob: 00091abb42da77460de9dc64348013ec8084ed78 [file] [log] [blame]
/*
* Copyright (C) 2014 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.deskclock.alarms;
import static android.accessibilityservice.AccessibilityServiceInfo.FEEDBACK_GENERIC;
import android.accessibilityservice.AccessibilityServiceInfo;
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.AnimatorSet;
import android.animation.ObjectAnimator;
import android.animation.PropertyValuesHolder;
import android.animation.TimeInterpolator;
import android.animation.ValueAnimator;
import android.content.BroadcastReceiver;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.ServiceConnection;
import android.content.pm.ActivityInfo;
import android.graphics.Color;
import android.graphics.Rect;
import android.graphics.drawable.ColorDrawable;
import android.media.AudioManager;
import android.os.Bundle;
import android.os.Handler;
import android.os.IBinder;
import android.os.Looper;
import android.view.KeyEvent;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowManager;
import android.view.accessibility.AccessibilityManager;
import android.widget.ImageView;
import android.widget.TextClock;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.core.graphics.ColorUtils;
import androidx.core.view.animation.PathInterpolatorCompat;
import com.android.deskclock.AnimatorUtils;
import com.android.deskclock.BaseActivity;
import com.android.deskclock.LogUtils;
import com.android.deskclock.R;
import com.android.deskclock.ThemeUtils;
import com.android.deskclock.Utils;
import com.android.deskclock.data.DataModel;
import com.android.deskclock.data.DataModel.AlarmVolumeButtonBehavior;
import com.android.deskclock.events.Events;
import com.android.deskclock.provider.AlarmInstance;
import com.android.deskclock.widget.CircleView;
import java.util.List;
public class AlarmActivity extends BaseActivity
implements View.OnClickListener, View.OnTouchListener {
private static final LogUtils.Logger LOGGER = new LogUtils.Logger("AlarmActivity");
private static final TimeInterpolator PULSE_INTERPOLATOR =
PathInterpolatorCompat.create(0.4f, 0.0f, 0.2f, 1.0f);
private static final TimeInterpolator REVEAL_INTERPOLATOR =
PathInterpolatorCompat.create(0.0f, 0.0f, 0.2f, 1.0f);
private static final int PULSE_DURATION_MILLIS = 1000;
private static final int ALARM_BOUNCE_DURATION_MILLIS = 500;
private static final int ALERT_REVEAL_DURATION_MILLIS = 500;
private static final int ALERT_FADE_DURATION_MILLIS = 500;
private static final int ALERT_DISMISS_DELAY_MILLIS = 2000;
private static final float BUTTON_SCALE_DEFAULT = 0.7f;
private static final int BUTTON_DRAWABLE_ALPHA_DEFAULT = 165;
private final Handler mHandler = new Handler(Looper.myLooper());
private final BroadcastReceiver mReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
final String action = intent.getAction();
LOGGER.v("Received broadcast: %s", action);
if (!mAlarmHandled) {
switch (action) {
case AlarmService.ALARM_SNOOZE_ACTION:
snooze();
break;
case AlarmService.ALARM_DISMISS_ACTION:
dismiss();
break;
case AlarmService.ALARM_DONE_ACTION:
finish();
break;
default:
LOGGER.i("Unknown broadcast: %s", action);
break;
}
} else {
LOGGER.v("Ignored broadcast: %s", action);
}
}
};
private final ServiceConnection mConnection = new ServiceConnection() {
@Override
public void onServiceConnected(ComponentName name, IBinder service) {
LOGGER.i("Finished binding to AlarmService");
}
@Override
public void onServiceDisconnected(ComponentName name) {
LOGGER.i("Disconnected from AlarmService");
}
};
private AlarmInstance mAlarmInstance;
private boolean mAlarmHandled;
private AlarmVolumeButtonBehavior mVolumeBehavior;
private int mCurrentHourColor;
private boolean mReceiverRegistered;
/** Whether the AlarmService is currently bound */
private boolean mServiceBound;
private AccessibilityManager mAccessibilityManager;
private ViewGroup mAlertView;
private TextView mAlertTitleView;
private TextView mAlertInfoView;
private ViewGroup mContentView;
private ImageView mAlarmButton;
private ImageView mSnoozeButton;
private ImageView mDismissButton;
private TextView mHintView;
private ValueAnimator mAlarmAnimator;
private ValueAnimator mSnoozeAnimator;
private ValueAnimator mDismissAnimator;
private ValueAnimator mPulseAnimator;
private int mInitialPointerIndex = MotionEvent.INVALID_POINTER_ID;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setVolumeControlStream(AudioManager.STREAM_ALARM);
final long instanceId = AlarmInstance.getId(getIntent().getData());
mAlarmInstance = AlarmInstance.getInstance(getContentResolver(), instanceId);
if (mAlarmInstance == null) {
// The alarm was deleted before the activity got created, so just finish()
LOGGER.e("Error displaying alarm for intent: %s", getIntent());
finish();
return;
} else if (mAlarmInstance.mAlarmState != AlarmInstance.FIRED_STATE) {
LOGGER.i("Skip displaying alarm for instance: %s", mAlarmInstance);
finish();
return;
}
LOGGER.i("Displaying alarm for instance: %s", mAlarmInstance);
// Get the volume/camera button behavior setting
mVolumeBehavior = DataModel.getDataModel().getAlarmVolumeButtonBehavior();
setShowWhenLocked(true);
setTurnScreenOn(true);
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
| WindowManager.LayoutParams.FLAG_ALLOW_LOCK_WHILE_SCREEN_ON);
// Hide navigation bar to minimize accidental tap on Home key
hideNavigationBar();
// Honor rotation on tablets; fix the orientation on phones.
if (!getResources().getBoolean(R.bool.rotateAlarmAlert)) {
setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_NOSENSOR);
}
mAccessibilityManager = (AccessibilityManager) getSystemService(ACCESSIBILITY_SERVICE);
setContentView(R.layout.alarm_activity);
mAlertView = findViewById(R.id.alert);
mAlertTitleView = mAlertView.findViewById(R.id.alert_title);
mAlertInfoView = mAlertView.findViewById(R.id.alert_info);
mContentView = findViewById(R.id.content);
mAlarmButton = mContentView.findViewById(R.id.alarm);
mSnoozeButton = mContentView.findViewById(R.id.snooze);
mDismissButton = mContentView.findViewById(R.id.dismiss);
mHintView = mContentView.findViewById(R.id.hint);
final TextView titleView = mContentView.findViewById(R.id.title);
final TextClock digitalClock = mContentView.findViewById(R.id.digital_clock);
final CircleView pulseView = mContentView.findViewById(R.id.pulse);
titleView.setText(mAlarmInstance.getLabelOrDefault(this));
Utils.setTimeFormat(digitalClock, false);
mCurrentHourColor = ThemeUtils.resolveColor(this, android.R.attr.windowBackground);
getWindow().setBackgroundDrawable(new ColorDrawable(mCurrentHourColor));
mAlarmButton.setOnTouchListener(this);
mSnoozeButton.setOnClickListener(this);
mDismissButton.setOnClickListener(this);
mAlarmAnimator = AnimatorUtils.getScaleAnimator(mAlarmButton, 1.0f, 0.0f);
mSnoozeAnimator = getButtonAnimator(mSnoozeButton, Color.WHITE);
mDismissAnimator = getButtonAnimator(mDismissButton, mCurrentHourColor);
mPulseAnimator = ObjectAnimator.ofPropertyValuesHolder(pulseView,
PropertyValuesHolder.ofFloat(CircleView.RADIUS, 0.0f, pulseView.getRadius()),
PropertyValuesHolder.ofObject(CircleView.FILL_COLOR, AnimatorUtils.ARGB_EVALUATOR,
ColorUtils.setAlphaComponent(pulseView.getFillColor(), 0)));
mPulseAnimator.setDuration(PULSE_DURATION_MILLIS);
mPulseAnimator.setInterpolator(PULSE_INTERPOLATOR);
mPulseAnimator.setRepeatCount(ValueAnimator.INFINITE);
mPulseAnimator.start();
}
@Override
protected void onResume() {
super.onResume();
// Re-query for AlarmInstance in case the state has changed externally
final long instanceId = AlarmInstance.getId(getIntent().getData());
mAlarmInstance = AlarmInstance.getInstance(getContentResolver(), instanceId);
if (mAlarmInstance == null) {
LOGGER.i("No alarm instance for instanceId: %d", instanceId);
finish();
return;
}
// Verify that the alarm is still firing before showing the activity
if (mAlarmInstance.mAlarmState != AlarmInstance.FIRED_STATE) {
LOGGER.i("Skip displaying alarm for instance: %s", mAlarmInstance);
finish();
return;
}
if (!mReceiverRegistered) {
// Register to get the alarm done/snooze/dismiss intent.
final IntentFilter filter = new IntentFilter(AlarmService.ALARM_DONE_ACTION);
filter.addAction(AlarmService.ALARM_SNOOZE_ACTION);
filter.addAction(AlarmService.ALARM_DISMISS_ACTION);
registerReceiver(mReceiver, filter, Context.RECEIVER_EXPORTED);
mReceiverRegistered = true;
}
bindAlarmService();
resetAnimations();
}
@Override
protected void onPause() {
super.onPause();
unbindAlarmService();
// Skip if register didn't happen to avoid IllegalArgumentException
if (mReceiverRegistered) {
unregisterReceiver(mReceiver);
mReceiverRegistered = false;
}
}
@Override
public boolean dispatchKeyEvent(@NonNull KeyEvent keyEvent) {
// Do this in dispatch to intercept a few of the system keys.
LOGGER.v("dispatchKeyEvent: %s", keyEvent);
final int keyCode = keyEvent.getKeyCode();
switch (keyCode) {
// Volume keys and camera keys dismiss the alarm.
case KeyEvent.KEYCODE_VOLUME_UP:
case KeyEvent.KEYCODE_VOLUME_DOWN:
case KeyEvent.KEYCODE_VOLUME_MUTE:
case KeyEvent.KEYCODE_HEADSETHOOK:
case KeyEvent.KEYCODE_CAMERA:
case KeyEvent.KEYCODE_FOCUS:
if (!mAlarmHandled) {
switch (mVolumeBehavior) {
case SNOOZE:
if (keyEvent.getAction() == KeyEvent.ACTION_UP) {
snooze();
}
return true;
case DISMISS:
if (keyEvent.getAction() == KeyEvent.ACTION_UP) {
dismiss();
}
return true;
}
}
}
return super.dispatchKeyEvent(keyEvent);
}
@Override
public void onBackPressed() {
// Don't allow back to dismiss.
}
@Override
public void onClick(View view) {
if (mAlarmHandled) {
LOGGER.v("onClick ignored: %s", view);
return;
}
LOGGER.v("onClick: %s", view);
// If in accessibility mode, allow snooze/dismiss by double tapping on respective icons.
if (isAccessibilityEnabled()) {
if (view == mSnoozeButton) {
snooze();
} else if (view == mDismissButton) {
dismiss();
}
return;
}
if (view == mSnoozeButton) {
hintSnooze();
} else if (view == mDismissButton) {
hintDismiss();
}
}
@Override
public boolean onTouch(View view, MotionEvent event) {
if (mAlarmHandled) {
LOGGER.v("onTouch ignored: %s", event);
return false;
}
final int action = event.getActionMasked();
if (action == MotionEvent.ACTION_DOWN) {
LOGGER.v("onTouch started: %s", event);
// Track the pointer that initiated the touch sequence.
mInitialPointerIndex = event.getPointerId(event.getActionIndex());
// Stop the pulse, allowing the last pulse to finish.
mPulseAnimator.setRepeatCount(0);
} else if (action == MotionEvent.ACTION_CANCEL) {
LOGGER.v("onTouch canceled: %s", event);
// Clear the pointer index.
mInitialPointerIndex = MotionEvent.INVALID_POINTER_ID;
// Reset everything.
resetAnimations();
}
final int actionIndex = event.getActionIndex();
if (mInitialPointerIndex == MotionEvent.INVALID_POINTER_ID
|| mInitialPointerIndex != event.getPointerId(actionIndex)) {
// Ignore any pointers other than the initial one, bail early.
return true;
}
final int[] contentLocation = {0, 0};
mContentView.getLocationOnScreen(contentLocation);
final float x = event.getRawX() - contentLocation[0];
final float y = event.getRawY() - contentLocation[1];
final int alarmLeft = mAlarmButton.getLeft() + mAlarmButton.getPaddingLeft();
final int alarmRight = mAlarmButton.getRight() - mAlarmButton.getPaddingRight();
final float snoozeFraction, dismissFraction;
if (mContentView.getLayoutDirection() == View.LAYOUT_DIRECTION_RTL) {
snoozeFraction = getFraction(alarmRight, mSnoozeButton.getLeft(), x);
dismissFraction = getFraction(alarmLeft, mDismissButton.getRight(), x);
} else {
snoozeFraction = getFraction(alarmLeft, mSnoozeButton.getRight(), x);
dismissFraction = getFraction(alarmRight, mDismissButton.getLeft(), x);
}
setAnimatedFractions(snoozeFraction, dismissFraction);
if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_POINTER_UP) {
LOGGER.v("onTouch ended: %s", event);
mInitialPointerIndex = MotionEvent.INVALID_POINTER_ID;
if (snoozeFraction == 1.0f) {
snooze();
} else if (dismissFraction == 1.0f) {
dismiss();
} else {
if (snoozeFraction > 0.0f || dismissFraction > 0.0f) {
// Animate back to the initial state.
AnimatorUtils.reverse(mAlarmAnimator, mSnoozeAnimator, mDismissAnimator);
} else if (mAlarmButton.getTop() <= y && y <= mAlarmButton.getBottom()) {
// User touched the alarm button, hint the dismiss action.
hintDismiss();
}
// Restart the pulse.
mPulseAnimator.setRepeatCount(ValueAnimator.INFINITE);
if (!mPulseAnimator.isStarted()) {
mPulseAnimator.start();
}
}
}
return true;
}
private void hideNavigationBar() {
getWindow().getDecorView().setSystemUiVisibility(View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
| View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
| View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY);
}
/**
* Returns {@code true} if accessibility is enabled, to enable alternate behavior for click
* handling, etc.
*/
private boolean isAccessibilityEnabled() {
if (mAccessibilityManager == null || !mAccessibilityManager.isEnabled()) {
// Accessibility is unavailable or disabled.
return false;
} else if (mAccessibilityManager.isTouchExplorationEnabled()) {
// TalkBack's touch exploration mode is enabled.
return true;
}
// Check if "Switch Access" is enabled.
final List<AccessibilityServiceInfo> enabledAccessibilityServices =
mAccessibilityManager.getEnabledAccessibilityServiceList(FEEDBACK_GENERIC);
return !enabledAccessibilityServices.isEmpty();
}
private void hintSnooze() {
final int alarmLeft = mAlarmButton.getLeft() + mAlarmButton.getPaddingLeft();
final int alarmRight = mAlarmButton.getRight() - mAlarmButton.getPaddingRight();
final float translationX = Math.max(mSnoozeButton.getLeft() - alarmRight, 0)
+ Math.min(mSnoozeButton.getRight() - alarmLeft, 0);
getAlarmBounceAnimator(translationX, translationX < 0.0f ?
R.string.description_direction_left : R.string.description_direction_right).start();
}
private void hintDismiss() {
final int alarmLeft = mAlarmButton.getLeft() + mAlarmButton.getPaddingLeft();
final int alarmRight = mAlarmButton.getRight() - mAlarmButton.getPaddingRight();
final float translationX = Math.max(mDismissButton.getLeft() - alarmRight, 0)
+ Math.min(mDismissButton.getRight() - alarmLeft, 0);
getAlarmBounceAnimator(translationX, translationX < 0.0f ?
R.string.description_direction_left : R.string.description_direction_right).start();
}
/**
* Set animators to initial values and restart pulse on alarm button.
*/
private void resetAnimations() {
// Set the animators to their initial values.
setAnimatedFractions(0.0f /* snoozeFraction */, 0.0f /* dismissFraction */);
// Restart the pulse.
mPulseAnimator.setRepeatCount(ValueAnimator.INFINITE);
if (!mPulseAnimator.isStarted()) {
mPulseAnimator.start();
}
}
/**
* Perform snooze animation and send snooze intent.
*/
private void snooze() {
mAlarmHandled = true;
LOGGER.v("Snoozed: %s", mAlarmInstance);
setAnimatedFractions(1.0f /* snoozeFraction */, 0.0f /* dismissFraction */);
final int snoozeMinutes = DataModel.getDataModel().getSnoozeLength();
final String infoText = getResources().getQuantityString(
R.plurals.alarm_alert_snooze_duration, snoozeMinutes, snoozeMinutes);
final String accessibilityText = getResources().getQuantityString(
R.plurals.alarm_alert_snooze_set, snoozeMinutes, snoozeMinutes);
getAlertAnimator(mSnoozeButton, R.string.alarm_alert_snoozed_text, infoText,
accessibilityText, Color.DKGRAY, mCurrentHourColor).start();
AlarmStateManager.setSnoozeState(this, mAlarmInstance, false /* showToast */);
Events.sendAlarmEvent(R.string.action_snooze, R.string.label_deskclock);
// Unbind here, otherwise alarm will keep ringing until activity finishes.
unbindAlarmService();
}
/**
* Perform dismiss animation and send dismiss intent.
*/
private void dismiss() {
mAlarmHandled = true;
LOGGER.v("Dismissed: %s", mAlarmInstance);
setAnimatedFractions(0.0f /* snoozeFraction */, 1.0f /* dismissFraction */);
getAlertAnimator(mDismissButton, R.string.alarm_alert_off_text, null /* infoText */,
getString(R.string.alarm_alert_off_text) /* accessibilityText */,
Color.DKGRAY, mCurrentHourColor).start();
AlarmStateManager.deleteInstanceAndUpdateParent(this, mAlarmInstance);
Events.sendAlarmEvent(R.string.action_dismiss, R.string.label_deskclock);
// Unbind here, otherwise alarm will keep ringing until activity finishes.
unbindAlarmService();
}
/**
* Bind AlarmService if not yet bound.
*/
private void bindAlarmService() {
if (!mServiceBound) {
final Intent intent = new Intent(this, AlarmService.class);
bindService(intent, mConnection, Context.BIND_AUTO_CREATE);
mServiceBound = true;
}
}
/**
* Unbind AlarmService if bound.
*/
private void unbindAlarmService() {
if (mServiceBound) {
unbindService(mConnection);
mServiceBound = false;
}
}
private void setAnimatedFractions(float snoozeFraction, float dismissFraction) {
final float alarmFraction = Math.max(snoozeFraction, dismissFraction);
mAlarmAnimator.setCurrentFraction(alarmFraction);
mSnoozeAnimator.setCurrentFraction(snoozeFraction);
mDismissAnimator.setCurrentFraction(dismissFraction);
}
private float getFraction(float x0, float x1, float x) {
return Math.max(Math.min((x - x0) / (x1 - x0), 1.0f), 0.0f);
}
private ValueAnimator getButtonAnimator(ImageView button, int tintColor) {
return ObjectAnimator.ofPropertyValuesHolder(button,
PropertyValuesHolder.ofFloat(View.SCALE_X, BUTTON_SCALE_DEFAULT, 1.0f),
PropertyValuesHolder.ofFloat(View.SCALE_Y, BUTTON_SCALE_DEFAULT, 1.0f),
PropertyValuesHolder.ofInt(AnimatorUtils.BACKGROUND_ALPHA, 0, 255),
PropertyValuesHolder.ofInt(AnimatorUtils.DRAWABLE_ALPHA,
BUTTON_DRAWABLE_ALPHA_DEFAULT, 255),
PropertyValuesHolder.ofObject(AnimatorUtils.DRAWABLE_TINT,
AnimatorUtils.ARGB_EVALUATOR, Color.WHITE, tintColor));
}
private ValueAnimator getAlarmBounceAnimator(float translationX, final int hintResId) {
final ValueAnimator bounceAnimator = ObjectAnimator.ofFloat(mAlarmButton,
View.TRANSLATION_X, mAlarmButton.getTranslationX(), translationX, 0.0f);
bounceAnimator.setInterpolator(AnimatorUtils.DECELERATE_ACCELERATE_INTERPOLATOR);
bounceAnimator.setDuration(ALARM_BOUNCE_DURATION_MILLIS);
bounceAnimator.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationStart(Animator animator) {
mHintView.setText(hintResId);
if (mHintView.getVisibility() != View.VISIBLE) {
mHintView.setVisibility(View.VISIBLE);
ObjectAnimator.ofFloat(mHintView, View.ALPHA, 0.0f, 1.0f).start();
}
}
});
return bounceAnimator;
}
private Animator getAlertAnimator(final View source, final int titleResId,
final String infoText, final String accessibilityText, final int revealColor,
final int backgroundColor) {
final ViewGroup containerView = findViewById(android.R.id.content);
final Rect sourceBounds = new Rect(0, 0, source.getHeight(), source.getWidth());
containerView.offsetDescendantRectToMyCoords(source, sourceBounds);
final int centerX = sourceBounds.centerX();
final int centerY = sourceBounds.centerY();
final int xMax = Math.max(centerX, containerView.getWidth() - centerX);
final int yMax = Math.max(centerY, containerView.getHeight() - centerY);
final float startRadius = Math.max(sourceBounds.width(), sourceBounds.height()) / 2.0f;
final float endRadius = (float) Math.sqrt(xMax * xMax + yMax * yMax);
final CircleView revealView = new CircleView(this)
.setCenterX(centerX)
.setCenterY(centerY)
.setFillColor(revealColor);
containerView.addView(revealView);
// TODO: Fade out source icon over the reveal (like LOLLIPOP version).
final Animator revealAnimator = ObjectAnimator.ofFloat(
revealView, CircleView.RADIUS, startRadius, endRadius);
revealAnimator.setDuration(ALERT_REVEAL_DURATION_MILLIS);
revealAnimator.setInterpolator(REVEAL_INTERPOLATOR);
revealAnimator.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animator) {
mAlertView.setVisibility(View.VISIBLE);
mAlertTitleView.setText(titleResId);
if (infoText != null) {
mAlertInfoView.setText(infoText);
mAlertInfoView.setVisibility(View.VISIBLE);
}
mContentView.setVisibility(View.GONE);
getWindow().setBackgroundDrawable(new ColorDrawable(backgroundColor));
}
});
final ValueAnimator fadeAnimator = ObjectAnimator.ofFloat(revealView, View.ALPHA, 0.0f);
fadeAnimator.setDuration(ALERT_FADE_DURATION_MILLIS);
fadeAnimator.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
containerView.removeView(revealView);
}
});
final AnimatorSet alertAnimator = new AnimatorSet();
alertAnimator.play(revealAnimator).before(fadeAnimator);
alertAnimator.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animator) {
mAlertView.announceForAccessibility(accessibilityText);
mHandler.postDelayed(() -> finish(), ALERT_DISMISS_DELAY_MILLIS);
}
});
return alertAnimator;
}
}