diff options
Diffstat (limited to 'java/com/android/incallui/answer/impl')
92 files changed, 8984 insertions, 0 deletions
diff --git a/java/com/android/incallui/answer/impl/AffordanceHolderLayout.java b/java/com/android/incallui/answer/impl/AffordanceHolderLayout.java new file mode 100644 index 000000000..0f93abe68 --- /dev/null +++ b/java/com/android/incallui/answer/impl/AffordanceHolderLayout.java @@ -0,0 +1,178 @@ +/* + * Copyright (C) 2016 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.incallui.answer.impl; + +import android.content.Context; +import android.content.res.Configuration; +import android.support.annotation.Nullable; +import android.util.AttributeSet; +import android.view.MotionEvent; +import android.view.View; +import android.widget.FrameLayout; +import com.android.incallui.answer.impl.affordance.SwipeButtonHelper; +import com.android.incallui.answer.impl.affordance.SwipeButtonHelper.Callback; +import com.android.incallui.answer.impl.affordance.SwipeButtonView; +import com.android.incallui.util.AccessibilityUtil; + +/** Layout that delegates touches to its SwipeButtonHelper */ +public class AffordanceHolderLayout extends FrameLayout { + + private SwipeButtonHelper affordanceHelper; + + private Callback affordanceCallback; + + public AffordanceHolderLayout(Context context) { + this(context, null); + } + + public AffordanceHolderLayout(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public AffordanceHolderLayout(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + + affordanceHelper = + new SwipeButtonHelper( + new Callback() { + @Override + public void onAnimationToSideStarted( + boolean rightPage, float translation, float vel) { + if (affordanceCallback != null) { + affordanceCallback.onAnimationToSideStarted(rightPage, translation, vel); + } + } + + @Override + public void onAnimationToSideEnded() { + if (affordanceCallback != null) { + affordanceCallback.onAnimationToSideEnded(); + } + } + + @Override + public float getMaxTranslationDistance() { + if (affordanceCallback != null) { + return affordanceCallback.getMaxTranslationDistance(); + } + return 0; + } + + @Override + public void onSwipingStarted(boolean rightIcon) { + if (affordanceCallback != null) { + affordanceCallback.onSwipingStarted(rightIcon); + } + } + + @Override + public void onSwipingAborted() { + if (affordanceCallback != null) { + affordanceCallback.onSwipingAborted(); + } + } + + @Override + public void onIconClicked(boolean rightIcon) { + if (affordanceCallback != null) { + affordanceCallback.onIconClicked(rightIcon); + } + } + + @Nullable + @Override + public SwipeButtonView getLeftIcon() { + if (affordanceCallback != null) { + return affordanceCallback.getLeftIcon(); + } + return null; + } + + @Nullable + @Override + public SwipeButtonView getRightIcon() { + if (affordanceCallback != null) { + return affordanceCallback.getRightIcon(); + } + return null; + } + + @Nullable + @Override + public View getLeftPreview() { + if (affordanceCallback != null) { + return affordanceCallback.getLeftPreview(); + } + return null; + } + + @Nullable + @Override + public View getRightPreview() { + if (affordanceCallback != null) { + affordanceCallback.getRightPreview(); + } + return null; + } + + @Override + public float getAffordanceFalsingFactor() { + if (affordanceCallback != null) { + return affordanceCallback.getAffordanceFalsingFactor(); + } + return 1.0f; + } + }, + context); + } + + public void setAffordanceCallback(@Nullable Callback callback) { + affordanceCallback = callback; + affordanceHelper.init(); + } + + public void startHintAnimation(boolean rightIcon, @Nullable Runnable onFinishListener) { + affordanceHelper.startHintAnimation(rightIcon, onFinishListener); + } + + public void animateHideLeftRightIcon() { + affordanceHelper.animateHideLeftRightIcon(); + } + + public void reset(boolean animate) { + affordanceHelper.reset(animate); + } + + @Override + public boolean onInterceptTouchEvent(MotionEvent event) { + if (AccessibilityUtil.isTouchExplorationEnabled(getContext())) { + return false; + } + return affordanceHelper.onTouchEvent(event) || super.onInterceptTouchEvent(event); + } + + @Override + public boolean onTouchEvent(MotionEvent event) { + return affordanceHelper.onTouchEvent(event) || super.onTouchEvent(event); + } + + @Override + protected void onConfigurationChanged(Configuration newConfig) { + super.onConfigurationChanged(newConfig); + affordanceHelper.onConfigurationChanged(); + } +} diff --git a/java/com/android/incallui/answer/impl/AndroidManifest.xml b/java/com/android/incallui/answer/impl/AndroidManifest.xml new file mode 100644 index 000000000..482c716db --- /dev/null +++ b/java/com/android/incallui/answer/impl/AndroidManifest.xml @@ -0,0 +1,3 @@ +<manifest + package="com.android.incallui.answer.impl"> +</manifest> diff --git a/java/com/android/incallui/answer/impl/AnswerFragment.java b/java/com/android/incallui/answer/impl/AnswerFragment.java new file mode 100644 index 000000000..98439ee7f --- /dev/null +++ b/java/com/android/incallui/answer/impl/AnswerFragment.java @@ -0,0 +1,981 @@ +/* + * Copyright (C) 2016 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.incallui.answer.impl; + +import android.Manifest.permission; +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.AnimatorSet; +import android.animation.ObjectAnimator; +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.pm.PackageManager; +import android.location.Location; +import android.net.Uri; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.support.annotation.DrawableRes; +import android.support.annotation.FloatRange; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.annotation.StringRes; +import android.support.annotation.VisibleForTesting; +import android.support.transition.TransitionManager; +import android.support.v4.app.Fragment; +import android.telecom.VideoProfile; +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.view.View; +import android.view.View.AccessibilityDelegate; +import android.view.View.OnClickListener; +import android.view.ViewGroup; +import android.view.ViewTreeObserver.OnGlobalLayoutListener; +import android.view.accessibility.AccessibilityEvent; +import android.view.accessibility.AccessibilityNodeInfo; +import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction; +import android.widget.ImageView; +import com.android.dialer.common.Assert; +import com.android.dialer.common.FragmentUtils; +import com.android.dialer.common.LogUtil; +import com.android.dialer.common.MathUtil; +import com.android.dialer.compat.ActivityCompat; +import com.android.dialer.logging.Logger; +import com.android.dialer.logging.nano.DialerImpression; +import com.android.dialer.multimedia.MultimediaData; +import com.android.dialer.util.ViewUtil; +import com.android.incallui.answer.impl.CreateCustomSmsDialogFragment.CreateCustomSmsHolder; +import com.android.incallui.answer.impl.SmsBottomSheetFragment.SmsSheetHolder; +import com.android.incallui.answer.impl.affordance.SwipeButtonHelper.Callback; +import com.android.incallui.answer.impl.affordance.SwipeButtonView; +import com.android.incallui.answer.impl.answermethod.AnswerMethod; +import com.android.incallui.answer.impl.answermethod.AnswerMethodFactory; +import com.android.incallui.answer.impl.answermethod.AnswerMethodHolder; +import com.android.incallui.answer.impl.utils.Interpolators; +import com.android.incallui.answer.protocol.AnswerScreen; +import com.android.incallui.answer.protocol.AnswerScreenDelegate; +import com.android.incallui.answer.protocol.AnswerScreenDelegateFactory; +import com.android.incallui.call.DialerCall.State; +import com.android.incallui.call.VideoUtils; +import com.android.incallui.contactgrid.ContactGridManager; +import com.android.incallui.incall.protocol.ContactPhotoType; +import com.android.incallui.incall.protocol.InCallScreen; +import com.android.incallui.incall.protocol.InCallScreenDelegate; +import com.android.incallui.incall.protocol.InCallScreenDelegateFactory; +import com.android.incallui.incall.protocol.PrimaryCallState; +import com.android.incallui.incall.protocol.PrimaryInfo; +import com.android.incallui.incall.protocol.SecondaryInfo; +import com.android.incallui.maps.StaticMapBinding; +import com.android.incallui.sessiondata.AvatarPresenter; +import com.android.incallui.sessiondata.MultimediaFragment; +import com.android.incallui.util.AccessibilityUtil; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** The new version of the incoming call screen. */ +@SuppressLint("ClickableViewAccessibility") +public class AnswerFragment extends Fragment + implements AnswerScreen, + InCallScreen, + SmsSheetHolder, + CreateCustomSmsHolder, + AnswerMethodHolder, + MultimediaFragment.Holder { + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + static final String ARG_CALL_ID = "call_id"; + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + static final String ARG_VIDEO_STATE = "video_state"; + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + static final String ARG_IS_VIDEO_UPGRADE_REQUEST = "is_video_upgrade_request"; + + private static final String STATE_HAS_ANIMATED_ENTRY = "hasAnimated"; + + private static final int HINT_SECONDARY_SHOW_DURATION_MILLIS = 5000; + private static final float ANIMATE_LERP_PROGRESS = 0.5f; + private static final int STATUS_BAR_DISABLE_RECENT = 0x01000000; + private static final int STATUS_BAR_DISABLE_HOME = 0x00200000; + private static final int STATUS_BAR_DISABLE_BACK = 0x00400000; + + private static void fadeToward(View view, float newAlpha) { + view.setAlpha(MathUtil.lerp(view.getAlpha(), newAlpha, ANIMATE_LERP_PROGRESS)); + } + + private static void scaleToward(View view, float newScale) { + view.setScaleX(MathUtil.lerp(view.getScaleX(), newScale, ANIMATE_LERP_PROGRESS)); + view.setScaleY(MathUtil.lerp(view.getScaleY(), newScale, ANIMATE_LERP_PROGRESS)); + } + + private AnswerScreenDelegate answerScreenDelegate; + private InCallScreenDelegate inCallScreenDelegate; + + private View importanceBadge; + private SwipeButtonView secondaryButton; + private AffordanceHolderLayout affordanceHolderLayout; + // Use these flags to prevent user from clicking accept/reject buttons multiple times. + // We use separate flags because in some rare cases accepting a call may fail to join the room, + // and then user is stuck in the incoming call view until it times out. Two flags at least give + // the user a chance to get out of the CallActivity. + private boolean buttonAcceptClicked; + private boolean buttonRejectClicked; + private boolean hasAnimatedEntry; + private PrimaryInfo primaryInfo = PrimaryInfo.createEmptyPrimaryInfo(); + private PrimaryCallState primaryCallState; + private ArrayList<CharSequence> textResponses; + private SmsBottomSheetFragment textResponsesFragment; + private CreateCustomSmsDialogFragment createCustomSmsDialogFragment; + private SecondaryBehavior secondaryBehavior = SecondaryBehavior.REJECT_WITH_SMS; + private ContactGridManager contactGridManager; + private AnswerVideoCallScreen answerVideoCallScreen; + private Handler handler = new Handler(Looper.getMainLooper()); + + private enum SecondaryBehavior { + REJECT_WITH_SMS( + R.drawable.quantum_ic_message_white_24, + R.string.a11y_description_incoming_call_reject_with_sms, + R.string.a11y_incoming_call_reject_with_sms, + R.string.call_incoming_swipe_to_decline_with_message) { + @Override + public void performAction(AnswerFragment fragment) { + fragment.showMessageMenu(); + } + }, + + ANSWER_VIDEO_AS_AUDIO( + R.drawable.quantum_ic_videocam_off_white_24, + R.string.a11y_description_incoming_call_answer_video_as_audio, + R.string.a11y_incoming_call_answer_video_as_audio, + R.string.call_incoming_swipe_to_answer_video_as_audio) { + @Override + public void performAction(AnswerFragment fragment) { + fragment.acceptCallByUser(true /* answerVideoAsAudio */); + } + }; + + @DrawableRes public final int icon; + @StringRes public final int contentDescription; + @StringRes public final int accessibilityLabel; + @StringRes public final int hintText; + + SecondaryBehavior( + @DrawableRes int icon, + @StringRes int contentDescription, + @StringRes int accessibilityLabel, + @StringRes int hintText) { + this.icon = icon; + this.contentDescription = contentDescription; + this.accessibilityLabel = accessibilityLabel; + this.hintText = hintText; + } + + public abstract void performAction(AnswerFragment fragment); + + public void applyToView(ImageView view) { + view.setImageResource(icon); + view.setContentDescription(view.getContext().getText(contentDescription)); + } + } + + private AccessibilityDelegate accessibilityDelegate = + new AccessibilityDelegate() { + @Override + public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfo info) { + super.onInitializeAccessibilityNodeInfo(host, info); + if (host == secondaryButton) { + CharSequence label = getText(secondaryBehavior.accessibilityLabel); + info.addAction(new AccessibilityAction(AccessibilityNodeInfo.ACTION_CLICK, label)); + } + } + + @Override + public boolean performAccessibilityAction(View host, int action, Bundle args) { + if (action == AccessibilityNodeInfo.ACTION_CLICK) { + if (host == secondaryButton) { + performSecondaryButtonAction(); + return true; + } + } + return super.performAccessibilityAction(host, action, args); + } + }; + + private Callback affordanceCallback = + new Callback() { + @Override + public void onAnimationToSideStarted(boolean rightPage, float translation, float vel) {} + + @Override + public void onAnimationToSideEnded() { + performSecondaryButtonAction(); + } + + @Override + public float getMaxTranslationDistance() { + View view = getView(); + if (view == null) { + return 0; + } + return (float) Math.hypot(view.getWidth(), view.getHeight()); + } + + @Override + public void onSwipingStarted(boolean rightIcon) {} + + @Override + public void onSwipingAborted() {} + + @Override + public void onIconClicked(boolean rightIcon) { + affordanceHolderLayout.startHintAnimation(rightIcon, null); + getAnswerMethod().setHintText(getText(secondaryBehavior.hintText)); + handler.removeCallbacks(swipeHintRestoreTimer); + handler.postDelayed(swipeHintRestoreTimer, HINT_SECONDARY_SHOW_DURATION_MILLIS); + } + + @Override + public SwipeButtonView getLeftIcon() { + return secondaryButton; + } + + @Override + public SwipeButtonView getRightIcon() { + return null; + } + + @Override + public View getLeftPreview() { + return null; + } + + @Override + public View getRightPreview() { + return null; + } + + @Override + public float getAffordanceFalsingFactor() { + return 1.0f; + } + }; + + private Runnable swipeHintRestoreTimer = + new Runnable() { + @Override + public void run() { + restoreSwipeHintTexts(); + } + }; + + private void performSecondaryButtonAction() { + secondaryBehavior.performAction(this); + } + + public static AnswerFragment newInstance( + String callId, int videoState, boolean isVideoUpgradeRequest) { + Bundle bundle = new Bundle(); + bundle.putString(ARG_CALL_ID, Assert.isNotNull(callId)); + bundle.putInt(ARG_VIDEO_STATE, videoState); + bundle.putBoolean(ARG_IS_VIDEO_UPGRADE_REQUEST, isVideoUpgradeRequest); + + AnswerFragment instance = new AnswerFragment(); + instance.setArguments(bundle); + return instance; + } + + @Override + @NonNull + public String getCallId() { + return Assert.isNotNull(getArguments().getString(ARG_CALL_ID)); + } + + @Override + public int getVideoState() { + return getArguments().getInt(ARG_VIDEO_STATE); + } + + @Override + public boolean isVideoUpgradeRequest() { + return getArguments().getBoolean(ARG_IS_VIDEO_UPGRADE_REQUEST); + } + + @Override + public void setTextResponses(List<String> textResponses) { + if (isVideoCall()) { + LogUtil.i("AnswerFragment.setTextResponses", "no-op for video calls"); + } else if (textResponses == null) { + LogUtil.i("AnswerFragment.setTextResponses", "no text responses, hiding secondary button"); + this.textResponses = null; + secondaryButton.setVisibility(View.INVISIBLE); + } else if (ActivityCompat.isInMultiWindowMode(getActivity())) { + LogUtil.i("AnswerFragment.setTextResponses", "in multiwindow, hiding secondary button"); + this.textResponses = null; + secondaryButton.setVisibility(View.INVISIBLE); + } else { + LogUtil.i("AnswerFragment.setTextResponses", "textResponses.size: " + textResponses.size()); + this.textResponses = new ArrayList<CharSequence>(textResponses); + secondaryButton.setVisibility(View.VISIBLE); + } + } + + private void initSecondaryButton() { + secondaryBehavior = + isVideoCall() ? SecondaryBehavior.ANSWER_VIDEO_AS_AUDIO : SecondaryBehavior.REJECT_WITH_SMS; + secondaryBehavior.applyToView(secondaryButton); + + secondaryButton.setOnClickListener( + new OnClickListener() { + @Override + public void onClick(View v) { + performSecondaryButtonAction(); + } + }); + secondaryButton.setClickable(AccessibilityUtil.isAccessibilityEnabled(getContext())); + secondaryButton.setFocusable(AccessibilityUtil.isAccessibilityEnabled(getContext())); + secondaryButton.setAccessibilityDelegate(accessibilityDelegate); + + if (isVideoCall()) { + //noinspection WrongConstant + if (!isVideoUpgradeRequest() && VideoProfile.isTransmissionEnabled(getVideoState())) { + secondaryButton.setVisibility(View.VISIBLE); + } else { + secondaryButton.setVisibility(View.INVISIBLE); + } + } + } + + @Override + public boolean hasPendingDialogs() { + boolean hasPendingDialogs = + textResponsesFragment != null || createCustomSmsDialogFragment != null; + LogUtil.i("AnswerFragment.hasPendingDialogs", "" + hasPendingDialogs); + return hasPendingDialogs; + } + + @Override + public void dismissPendingDialogs() { + LogUtil.i("AnswerFragment.dismissPendingDialogs", null); + if (textResponsesFragment != null) { + textResponsesFragment.dismiss(); + textResponsesFragment = null; + } + + if (createCustomSmsDialogFragment != null) { + createCustomSmsDialogFragment.dismiss(); + createCustomSmsDialogFragment = null; + } + } + + @Override + public boolean isShowingLocationUi() { + Fragment fragment = getChildFragmentManager().findFragmentById(R.id.incall_location_holder); + return fragment != null && fragment.isVisible(); + } + + @Override + public void showLocationUi(@Nullable Fragment locationUi) { + boolean isShowing = isShowingLocationUi(); + if (!isShowing && locationUi != null) { + // Show the location fragment. + getChildFragmentManager() + .beginTransaction() + .replace(R.id.incall_location_holder, locationUi) + .commitAllowingStateLoss(); + } else if (isShowing && locationUi == null) { + // Hide the location fragment + Fragment fragment = getChildFragmentManager().findFragmentById(R.id.incall_location_holder); + getChildFragmentManager().beginTransaction().remove(fragment).commitAllowingStateLoss(); + } + } + + @Override + public Fragment getAnswerScreenFragment() { + return this; + } + + private AnswerMethod getAnswerMethod() { + return ((AnswerMethod) + getChildFragmentManager().findFragmentById(R.id.answer_method_container)); + } + + @Override + public void setPrimary(PrimaryInfo primaryInfo) { + LogUtil.i("AnswerFragment.setPrimary", primaryInfo.toString()); + this.primaryInfo = primaryInfo; + updatePrimaryUI(); + updateImportanceBadgeVisibility(); + } + + private void updatePrimaryUI() { + if (getView() == null) { + return; + } + contactGridManager.setPrimary(primaryInfo); + getAnswerMethod().setShowIncomingWillDisconnect(primaryInfo.answeringDisconnectsOngoingCall); + getAnswerMethod() + .setContactPhoto( + primaryInfo.photoType == ContactPhotoType.CONTACT ? primaryInfo.photo : null); + updateDataFragment(); + + if (primaryInfo.shouldShowLocation) { + // Hide the avatar to make room for location + contactGridManager.setAvatarHidden(true); + } + } + + private void updateDataFragment() { + if (!isAdded()) { + return; + } + Fragment current = getChildFragmentManager().findFragmentById(R.id.incall_data_container); + Fragment newFragment = null; + + MultimediaData multimediaData = getSessionData(); + if (multimediaData != null + && (!TextUtils.isEmpty(multimediaData.getSubject()) + || (multimediaData.getImageUri() != null) + || (multimediaData.getLocation() != null && canShowMap()))) { + // Need message fragment + String subject = multimediaData.getSubject(); + Uri imageUri = multimediaData.getImageUri(); + Location location = multimediaData.getLocation(); + if (!(current instanceof MultimediaFragment) + || !Objects.equals(((MultimediaFragment) current).getSubject(), subject) + || !Objects.equals(((MultimediaFragment) current).getImageUri(), imageUri) + || !Objects.equals(((MultimediaFragment) current).getLocation(), location)) { + // Needs replacement + newFragment = + MultimediaFragment.newInstance( + multimediaData, false /* isInteractive */, true /* showAvatar */); + } + } else if (shouldShowAvatar()) { + // Needs Avatar + if (!(current instanceof AvatarFragment)) { + // Needs replacement + newFragment = new AvatarFragment(); + } + } else { + // Needs empty + if (current != null) { + getChildFragmentManager().beginTransaction().remove(current).commitNow(); + } + contactGridManager.setAvatarImageView(null, 0, false); + } + + if (newFragment != null) { + getChildFragmentManager() + .beginTransaction() + .replace(R.id.incall_data_container, newFragment) + .commitNow(); + } + } + + private boolean shouldShowAvatar() { + return !isVideoCall(); + } + + private boolean canShowMap() { + return StaticMapBinding.get(getActivity().getApplication()) != null; + } + + @Override + public void updateAvatar(AvatarPresenter avatarContainer) { + contactGridManager.setAvatarImageView( + avatarContainer.getAvatarImageView(), + avatarContainer.getAvatarSize(), + avatarContainer.shouldShowAnonymousAvatar()); + } + + @Override + public void setSecondary(@NonNull SecondaryInfo secondaryInfo) {} + + @Override + public void setCallState(@NonNull PrimaryCallState primaryCallState) { + LogUtil.i("AnswerFragment.setCallState", primaryCallState.toString()); + this.primaryCallState = primaryCallState; + contactGridManager.setCallState(primaryCallState); + } + + @Override + public void setEndCallButtonEnabled(boolean enabled, boolean animate) {} + + @Override + public void showManageConferenceCallButton(boolean visible) {} + + @Override + public boolean isManageConferenceVisible() { + return false; + } + + @Override + public void dispatchPopulateAccessibilityEvent(AccessibilityEvent event) { + contactGridManager.dispatchPopulateAccessibilityEvent(event); + // Add prompt of how to accept/decline call with swipe gesture. + if (AccessibilityUtil.isTouchExplorationEnabled(getContext())) { + event + .getText() + .add(getResources().getString(R.string.a11y_incoming_call_swipe_gesture_prompt)); + } + } + + @Override + public void showNoteSentToast() {} + + @Override + public void updateInCallScreenColors() {} + + @Override + public void onInCallScreenDialpadVisibilityChange(boolean isShowing) {} + + @Override + public int getAnswerAndDialpadContainerResourceId() { + Assert.fail(); + return 0; + } + + @Override + public Fragment getInCallScreenFragment() { + return this; + } + + @Override + public void onDestroy() { + super.onDestroy(); + } + + @Override + public View onCreateView( + LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + Bundle arguments = getArguments(); + Assert.checkState(arguments.containsKey(ARG_CALL_ID)); + Assert.checkState(arguments.containsKey(ARG_VIDEO_STATE)); + Assert.checkState(arguments.containsKey(ARG_IS_VIDEO_UPGRADE_REQUEST)); + + buttonAcceptClicked = false; + buttonRejectClicked = false; + + View view = inflater.inflate(R.layout.fragment_incoming_call, container, false); + secondaryButton = (SwipeButtonView) view.findViewById(R.id.incoming_secondary_button); + + affordanceHolderLayout = (AffordanceHolderLayout) view.findViewById(R.id.incoming_container); + affordanceHolderLayout.setAffordanceCallback(affordanceCallback); + + importanceBadge = view.findViewById(R.id.incall_important_call_badge); + PillDrawable importanceBackground = new PillDrawable(); + importanceBackground.setColor(getContext().getColor(android.R.color.white)); + importanceBadge.setBackground(importanceBackground); + importanceBadge + .getViewTreeObserver() + .addOnGlobalLayoutListener( + new OnGlobalLayoutListener() { + @Override + public void onGlobalLayout() { + int leftRightPadding = importanceBadge.getHeight() / 2; + importanceBadge.setPadding( + leftRightPadding, + importanceBadge.getPaddingTop(), + leftRightPadding, + importanceBadge.getPaddingBottom()); + } + }); + updateImportanceBadgeVisibility(); + + boolean isVideoCall = isVideoCall(); + contactGridManager = new ContactGridManager(view, null, 0, false /* showAnonymousAvatar */); + + Fragment answerMethod = + getChildFragmentManager().findFragmentById(R.id.answer_method_container); + if (AnswerMethodFactory.needsReplacement(answerMethod)) { + getChildFragmentManager() + .beginTransaction() + .replace( + R.id.answer_method_container, AnswerMethodFactory.createAnswerMethod(getActivity())) + .commitNow(); + } + + answerScreenDelegate = + FragmentUtils.getParentUnsafe(this, AnswerScreenDelegateFactory.class) + .newAnswerScreenDelegate(this); + + initSecondaryButton(); + + int flags = View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION; + if (!ActivityCompat.isInMultiWindowMode(getActivity()) + && (getActivity().checkSelfPermission(permission.STATUS_BAR) + == PackageManager.PERMISSION_GRANTED)) { + LogUtil.i("AnswerFragment.onCreateView", "STATUS_BAR permission granted, disabling nav bar"); + // These flags will suppress the alert that the activity is in full view mode + // during an incoming call on a fresh system/factory reset of the app + flags |= STATUS_BAR_DISABLE_BACK | STATUS_BAR_DISABLE_HOME | STATUS_BAR_DISABLE_RECENT; + } + view.setSystemUiVisibility(flags); + if (isVideoCall) { + if (VideoUtils.hasCameraPermissionAndAllowedByUser(getContext())) { + answerVideoCallScreen = new AnswerVideoCallScreen(this, view); + } else { + view.findViewById(R.id.videocall_video_off).setVisibility(View.VISIBLE); + } + } + + return view; + } + + @Override + public void onAttach(Context context) { + super.onAttach(context); + FragmentUtils.checkParent(this, InCallScreenDelegateFactory.class); + } + + @Override + public void onViewCreated(final View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + createInCallScreenDelegate(); + updateUI(); + + if (savedInstanceState == null || !savedInstanceState.getBoolean(STATE_HAS_ANIMATED_ENTRY)) { + ViewUtil.doOnPreDraw(view, false, this::animateEntry); + } + } + + @Override + public void onResume() { + super.onResume(); + LogUtil.i("AnswerFragment.onResume", null); + inCallScreenDelegate.onInCallScreenResumed(); + } + + @Override + public void onStart() { + super.onStart(); + LogUtil.i("AnswerFragment.onStart", null); + + updateUI(); + if (answerVideoCallScreen != null) { + answerVideoCallScreen.onStart(); + } + } + + @Override + public void onStop() { + super.onStop(); + LogUtil.i("AnswerFragment.onStop", null); + + handler.removeCallbacks(swipeHintRestoreTimer); + if (answerVideoCallScreen != null) { + answerVideoCallScreen.onStop(); + } + } + + @Override + public void onPause() { + super.onPause(); + LogUtil.i("AnswerFragment.onPause", null); + } + + @Override + public void onDestroyView() { + LogUtil.i("AnswerFragment.onDestroyView", null); + if (answerVideoCallScreen != null) { + answerVideoCallScreen = null; + } + super.onDestroyView(); + inCallScreenDelegate.onInCallScreenUnready(); + answerScreenDelegate.onAnswerScreenUnready(); + } + + @Override + public void onSaveInstanceState(Bundle bundle) { + super.onSaveInstanceState(bundle); + bundle.putBoolean(STATE_HAS_ANIMATED_ENTRY, hasAnimatedEntry); + } + + private void updateUI() { + if (getView() == null) { + return; + } + + if (primaryInfo != null) { + updatePrimaryUI(); + } + if (primaryCallState != null) { + contactGridManager.setCallState(primaryCallState); + } + + restoreBackgroundMaskColor(); + } + + @Override + public boolean isVideoCall() { + return VideoUtils.isVideoCall(getVideoState()); + } + + @Override + public void onAnswerProgressUpdate(@FloatRange(from = -1f, to = 1f) float answerProgress) { + // Don't fade the window background for call waiting or video upgrades. Fading the background + // shows the system wallpaper which looks bad because on reject we switch to another call. + if (primaryCallState.state == State.INCOMING && !isVideoCall()) { + answerScreenDelegate.updateWindowBackgroundColor(answerProgress); + } + + // Fade and scale contact name and video call text + float startDelay = .25f; + // Header progress is zero over positiveAdjustedProgress = [0, startDelay], + // linearly increases over (startDelay, 1] until reaching 1 when positiveAdjustedProgress = 1 + float headerProgress = Math.max(0, (Math.abs(answerProgress) - 1) / (1 - startDelay) + 1); + fadeToward(contactGridManager.getContainerView(), 1 - headerProgress); + scaleToward(contactGridManager.getContainerView(), MathUtil.lerp(1f, .75f, headerProgress)); + + if (Math.abs(answerProgress) >= .0001) { + affordanceHolderLayout.animateHideLeftRightIcon(); + handler.removeCallbacks(swipeHintRestoreTimer); + restoreSwipeHintTexts(); + } + } + + @Override + public void answerFromMethod() { + acceptCallByUser(false /* answerVideoAsAudio */); + } + + @Override + public void rejectFromMethod() { + rejectCall(); + } + + @Override + public void resetAnswerProgress() { + affordanceHolderLayout.reset(true); + restoreBackgroundMaskColor(); + } + + private void animateEntry(@NonNull View rootView) { + contactGridManager.getContainerView().setAlpha(0f); + Animator alpha = + ObjectAnimator.ofFloat(contactGridManager.getContainerView(), View.ALPHA, 0, 1); + Animator topRow = createTranslation(rootView.findViewById(R.id.contactgrid_top_row)); + Animator contactName = createTranslation(rootView.findViewById(R.id.contactgrid_contact_name)); + Animator bottomRow = createTranslation(rootView.findViewById(R.id.contactgrid_bottom_row)); + Animator important = createTranslation(importanceBadge); + Animator dataContainer = createTranslation(rootView.findViewById(R.id.incall_data_container)); + + AnimatorSet animatorSet = new AnimatorSet(); + animatorSet + .play(alpha) + .with(topRow) + .with(contactName) + .with(bottomRow) + .with(important) + .with(dataContainer); + animatorSet.setDuration(getResources().getInteger(android.R.integer.config_longAnimTime)); + animatorSet.addListener( + new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + hasAnimatedEntry = true; + } + }); + animatorSet.start(); + } + + private ObjectAnimator createTranslation(View view) { + float translationY = view.getTop() * 0.5f; + ObjectAnimator animator = ObjectAnimator.ofFloat(view, View.TRANSLATION_Y, translationY, 0); + animator.setInterpolator(Interpolators.LINEAR_OUT_SLOW_IN); + return animator; + } + + private void acceptCallByUser(boolean answerVideoAsAudio) { + LogUtil.i("AnswerFragment.acceptCallByUser", answerVideoAsAudio ? " answerVideoAsAudio" : ""); + if (!buttonAcceptClicked) { + int desiredVideoState = getVideoState(); + if (answerVideoAsAudio) { + desiredVideoState = VideoProfile.STATE_AUDIO_ONLY; + } + + // Notify the lower layer first to start signaling ASAP. + answerScreenDelegate.onAnswer(desiredVideoState); + + buttonAcceptClicked = true; + } + } + + private void rejectCall() { + LogUtil.i("AnswerFragment.rejectCall", null); + if (!buttonRejectClicked) { + Context context = getContext(); + if (context == null) { + LogUtil.w( + "AnswerFragment.rejectCall", + "Null context when rejecting call. Logger call was skipped"); + } else { + Logger.get(context) + .logImpression(DialerImpression.Type.REJECT_INCOMING_CALL_FROM_ANSWER_SCREEN); + } + buttonRejectClicked = true; + answerScreenDelegate.onReject(); + } + } + + private void restoreBackgroundMaskColor() { + answerScreenDelegate.updateWindowBackgroundColor(0); + } + + private void restoreSwipeHintTexts() { + if (getAnswerMethod() != null) { + getAnswerMethod().setHintText(null); + } + } + + private void showMessageMenu() { + LogUtil.i("AnswerFragment.showMessageMenu", "Show sms menu."); + + textResponsesFragment = SmsBottomSheetFragment.newInstance(textResponses); + textResponsesFragment.show(getChildFragmentManager(), null); + secondaryButton + .animate() + .alpha(0) + .withEndAction( + new Runnable() { + @Override + public void run() { + affordanceHolderLayout.reset(false); + secondaryButton.animate().alpha(1); + } + }); + } + + @Override + public void smsSelected(@Nullable CharSequence text) { + LogUtil.i("AnswerFragment.smsSelected", null); + textResponsesFragment = null; + + if (text == null) { + createCustomSmsDialogFragment = CreateCustomSmsDialogFragment.newInstance(); + createCustomSmsDialogFragment.show(getChildFragmentManager(), null); + return; + } + + if (primaryCallState != null && canRejectCallWithSms()) { + rejectCall(); + answerScreenDelegate.onRejectCallWithMessage(text.toString()); + } + } + + @Override + public void smsDismissed() { + LogUtil.i("AnswerFragment.smsDismissed", null); + textResponsesFragment = null; + answerScreenDelegate.onDismissDialog(); + } + + @Override + public void customSmsCreated(@NonNull CharSequence text) { + LogUtil.i("AnswerFragment.customSmsCreated", null); + createCustomSmsDialogFragment = null; + if (primaryCallState != null && canRejectCallWithSms()) { + rejectCall(); + answerScreenDelegate.onRejectCallWithMessage(text.toString()); + } + } + + @Override + public void customSmsDismissed() { + LogUtil.i("AnswerFragment.customSmsDismissed", null); + createCustomSmsDialogFragment = null; + answerScreenDelegate.onDismissDialog(); + } + + private boolean canRejectCallWithSms() { + return primaryCallState != null + && !(primaryCallState.state == State.DISCONNECTED + || primaryCallState.state == State.DISCONNECTING + || primaryCallState.state == State.IDLE); + } + + private void createInCallScreenDelegate() { + inCallScreenDelegate = + FragmentUtils.getParentUnsafe(this, InCallScreenDelegateFactory.class) + .newInCallScreenDelegate(); + Assert.isNotNull(inCallScreenDelegate); + inCallScreenDelegate.onInCallScreenDelegateInit(this); + inCallScreenDelegate.onInCallScreenReady(); + } + + private void updateImportanceBadgeVisibility() { + if (!isAdded()) { + return; + } + + if (!getResources().getBoolean(R.bool.answer_important_call_allowed)) { + importanceBadge.setVisibility(View.GONE); + return; + } + + MultimediaData multimediaData = getSessionData(); + boolean showImportant = multimediaData != null && multimediaData.isImportant(); + TransitionManager.beginDelayedTransition((ViewGroup) importanceBadge.getParent()); + // TODO (keyboardr): Change this back to being View.INVISIBLE once mocks are available to + // properly handle smaller screens + importanceBadge.setVisibility(showImportant ? View.VISIBLE : View.GONE); + } + + @Nullable + private MultimediaData getSessionData() { + if (primaryInfo == null) { + return null; + } + return primaryInfo.multimediaData; + } + + /** Shows the Avatar image if available. */ + public static class AvatarFragment extends Fragment implements AvatarPresenter { + + private ImageView avatarImageView; + + @Nullable + @Override + public View onCreateView( + LayoutInflater layoutInflater, @Nullable ViewGroup viewGroup, @Nullable Bundle bundle) { + return layoutInflater.inflate(R.layout.fragment_avatar, viewGroup, false); + } + + @Override + public void onViewCreated(View view, @Nullable Bundle bundle) { + super.onViewCreated(view, bundle); + avatarImageView = ((ImageView) view.findViewById(R.id.contactgrid_avatar)); + FragmentUtils.getParentUnsafe(this, MultimediaFragment.Holder.class).updateAvatar(this); + } + + @NonNull + @Override + public ImageView getAvatarImageView() { + return avatarImageView; + } + + @Override + public int getAvatarSize() { + return getResources().getDimensionPixelSize(R.dimen.answer_avatar_size); + } + + @Override + public boolean shouldShowAnonymousAvatar() { + return false; + } + } +} diff --git a/java/com/android/incallui/answer/impl/AnswerVideoCallScreen.java b/java/com/android/incallui/answer/impl/AnswerVideoCallScreen.java new file mode 100644 index 000000000..0316a5fab --- /dev/null +++ b/java/com/android/incallui/answer/impl/AnswerVideoCallScreen.java @@ -0,0 +1,127 @@ +/* + * Copyright (C) 2016 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.incallui.answer.impl; + +import android.content.res.Configuration; +import android.graphics.Point; +import android.support.annotation.NonNull; +import android.support.v4.app.Fragment; +import android.view.TextureView; +import android.view.View; +import com.android.dialer.common.Assert; +import com.android.dialer.common.FragmentUtils; +import com.android.dialer.common.LogUtil; +import com.android.incallui.video.protocol.VideoCallScreen; +import com.android.incallui.video.protocol.VideoCallScreenDelegate; +import com.android.incallui.video.protocol.VideoCallScreenDelegateFactory; +import com.android.incallui.videosurface.bindings.VideoSurfaceBindings; + +/** Shows a video preview for an incoming call. */ +public class AnswerVideoCallScreen implements VideoCallScreen { + @NonNull private final Fragment fragment; + @NonNull private final TextureView textureView; + @NonNull private final VideoCallScreenDelegate delegate; + + public AnswerVideoCallScreen(@NonNull Fragment fragment, @NonNull View view) { + this.fragment = fragment; + + textureView = + Assert.isNotNull((TextureView) view.findViewById(R.id.incoming_preview_texture_view)); + View overlayView = + Assert.isNotNull(view.findViewById(R.id.incoming_preview_texture_view_overlay)); + view.setBackgroundColor(0xff000000); + delegate = + FragmentUtils.getParentUnsafe(fragment, VideoCallScreenDelegateFactory.class) + .newVideoCallScreenDelegate(); + delegate.initVideoCallScreenDelegate(fragment.getContext(), this); + + textureView.setVisibility(View.VISIBLE); + overlayView.setVisibility(View.VISIBLE); + } + + public void onStart() { + LogUtil.i("AnswerVideoCallScreen.onStart", null); + delegate.onVideoCallScreenUiReady(); + delegate.getLocalVideoSurfaceTexture().attachToTextureView(textureView); + } + + public void onStop() { + LogUtil.i("AnswerVideoCallScreen.onStop", null); + delegate.onVideoCallScreenUiUnready(); + } + + @Override + public void showVideoViews( + boolean shouldShowPreview, boolean shouldShowRemote, boolean isRemotelyHeld) { + LogUtil.i( + "AnswerVideoCallScreen.showVideoViews", + "showPreview: %b, shouldShowRemote: %b", + shouldShowPreview, + shouldShowRemote); + } + + @Override + public void onLocalVideoDimensionsChanged() { + LogUtil.i("AnswerVideoCallScreen.onLocalVideoDimensionsChanged", null); + updatePreviewVideoScaling(); + } + + @Override + public void onRemoteVideoDimensionsChanged() {} + + @Override + public void onLocalVideoOrientationChanged() { + LogUtil.i("AnswerVideoCallScreen.onLocalVideoOrientationChanged", null); + updatePreviewVideoScaling(); + } + + @Override + public void updateFullscreenAndGreenScreenMode( + boolean shouldShowFullscreen, boolean shouldShowGreenScreen) {} + + @Override + public Fragment getVideoCallScreenFragment() { + return fragment; + } + + private void updatePreviewVideoScaling() { + if (textureView.getWidth() == 0 || textureView.getHeight() == 0) { + LogUtil.i( + "AnswerVideoCallScreen.updatePreviewVideoScaling", "view layout hasn't finished yet"); + return; + } + Point cameraDimensions = delegate.getLocalVideoSurfaceTexture().getSurfaceDimensions(); + if (cameraDimensions == null) { + LogUtil.i("AnswerVideoCallScreen.updatePreviewVideoScaling", "camera dimensions not set"); + return; + } + if (isLandscape()) { + VideoSurfaceBindings.scaleVideoAndFillView( + textureView, cameraDimensions.x, cameraDimensions.y, delegate.getDeviceOrientation()); + } else { + // Landscape, so dimensions are swapped + //noinspection SuspiciousNameCombination + VideoSurfaceBindings.scaleVideoAndFillView( + textureView, cameraDimensions.y, cameraDimensions.x, delegate.getDeviceOrientation()); + } + } + + private boolean isLandscape() { + return fragment.getResources().getConfiguration().orientation + == Configuration.ORIENTATION_LANDSCAPE; + } +} diff --git a/java/com/android/incallui/answer/impl/CreateCustomSmsDialogFragment.java b/java/com/android/incallui/answer/impl/CreateCustomSmsDialogFragment.java new file mode 100644 index 000000000..b49409258 --- /dev/null +++ b/java/com/android/incallui/answer/impl/CreateCustomSmsDialogFragment.java @@ -0,0 +1,137 @@ +/* + * Copyright (C) 2016 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.incallui.answer.impl; + +import android.app.AlertDialog; +import android.app.Dialog; +import android.content.DialogInterface; +import android.content.DialogInterface.OnCancelListener; +import android.content.DialogInterface.OnShowListener; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.v7.app.AppCompatDialogFragment; +import android.text.Editable; +import android.text.TextWatcher; +import android.view.View; +import android.view.WindowManager.LayoutParams; +import android.widget.Button; +import android.widget.EditText; +import com.android.dialer.common.FragmentUtils; + +/** + * Shows the dialog for users to enter a custom message when rejecting a call with an SMS message. + */ +public class CreateCustomSmsDialogFragment extends AppCompatDialogFragment { + + private static final String ARG_ENTERED_TEXT = "enteredText"; + + private EditText editText; + + public static CreateCustomSmsDialogFragment newInstance() { + return new CreateCustomSmsDialogFragment(); + } + + @NonNull + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + super.onCreateDialog(savedInstanceState); + AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()); + View view = View.inflate(builder.getContext(), R.layout.fragment_custom_sms_dialog, null); + editText = (EditText) view.findViewById(R.id.custom_sms_input); + if (savedInstanceState != null) { + editText.setText(savedInstanceState.getCharSequence(ARG_ENTERED_TEXT)); + } + builder + .setCancelable(true) + .setView(view) + .setPositiveButton( + R.string.call_incoming_custom_message_send, + new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialogInterface, int i) { + FragmentUtils.getParentUnsafe( + CreateCustomSmsDialogFragment.this, CreateCustomSmsHolder.class) + .customSmsCreated(editText.getText().toString().trim()); + dismiss(); + } + }) + .setNegativeButton( + R.string.call_incoming_custom_message_cancel, + new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialogInterface, int i) { + dismiss(); + } + }) + .setOnCancelListener( + new OnCancelListener() { + @Override + public void onCancel(DialogInterface dialogInterface) { + dismiss(); + } + }) + .setTitle(R.string.call_incoming_respond_via_sms_custom_message); + final AlertDialog customMessagePopup = builder.create(); + customMessagePopup.setOnShowListener( + new OnShowListener() { + @Override + public void onShow(DialogInterface dialogInterface) { + ((AlertDialog) dialogInterface) + .getButton(AlertDialog.BUTTON_POSITIVE) + .setEnabled(false); + } + }); + + editText.addTextChangedListener( + new TextWatcher() { + @Override + public void beforeTextChanged(CharSequence charSequence, int i, int i1, int i2) {} + + @Override + public void onTextChanged(CharSequence charSequence, int i, int i1, int i2) {} + + @Override + public void afterTextChanged(Editable editable) { + Button sendButton = customMessagePopup.getButton(DialogInterface.BUTTON_POSITIVE); + sendButton.setEnabled(editable != null && editable.toString().trim().length() != 0); + } + }); + customMessagePopup.getWindow().setSoftInputMode(LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE); + customMessagePopup.getWindow().addFlags(LayoutParams.FLAG_SHOW_WHEN_LOCKED); + return customMessagePopup; + } + + @Override + public void onSaveInstanceState(@NonNull Bundle outState) { + super.onSaveInstanceState(outState); + outState.putCharSequence(ARG_ENTERED_TEXT, editText.getText()); + } + + @Override + public void onDismiss(DialogInterface dialogInterface) { + super.onDismiss(dialogInterface); + FragmentUtils.getParentUnsafe(this, CreateCustomSmsHolder.class).customSmsDismissed(); + } + + /** Call back for {@link CreateCustomSmsDialogFragment} */ + public interface CreateCustomSmsHolder { + + void customSmsCreated(@NonNull CharSequence text); + + void customSmsDismissed(); + } +} diff --git a/java/com/android/incallui/answer/impl/PillDrawable.java b/java/com/android/incallui/answer/impl/PillDrawable.java new file mode 100644 index 000000000..57d84c45f --- /dev/null +++ b/java/com/android/incallui/answer/impl/PillDrawable.java @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2016 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.incallui.answer.impl; + +import android.graphics.Rect; +import android.graphics.drawable.GradientDrawable; + +/** Draws a pill-shaped background */ +public class PillDrawable extends GradientDrawable { + + public PillDrawable() { + super(); + setShape(RECTANGLE); + } + + @Override + protected void onBoundsChange(Rect r) { + super.onBoundsChange(r); + setCornerRadius(r.height() / 2); + } + + @Override + public void setShape(int shape) { + if (shape != GradientDrawable.RECTANGLE) { + throw new UnsupportedOperationException("PillDrawable must be a rectangle"); + } + super.setShape(shape); + } +} diff --git a/java/com/android/incallui/answer/impl/SmsBottomSheetFragment.java b/java/com/android/incallui/answer/impl/SmsBottomSheetFragment.java new file mode 100644 index 000000000..085430ea2 --- /dev/null +++ b/java/com/android/incallui/answer/impl/SmsBottomSheetFragment.java @@ -0,0 +1,136 @@ +/* + * Copyright (C) 2016 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.incallui.answer.impl; + +import android.app.Dialog; +import android.content.Context; +import android.content.DialogInterface; +import android.content.res.TypedArray; +import android.graphics.drawable.Drawable; +import android.os.Bundle; +import android.support.annotation.Nullable; +import android.support.design.widget.BottomSheetDialogFragment; +import android.view.ContextThemeWrapper; +import android.view.LayoutInflater; +import android.view.View; +import android.view.View.OnClickListener; +import android.view.ViewGroup; +import android.view.ViewGroup.LayoutParams; +import android.view.WindowManager; +import android.widget.LinearLayout; +import android.widget.TextView; +import com.android.dialer.common.DpUtil; +import com.android.dialer.common.FragmentUtils; +import com.android.dialer.common.LogUtil; +import java.util.ArrayList; +import java.util.List; + +/** Shows options for rejecting call with SMS */ +public class SmsBottomSheetFragment extends BottomSheetDialogFragment { + + private static final String ARG_OPTIONS = "options"; + + public static SmsBottomSheetFragment newInstance(@Nullable ArrayList<CharSequence> options) { + SmsBottomSheetFragment fragment = new SmsBottomSheetFragment(); + Bundle args = new Bundle(); + args.putCharSequenceArrayList(ARG_OPTIONS, options); + fragment.setArguments(args); + return fragment; + } + + @Nullable + @Override + public View onCreateView( + LayoutInflater layoutInflater, @Nullable ViewGroup viewGroup, @Nullable Bundle bundle) { + LinearLayout layout = new LinearLayout(getContext()); + layout.setOrientation(LinearLayout.VERTICAL); + List<CharSequence> items = getArguments().getCharSequenceArrayList(ARG_OPTIONS); + if (items != null) { + for (CharSequence item : items) { + layout.addView(newTextViewItem(item)); + } + } + layout.addView(newTextViewItem(null)); + layout.setLayoutParams(new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); + return layout; + } + + @Override + public void onAttach(Context context) { + super.onAttach(context); + FragmentUtils.checkParent(this, SmsSheetHolder.class); + } + + @Override + public Dialog onCreateDialog(final Bundle savedInstanceState) { + LogUtil.i("SmsBottomSheetFragment.onCreateDialog", null); + Dialog dialog = super.onCreateDialog(savedInstanceState); + dialog.getWindow().addFlags(WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED); + return dialog; + } + + private TextView newTextViewItem(@Nullable final CharSequence text) { + int[] attrs = new int[] {android.R.attr.selectableItemBackground}; + Context context = new ContextThemeWrapper(getContext(), getTheme()); + TypedArray typedArray = context.obtainStyledAttributes(attrs); + Drawable background = typedArray.getDrawable(0); + //noinspection ResourceType + typedArray.recycle(); + + TextView textView = new TextView(context); + textView.setText(text == null ? getString(R.string.call_incoming_message_custom) : text); + int padding = (int) DpUtil.dpToPx(context, 16); + textView.setPadding(padding, padding, padding, padding); + textView.setBackground(background); + textView.setTextColor(context.getColor(R.color.blue_grey_100)); + textView.setTextAppearance(R.style.TextAppearance_AppCompat_Widget_PopupMenu_Large); + + LayoutParams params = + new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + textView.setLayoutParams(params); + + textView.setOnClickListener( + new OnClickListener() { + @Override + public void onClick(View v) { + FragmentUtils.getParentUnsafe(SmsBottomSheetFragment.this, SmsSheetHolder.class) + .smsSelected(text); + dismiss(); + } + }); + return textView; + } + + @Override + public int getTheme() { + return R.style.Theme_Design_Light_BottomSheetDialog; + } + + @Override + public void onDismiss(DialogInterface dialogInterface) { + super.onDismiss(dialogInterface); + FragmentUtils.getParentUnsafe(this, SmsSheetHolder.class).smsDismissed(); + } + + /** Callback interface for {@link SmsBottomSheetFragment} */ + public interface SmsSheetHolder { + + void smsSelected(@Nullable CharSequence text); + + void smsDismissed(); + } +} diff --git a/java/com/android/incallui/answer/impl/affordance/AndroidManifest.xml b/java/com/android/incallui/answer/impl/affordance/AndroidManifest.xml new file mode 100644 index 000000000..960fd71c1 --- /dev/null +++ b/java/com/android/incallui/answer/impl/affordance/AndroidManifest.xml @@ -0,0 +1,3 @@ +<manifest + package="com.android.incallui.answer.impl.affordance"> +</manifest> diff --git a/java/com/android/incallui/answer/impl/affordance/SwipeButtonHelper.java b/java/com/android/incallui/answer/impl/affordance/SwipeButtonHelper.java new file mode 100644 index 000000000..62845b748 --- /dev/null +++ b/java/com/android/incallui/answer/impl/affordance/SwipeButtonHelper.java @@ -0,0 +1,642 @@ +/* + * Copyright (C) 2016 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.incallui.answer.impl.affordance; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.ValueAnimator; +import android.content.Context; +import android.support.annotation.Nullable; +import android.view.MotionEvent; +import android.view.VelocityTracker; +import android.view.View; +import android.view.ViewConfiguration; +import com.android.incallui.answer.impl.utils.FlingAnimationUtils; +import com.android.incallui.answer.impl.utils.Interpolators; + +/** A touch handler of the swipe buttons */ +public class SwipeButtonHelper { + + public static final float SWIPE_RESTING_ALPHA_AMOUNT = 0.87f; + public static final long HINT_PHASE1_DURATION = 200; + private static final long HINT_PHASE2_DURATION = 350; + private static final float BACKGROUND_RADIUS_SCALE_FACTOR = 0.25f; + private static final int HINT_CIRCLE_OPEN_DURATION = 500; + + private final Context context; + private final Callback callback; + + private FlingAnimationUtils flingAnimationUtils; + private VelocityTracker velocityTracker; + private boolean swipingInProgress; + private float initialTouchX; + private float initialTouchY; + private float translation; + private float translationOnDown; + private int touchSlop; + private int minTranslationAmount; + private int minFlingVelocity; + private int hintGrowAmount; + @Nullable private SwipeButtonView leftIcon; + @Nullable private SwipeButtonView rightIcon; + private Animator swipeAnimator; + private int minBackgroundRadius; + private boolean motionCancelled; + private int touchTargetSize; + private View targetedView; + private boolean touchSlopExeeded; + private AnimatorListenerAdapter flingEndListener = + new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + swipeAnimator = null; + swipingInProgress = false; + targetedView = null; + } + }; + private Runnable animationEndRunnable = + new Runnable() { + @Override + public void run() { + callback.onAnimationToSideEnded(); + } + }; + + public SwipeButtonHelper(Callback callback, Context context) { + this.context = context; + this.callback = callback; + init(); + } + + public void init() { + initIcons(); + updateIcon( + leftIcon, + 0.0f, + leftIcon != null ? leftIcon.getRestingAlpha() : 0, + false, + false, + true, + false); + updateIcon( + rightIcon, + 0.0f, + rightIcon != null ? rightIcon.getRestingAlpha() : 0, + false, + false, + true, + false); + initDimens(); + } + + private void initDimens() { + final ViewConfiguration configuration = ViewConfiguration.get(context); + touchSlop = configuration.getScaledPagingTouchSlop(); + minFlingVelocity = configuration.getScaledMinimumFlingVelocity(); + minTranslationAmount = + context.getResources().getDimensionPixelSize(R.dimen.answer_min_swipe_amount); + minBackgroundRadius = + context + .getResources() + .getDimensionPixelSize(R.dimen.answer_affordance_min_background_radius); + touchTargetSize = + context.getResources().getDimensionPixelSize(R.dimen.answer_affordance_touch_target_size); + hintGrowAmount = + context.getResources().getDimensionPixelSize(R.dimen.hint_grow_amount_sideways); + flingAnimationUtils = new FlingAnimationUtils(context, 0.4f); + } + + private void initIcons() { + leftIcon = callback.getLeftIcon(); + rightIcon = callback.getRightIcon(); + updatePreviews(); + } + + public void updatePreviews() { + if (leftIcon != null) { + leftIcon.setPreviewView(callback.getLeftPreview()); + } + if (rightIcon != null) { + rightIcon.setPreviewView(callback.getRightPreview()); + } + } + + public boolean onTouchEvent(MotionEvent event) { + int action = event.getActionMasked(); + if (motionCancelled && action != MotionEvent.ACTION_DOWN) { + return false; + } + final float y = event.getY(); + final float x = event.getX(); + + boolean isUp = false; + switch (action) { + case MotionEvent.ACTION_DOWN: + View targetView = getIconAtPosition(x, y); + if (targetView == null || (targetedView != null && targetedView != targetView)) { + motionCancelled = true; + return false; + } + if (targetedView != null) { + cancelAnimation(); + } else { + touchSlopExeeded = false; + } + startSwiping(targetView); + initialTouchX = x; + initialTouchY = y; + translationOnDown = translation; + initVelocityTracker(); + trackMovement(event); + motionCancelled = false; + break; + case MotionEvent.ACTION_POINTER_DOWN: + motionCancelled = true; + endMotion(true /* forceSnapBack */, x, y); + break; + case MotionEvent.ACTION_MOVE: + trackMovement(event); + float xDist = x - initialTouchX; + float yDist = y - initialTouchY; + float distance = (float) Math.hypot(xDist, yDist); + if (!touchSlopExeeded && distance > touchSlop) { + touchSlopExeeded = true; + } + if (swipingInProgress) { + if (targetedView == rightIcon) { + distance = translationOnDown - distance; + distance = Math.min(0, distance); + } else { + distance = translationOnDown + distance; + distance = Math.max(0, distance); + } + setTranslation(distance, false /* isReset */, false /* animateReset */); + } + break; + + case MotionEvent.ACTION_UP: + isUp = true; + //fallthrough_intended + case MotionEvent.ACTION_CANCEL: + boolean hintOnTheRight = targetedView == rightIcon; + trackMovement(event); + endMotion(!isUp, x, y); + if (!touchSlopExeeded && isUp) { + callback.onIconClicked(hintOnTheRight); + } + break; + } + return true; + } + + private void startSwiping(View targetView) { + callback.onSwipingStarted(targetView == rightIcon); + swipingInProgress = true; + targetedView = targetView; + } + + private View getIconAtPosition(float x, float y) { + if (leftSwipePossible() && isOnIcon(leftIcon, x, y)) { + return leftIcon; + } + if (rightSwipePossible() && isOnIcon(rightIcon, x, y)) { + return rightIcon; + } + return null; + } + + public boolean isOnAffordanceIcon(float x, float y) { + return isOnIcon(leftIcon, x, y) || isOnIcon(rightIcon, x, y); + } + + private boolean isOnIcon(View icon, float x, float y) { + float iconX = icon.getX() + icon.getWidth() / 2.0f; + float iconY = icon.getY() + icon.getHeight() / 2.0f; + double distance = Math.hypot(x - iconX, y - iconY); + return distance <= touchTargetSize / 2; + } + + private void endMotion(boolean forceSnapBack, float lastX, float lastY) { + if (swipingInProgress) { + flingWithCurrentVelocity(forceSnapBack, lastX, lastY); + } else { + targetedView = null; + } + if (velocityTracker != null) { + velocityTracker.recycle(); + velocityTracker = null; + } + } + + private boolean rightSwipePossible() { + return rightIcon != null && rightIcon.getVisibility() == View.VISIBLE; + } + + private boolean leftSwipePossible() { + return leftIcon != null && leftIcon.getVisibility() == View.VISIBLE; + } + + public void startHintAnimation(boolean right, @Nullable Runnable onFinishedListener) { + cancelAnimation(); + startHintAnimationPhase1(right, onFinishedListener); + } + + private void startHintAnimationPhase1( + final boolean right, @Nullable final Runnable onFinishedListener) { + final SwipeButtonView targetView = right ? rightIcon : leftIcon; + ValueAnimator animator = getAnimatorToRadius(right, hintGrowAmount); + if (animator == null) { + if (onFinishedListener != null) { + onFinishedListener.run(); + } + return; + } + animator.addListener( + new AnimatorListenerAdapter() { + private boolean mCancelled; + + @Override + public void onAnimationCancel(Animator animation) { + mCancelled = true; + } + + @Override + public void onAnimationEnd(Animator animation) { + if (mCancelled) { + swipeAnimator = null; + targetedView = null; + if (onFinishedListener != null) { + onFinishedListener.run(); + } + } else { + startUnlockHintAnimationPhase2(right, onFinishedListener); + } + } + }); + animator.setInterpolator(Interpolators.LINEAR_OUT_SLOW_IN); + animator.setDuration(HINT_PHASE1_DURATION); + animator.start(); + swipeAnimator = animator; + targetedView = targetView; + } + + /** Phase 2: Move back. */ + private void startUnlockHintAnimationPhase2( + boolean right, @Nullable final Runnable onFinishedListener) { + ValueAnimator animator = getAnimatorToRadius(right, 0); + if (animator == null) { + if (onFinishedListener != null) { + onFinishedListener.run(); + } + return; + } + animator.addListener( + new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + swipeAnimator = null; + targetedView = null; + if (onFinishedListener != null) { + onFinishedListener.run(); + } + } + }); + animator.setInterpolator(Interpolators.FAST_OUT_LINEAR_IN); + animator.setDuration(HINT_PHASE2_DURATION); + animator.setStartDelay(HINT_CIRCLE_OPEN_DURATION); + animator.start(); + swipeAnimator = animator; + } + + private ValueAnimator getAnimatorToRadius(final boolean right, int radius) { + final SwipeButtonView targetView = right ? rightIcon : leftIcon; + if (targetView == null) { + return null; + } + ValueAnimator animator = ValueAnimator.ofFloat(targetView.getCircleRadius(), radius); + animator.addUpdateListener( + new ValueAnimator.AnimatorUpdateListener() { + @Override + public void onAnimationUpdate(ValueAnimator animation) { + float newRadius = (float) animation.getAnimatedValue(); + targetView.setCircleRadiusWithoutAnimation(newRadius); + float translation = getTranslationFromRadius(newRadius); + SwipeButtonHelper.this.translation = right ? -translation : translation; + updateIconsFromTranslation(targetView); + } + }); + return animator; + } + + private void cancelAnimation() { + if (swipeAnimator != null) { + swipeAnimator.cancel(); + } + } + + private void flingWithCurrentVelocity(boolean forceSnapBack, float lastX, float lastY) { + float vel = getCurrentVelocity(lastX, lastY); + + // We snap back if the current translation is not far enough + boolean snapBack = isBelowFalsingThreshold(); + + // or if the velocity is in the opposite direction. + boolean velIsInWrongDirection = vel * translation < 0; + snapBack |= Math.abs(vel) > minFlingVelocity && velIsInWrongDirection; + vel = snapBack ^ velIsInWrongDirection ? 0 : vel; + fling(vel, snapBack || forceSnapBack, translation < 0); + } + + private boolean isBelowFalsingThreshold() { + return Math.abs(translation) < Math.abs(translationOnDown) + getMinTranslationAmount(); + } + + private int getMinTranslationAmount() { + float factor = callback.getAffordanceFalsingFactor(); + return (int) (minTranslationAmount * factor); + } + + private void fling(float vel, final boolean snapBack, boolean right) { + float target = + right ? -callback.getMaxTranslationDistance() : callback.getMaxTranslationDistance(); + target = snapBack ? 0 : target; + + ValueAnimator animator = ValueAnimator.ofFloat(translation, target); + flingAnimationUtils.apply(animator, translation, target, vel); + animator.addUpdateListener( + new ValueAnimator.AnimatorUpdateListener() { + @Override + public void onAnimationUpdate(ValueAnimator animation) { + translation = (float) animation.getAnimatedValue(); + } + }); + animator.addListener(flingEndListener); + if (!snapBack) { + startFinishingCircleAnimation(vel * 0.375f, animationEndRunnable, right); + callback.onAnimationToSideStarted(right, translation, vel); + } else { + reset(true); + } + animator.start(); + swipeAnimator = animator; + if (snapBack) { + callback.onSwipingAborted(); + } + } + + private void startFinishingCircleAnimation( + float velocity, Runnable mAnimationEndRunnable, boolean right) { + SwipeButtonView targetView = right ? rightIcon : leftIcon; + if (targetView != null) { + targetView.finishAnimation(velocity, mAnimationEndRunnable); + } + } + + private void setTranslation(float translation, boolean isReset, boolean animateReset) { + translation = rightSwipePossible() ? translation : Math.max(0, translation); + translation = leftSwipePossible() ? translation : Math.min(0, translation); + float absTranslation = Math.abs(translation); + if (translation != this.translation || isReset) { + SwipeButtonView targetView = translation > 0 ? leftIcon : rightIcon; + SwipeButtonView otherView = translation > 0 ? rightIcon : leftIcon; + float alpha = absTranslation / getMinTranslationAmount(); + + // We interpolate the alpha of the other icons to 0 + float fadeOutAlpha = 1.0f - alpha; + fadeOutAlpha = Math.max(fadeOutAlpha, 0.0f); + + boolean animateIcons = isReset && animateReset; + boolean forceNoCircleAnimation = isReset && !animateReset; + float radius = getRadiusFromTranslation(absTranslation); + boolean slowAnimation = isReset && isBelowFalsingThreshold(); + if (targetView != null) { + if (!isReset) { + updateIcon( + targetView, + radius, + alpha + fadeOutAlpha * targetView.getRestingAlpha(), + false, + false, + false, + false); + } else { + updateIcon( + targetView, + 0.0f, + fadeOutAlpha * targetView.getRestingAlpha(), + animateIcons, + slowAnimation, + false, + forceNoCircleAnimation); + } + } + if (otherView != null) { + updateIcon( + otherView, + 0.0f, + fadeOutAlpha * otherView.getRestingAlpha(), + animateIcons, + slowAnimation, + false, + forceNoCircleAnimation); + } + + this.translation = translation; + } + } + + private void updateIconsFromTranslation(SwipeButtonView targetView) { + float absTranslation = Math.abs(translation); + float alpha = absTranslation / getMinTranslationAmount(); + + // We interpolate the alpha of the other icons to 0 + float fadeOutAlpha = 1.0f - alpha; + fadeOutAlpha = Math.max(0.0f, fadeOutAlpha); + + // We interpolate the alpha of the targetView to 1 + SwipeButtonView otherView = targetView == rightIcon ? leftIcon : rightIcon; + updateIconAlpha(targetView, alpha + fadeOutAlpha * targetView.getRestingAlpha(), false); + if (otherView != null) { + updateIconAlpha(otherView, fadeOutAlpha * otherView.getRestingAlpha(), false); + } + } + + private float getTranslationFromRadius(float circleSize) { + float translation = (circleSize - minBackgroundRadius) / BACKGROUND_RADIUS_SCALE_FACTOR; + return translation > 0.0f ? translation + touchSlop : 0.0f; + } + + private float getRadiusFromTranslation(float translation) { + if (translation <= touchSlop) { + return 0.0f; + } + return (translation - touchSlop) * BACKGROUND_RADIUS_SCALE_FACTOR + minBackgroundRadius; + } + + public void animateHideLeftRightIcon() { + cancelAnimation(); + updateIcon(rightIcon, 0f, 0f, true, false, false, false); + updateIcon(leftIcon, 0f, 0f, true, false, false, false); + } + + private void updateIcon( + @Nullable SwipeButtonView view, + float circleRadius, + float alpha, + boolean animate, + boolean slowRadiusAnimation, + boolean force, + boolean forceNoCircleAnimation) { + if (view == null) { + return; + } + if (view.getVisibility() != View.VISIBLE && !force) { + return; + } + if (forceNoCircleAnimation) { + view.setCircleRadiusWithoutAnimation(circleRadius); + } else { + view.setCircleRadius(circleRadius, slowRadiusAnimation); + } + updateIconAlpha(view, alpha, animate); + } + + private void updateIconAlpha(SwipeButtonView view, float alpha, boolean animate) { + float scale = getScale(alpha, view); + alpha = Math.min(1.0f, alpha); + view.setImageAlpha(alpha, animate); + view.setImageScale(scale, animate); + } + + private float getScale(float alpha, SwipeButtonView icon) { + float scale = alpha / icon.getRestingAlpha() * 0.2f + SwipeButtonView.MIN_ICON_SCALE_AMOUNT; + return Math.min(scale, SwipeButtonView.MAX_ICON_SCALE_AMOUNT); + } + + private void trackMovement(MotionEvent event) { + if (velocityTracker != null) { + velocityTracker.addMovement(event); + } + } + + private void initVelocityTracker() { + if (velocityTracker != null) { + velocityTracker.recycle(); + } + velocityTracker = VelocityTracker.obtain(); + } + + private float getCurrentVelocity(float lastX, float lastY) { + if (velocityTracker == null) { + return 0; + } + velocityTracker.computeCurrentVelocity(1000); + float aX = velocityTracker.getXVelocity(); + float aY = velocityTracker.getYVelocity(); + float bX = lastX - initialTouchX; + float bY = lastY - initialTouchY; + float bLen = (float) Math.hypot(bX, bY); + // Project the velocity onto the distance vector: a * b / |b| + float projectedVelocity = (aX * bX + aY * bY) / bLen; + if (targetedView == rightIcon) { + projectedVelocity = -projectedVelocity; + } + return projectedVelocity; + } + + public void onConfigurationChanged() { + initDimens(); + initIcons(); + } + + public void onRtlPropertiesChanged() { + initIcons(); + } + + public void reset(boolean animate) { + cancelAnimation(); + setTranslation(0.0f, true, animate); + motionCancelled = true; + if (swipingInProgress) { + callback.onSwipingAborted(); + swipingInProgress = false; + } + } + + public boolean isSwipingInProgress() { + return swipingInProgress; + } + + public void launchAffordance(boolean animate, boolean left) { + SwipeButtonView targetView = left ? leftIcon : rightIcon; + if (swipingInProgress || targetView == null) { + // We don't want to mess with the state if the user is actually swiping already. + return; + } + SwipeButtonView otherView = left ? rightIcon : leftIcon; + startSwiping(targetView); + if (animate) { + fling(0, false, !left); + updateIcon(otherView, 0.0f, 0, true, false, true, false); + } else { + callback.onAnimationToSideStarted(!left, translation, 0); + translation = + left ? callback.getMaxTranslationDistance() : callback.getMaxTranslationDistance(); + updateIcon(otherView, 0.0f, 0.0f, false, false, true, false); + targetView.instantFinishAnimation(); + flingEndListener.onAnimationEnd(null); + animationEndRunnable.run(); + } + } + + /** Callback interface for various actions */ + public interface Callback { + + /** + * Notifies the callback when an animation to a side page was started. + * + * @param rightPage Is the page animated to the right page? + */ + void onAnimationToSideStarted(boolean rightPage, float translation, float vel); + + /** Notifies the callback the animation to a side page has ended. */ + void onAnimationToSideEnded(); + + float getMaxTranslationDistance(); + + void onSwipingStarted(boolean rightIcon); + + void onSwipingAborted(); + + void onIconClicked(boolean rightIcon); + + @Nullable + SwipeButtonView getLeftIcon(); + + @Nullable + SwipeButtonView getRightIcon(); + + @Nullable + View getLeftPreview(); + + @Nullable + View getRightPreview(); + + /** @return The factor the minimum swipe amount should be multiplied with. */ + float getAffordanceFalsingFactor(); + } +} diff --git a/java/com/android/incallui/answer/impl/affordance/SwipeButtonView.java b/java/com/android/incallui/answer/impl/affordance/SwipeButtonView.java new file mode 100644 index 000000000..46879ea3f --- /dev/null +++ b/java/com/android/incallui/answer/impl/affordance/SwipeButtonView.java @@ -0,0 +1,505 @@ +/* + * Copyright (C) 2016 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.incallui.answer.impl.affordance; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.ArgbEvaluator; +import android.animation.PropertyValuesHolder; +import android.animation.ValueAnimator; +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.PorterDuff; +import android.graphics.drawable.Drawable; +import android.support.annotation.Nullable; +import android.util.AttributeSet; +import android.view.View; +import android.view.ViewAnimationUtils; +import android.view.animation.Interpolator; +import android.widget.ImageView; +import com.android.incallui.answer.impl.utils.FlingAnimationUtils; +import com.android.incallui.answer.impl.utils.Interpolators; + +/** Button that allows swiping to trigger */ +public class SwipeButtonView extends ImageView { + + private static final long CIRCLE_APPEAR_DURATION = 80; + private static final long CIRCLE_DISAPPEAR_MAX_DURATION = 200; + private static final long NORMAL_ANIMATION_DURATION = 200; + public static final float MAX_ICON_SCALE_AMOUNT = 1.5f; + public static final float MIN_ICON_SCALE_AMOUNT = 0.8f; + + private final int minBackgroundRadius; + private final Paint circlePaint; + private final int inverseColor; + private final int normalColor; + private final ArgbEvaluator colorInterpolator; + private final FlingAnimationUtils flingAnimationUtils; + private float circleRadius; + private int centerX; + private int centerY; + private ValueAnimator circleAnimator; + private ValueAnimator alphaAnimator; + private ValueAnimator scaleAnimator; + private float circleStartValue; + private boolean circleWillBeHidden; + private int[] tempPoint = new int[2]; + private float tmageScale = 1f; + private int circleColor; + private View previewView; + private float circleStartRadius; + private float maxCircleSize; + private Animator previewClipper; + private float restingAlpha = SwipeButtonHelper.SWIPE_RESTING_ALPHA_AMOUNT; + private boolean finishing; + private boolean launchingAffordance; + + private AnimatorListenerAdapter clipEndListener = + new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + previewClipper = null; + } + }; + private AnimatorListenerAdapter circleEndListener = + new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + circleAnimator = null; + } + }; + private AnimatorListenerAdapter scaleEndListener = + new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + scaleAnimator = null; + } + }; + private AnimatorListenerAdapter alphaEndListener = + new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + alphaAnimator = null; + } + }; + + public SwipeButtonView(Context context) { + this(context, null); + } + + public SwipeButtonView(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public SwipeButtonView(Context context, AttributeSet attrs, int defStyleAttr) { + this(context, attrs, defStyleAttr, 0); + } + + public SwipeButtonView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + circlePaint = new Paint(); + circlePaint.setAntiAlias(true); + circleColor = 0xffffffff; + circlePaint.setColor(circleColor); + + normalColor = 0xffffffff; + inverseColor = 0xff000000; + minBackgroundRadius = + context + .getResources() + .getDimensionPixelSize(R.dimen.answer_affordance_min_background_radius); + colorInterpolator = new ArgbEvaluator(); + flingAnimationUtils = new FlingAnimationUtils(context, 0.3f); + } + + @Override + protected void onLayout(boolean changed, int left, int top, int right, int bottom) { + super.onLayout(changed, left, top, right, bottom); + centerX = getWidth() / 2; + centerY = getHeight() / 2; + maxCircleSize = getMaxCircleSize(); + } + + @Override + protected void onDraw(Canvas canvas) { + drawBackgroundCircle(canvas); + canvas.save(); + canvas.scale(tmageScale, tmageScale, getWidth() / 2, getHeight() / 2); + super.onDraw(canvas); + canvas.restore(); + } + + public void setPreviewView(@Nullable View v) { + View oldPreviewView = previewView; + previewView = v; + if (previewView != null) { + previewView.setVisibility(launchingAffordance ? oldPreviewView.getVisibility() : INVISIBLE); + } + } + + private void updateIconColor() { + Drawable drawable = getDrawable().mutate(); + float alpha = circleRadius / minBackgroundRadius; + alpha = Math.min(1.0f, alpha); + int color = (int) colorInterpolator.evaluate(alpha, normalColor, inverseColor); + drawable.setColorFilter(color, PorterDuff.Mode.SRC_ATOP); + } + + private void drawBackgroundCircle(Canvas canvas) { + if (circleRadius > 0 || finishing) { + updateCircleColor(); + canvas.drawCircle(centerX, centerY, circleRadius, circlePaint); + } + } + + private void updateCircleColor() { + float fraction = + 0.5f + + 0.5f + * Math.max( + 0.0f, + Math.min( + 1.0f, (circleRadius - minBackgroundRadius) / (0.5f * minBackgroundRadius))); + if (previewView != null && previewView.getVisibility() == VISIBLE) { + float finishingFraction = + 1 - Math.max(0, circleRadius - circleStartRadius) / (maxCircleSize - circleStartRadius); + fraction *= finishingFraction; + } + int color = + Color.argb( + (int) (Color.alpha(circleColor) * fraction), + Color.red(circleColor), + Color.green(circleColor), + Color.blue(circleColor)); + circlePaint.setColor(color); + } + + public void finishAnimation(float velocity, @Nullable final Runnable mAnimationEndRunnable) { + cancelAnimator(circleAnimator); + cancelAnimator(previewClipper); + finishing = true; + circleStartRadius = circleRadius; + final float maxCircleSize = getMaxCircleSize(); + Animator animatorToRadius; + animatorToRadius = getAnimatorToRadius(maxCircleSize); + flingAnimationUtils.applyDismissing( + animatorToRadius, circleRadius, maxCircleSize, velocity, maxCircleSize); + animatorToRadius.addListener( + new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + if (mAnimationEndRunnable != null) { + mAnimationEndRunnable.run(); + } + finishing = false; + circleRadius = maxCircleSize; + invalidate(); + } + }); + animatorToRadius.start(); + setImageAlpha(0, true); + if (previewView != null) { + previewView.setVisibility(View.VISIBLE); + previewClipper = + ViewAnimationUtils.createCircularReveal( + previewView, getLeft() + centerX, getTop() + centerY, circleRadius, maxCircleSize); + flingAnimationUtils.applyDismissing( + previewClipper, circleRadius, maxCircleSize, velocity, maxCircleSize); + previewClipper.addListener(clipEndListener); + previewClipper.start(); + } + } + + public void instantFinishAnimation() { + cancelAnimator(previewClipper); + if (previewView != null) { + previewView.setClipBounds(null); + previewView.setVisibility(View.VISIBLE); + } + circleRadius = getMaxCircleSize(); + setImageAlpha(0, false); + invalidate(); + } + + private float getMaxCircleSize() { + getLocationInWindow(tempPoint); + float rootWidth = getRootView().getWidth(); + float width = tempPoint[0] + centerX; + width = Math.max(rootWidth - width, width); + float height = tempPoint[1] + centerY; + return (float) Math.hypot(width, height); + } + + public void setCircleRadius(float circleRadius) { + setCircleRadius(circleRadius, false, false); + } + + public void setCircleRadius(float circleRadius, boolean slowAnimation) { + setCircleRadius(circleRadius, slowAnimation, false); + } + + public void setCircleRadiusWithoutAnimation(float circleRadius) { + cancelAnimator(circleAnimator); + setCircleRadius(circleRadius, false, true); + } + + private void setCircleRadius(float circleRadius, boolean slowAnimation, boolean noAnimation) { + + // Check if we need a new animation + boolean radiusHidden = + (circleAnimator != null && circleWillBeHidden) + || (circleAnimator == null && this.circleRadius == 0.0f); + boolean nowHidden = circleRadius == 0.0f; + boolean radiusNeedsAnimation = (radiusHidden != nowHidden) && !noAnimation; + if (!radiusNeedsAnimation) { + if (circleAnimator == null) { + this.circleRadius = circleRadius; + updateIconColor(); + invalidate(); + if (nowHidden) { + if (previewView != null) { + previewView.setVisibility(View.INVISIBLE); + } + } + } else if (!circleWillBeHidden) { + + // We just update the end value + float diff = circleRadius - minBackgroundRadius; + PropertyValuesHolder[] values = circleAnimator.getValues(); + values[0].setFloatValues(circleStartValue + diff, circleRadius); + circleAnimator.setCurrentPlayTime(circleAnimator.getCurrentPlayTime()); + } + } else { + cancelAnimator(circleAnimator); + cancelAnimator(previewClipper); + ValueAnimator animator = getAnimatorToRadius(circleRadius); + Interpolator interpolator = + circleRadius == 0.0f + ? Interpolators.FAST_OUT_LINEAR_IN + : Interpolators.LINEAR_OUT_SLOW_IN; + animator.setInterpolator(interpolator); + long duration = 250; + if (!slowAnimation) { + float durationFactor = + Math.abs(this.circleRadius - circleRadius) / (float) minBackgroundRadius; + duration = (long) (CIRCLE_APPEAR_DURATION * durationFactor); + duration = Math.min(duration, CIRCLE_DISAPPEAR_MAX_DURATION); + } + animator.setDuration(duration); + animator.start(); + if (previewView != null && previewView.getVisibility() == View.VISIBLE) { + previewView.setVisibility(View.VISIBLE); + previewClipper = + ViewAnimationUtils.createCircularReveal( + previewView, + getLeft() + centerX, + getTop() + centerY, + this.circleRadius, + circleRadius); + previewClipper.setInterpolator(interpolator); + previewClipper.setDuration(duration); + previewClipper.addListener(clipEndListener); + previewClipper.addListener( + new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + previewView.setVisibility(View.INVISIBLE); + } + }); + previewClipper.start(); + } + } + } + + private ValueAnimator getAnimatorToRadius(float circleRadius) { + ValueAnimator animator = ValueAnimator.ofFloat(this.circleRadius, circleRadius); + circleAnimator = animator; + circleStartValue = this.circleRadius; + circleWillBeHidden = circleRadius == 0.0f; + animator.addUpdateListener( + new ValueAnimator.AnimatorUpdateListener() { + @Override + public void onAnimationUpdate(ValueAnimator animation) { + SwipeButtonView.this.circleRadius = (float) animation.getAnimatedValue(); + updateIconColor(); + invalidate(); + } + }); + animator.addListener(circleEndListener); + return animator; + } + + private void cancelAnimator(Animator animator) { + if (animator != null) { + animator.cancel(); + } + } + + public void setImageScale(float imageScale, boolean animate) { + setImageScale(imageScale, animate, -1, null); + } + + /** + * Sets the scale of the containing image + * + * @param imageScale The new Scale. + * @param animate Should an animation be performed + * @param duration If animate, whats the duration? When -1 we take the default duration + * @param interpolator If animate, whats the interpolator? When null we take the default + * interpolator. + */ + public void setImageScale( + float imageScale, boolean animate, long duration, @Nullable Interpolator interpolator) { + cancelAnimator(scaleAnimator); + if (!animate) { + tmageScale = imageScale; + invalidate(); + } else { + ValueAnimator animator = ValueAnimator.ofFloat(tmageScale, imageScale); + scaleAnimator = animator; + animator.addUpdateListener( + new ValueAnimator.AnimatorUpdateListener() { + @Override + public void onAnimationUpdate(ValueAnimator animation) { + tmageScale = (float) animation.getAnimatedValue(); + invalidate(); + } + }); + animator.addListener(scaleEndListener); + if (interpolator == null) { + interpolator = + imageScale == 0.0f + ? Interpolators.FAST_OUT_LINEAR_IN + : Interpolators.LINEAR_OUT_SLOW_IN; + } + animator.setInterpolator(interpolator); + if (duration == -1) { + float durationFactor = Math.abs(tmageScale - imageScale) / (1.0f - MIN_ICON_SCALE_AMOUNT); + durationFactor = Math.min(1.0f, durationFactor); + duration = (long) (NORMAL_ANIMATION_DURATION * durationFactor); + } + animator.setDuration(duration); + animator.start(); + } + } + + public void setRestingAlpha(float alpha) { + restingAlpha = alpha; + + // TODO: Handle the case an animation is playing. + setImageAlpha(alpha, false); + } + + public float getRestingAlpha() { + return restingAlpha; + } + + public void setImageAlpha(float alpha, boolean animate) { + setImageAlpha(alpha, animate, -1, null, null); + } + + /** + * Sets the alpha of the containing image + * + * @param alpha The new alpha. + * @param animate Should an animation be performed + * @param duration If animate, whats the duration? When -1 we take the default duration + * @param interpolator If animate, whats the interpolator? When null we take the default + * interpolator. + */ + public void setImageAlpha( + float alpha, + boolean animate, + long duration, + @Nullable Interpolator interpolator, + @Nullable Runnable runnable) { + cancelAnimator(alphaAnimator); + alpha = launchingAffordance ? 0 : alpha; + int endAlpha = (int) (alpha * 255); + final Drawable background = getBackground(); + if (!animate) { + if (background != null) { + background.mutate().setAlpha(endAlpha); + } + setImageAlpha(endAlpha); + } else { + int currentAlpha = getImageAlpha(); + ValueAnimator animator = ValueAnimator.ofInt(currentAlpha, endAlpha); + alphaAnimator = animator; + animator.addUpdateListener( + new ValueAnimator.AnimatorUpdateListener() { + @Override + public void onAnimationUpdate(ValueAnimator animation) { + int alpha = (int) animation.getAnimatedValue(); + if (background != null) { + background.mutate().setAlpha(alpha); + } + setImageAlpha(alpha); + } + }); + animator.addListener(alphaEndListener); + if (interpolator == null) { + interpolator = + alpha == 0.0f ? Interpolators.FAST_OUT_LINEAR_IN : Interpolators.LINEAR_OUT_SLOW_IN; + } + animator.setInterpolator(interpolator); + if (duration == -1) { + float durationFactor = Math.abs(currentAlpha - endAlpha) / 255f; + durationFactor = Math.min(1.0f, durationFactor); + duration = (long) (NORMAL_ANIMATION_DURATION * durationFactor); + } + animator.setDuration(duration); + if (runnable != null) { + animator.addListener(getEndListener(runnable)); + } + animator.start(); + } + } + + private Animator.AnimatorListener getEndListener(final Runnable runnable) { + return new AnimatorListenerAdapter() { + boolean mCancelled; + + @Override + public void onAnimationCancel(Animator animation) { + mCancelled = true; + } + + @Override + public void onAnimationEnd(Animator animation) { + if (!mCancelled) { + runnable.run(); + } + } + }; + } + + public float getCircleRadius() { + return circleRadius; + } + + @Override + public boolean performClick() { + return isClickable() && super.performClick(); + } + + public void setLaunchingAffordance(boolean launchingAffordance) { + this.launchingAffordance = launchingAffordance; + } +} diff --git a/java/com/android/incallui/answer/impl/affordance/res/values/dimens.xml b/java/com/android/incallui/answer/impl/affordance/res/values/dimens.xml new file mode 100644 index 000000000..71d014dd9 --- /dev/null +++ b/java/com/android/incallui/answer/impl/affordance/res/values/dimens.xml @@ -0,0 +1,23 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2016 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 + --> + +<resources> + <dimen name="answer_min_swipe_amount">110dp</dimen> + <dimen name="answer_affordance_min_background_radius">30dp</dimen> + <dimen name="answer_affordance_touch_target_size">120dp</dimen> + <dimen name="hint_grow_amount_sideways">60dp</dimen> +</resources> diff --git a/java/com/android/incallui/answer/impl/answermethod/AndroidManifest.xml b/java/com/android/incallui/answer/impl/answermethod/AndroidManifest.xml new file mode 100644 index 000000000..9082407f1 --- /dev/null +++ b/java/com/android/incallui/answer/impl/answermethod/AndroidManifest.xml @@ -0,0 +1,3 @@ +<manifest + package="com.android.incallui.answer.impl.answermethod"> +</manifest> diff --git a/java/com/android/incallui/answer/impl/answermethod/AnswerMethod.java b/java/com/android/incallui/answer/impl/answermethod/AnswerMethod.java new file mode 100644 index 000000000..5efd3f05b --- /dev/null +++ b/java/com/android/incallui/answer/impl/answermethod/AnswerMethod.java @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2016 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.incallui.answer.impl.answermethod; + +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.support.annotation.Nullable; +import android.support.v4.app.Fragment; +import com.android.dialer.common.FragmentUtils; + +/** A fragment that can be used to answer/reject calls. */ +public abstract class AnswerMethod extends Fragment { + + public abstract void setHintText(@Nullable CharSequence hintText); + + public abstract void setShowIncomingWillDisconnect(boolean incomingWillDisconnect); + + public void setContactPhoto(@Nullable Drawable contactPhoto) { + // default implementation does nothing. Only some AnswerMethods show a photo + } + + protected AnswerMethodHolder getParent() { + return FragmentUtils.getParentUnsafe(this, AnswerMethodHolder.class); + } + + @Override + public void onAttach(Context context) { + super.onAttach(context); + FragmentUtils.checkParent(this, AnswerMethodHolder.class); + } +} diff --git a/java/com/android/incallui/answer/impl/answermethod/AnswerMethodFactory.java b/java/com/android/incallui/answer/impl/answermethod/AnswerMethodFactory.java new file mode 100644 index 000000000..35f36f727 --- /dev/null +++ b/java/com/android/incallui/answer/impl/answermethod/AnswerMethodFactory.java @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2016 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.incallui.answer.impl.answermethod; + +import android.app.Activity; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v4.app.Fragment; +import com.android.dialer.compat.ActivityCompat; +import com.android.incallui.util.AccessibilityUtil; + +/** Creates the appropriate {@link AnswerMethod} for the circumstances. */ +public class AnswerMethodFactory { + + @NonNull + public static AnswerMethod createAnswerMethod(@NonNull Activity activity) { + if (needTwoButton(activity)) { + return new TwoButtonMethod(); + } else { + return new FlingUpDownMethod(); + } + } + + public static boolean needsReplacement(@Nullable Fragment answerMethod) { + //noinspection SimplifiableIfStatement + if (answerMethod == null) { + return true; + } + // If we have already started showing TwoButtonMethod, we should keep showing TwoButtonMethod. + // Otherwise check if we need to change to TwoButtonMethod + return !(answerMethod instanceof TwoButtonMethod) && needTwoButton(answerMethod.getActivity()); + } + + private static boolean needTwoButton(@NonNull Activity activity) { + return AccessibilityUtil.isTouchExplorationEnabled(activity) + || ActivityCompat.isInMultiWindowMode(activity); + } +} diff --git a/java/com/android/incallui/answer/impl/answermethod/AnswerMethodHolder.java b/java/com/android/incallui/answer/impl/answermethod/AnswerMethodHolder.java new file mode 100644 index 000000000..4052281b7 --- /dev/null +++ b/java/com/android/incallui/answer/impl/answermethod/AnswerMethodHolder.java @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2016 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.incallui.answer.impl.answermethod; + +import android.support.annotation.FloatRange; + +/** Defines callbacks {@link AnswerMethod AnswerMethods} may use to update their parent. */ +public interface AnswerMethodHolder { + + /** + * Update animation based on method progress. + * + * @param answerProgress float representing progress. -1 is fully declined, 1 is fully answered, + * and 0 is neutral. + */ + void onAnswerProgressUpdate(@FloatRange(from = -1f, to = 1f) float answerProgress); + + /** Answer the current call. */ + void answerFromMethod(); + + /** Reject the current call. */ + void rejectFromMethod(); + + /** Set AnswerProgress to zero (not due to normal updates). */ + void resetAnswerProgress(); + + /** + * Check whether the current call is a video call. + * + * @return true iff the current call is a video call. + */ + boolean isVideoCall(); +} diff --git a/java/com/android/incallui/answer/impl/answermethod/FlingUpDownMethod.java b/java/com/android/incallui/answer/impl/answermethod/FlingUpDownMethod.java new file mode 100644 index 000000000..0bc65818c --- /dev/null +++ b/java/com/android/incallui/answer/impl/answermethod/FlingUpDownMethod.java @@ -0,0 +1,1149 @@ +/* + * Copyright (C) 2016 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.incallui.answer.impl.answermethod; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.AnimatorSet; +import android.animation.ObjectAnimator; +import android.animation.PropertyValuesHolder; +import android.animation.ValueAnimator; +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.res.ColorStateList; +import android.graphics.PorterDuff.Mode; +import android.graphics.drawable.Drawable; +import android.os.Bundle; +import android.support.annotation.ColorInt; +import android.support.annotation.FloatRange; +import android.support.annotation.IntDef; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.annotation.VisibleForTesting; +import android.support.v4.graphics.ColorUtils; +import android.support.v4.view.animation.FastOutLinearInInterpolator; +import android.support.v4.view.animation.FastOutSlowInInterpolator; +import android.support.v4.view.animation.LinearOutSlowInInterpolator; +import android.support.v4.view.animation.PathInterpolatorCompat; +import android.view.LayoutInflater; +import android.view.MotionEvent; +import android.view.View; +import android.view.View.AccessibilityDelegate; +import android.view.ViewGroup; +import android.view.accessibility.AccessibilityNodeInfo; +import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction; +import android.view.animation.BounceInterpolator; +import android.view.animation.DecelerateInterpolator; +import android.view.animation.Interpolator; +import android.widget.ImageView; +import android.widget.TextView; +import com.android.dialer.common.DpUtil; +import com.android.dialer.common.LogUtil; +import com.android.dialer.common.MathUtil; +import com.android.dialer.util.DrawableConverter; +import com.android.dialer.util.ViewUtil; +import com.android.incallui.answer.impl.answermethod.FlingUpDownTouchHandler.OnProgressChangedListener; +import com.android.incallui.answer.impl.classifier.FalsingManager; +import com.android.incallui.answer.impl.hint.AnswerHint; +import com.android.incallui.answer.impl.hint.AnswerHintFactory; +import com.android.incallui.answer.impl.hint.EventPayloadLoaderImpl; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** Answer method that swipes up to answer or down to reject. */ +@SuppressLint("ClickableViewAccessibility") +public class FlingUpDownMethod extends AnswerMethod implements OnProgressChangedListener { + + private static final float SWIPE_LERP_PROGRESS_FACTOR = 0.5f; + private static final long ANIMATE_DURATION_SHORT_MILLIS = 667; + private static final long ANIMATE_DURATION_NORMAL_MILLIS = 1_333; + private static final long ANIMATE_DURATION_LONG_MILLIS = 1_500; + private static final long BOUNCE_ANIMATION_DELAY = 167; + private static final long VIBRATION_TIME_MILLIS = 1_833; + private static final long SETTLE_ANIMATION_DURATION_MILLIS = 100; + private static final int HINT_JUMP_DP = 60; + private static final int HINT_DIP_DP = 8; + private static final float HINT_SCALE_RATIO = 1.15f; + private static final long SWIPE_TO_DECLINE_FADE_IN_DELAY_MILLIS = 333; + private static final int HINT_REJECT_SHOW_DURATION_MILLIS = 2000; + private static final int ICON_END_CALL_ROTATION_DEGREES = 135; + private static final int HINT_REJECT_FADE_TRANSLATION_Y_DP = -8; + private static final float SWIPE_TO_ANSWER_MAX_TRANSLATION_Y_DP = 150; + private static final int SWIPE_TO_REJECT_MAX_TRANSLATION_Y_DP = 24; + + @Retention(RetentionPolicy.SOURCE) + @IntDef( + value = { + AnimationState.NONE, + AnimationState.ENTRY, + AnimationState.BOUNCE, + AnimationState.SWIPE, + AnimationState.SETTLE, + AnimationState.HINT, + AnimationState.COMPLETED + } + ) + @VisibleForTesting + @interface AnimationState { + + int NONE = 0; + int ENTRY = 1; // Entry animation for incoming call + int BOUNCE = 2; // An idle state in which text and icon slightly bounces off its base repeatedly + int SWIPE = 3; // A special state in which text and icon follows the finger movement + int SETTLE = 4; // A short animation to reset from swipe and prepare for hint or bounce + int HINT = 5; // Jump animation to suggest what to do + int COMPLETED = 6; // Animation loop completed. Occurs after user swipes beyond threshold + } + + private static void moveTowardY(View view, float newY) { + view.setTranslationY(MathUtil.lerp(view.getTranslationY(), newY, SWIPE_LERP_PROGRESS_FACTOR)); + } + + private static void moveTowardX(View view, float newX) { + view.setTranslationX(MathUtil.lerp(view.getTranslationX(), newX, SWIPE_LERP_PROGRESS_FACTOR)); + } + + private static void fadeToward(View view, float newAlpha) { + view.setAlpha(MathUtil.lerp(view.getAlpha(), newAlpha, SWIPE_LERP_PROGRESS_FACTOR)); + } + + private static void rotateToward(View view, float newRotation) { + view.setRotation(MathUtil.lerp(view.getRotation(), newRotation, SWIPE_LERP_PROGRESS_FACTOR)); + } + + private TextView swipeToAnswerText; + private TextView swipeToRejectText; + private View contactPuckContainer; + private ImageView contactPuckBackground; + private ImageView contactPuckIcon; + private View incomingDisconnectText; + private Animator lockBounceAnim; + private AnimatorSet lockEntryAnim; + private AnimatorSet lockHintAnim; + private AnimatorSet lockSettleAnim; + @AnimationState private int animationState = AnimationState.NONE; + @AnimationState private int afterSettleAnimationState = AnimationState.NONE; + // a value for finger swipe progress. -1 or less for "reject"; 1 or more for "accept". + private float swipeProgress; + private Animator rejectHintHide; + private Animator vibrationAnimator; + private Drawable contactPhoto; + private boolean incomingWillDisconnect; + private FlingUpDownTouchHandler touchHandler; + private FalsingManager falsingManager; + + private AnswerHint answerHint; + + @Override + public void onCreate(@Nullable Bundle bundle) { + super.onCreate(bundle); + falsingManager = new FalsingManager(getContext()); + } + + @Override + public void onStart() { + super.onStart(); + falsingManager.onScreenOn(); + if (getView() != null) { + if (animationState == AnimationState.SWIPE || animationState == AnimationState.HINT) { + swipeProgress = 0; + updateContactPuck(); + onMoveReset(false); + } else if (animationState == AnimationState.ENTRY) { + // When starting from the lock screen, the activity may be stopped and started briefly. + // Don't let that interrupt the entry animation + startSwipeToAnswerEntryAnimation(); + } + } + } + + @Override + public void onStop() { + endAnimation(); + falsingManager.onScreenOff(); + if (getActivity().isFinishing()) { + setAnimationState(AnimationState.COMPLETED); + } + super.onStop(); + } + + @Nullable + @Override + public View onCreateView( + LayoutInflater layoutInflater, @Nullable ViewGroup viewGroup, @Nullable Bundle bundle) { + View view = layoutInflater.inflate(R.layout.swipe_up_down_method, viewGroup, false); + + contactPuckContainer = view.findViewById(R.id.incoming_call_puck_container); + contactPuckBackground = (ImageView) view.findViewById(R.id.incoming_call_puck_bg); + contactPuckIcon = (ImageView) view.findViewById(R.id.incoming_call_puck_icon); + swipeToAnswerText = (TextView) view.findViewById(R.id.incoming_swipe_to_answer_text); + swipeToRejectText = (TextView) view.findViewById(R.id.incoming_swipe_to_reject_text); + incomingDisconnectText = view.findViewById(R.id.incoming_will_disconnect_text); + incomingDisconnectText.setAlpha(incomingWillDisconnect ? 1 : 0); + + view.setAccessibilityDelegate( + new AccessibilityDelegate() { + @Override + public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfo info) { + super.onInitializeAccessibilityNodeInfo(host, info); + info.addAction( + new AccessibilityAction( + R.id.accessibility_action_answer, getString(R.string.call_incoming_answer))); + info.addAction( + new AccessibilityAction( + R.id.accessibility_action_decline, getString(R.string.call_incoming_decline))); + } + + @Override + public boolean performAccessibilityAction(View host, int action, Bundle args) { + if (action == R.id.accessibility_action_answer) { + performAccept(); + return true; + } else if (action == R.id.accessibility_action_decline) { + performReject(); + return true; + } + return super.performAccessibilityAction(host, action, args); + } + }); + + swipeProgress = 0; + + updateContactPuck(); + + touchHandler = FlingUpDownTouchHandler.attach(view, this, falsingManager); + + answerHint = + new AnswerHintFactory(new EventPayloadLoaderImpl()) + .create(getContext(), ANIMATE_DURATION_LONG_MILLIS, BOUNCE_ANIMATION_DELAY); + answerHint.onCreateView( + layoutInflater, + (ViewGroup) view.findViewById(R.id.hint_container), + contactPuckContainer, + swipeToAnswerText); + return view; + } + + @Override + public void onViewCreated(View view, @Nullable Bundle bundle) { + super.onViewCreated(view, bundle); + setAnimationState(AnimationState.ENTRY); + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + if (touchHandler != null) { + touchHandler.detach(); + touchHandler = null; + } + } + + @Override + public void onProgressChanged(@FloatRange(from = -1f, to = 1f) float progress) { + swipeProgress = progress; + if (animationState == AnimationState.SWIPE && getContext() != null && isVisible()) { + updateSwipeTextAndPuckForTouch(); + } + } + + @Override + public void onTrackingStart() { + setAnimationState(AnimationState.SWIPE); + } + + @Override + public void onTrackingStopped() {} + + @Override + public void onMoveReset(boolean showHint) { + if (showHint) { + showSwipeHint(); + } else { + setAnimationState(AnimationState.BOUNCE); + } + resetTouchState(); + getParent().resetAnswerProgress(); + } + + @Override + public void onMoveFinish(boolean accept) { + touchHandler.setTouchEnabled(false); + answerHint.onAnswered(); + if (accept) { + performAccept(); + } else { + performReject(); + } + } + + @Override + public boolean shouldUseFalsing(@NonNull MotionEvent downEvent) { + if (contactPuckContainer == null) { + return false; + } + + float puckCenterX = contactPuckContainer.getX() + (contactPuckContainer.getWidth() / 2); + float puckCenterY = contactPuckContainer.getY() + (contactPuckContainer.getHeight() / 2); + double radius = contactPuckContainer.getHeight() / 2; + + // Squaring a number is more performant than taking a sqrt, so we compare the square of the + // distance with the square of the radius. + double distSq = + Math.pow(downEvent.getX() - puckCenterX, 2) + Math.pow(downEvent.getY() - puckCenterY, 2); + return distSq >= Math.pow(radius, 2); + } + + @Override + public void setContactPhoto(Drawable contactPhoto) { + this.contactPhoto = contactPhoto; + + updateContactPuck(); + } + + private void updateContactPuck() { + if (contactPuckIcon == null) { + return; + } + if (getParent().isVideoCall()) { + contactPuckIcon.setImageResource(R.drawable.quantum_ic_videocam_white_24); + } else { + contactPuckIcon.setImageResource(R.drawable.quantum_ic_call_white_24); + } + + int size = + contactPuckBackground + .getResources() + .getDimensionPixelSize( + shouldShowPhotoInPuck() + ? R.dimen.answer_contact_puck_size_photo + : R.dimen.answer_contact_puck_size_no_photo); + contactPuckBackground.setImageDrawable( + shouldShowPhotoInPuck() + ? makeRoundedDrawable(contactPuckBackground.getContext(), contactPhoto, size) + : null); + ViewGroup.LayoutParams contactPuckParams = contactPuckBackground.getLayoutParams(); + contactPuckParams.height = size; + contactPuckParams.width = size; + contactPuckBackground.setLayoutParams(contactPuckParams); + contactPuckIcon.setAlpha(shouldShowPhotoInPuck() ? 0f : 1f); + } + + private Drawable makeRoundedDrawable(Context context, Drawable contactPhoto, int size) { + return DrawableConverter.getRoundedDrawable(context, contactPhoto, size, size); + } + + private boolean shouldShowPhotoInPuck() { + return getParent().isVideoCall() && contactPhoto != null; + } + + @Override + public void setHintText(@Nullable CharSequence hintText) { + if (hintText == null) { + swipeToAnswerText.setText(R.string.call_incoming_swipe_to_answer); + swipeToRejectText.setText(R.string.call_incoming_swipe_to_reject); + } else { + swipeToAnswerText.setText(hintText); + swipeToRejectText.setText(null); + } + } + + @Override + public void setShowIncomingWillDisconnect(boolean incomingWillDisconnect) { + this.incomingWillDisconnect = incomingWillDisconnect; + if (incomingDisconnectText != null) { + incomingDisconnectText.animate().alpha(incomingWillDisconnect ? 1 : 0); + } + } + + private void showSwipeHint() { + setAnimationState(AnimationState.HINT); + } + + private void updateSwipeTextAndPuckForTouch() { + // Clamp progress value between -1 and 1. + final float clampedProgress = MathUtil.clamp(swipeProgress, -1 /* min */, 1 /* max */); + final float positiveAdjustedProgress = Math.abs(clampedProgress); + final boolean isAcceptingFlow = clampedProgress >= 0; + + // Cancel view property animators on views we're about to mutate + swipeToAnswerText.animate().cancel(); + contactPuckIcon.animate().cancel(); + + // Since the animation progression is controlled by user gesture instead of real timeline, the + // spec timeline can be divided into 9 slots. Each slot is equivalent to 83ms in the spec. + // Therefore, we use 9 slots of 83ms to map user gesture into the spec timeline. + final float progressSlots = 9; + + // Fade out the "swipe up to answer". It only takes 1 slot to complete the fade. + float swipeTextAlpha = Math.max(0, 1 - Math.abs(clampedProgress) * progressSlots); + fadeToward(swipeToAnswerText, swipeTextAlpha); + // Fade out the "swipe down to dismiss" at the same time. Don't ever increase its alpha + fadeToward(swipeToRejectText, Math.min(swipeTextAlpha, swipeToRejectText.getAlpha())); + // Fade out the "incoming will disconnect" text + fadeToward(incomingDisconnectText, incomingWillDisconnect ? swipeTextAlpha : 0); + + // Move swipe text back to zero. + moveTowardX(swipeToAnswerText, 0 /* newX */); + moveTowardY(swipeToAnswerText, 0 /* newY */); + + // Animate puck color + @ColorInt + int destPuckColor = + getContext() + .getColor( + isAcceptingFlow ? R.color.call_accept_background : R.color.call_hangup_background); + destPuckColor = + ColorUtils.setAlphaComponent(destPuckColor, (int) (0xFF * positiveAdjustedProgress)); + contactPuckBackground.setBackgroundTintList(ColorStateList.valueOf(destPuckColor)); + contactPuckBackground.setBackgroundTintMode(Mode.SRC_ATOP); + contactPuckBackground.setColorFilter(destPuckColor); + + // Animate decline icon + if (isAcceptingFlow || getParent().isVideoCall()) { + rotateToward(contactPuckIcon, 0f); + } else { + rotateToward(contactPuckIcon, positiveAdjustedProgress * ICON_END_CALL_ROTATION_DEGREES); + } + + // Fade in icon + if (shouldShowPhotoInPuck()) { + fadeToward(contactPuckIcon, positiveAdjustedProgress); + } + float iconProgress = Math.min(1f, positiveAdjustedProgress * 4); + @ColorInt + int iconColor = + ColorUtils.setAlphaComponent( + contactPuckIcon.getContext().getColor(R.color.incoming_answer_icon), + (int) (0xFF * (1 - iconProgress))); + contactPuckIcon.setImageTintList(ColorStateList.valueOf(iconColor)); + + // Move puck. + if (isAcceptingFlow) { + moveTowardY( + contactPuckContainer, + -clampedProgress * DpUtil.dpToPx(getContext(), SWIPE_TO_ANSWER_MAX_TRANSLATION_Y_DP)); + } else { + moveTowardY( + contactPuckContainer, + -clampedProgress * DpUtil.dpToPx(getContext(), SWIPE_TO_REJECT_MAX_TRANSLATION_Y_DP)); + } + + getParent().onAnswerProgressUpdate(clampedProgress); + } + + private void startSwipeToAnswerSwipeAnimation() { + LogUtil.i("FlingUpDownMethod.startSwipeToAnswerSwipeAnimation", "Start swipe animation."); + resetTouchState(); + endAnimation(); + } + + private void setPuckTouchState() { + contactPuckBackground.setActivated(touchHandler.isTracking()); + } + + private void resetTouchState() { + if (getContext() == null) { + // State will be reset in onStart(), so just abort. + return; + } + contactPuckContainer.animate().scaleX(1 /* scaleX */); + contactPuckContainer.animate().scaleY(1 /* scaleY */); + contactPuckBackground.animate().scaleX(1 /* scaleX */); + contactPuckBackground.animate().scaleY(1 /* scaleY */); + contactPuckBackground.setBackgroundTintList(null); + contactPuckBackground.setColorFilter(null); + contactPuckIcon.setImageTintList( + ColorStateList.valueOf(getContext().getColor(R.color.incoming_answer_icon))); + contactPuckIcon.animate().rotation(0); + + getParent().resetAnswerProgress(); + setPuckTouchState(); + + final float alpha = 1; + swipeToAnswerText.animate().alpha(alpha); + contactPuckContainer.animate().alpha(alpha); + contactPuckBackground.animate().alpha(alpha); + contactPuckIcon.animate().alpha(shouldShowPhotoInPuck() ? 0 : alpha); + } + + @VisibleForTesting + void setAnimationState(@AnimationState int state) { + if (state != AnimationState.HINT && animationState == state) { + return; + } + + if (animationState == AnimationState.COMPLETED) { + LogUtil.e( + "FlingUpDownMethod.setAnimationState", + "Animation loop has completed. Cannot switch to new state: " + state); + return; + } + + if (state == AnimationState.HINT || state == AnimationState.BOUNCE) { + if (animationState == AnimationState.SWIPE) { + afterSettleAnimationState = state; + state = AnimationState.SETTLE; + } + } + + LogUtil.i("FlingUpDownMethod.setAnimationState", "animation state: " + state); + animationState = state; + + // Start animation after the current one is finished completely. + View view = getView(); + if (view != null) { + // As long as the fragment is added, we can start update the animation state. + if (isAdded() && (animationState == state)) { + updateAnimationState(); + } else { + endAnimation(); + } + } + } + + @AnimationState + @VisibleForTesting + int getAnimationState() { + return animationState; + } + + private void updateAnimationState() { + switch (animationState) { + case AnimationState.ENTRY: + startSwipeToAnswerEntryAnimation(); + break; + case AnimationState.BOUNCE: + startSwipeToAnswerBounceAnimation(); + break; + case AnimationState.SWIPE: + startSwipeToAnswerSwipeAnimation(); + break; + case AnimationState.SETTLE: + startSwipeToAnswerSettleAnimation(); + break; + case AnimationState.COMPLETED: + clearSwipeToAnswerUi(); + break; + case AnimationState.HINT: + startSwipeToAnswerHintAnimation(); + break; + case AnimationState.NONE: + default: + LogUtil.e( + "FlingUpDownMethod.updateAnimationState", + "Unexpected animation state: " + animationState); + break; + } + } + + private void startSwipeToAnswerEntryAnimation() { + LogUtil.i("FlingUpDownMethod.startSwipeToAnswerEntryAnimation", "Swipe entry animation."); + endAnimation(); + + lockEntryAnim = new AnimatorSet(); + Animator textUp = + ObjectAnimator.ofFloat( + swipeToAnswerText, + View.TRANSLATION_Y, + DpUtil.dpToPx(getContext(), 192 /* dp */), + DpUtil.dpToPx(getContext(), -20 /* dp */)); + textUp.setDuration(ANIMATE_DURATION_NORMAL_MILLIS); + textUp.setInterpolator(new LinearOutSlowInInterpolator()); + + Animator textDown = + ObjectAnimator.ofFloat( + swipeToAnswerText, + View.TRANSLATION_Y, + DpUtil.dpToPx(getContext(), -20) /* dp */, + 0 /* end pos */); + textDown.setDuration(ANIMATE_DURATION_NORMAL_MILLIS); + textUp.setInterpolator(new FastOutSlowInInterpolator()); + + // "Swipe down to reject" text fades in with a slight translation + swipeToRejectText.setAlpha(0f); + Animator rejectTextShow = + ObjectAnimator.ofPropertyValuesHolder( + swipeToRejectText, + PropertyValuesHolder.ofFloat(View.ALPHA, 1f), + PropertyValuesHolder.ofFloat( + View.TRANSLATION_Y, + DpUtil.dpToPx(getContext(), HINT_REJECT_FADE_TRANSLATION_Y_DP), + 0f)); + rejectTextShow.setInterpolator(new FastOutLinearInInterpolator()); + rejectTextShow.setDuration(ANIMATE_DURATION_SHORT_MILLIS); + rejectTextShow.setStartDelay(SWIPE_TO_DECLINE_FADE_IN_DELAY_MILLIS); + + Animator puckUp = + ObjectAnimator.ofFloat( + contactPuckContainer, + View.TRANSLATION_Y, + DpUtil.dpToPx(getContext(), 400 /* dp */), + DpUtil.dpToPx(getContext(), -12 /* dp */)); + puckUp.setDuration(ANIMATE_DURATION_LONG_MILLIS); + puckUp.setInterpolator( + PathInterpolatorCompat.create( + 0 /* controlX1 */, 0 /* controlY1 */, 0 /* controlX2 */, 1 /* controlY2 */)); + + Animator puckDown = + ObjectAnimator.ofFloat( + contactPuckContainer, + View.TRANSLATION_Y, + DpUtil.dpToPx(getContext(), -12 /* dp */), + 0 /* end pos */); + puckDown.setDuration(ANIMATE_DURATION_NORMAL_MILLIS); + puckDown.setInterpolator(new FastOutSlowInInterpolator()); + + Animator puckScaleUp = + createUniformScaleAnimators( + contactPuckBackground, + 0.33f /* beginScale */, + 1.1f /* endScale */, + ANIMATE_DURATION_NORMAL_MILLIS, + PathInterpolatorCompat.create( + 0.4f /* controlX1 */, 0 /* controlY1 */, 0 /* controlX2 */, 1 /* controlY2 */)); + Animator puckScaleDown = + createUniformScaleAnimators( + contactPuckBackground, + 1.1f /* beginScale */, + 1 /* endScale */, + ANIMATE_DURATION_NORMAL_MILLIS, + new FastOutSlowInInterpolator()); + + // Upward animation chain. + lockEntryAnim.play(textUp).with(puckScaleUp).with(puckUp); + + // Downward animation chain. + lockEntryAnim.play(textDown).with(puckDown).with(puckScaleDown).after(puckUp); + + lockEntryAnim.play(rejectTextShow).after(puckUp); + + // Add vibration animation. + addVibrationAnimator(lockEntryAnim); + + lockEntryAnim.addListener( + new AnimatorListenerAdapter() { + + public boolean canceled; + + @Override + public void onAnimationCancel(Animator animation) { + super.onAnimationCancel(animation); + canceled = true; + } + + @Override + public void onAnimationEnd(Animator animation) { + super.onAnimationEnd(animation); + if (!canceled) { + onEntryAnimationDone(); + } + } + }); + lockEntryAnim.start(); + } + + @VisibleForTesting + void onEntryAnimationDone() { + LogUtil.i("FlingUpDownMethod.onEntryAnimationDone", "Swipe entry anim ends."); + if (animationState == AnimationState.ENTRY) { + setAnimationState(AnimationState.BOUNCE); + } + } + + private void startSwipeToAnswerBounceAnimation() { + LogUtil.i("FlingUpDownMethod.startSwipeToAnswerBounceAnimation", "Swipe bounce animation."); + endAnimation(); + + if (ViewUtil.areAnimationsDisabled(getContext())) { + swipeToAnswerText.setTranslationY(0); + contactPuckContainer.setTranslationY(0); + contactPuckBackground.setScaleY(1f); + contactPuckBackground.setScaleX(1f); + swipeToRejectText.setAlpha(1f); + swipeToRejectText.setTranslationY(0); + return; + } + + lockBounceAnim = createBreatheAnimation(); + + answerHint.onBounceStart(); + lockBounceAnim.addListener( + new AnimatorListenerAdapter() { + boolean firstPass = true; + + @Override + public void onAnimationEnd(Animator animation) { + super.onAnimationEnd(animation); + if (getContext() != null + && lockBounceAnim != null + && animationState == AnimationState.BOUNCE) { + // AnimatorSet doesn't have repeat settings. Instead, we start a new one after the + // previous set is completed, until endAnimation is called. + LogUtil.v("FlingUpDownMethod.onAnimationEnd", "Bounce again."); + + // If this is the first time repeating the animation, we should recreate it so its + // starting values will be correct + if (firstPass) { + lockBounceAnim = createBreatheAnimation(); + lockBounceAnim.addListener(this); + } + firstPass = false; + answerHint.onBounceStart(); + lockBounceAnim.start(); + } + } + }); + lockBounceAnim.start(); + } + + private Animator createBreatheAnimation() { + AnimatorSet breatheAnimation = new AnimatorSet(); + float textOffset = DpUtil.dpToPx(getContext(), 42 /* dp */); + Animator textUp = + ObjectAnimator.ofFloat( + swipeToAnswerText, View.TRANSLATION_Y, 0 /* begin pos */, -textOffset); + textUp.setInterpolator(new FastOutSlowInInterpolator()); + textUp.setDuration(ANIMATE_DURATION_NORMAL_MILLIS); + + Animator textDown = + ObjectAnimator.ofFloat(swipeToAnswerText, View.TRANSLATION_Y, -textOffset, 0 /* end pos */); + textDown.setInterpolator(new FastOutSlowInInterpolator()); + textDown.setDuration(ANIMATE_DURATION_NORMAL_MILLIS); + + // "Swipe down to reject" text fade in + Animator rejectTextShow = ObjectAnimator.ofFloat(swipeToRejectText, View.ALPHA, 1f); + rejectTextShow.setInterpolator(new LinearOutSlowInInterpolator()); + rejectTextShow.setDuration(ANIMATE_DURATION_SHORT_MILLIS); + rejectTextShow.setStartDelay(SWIPE_TO_DECLINE_FADE_IN_DELAY_MILLIS); + + // reject hint text translate in + Animator rejectTextTranslate = + ObjectAnimator.ofFloat( + swipeToRejectText, + View.TRANSLATION_Y, + DpUtil.dpToPx(getContext(), HINT_REJECT_FADE_TRANSLATION_Y_DP), + 0f); + rejectTextTranslate.setInterpolator(new FastOutSlowInInterpolator()); + rejectTextTranslate.setDuration(ANIMATE_DURATION_NORMAL_MILLIS); + + // reject hint text fade out + Animator rejectTextHide = ObjectAnimator.ofFloat(swipeToRejectText, View.ALPHA, 0f); + rejectTextHide.setInterpolator(new FastOutLinearInInterpolator()); + rejectTextHide.setDuration(ANIMATE_DURATION_SHORT_MILLIS); + + Interpolator curve = + PathInterpolatorCompat.create( + 0.4f /* controlX1 */, 0 /* controlY1 */, 0 /* controlX2 */, 1 /* controlY2 */); + float puckOffset = DpUtil.dpToPx(getContext(), 42 /* dp */); + Animator puckUp = ObjectAnimator.ofFloat(contactPuckContainer, View.TRANSLATION_Y, -puckOffset); + puckUp.setInterpolator(curve); + puckUp.setDuration(ANIMATE_DURATION_LONG_MILLIS); + + final float scale = 1.0625f; + Animator puckScaleUp = + createUniformScaleAnimators( + contactPuckBackground, + 1 /* beginScale */, + scale, + ANIMATE_DURATION_NORMAL_MILLIS, + curve); + + Animator puckDown = + ObjectAnimator.ofFloat(contactPuckContainer, View.TRANSLATION_Y, 0 /* end pos */); + puckDown.setInterpolator(new FastOutSlowInInterpolator()); + puckDown.setDuration(ANIMATE_DURATION_NORMAL_MILLIS); + + Animator puckScaleDown = + createUniformScaleAnimators( + contactPuckBackground, + scale, + 1 /* endScale */, + ANIMATE_DURATION_NORMAL_MILLIS, + new FastOutSlowInInterpolator()); + + // Bounce upward animation chain. + breatheAnimation + .play(textUp) + .with(rejectTextHide) + .with(puckUp) + .with(puckScaleUp) + .after(167 /* delay */); + + // Bounce downward animation chain. + breatheAnimation + .play(puckDown) + .with(textDown) + .with(puckScaleDown) + .with(rejectTextShow) + .with(rejectTextTranslate) + .after(puckUp); + + // Add vibration animation to the animator set. + addVibrationAnimator(breatheAnimation); + + return breatheAnimation; + } + + private void startSwipeToAnswerSettleAnimation() { + endAnimation(); + + ObjectAnimator puckScale = + ObjectAnimator.ofPropertyValuesHolder( + contactPuckBackground, + PropertyValuesHolder.ofFloat(View.SCALE_X, 1), + PropertyValuesHolder.ofFloat(View.SCALE_Y, 1)); + puckScale.setDuration(SETTLE_ANIMATION_DURATION_MILLIS); + + ObjectAnimator iconRotation = ObjectAnimator.ofFloat(contactPuckIcon, View.ROTATION, 0); + iconRotation.setDuration(SETTLE_ANIMATION_DURATION_MILLIS); + + ObjectAnimator swipeToAnswerTextFade = + createFadeAnimation(swipeToAnswerText, 1, SETTLE_ANIMATION_DURATION_MILLIS); + + ObjectAnimator contactPuckContainerFade = + createFadeAnimation(contactPuckContainer, 1, SETTLE_ANIMATION_DURATION_MILLIS); + + ObjectAnimator contactPuckBackgroundFade = + createFadeAnimation(contactPuckBackground, 1, SETTLE_ANIMATION_DURATION_MILLIS); + + ObjectAnimator contactPuckIconFade = + createFadeAnimation( + contactPuckIcon, shouldShowPhotoInPuck() ? 0 : 1, SETTLE_ANIMATION_DURATION_MILLIS); + + ObjectAnimator contactPuckTranslation = + ObjectAnimator.ofPropertyValuesHolder( + contactPuckContainer, + PropertyValuesHolder.ofFloat(View.TRANSLATION_X, 0), + PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, 0)); + contactPuckTranslation.setDuration(SETTLE_ANIMATION_DURATION_MILLIS); + + lockSettleAnim = new AnimatorSet(); + lockSettleAnim + .play(puckScale) + .with(iconRotation) + .with(swipeToAnswerTextFade) + .with(contactPuckContainerFade) + .with(contactPuckBackgroundFade) + .with(contactPuckIconFade) + .with(contactPuckTranslation); + + lockSettleAnim.addListener( + new AnimatorListenerAdapter() { + @Override + public void onAnimationCancel(Animator animation) { + afterSettleAnimationState = AnimationState.NONE; + } + + @Override + public void onAnimationEnd(Animator animation) { + onSettleAnimationDone(); + } + }); + + lockSettleAnim.start(); + } + + @VisibleForTesting + void onSettleAnimationDone() { + if (afterSettleAnimationState != AnimationState.NONE) { + int nextState = afterSettleAnimationState; + afterSettleAnimationState = AnimationState.NONE; + lockSettleAnim = null; + + setAnimationState(nextState); + } + } + + private ObjectAnimator createFadeAnimation(View target, float targetAlpha, long duration) { + ObjectAnimator objectAnimator = ObjectAnimator.ofFloat(target, View.ALPHA, targetAlpha); + objectAnimator.setDuration(duration); + return objectAnimator; + } + + private void startSwipeToAnswerHintAnimation() { + if (rejectHintHide != null) { + rejectHintHide.cancel(); + } + + endAnimation(); + resetTouchState(); + + if (ViewUtil.areAnimationsDisabled(getContext())) { + onHintAnimationDone(false); + return; + } + + lockHintAnim = new AnimatorSet(); + float jumpOffset = DpUtil.dpToPx(getContext(), HINT_JUMP_DP); + float dipOffset = DpUtil.dpToPx(getContext(), HINT_DIP_DP); + float scaleSize = HINT_SCALE_RATIO; + float textOffset = jumpOffset + (scaleSize - 1) * contactPuckBackground.getHeight(); + int shortAnimTime = + getContext().getResources().getInteger(android.R.integer.config_shortAnimTime); + int mediumAnimTime = + getContext().getResources().getInteger(android.R.integer.config_mediumAnimTime); + + // Puck squashes to anticipate jump + ObjectAnimator puckAnticipate = + ObjectAnimator.ofPropertyValuesHolder( + contactPuckContainer, + PropertyValuesHolder.ofFloat(View.SCALE_Y, .95f), + PropertyValuesHolder.ofFloat(View.SCALE_X, 1.05f)); + puckAnticipate.setRepeatCount(1); + puckAnticipate.setRepeatMode(ValueAnimator.REVERSE); + puckAnticipate.setDuration(shortAnimTime / 2); + puckAnticipate.setInterpolator(new DecelerateInterpolator()); + puckAnticipate.addListener( + new AnimatorListenerAdapter() { + @Override + public void onAnimationStart(Animator animation) { + super.onAnimationStart(animation); + contactPuckContainer.setPivotY(contactPuckContainer.getHeight()); + } + + @Override + public void onAnimationEnd(Animator animation) { + super.onAnimationEnd(animation); + contactPuckContainer.setPivotY(contactPuckContainer.getHeight() / 2); + } + }); + + // Ensure puck is at the right starting point for the jump + ObjectAnimator puckResetTranslation = + ObjectAnimator.ofPropertyValuesHolder( + contactPuckContainer, + PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, 0), + PropertyValuesHolder.ofFloat(View.TRANSLATION_X, 0)); + puckResetTranslation.setDuration(shortAnimTime / 2); + puckAnticipate.setInterpolator(new DecelerateInterpolator()); + + Animator textUp = ObjectAnimator.ofFloat(swipeToAnswerText, View.TRANSLATION_Y, -textOffset); + textUp.setInterpolator(new LinearOutSlowInInterpolator()); + textUp.setDuration(shortAnimTime); + + Animator puckUp = ObjectAnimator.ofFloat(contactPuckContainer, View.TRANSLATION_Y, -jumpOffset); + puckUp.setInterpolator(new LinearOutSlowInInterpolator()); + puckUp.setDuration(shortAnimTime); + + Animator puckScaleUp = + createUniformScaleAnimators( + contactPuckBackground, 1f, scaleSize, shortAnimTime, new LinearOutSlowInInterpolator()); + + Animator rejectHintShow = + ObjectAnimator.ofPropertyValuesHolder( + swipeToRejectText, + PropertyValuesHolder.ofFloat(View.ALPHA, 1f), + PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, 0f)); + rejectHintShow.setDuration(shortAnimTime); + + Animator rejectHintDip = + ObjectAnimator.ofFloat(swipeToRejectText, View.TRANSLATION_Y, dipOffset); + rejectHintDip.setInterpolator(new LinearOutSlowInInterpolator()); + rejectHintDip.setDuration(shortAnimTime); + + Animator textDown = ObjectAnimator.ofFloat(swipeToAnswerText, View.TRANSLATION_Y, 0); + textDown.setInterpolator(new LinearOutSlowInInterpolator()); + textDown.setDuration(mediumAnimTime); + + Animator puckDown = ObjectAnimator.ofFloat(contactPuckContainer, View.TRANSLATION_Y, 0); + BounceInterpolator bounce = new BounceInterpolator(); + puckDown.setInterpolator(bounce); + puckDown.setDuration(mediumAnimTime); + + Animator puckScaleDown = + createUniformScaleAnimators( + contactPuckBackground, scaleSize, 1f, shortAnimTime, new LinearOutSlowInInterpolator()); + + Animator rejectHintUp = ObjectAnimator.ofFloat(swipeToRejectText, View.TRANSLATION_Y, 0); + rejectHintUp.setInterpolator(new LinearOutSlowInInterpolator()); + rejectHintUp.setDuration(mediumAnimTime); + + lockHintAnim.play(puckAnticipate).with(puckResetTranslation).before(puckUp); + lockHintAnim + .play(textUp) + .with(puckUp) + .with(puckScaleUp) + .with(rejectHintDip) + .with(rejectHintShow); + lockHintAnim.play(textDown).with(puckDown).with(puckScaleDown).with(rejectHintUp).after(puckUp); + lockHintAnim.start(); + + rejectHintHide = ObjectAnimator.ofFloat(swipeToRejectText, View.ALPHA, 0); + rejectHintHide.setStartDelay(HINT_REJECT_SHOW_DURATION_MILLIS); + rejectHintHide.addListener( + new AnimatorListenerAdapter() { + + private boolean canceled; + + @Override + public void onAnimationCancel(Animator animation) { + super.onAnimationCancel(animation); + canceled = true; + rejectHintHide = null; + } + + @Override + public void onAnimationEnd(Animator animation) { + super.onAnimationEnd(animation); + onHintAnimationDone(canceled); + } + }); + rejectHintHide.start(); + } + + @VisibleForTesting + void onHintAnimationDone(boolean canceled) { + if (!canceled && animationState == AnimationState.HINT) { + setAnimationState(AnimationState.BOUNCE); + } + rejectHintHide = null; + } + + private void clearSwipeToAnswerUi() { + LogUtil.i("FlingUpDownMethod.clearSwipeToAnswerUi", "Clear swipe animation."); + endAnimation(); + swipeToAnswerText.setVisibility(View.GONE); + contactPuckContainer.setVisibility(View.GONE); + } + + private void endAnimation() { + LogUtil.i("FlingUpDownMethod.endAnimation", "End animations."); + if (lockSettleAnim != null) { + lockSettleAnim.cancel(); + lockSettleAnim = null; + } + if (lockBounceAnim != null) { + lockBounceAnim.cancel(); + lockBounceAnim = null; + } + if (lockEntryAnim != null) { + lockEntryAnim.cancel(); + lockEntryAnim = null; + } + if (lockHintAnim != null) { + lockHintAnim.cancel(); + lockHintAnim = null; + } + if (rejectHintHide != null) { + rejectHintHide.cancel(); + rejectHintHide = null; + } + if (vibrationAnimator != null) { + vibrationAnimator.end(); + vibrationAnimator = null; + } + answerHint.onBounceEnd(); + } + + // Create an animator to scale on X/Y directions uniformly. + private Animator createUniformScaleAnimators( + View target, float begin, float end, long duration, Interpolator interpolator) { + ObjectAnimator animator = + ObjectAnimator.ofPropertyValuesHolder( + target, + PropertyValuesHolder.ofFloat(View.SCALE_X, begin, end), + PropertyValuesHolder.ofFloat(View.SCALE_Y, begin, end)); + animator.setDuration(duration); + animator.setInterpolator(interpolator); + return animator; + } + + private void addVibrationAnimator(AnimatorSet animatorSet) { + if (vibrationAnimator != null) { + vibrationAnimator.end(); + } + + // Note that we animate the value between 0 and 1, but internally VibrateInterpolator will + // translate it into actually X translation value. + vibrationAnimator = + ObjectAnimator.ofFloat( + contactPuckContainer, View.TRANSLATION_X, 0 /* begin value */, 1 /* end value */); + vibrationAnimator.setDuration(VIBRATION_TIME_MILLIS); + vibrationAnimator.setInterpolator(new VibrateInterpolator(getContext())); + + animatorSet.play(vibrationAnimator).after(0 /* delay */); + } + + private void performAccept() { + LogUtil.i("FlingUpDownMethod.performAccept", null); + swipeToAnswerText.setVisibility(View.GONE); + contactPuckContainer.setVisibility(View.GONE); + + // Complete the animation loop. + setAnimationState(AnimationState.COMPLETED); + getParent().answerFromMethod(); + } + + private void performReject() { + LogUtil.i("FlingUpDownMethod.performReject", null); + swipeToAnswerText.setVisibility(View.GONE); + contactPuckContainer.setVisibility(View.GONE); + + // Complete the animation loop. + setAnimationState(AnimationState.COMPLETED); + getParent().rejectFromMethod(); + } + + /** Custom interpolator class for puck vibration. */ + private static class VibrateInterpolator implements Interpolator { + + private static final long RAMP_UP_BEGIN_MS = 583; + private static final long RAMP_UP_DURATION_MS = 167; + private static final long RAMP_UP_END_MS = RAMP_UP_BEGIN_MS + RAMP_UP_DURATION_MS; + private static final long RAMP_DOWN_BEGIN_MS = 1_583; + private static final long RAMP_DOWN_DURATION_MS = 250; + private static final long RAMP_DOWN_END_MS = RAMP_DOWN_BEGIN_MS + RAMP_DOWN_DURATION_MS; + private static final long RAMP_TOTAL_TIME_MS = RAMP_DOWN_END_MS; + private final float ampMax; + private final float freqMax = 80; + private Interpolator sliderInterpolator = new FastOutSlowInInterpolator(); + + VibrateInterpolator(Context context) { + ampMax = DpUtil.dpToPx(context, 1 /* dp */); + } + + @Override + public float getInterpolation(float t) { + float slider = 0; + float time = t * RAMP_TOTAL_TIME_MS; + + // Calculate the slider value based on RAMP_UP and RAMP_DOWN times. Between RAMP_UP and + // RAMP_DOWN, the slider remains the maximum value of 1. + if (time > RAMP_UP_BEGIN_MS && time < RAMP_UP_END_MS) { + // Ramp up. + slider = + sliderInterpolator.getInterpolation( + (time - RAMP_UP_BEGIN_MS) / (float) RAMP_UP_DURATION_MS); + } else if ((time >= RAMP_UP_END_MS) && time <= RAMP_DOWN_BEGIN_MS) { + // Vibrate at maximum + slider = 1; + } else if (time > RAMP_DOWN_BEGIN_MS && time < RAMP_DOWN_END_MS) { + // Ramp down. + slider = + 1 + - sliderInterpolator.getInterpolation( + (time - RAMP_DOWN_BEGIN_MS) / (float) RAMP_DOWN_DURATION_MS); + } + + float ampNormalized = ampMax * slider; + float freqNormalized = freqMax * slider; + + return (float) (ampNormalized * Math.sin(time * freqNormalized)); + } + } +} diff --git a/java/com/android/incallui/answer/impl/answermethod/FlingUpDownTouchHandler.java b/java/com/android/incallui/answer/impl/answermethod/FlingUpDownTouchHandler.java new file mode 100644 index 000000000..a21073d65 --- /dev/null +++ b/java/com/android/incallui/answer/impl/answermethod/FlingUpDownTouchHandler.java @@ -0,0 +1,496 @@ +/* + * Copyright (C) 2016 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.incallui.answer.impl.answermethod; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.ValueAnimator; +import android.animation.ValueAnimator.AnimatorUpdateListener; +import android.annotation.SuppressLint; +import android.content.Context; +import android.support.annotation.FloatRange; +import android.support.annotation.IntDef; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.view.MotionEvent; +import android.view.VelocityTracker; +import android.view.View; +import android.view.View.OnTouchListener; +import android.view.ViewConfiguration; +import com.android.dialer.common.DpUtil; +import com.android.dialer.common.LogUtil; +import com.android.dialer.common.MathUtil; +import com.android.incallui.answer.impl.classifier.FalsingManager; +import com.android.incallui.answer.impl.utils.FlingAnimationUtils; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** Touch handler that keeps track of flings for {@link FlingUpDownMethod}. */ +@SuppressLint("ClickableViewAccessibility") +class FlingUpDownTouchHandler implements OnTouchListener { + + /** Callback interface for significant events with this touch handler */ + interface OnProgressChangedListener { + + /** + * Called when the visible answer progress has changed. Implementations should use this for + * animation, but should not perform accepts or rejects until {@link #onMoveFinish(boolean)} is + * called. + * + * @param progress float representation of the progress with +1f fully accepted, -1f fully + * rejected, and 0 neutral. + */ + void onProgressChanged(@FloatRange(from = -1f, to = 1f) float progress); + + /** Called when a touch event has started being tracked. */ + void onTrackingStart(); + + /** Called when touch events stop being tracked. */ + void onTrackingStopped(); + + /** + * Called when the progress has fully animated back to neutral. Normal resting animation should + * resume, possibly with a hint animation first. + * + * @param showHint {@code true} iff the hint animation should be run before resuming normal + * animation. + */ + void onMoveReset(boolean showHint); + + /** + * Called when the progress has animated fully to accept or reject. + * + * @param accept {@code true} if the call has been accepted, {@code false} if it has been + * rejected. + */ + void onMoveFinish(boolean accept); + + /** + * Determine whether this gesture should use the {@link FalsingManager} to reject accidental + * touches + * + * @param downEvent the MotionEvent corresponding to the start of the gesture + * @return {@code true} if the {@link FalsingManager} should be used to reject accidental + * touches for this gesture + */ + boolean shouldUseFalsing(@NonNull MotionEvent downEvent); + } + + // Progress that must be moved through to not show the hint animation after gesture completes + private static final float HINT_MOVE_THRESHOLD_RATIO = .1f; + // Dp touch needs to move upward to be considered fully accepted + private static final int ACCEPT_THRESHOLD_DP = 150; + // Dp touch needs to move downward to be considered fully rejected + private static final int REJECT_THRESHOLD_DP = 150; + // Dp touch needs to move for it to not be considered a false touch (if FalsingManager is not + // enabled) + private static final int FALSING_THRESHOLD_DP = 40; + + // Progress at which a fling in the opposite direction will recenter instead of + // accepting/rejecting + private static final float PROGRESS_FLING_RECENTER = .1f; + + // Progress at which a slow swipe would continue toward accept/reject after the + // touch has been let go, otherwise will recenter + private static final float PROGRESS_SWIPE_RECENTER = .8f; + + private static final float REJECT_FLING_THRESHOLD_MODIFIER = 2f; + + @Retention(RetentionPolicy.SOURCE) + @IntDef({FlingTarget.CENTER, FlingTarget.ACCEPT, FlingTarget.REJECT}) + private @interface FlingTarget { + int CENTER = 0; + int ACCEPT = 1; + int REJECT = -1; + } + + /** + * Create a new FlingUpDownTouchHandler and attach it to the target. Will call {@link + * View#setOnTouchListener(OnTouchListener)} before returning. + * + * @param target View whose touches are to be listened to + * @param listener Callback to listen to major events + * @param falsingManager FalsingManager to identify false touches + * @return the instance of FlingUpDownTouchHandler that has been added as a touch listener + */ + public static FlingUpDownTouchHandler attach( + @NonNull View target, + @NonNull OnProgressChangedListener listener, + @Nullable FalsingManager falsingManager) { + FlingUpDownTouchHandler handler = new FlingUpDownTouchHandler(target, listener, falsingManager); + target.setOnTouchListener(handler); + return handler; + } + + @NonNull private final View target; + @NonNull private final OnProgressChangedListener listener; + + private VelocityTracker velocityTracker; + private FlingAnimationUtils flingAnimationUtils; + + private boolean touchEnabled = true; + private boolean flingEnabled = true; + private float currentProgress; + private boolean tracking; + + private boolean motionAborted; + private boolean touchSlopExceeded; + private boolean hintDistanceExceeded; + private int trackingPointer; + private Animator progressAnimator; + + private float touchSlop; + private float initialTouchY; + private float acceptThresholdY; + private float rejectThresholdY; + private float zeroY; + + private boolean touchAboveFalsingThreshold; + private float falsingThresholdPx; + private boolean touchUsesFalsing; + + private final float acceptThresholdPx; + private final float rejectThresholdPx; + private final float deadZoneTopPx; + + @Nullable private final FalsingManager falsingManager; + + private FlingUpDownTouchHandler( + @NonNull View target, + @NonNull OnProgressChangedListener listener, + @Nullable FalsingManager falsingManager) { + this.target = target; + this.listener = listener; + Context context = target.getContext(); + touchSlop = ViewConfiguration.get(context).getScaledTouchSlop(); + flingAnimationUtils = new FlingAnimationUtils(context, .6f); + falsingThresholdPx = DpUtil.dpToPx(context, FALSING_THRESHOLD_DP); + acceptThresholdPx = DpUtil.dpToPx(context, ACCEPT_THRESHOLD_DP); + rejectThresholdPx = DpUtil.dpToPx(context, REJECT_THRESHOLD_DP); + + deadZoneTopPx = + Math.max( + context.getResources().getDimension(R.dimen.answer_swipe_dead_zone_top), + acceptThresholdPx); + this.falsingManager = falsingManager; + } + + /** Returns {@code true} iff a touch is being tracked */ + public boolean isTracking() { + return tracking; + } + + /** + * Sets whether touch events will continue to be listened to + * + * @param touchEnabled whether future touch events will be listened to + */ + public void setTouchEnabled(boolean touchEnabled) { + this.touchEnabled = touchEnabled; + } + + /** + * Sets whether fling velocity is used to affect accept/reject behavior + * + * @param flingEnabled whether fling velocity will be used when determining whether to + * accept/reject or recenter + */ + public void setFlingEnabled(boolean flingEnabled) { + this.flingEnabled = flingEnabled; + } + + public void detach() { + cancelProgressAnimator(); + setTouchEnabled(false); + } + + @Override + public boolean onTouch(View v, MotionEvent event) { + if (falsingManager != null) { + falsingManager.onTouchEvent(event); + } + if (!touchEnabled) { + return false; + } + if (motionAborted && (event.getActionMasked() != MotionEvent.ACTION_DOWN)) { + return false; + } + + int pointerIndex = event.findPointerIndex(trackingPointer); + if (pointerIndex < 0) { + pointerIndex = 0; + trackingPointer = event.getPointerId(pointerIndex); + } + final float pointerY = event.getY(pointerIndex); + + switch (event.getActionMasked()) { + case MotionEvent.ACTION_DOWN: + if (pointerY < deadZoneTopPx) { + return false; + } + motionAborted = false; + startMotion(pointerY, false, currentProgress); + touchAboveFalsingThreshold = false; + touchUsesFalsing = listener.shouldUseFalsing(event); + if (velocityTracker == null) { + initVelocityTracker(); + } + trackMovement(event); + cancelProgressAnimator(); + touchSlopExceeded = progressAnimator != null; + onTrackingStarted(); + break; + case MotionEvent.ACTION_POINTER_UP: + final int upPointer = event.getPointerId(event.getActionIndex()); + if (trackingPointer == upPointer) { + // gesture is ongoing, find a new pointer to track + int newIndex = event.getPointerId(0) != upPointer ? 0 : 1; + float newY = event.getY(newIndex); + trackingPointer = event.getPointerId(newIndex); + startMotion(newY, true, currentProgress); + } + break; + case MotionEvent.ACTION_POINTER_DOWN: + motionAborted = true; + endMotionEvent(event, pointerY, true); + return false; + case MotionEvent.ACTION_MOVE: + float deltaY = pointerY - initialTouchY; + + if (Math.abs(deltaY) > touchSlop) { + touchSlopExceeded = true; + } + if (Math.abs(deltaY) >= falsingThresholdPx) { + touchAboveFalsingThreshold = true; + } + setCurrentProgress(pointerYToProgress(pointerY)); + trackMovement(event); + break; + + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_CANCEL: + trackMovement(event); + endMotionEvent(event, pointerY, false); + } + return true; + } + + private void endMotionEvent(MotionEvent event, float pointerY, boolean forceCancel) { + trackingPointer = -1; + if ((tracking && touchSlopExceeded) + || Math.abs(pointerY - initialTouchY) > touchSlop + || event.getActionMasked() == MotionEvent.ACTION_CANCEL + || forceCancel) { + float vel = 0f; + float vectorVel = 0f; + if (velocityTracker != null) { + velocityTracker.computeCurrentVelocity(1000); + vel = velocityTracker.getYVelocity(); + vectorVel = + Math.copySign( + (float) Math.hypot(velocityTracker.getXVelocity(), velocityTracker.getYVelocity()), + vel); + } + + boolean falseTouch = isFalseTouch(); + boolean forceRecenter = + falseTouch + || !touchSlopExceeded + || forceCancel + || event.getActionMasked() == MotionEvent.ACTION_CANCEL; + + @FlingTarget + int target = forceRecenter ? FlingTarget.CENTER : getFlingTarget(pointerY, vectorVel); + + fling(vel, target, falseTouch); + onTrackingStopped(); + } else { + onTrackingStopped(); + setCurrentProgress(0); + onMoveEnded(); + } + + if (velocityTracker != null) { + velocityTracker.recycle(); + velocityTracker = null; + } + } + + @FlingTarget + private int getFlingTarget(float pointerY, float vectorVel) { + float progress = pointerYToProgress(pointerY); + + float minVelocityPxPerSecond = flingAnimationUtils.getMinVelocityPxPerSecond(); + if (vectorVel > 0) { + minVelocityPxPerSecond *= REJECT_FLING_THRESHOLD_MODIFIER; + } + if (!flingEnabled || Math.abs(vectorVel) < minVelocityPxPerSecond) { + // Not a fling + if (Math.abs(progress) > PROGRESS_SWIPE_RECENTER) { + // Progress near one of the edges + return progress > 0 ? FlingTarget.ACCEPT : FlingTarget.REJECT; + } else { + return FlingTarget.CENTER; + } + } + + boolean sameDirection = vectorVel < 0 == progress > 0; + if (!sameDirection && Math.abs(progress) >= PROGRESS_FLING_RECENTER) { + // Being flung back toward center + return FlingTarget.CENTER; + } + // Flung toward an edge + return vectorVel < 0 ? FlingTarget.ACCEPT : FlingTarget.REJECT; + } + + @FloatRange(from = -1f, to = 1f) + private float pointerYToProgress(float pointerY) { + boolean pointerAboveZero = pointerY > zeroY; + float nearestThreshold = pointerAboveZero ? rejectThresholdY : acceptThresholdY; + + float absoluteProgress = (pointerY - zeroY) / (nearestThreshold - zeroY); + return MathUtil.clamp(absoluteProgress * (pointerAboveZero ? -1 : 1), -1f, 1f); + } + + private boolean isFalseTouch() { + if (falsingManager != null && falsingManager.isEnabled()) { + if (falsingManager.isFalseTouch()) { + if (touchUsesFalsing) { + LogUtil.i("FlingUpDownTouchHandler.isFalseTouch", "rejecting false touch"); + return true; + } else { + LogUtil.i( + "FlingUpDownTouchHandler.isFalseTouch", + "Suspected false touch, but not using false touch rejection for this gesture"); + return false; + } + } else { + return false; + } + } + return !touchAboveFalsingThreshold; + } + + private void trackMovement(MotionEvent event) { + if (velocityTracker != null) { + velocityTracker.addMovement(event); + } + } + + private void fling(float velocity, @FlingTarget int target, boolean centerBecauseOfFalsing) { + ValueAnimator animator = createProgressAnimator(target); + if (target == FlingTarget.CENTER) { + flingAnimationUtils.apply(animator, currentProgress, target, velocity); + } else { + flingAnimationUtils.applyDismissing(animator, currentProgress, target, velocity, 1); + } + if (target == FlingTarget.CENTER && centerBecauseOfFalsing) { + velocity = 0; + } + if (velocity == 0) { + animator.setDuration(350); + } + + animator.addListener( + new AnimatorListenerAdapter() { + boolean canceled; + + @Override + public void onAnimationCancel(Animator animation) { + canceled = true; + } + + @Override + public void onAnimationEnd(Animator animation) { + progressAnimator = null; + if (!canceled) { + onMoveEnded(); + } + } + }); + progressAnimator = animator; + animator.start(); + } + + private void onMoveEnded() { + if (currentProgress == 0) { + listener.onMoveReset(!hintDistanceExceeded); + } else { + listener.onMoveFinish(currentProgress > 0); + } + } + + private ValueAnimator createProgressAnimator(float targetProgress) { + ValueAnimator animator = ValueAnimator.ofFloat(currentProgress, targetProgress); + animator.addUpdateListener( + new AnimatorUpdateListener() { + @Override + public void onAnimationUpdate(ValueAnimator animation) { + setCurrentProgress((Float) animation.getAnimatedValue()); + } + }); + return animator; + } + + private void initVelocityTracker() { + if (velocityTracker != null) { + velocityTracker.recycle(); + } + velocityTracker = VelocityTracker.obtain(); + } + + private void startMotion(float newY, boolean startTracking, float startProgress) { + initialTouchY = newY; + hintDistanceExceeded = false; + + if (startProgress <= .25) { + acceptThresholdY = Math.max(0, initialTouchY - acceptThresholdPx); + rejectThresholdY = Math.min(target.getHeight(), initialTouchY + rejectThresholdPx); + zeroY = initialTouchY; + } + + if (startTracking) { + touchSlopExceeded = true; + onTrackingStarted(); + setCurrentProgress(startProgress); + } + } + + private void onTrackingStarted() { + tracking = true; + listener.onTrackingStart(); + } + + private void onTrackingStopped() { + tracking = false; + listener.onTrackingStopped(); + } + + private void cancelProgressAnimator() { + if (progressAnimator != null) { + progressAnimator.cancel(); + } + } + + private void setCurrentProgress(float progress) { + if (Math.abs(progress) > HINT_MOVE_THRESHOLD_RATIO) { + hintDistanceExceeded = true; + } + currentProgress = progress; + listener.onProgressChanged(progress); + } +} diff --git a/java/com/android/incallui/answer/impl/answermethod/TwoButtonMethod.java b/java/com/android/incallui/answer/impl/answermethod/TwoButtonMethod.java new file mode 100644 index 000000000..67b1b9689 --- /dev/null +++ b/java/com/android/incallui/answer/impl/answermethod/TwoButtonMethod.java @@ -0,0 +1,268 @@ +/* + * Copyright (C) 2016 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.incallui.answer.impl.answermethod; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.AnimatorSet; +import android.animation.ObjectAnimator; +import android.animation.PropertyValuesHolder; +import android.animation.ValueAnimator; +import android.animation.ValueAnimator.AnimatorUpdateListener; +import android.os.Bundle; +import android.support.annotation.FloatRange; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.view.MotionEvent; +import android.view.View; +import android.view.View.OnClickListener; +import android.view.ViewGroup; +import android.widget.TextView; +import com.android.dialer.common.Assert; +import com.android.dialer.common.LogUtil; +import com.android.dialer.compat.ActivityCompat; +import com.android.incallui.answer.impl.answermethod.FlingUpDownTouchHandler.OnProgressChangedListener; +import com.android.incallui.util.AccessibilityUtil; + +/** Answer method that shows two buttons for answer/reject. */ +public class TwoButtonMethod extends AnswerMethod + implements OnClickListener, AnimatorUpdateListener { + + private static final String STATE_HINT_TEXT = "hintText"; + private static final String STATE_INCOMING_WILL_DISCONNECT = "incomingWillDisconnect"; + + private View answerButton; + private View answerLabel; + private View declineButton; + private View declineLabel; + private TextView hintTextView; + private boolean incomingWillDisconnect; + private boolean buttonClicked; + private CharSequence hintText; + @Nullable private FlingUpDownTouchHandler touchHandler; + + @Override + public void onCreate(@Nullable Bundle bundle) { + super.onCreate(bundle); + if (bundle != null) { + incomingWillDisconnect = bundle.getBoolean(STATE_INCOMING_WILL_DISCONNECT); + hintText = bundle.getCharSequence(STATE_HINT_TEXT); + } + } + + @Override + public void onSaveInstanceState(Bundle bundle) { + super.onSaveInstanceState(bundle); + bundle.putBoolean(STATE_INCOMING_WILL_DISCONNECT, incomingWillDisconnect); + bundle.putCharSequence(STATE_HINT_TEXT, hintText); + } + + @Nullable + @Override + public View onCreateView( + LayoutInflater layoutInflater, @Nullable ViewGroup viewGroup, @Nullable Bundle bundle) { + View view = layoutInflater.inflate(R.layout.two_button_method, viewGroup, false); + + hintTextView = (TextView) view.findViewById(R.id.two_button_hint_text); + updateHintText(); + + answerButton = view.findViewById(R.id.two_button_answer_button); + answerLabel = view.findViewById(R.id.two_button_answer_label); + declineButton = view.findViewById(R.id.two_button_decline_button); + declineLabel = view.findViewById(R.id.two_button_decline_label); + + boolean showLabels = getResources().getBoolean(R.bool.two_button_show_button_labels); + answerLabel.setVisibility(showLabels ? View.VISIBLE : View.GONE); + declineLabel.setVisibility(showLabels ? View.VISIBLE : View.GONE); + + answerButton.setOnClickListener(this); + declineButton.setOnClickListener(this); + + if (AccessibilityUtil.isTouchExplorationEnabled(getContext())) { + /* Falsing already handled by AccessibilityManager */ + touchHandler = + FlingUpDownTouchHandler.attach( + view, + new OnProgressChangedListener() { + @Override + public void onProgressChanged(@FloatRange(from = -1f, to = 1f) float progress) {} + + @Override + public void onTrackingStart() {} + + @Override + public void onTrackingStopped() {} + + @Override + public void onMoveReset(boolean showHint) {} + + @Override + public void onMoveFinish(boolean accept) { + if (accept) { + answerCall(); + } else { + rejectCall(); + } + } + + @Override + public boolean shouldUseFalsing(@NonNull MotionEvent downEvent) { + return false; + } + }, + null /* Falsing already handled by AccessibilityManager */); + touchHandler.setFlingEnabled(false); + } + return view; + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + if (touchHandler != null) { + touchHandler.detach(); + touchHandler = null; + } + } + + @Override + public void setHintText(@Nullable CharSequence hintText) { + this.hintText = hintText; + updateHintText(); + } + + @Override + public void setShowIncomingWillDisconnect(boolean incomingWillDisconnect) { + this.incomingWillDisconnect = incomingWillDisconnect; + updateHintText(); + } + + private void updateHintText() { + if (hintTextView == null) { + return; + } + hintTextView.setVisibility( + ActivityCompat.isInMultiWindowMode(getActivity()) ? View.GONE : View.VISIBLE); + if (!TextUtils.isEmpty(hintText) && !buttonClicked) { + hintTextView.setText(hintText); + hintTextView.animate().alpha(1f).start(); + } else if (incomingWillDisconnect && !buttonClicked) { + hintTextView.setText(R.string.call_incoming_will_disconnect); + hintTextView.animate().alpha(1f).start(); + } else { + hintTextView.animate().alpha(0f).start(); + } + } + + @Override + public void onClick(View view) { + if (view == answerButton) { + answerCall(); + LogUtil.v("TwoButtonMethod.onClick", "Call answered"); + } else if (view == declineButton) { + rejectCall(); + LogUtil.v("TwoButtonMethod.onClick", "two_buttonMethod Call rejected"); + } else { + Assert.fail("Unknown click from view: " + view); + } + buttonClicked = true; + } + + private void answerCall() { + ValueAnimator animator = ValueAnimator.ofFloat(0, 1); + animator.addUpdateListener(this); + animator.addListener( + new AnimatorListenerAdapter() { + private boolean canceled; + + @Override + public void onAnimationCancel(Animator animation) { + canceled = true; + } + + @Override + public void onAnimationEnd(Animator animation) { + if (!canceled) { + getParent().answerFromMethod(); + } + } + }); + AnimatorSet animatorSet = new AnimatorSet(); + animatorSet.play(animator).with(createViewHideAnimation()); + animatorSet.start(); + } + + private void rejectCall() { + ValueAnimator animator = ValueAnimator.ofFloat(0, -1); + animator.addUpdateListener(this); + animator.addListener( + new AnimatorListenerAdapter() { + private boolean canceled; + + @Override + public void onAnimationCancel(Animator animation) { + canceled = true; + } + + @Override + public void onAnimationEnd(Animator animation) { + if (!canceled) { + getParent().rejectFromMethod(); + } + } + }); + AnimatorSet animatorSet = new AnimatorSet(); + animatorSet.play(animator).with(createViewHideAnimation()); + animatorSet.start(); + } + + @Override + public void onAnimationUpdate(ValueAnimator animation) { + getParent().onAnswerProgressUpdate(((float) animation.getAnimatedValue())); + } + + private Animator createViewHideAnimation() { + ObjectAnimator answerButtonHide = + ObjectAnimator.ofPropertyValuesHolder( + answerButton, + PropertyValuesHolder.ofFloat(View.SCALE_X, 0f), + PropertyValuesHolder.ofFloat(View.SCALE_Y, 0f)); + + ObjectAnimator declineButtonHide = + ObjectAnimator.ofPropertyValuesHolder( + declineButton, + PropertyValuesHolder.ofFloat(View.SCALE_X, 0f), + PropertyValuesHolder.ofFloat(View.SCALE_Y, 0f)); + + ObjectAnimator answerLabelHide = ObjectAnimator.ofFloat(answerLabel, View.ALPHA, 0f); + + ObjectAnimator declineLabelHide = ObjectAnimator.ofFloat(declineLabel, View.ALPHA, 0f); + + ObjectAnimator hintHide = ObjectAnimator.ofFloat(hintTextView, View.ALPHA, 0f); + + AnimatorSet hideSet = new AnimatorSet(); + hideSet + .play(answerButtonHide) + .with(declineButtonHide) + .with(answerLabelHide) + .with(declineLabelHide) + .with(hintHide); + return hideSet; + } +} diff --git a/java/com/android/incallui/answer/impl/answermethod/res/drawable/call_answer.xml b/java/com/android/incallui/answer/impl/answermethod/res/drawable/call_answer.xml new file mode 100644 index 000000000..451c862fa --- /dev/null +++ b/java/com/android/incallui/answer/impl/answermethod/res/drawable/call_answer.xml @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="utf-8"?> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:height="24dp" + android:viewportHeight="32.0" + android:viewportWidth="32.0" + android:width="24dp"> + <group + android:name="rotationGroup" + android:pivotX="12" + android:pivotY="12" + android:translateX="4" + android:translateY="4" + android:rotation="0" + > + <path + android:fillColor="#FFFFFFFF" + android:pathData="M6.62,10.79c1.44,2.83 3.76,5.14 6.59,6.59l2.2,-2.2c0.27,-0.27 0.67,-0.36 1.02,-0.24 1.12,0.37 2.33,0.57 3.57,0.57 0.55,0 1,0.45 1,1V20c0,0.55 -0.45,1 -1,1 -9.39,0 -17,-7.61 -17,-17 0,-0.55 0.45,-1 1,-1h3.5c0.55,0 1,0.45 1,1 0,1.25 0.2,2.45 0.57,3.57 0.11,0.35 0.03,0.74 -0.25,1.02l-2.2,2.2z"/> + </group> +</vector> diff --git a/java/com/android/incallui/answer/impl/answermethod/res/drawable/circular_background.xml b/java/com/android/incallui/answer/impl/answermethod/res/drawable/circular_background.xml new file mode 100644 index 000000000..938ddc2be --- /dev/null +++ b/java/com/android/incallui/answer/impl/answermethod/res/drawable/circular_background.xml @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="utf-8"?> +<shape + xmlns:android="http://schemas.android.com/apk/res/android" + android:shape="oval"> + <solid android:color="#FFFFFFFF"/> +</shape> diff --git a/java/com/android/incallui/answer/impl/answermethod/res/layout/swipe_up_down_method.xml b/java/com/android/incallui/answer/impl/answermethod/res/layout/swipe_up_down_method.xml new file mode 100644 index 000000000..78e097958 --- /dev/null +++ b/java/com/android/incallui/answer/impl/answermethod/res/layout/swipe_up_down_method.xml @@ -0,0 +1,115 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2016 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 + --> + +<FrameLayout + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:layout_marginStart="@dimen/answer_swipe_dead_zone_sides" + android:clipChildren="false" + android:clipToPadding="false" + android:layout_marginEnd="@dimen/answer_swipe_dead_zone_sides"> + <LinearLayout + android:id="@+id/incoming_swipe_to_answer_container" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:accessibilityLiveRegion="polite" + android:clipChildren="false" + android:clipToPadding="false" + android:gravity="center_horizontal|bottom" + android:orientation="vertical" + android:visibility="visible"> + <TextView + android:id="@+id/incoming_will_disconnect_text" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginBottom="116dp" + android:layout_gravity="center_horizontal" + android:alpha="0" + android:text="@string/call_incoming_will_disconnect" + android:textColor="@color/blue_grey_100" + android:textSize="16sp" + tools:alpha="1"/> + <TextView + android:id="@+id/incoming_swipe_to_answer_text" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginBottom="18dp" + android:layout_gravity="center_horizontal" + android:focusable="false" + android:text="@string/call_incoming_swipe_to_answer" + android:textAppearance="@style/Dialer.Incall.TextAppearance.Hint"/> + + <FrameLayout + android:id="@+id/incoming_call_puck_container" + android:layout_width="@dimen/answer_contact_puck_size_photo" + android:layout_height="@dimen/answer_contact_puck_size_photo" + android:layout_marginBottom="10dp" + android:layout_gravity="center_horizontal" + android:clipChildren="false" + android:clipToPadding="false" + android:contentDescription="@string/a11y_incoming_call_swipe_to_answer"> + + <!-- Puck background and icon are hosted in the separated views to animate separately. --> + <ImageView + android:id="@+id/incoming_call_puck_bg" + android:layout_width="@dimen/answer_contact_puck_size_no_photo" + android:layout_height="@dimen/answer_contact_puck_size_no_photo" + android:layout_gravity="center" + android:background="@drawable/circular_background" + android:contentDescription="@null" + android:duplicateParentState="true" + android:elevation="8dp" + android:focusable="false" + android:stateListAnimator="@animator/activated_button_elevation"/> + + <ImageView + android:id="@+id/incoming_call_puck_icon" + android:layout_width="30dp" + android:layout_height="30dp" + android:layout_gravity="center" + android:contentDescription="@null" + android:duplicateParentState="true" + android:elevation="16dp" + android:focusable="false" + android:outlineProvider="none" + android:src="@drawable/quantum_ic_call_white_24" + android:tint="@color/incoming_answer_icon" + android:tintMode="src_atop" + tools:outlineProvider="background"/> + + </FrameLayout> + <TextView + android:id="@+id/incoming_swipe_to_reject_text" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginBottom="20dp" + android:layout_gravity="center_horizontal" + android:alpha="0" + android:focusable="false" + android:text="@string/call_incoming_swipe_to_reject" + android:textAppearance="@style/Dialer.Incall.TextAppearance.Hint" + tools:alpha="1"/> + </LinearLayout> + <FrameLayout + android:id="@+id/hint_container" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:clipChildren="false" + android:clipToPadding="false"/> +</FrameLayout> diff --git a/java/com/android/incallui/answer/impl/answermethod/res/layout/two_button_method.xml b/java/com/android/incallui/answer/impl/answermethod/res/layout/two_button_method.xml new file mode 100644 index 000000000..f92f3c428 --- /dev/null +++ b/java/com/android/incallui/answer/impl/answermethod/res/layout/two_button_method.xml @@ -0,0 +1,97 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2016 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 + --> + +<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:gravity="bottom|center_horizontal" + android:orientation="vertical"> + <TextView + android:id="@+id/two_button_hint_text" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginBottom="32dp" + android:accessibilityLiveRegion="polite" + android:alpha="0"/> + + <LinearLayout + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:paddingBottom="@dimen/two_button_bottom_padding" + android:gravity="bottom|center_horizontal" + android:orientation="horizontal"> + + <LinearLayout + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginEnd="88dp" + android:clipChildren="false" + android:clipToPadding="false" + android:padding="@dimen/incall_call_button_elevation" + android:gravity="center_horizontal" + android:orientation="vertical"> + + <ImageButton + android:id="@+id/two_button_decline_button" + style="@style/Answer.Button.Decline" + android:layout_width="@dimen/two_button_button_size" + android:layout_height="@dimen/two_button_button_size" + android:contentDescription="@string/a11y_call_incoming_decline_description" + android:src="@drawable/quantum_ic_call_end_white_24"/> + + <TextView + android:id="@+id/two_button_decline_label" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginTop="@dimen/two_button_label_padding" + android:importantForAccessibility="no" + android:text="@string/call_incoming_decline" + android:textColor="#ffffffff" + android:textSize="@dimen/two_button_label_size"/> + + </LinearLayout> + + <LinearLayout + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:clipChildren="false" + android:clipToPadding="false" + android:padding="@dimen/incall_call_button_elevation" + android:gravity="center_horizontal" + android:orientation="vertical"> + + <ImageButton + android:id="@+id/two_button_answer_button" + style="@style/Answer.Button.Answer" + android:layout_width="@dimen/two_button_button_size" + android:layout_height="@dimen/two_button_button_size" + android:contentDescription="@string/a11y_call_incoming_answer_description" + android:src="@drawable/quantum_ic_call_white_24"/> + + <TextView + android:id="@+id/two_button_answer_label" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginTop="@dimen/two_button_label_padding" + android:importantForAccessibility="no" + android:text="@string/call_incoming_answer" + android:textColor="#ffffffff" + android:textSize="@dimen/two_button_label_size"/> + + </LinearLayout> + </LinearLayout> +</LinearLayout> diff --git a/java/com/android/incallui/answer/impl/answermethod/res/values-h240dp/values.xml b/java/com/android/incallui/answer/impl/answermethod/res/values-h240dp/values.xml new file mode 100644 index 000000000..7d99b29aa --- /dev/null +++ b/java/com/android/incallui/answer/impl/answermethod/res/values-h240dp/values.xml @@ -0,0 +1,20 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2016 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 + --> + +<resources> + <bool name="two_button_show_button_labels">true</bool> +</resources> diff --git a/java/com/android/incallui/answer/impl/answermethod/res/values-h280dp/dimens.xml b/java/com/android/incallui/answer/impl/answermethod/res/values-h280dp/dimens.xml new file mode 100644 index 000000000..e7e223d8c --- /dev/null +++ b/java/com/android/incallui/answer/impl/answermethod/res/values-h280dp/dimens.xml @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2016 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 + --> + +<resources> + <dimen name="two_button_button_size">64dp</dimen> + <dimen name="two_button_label_padding">16dp</dimen> +</resources> diff --git a/java/com/android/incallui/answer/impl/answermethod/res/values-h480dp/dimens.xml b/java/com/android/incallui/answer/impl/answermethod/res/values-h480dp/dimens.xml new file mode 100644 index 000000000..b7b4bd894 --- /dev/null +++ b/java/com/android/incallui/answer/impl/answermethod/res/values-h480dp/dimens.xml @@ -0,0 +1,20 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2016 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 + --> + +<resources> + <dimen name="two_button_bottom_padding">60dp</dimen> +</resources> diff --git a/java/com/android/incallui/answer/impl/answermethod/res/values/dimens.xml b/java/com/android/incallui/answer/impl/answermethod/res/values/dimens.xml new file mode 100644 index 000000000..bf160f9ac --- /dev/null +++ b/java/com/android/incallui/answer/impl/answermethod/res/values/dimens.xml @@ -0,0 +1,27 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2016 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 + --> + +<resources> + <dimen name="answer_contact_puck_size_photo">88dp</dimen> + <dimen name="answer_contact_puck_size_no_photo">72dp</dimen> + <dimen name="two_button_button_size">48dp</dimen> + <dimen name="two_button_label_size">12sp</dimen> + <dimen name="two_button_label_padding">8dp</dimen> + <dimen name="two_button_bottom_padding">24dp</dimen> + <dimen name="answer_swipe_dead_zone_sides">50dp</dimen> + <dimen name="answer_swipe_dead_zone_top">150dp</dimen> +</resources> diff --git a/java/com/android/incallui/answer/impl/answermethod/res/values/ids.xml b/java/com/android/incallui/answer/impl/answermethod/res/values/ids.xml new file mode 100644 index 000000000..fc03cacbd --- /dev/null +++ b/java/com/android/incallui/answer/impl/answermethod/res/values/ids.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <item name="accessibility_action_answer" type="id"/> + <item name="accessibility_action_decline" type="id"/> +</resources>
\ No newline at end of file diff --git a/java/com/android/incallui/answer/impl/answermethod/res/values/strings.xml b/java/com/android/incallui/answer/impl/answermethod/res/values/strings.xml new file mode 100644 index 000000000..8b50dbf1a --- /dev/null +++ b/java/com/android/incallui/answer/impl/answermethod/res/values/strings.xml @@ -0,0 +1,14 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <string name="call_incoming_swipe_to_answer">Swipe up to answer</string> + <string name="call_incoming_swipe_to_reject">Swipe down to reject</string> + <string name="a11y_incoming_call_swipe_to_answer">Swipe up with two fingers to answer or down to reject the call</string> + <string name="call_incoming_will_disconnect">Answering this call will end your video call</string> + + <string name="a11y_call_incoming_decline_description">Decline</string> + <string name="call_incoming_decline">Decline</string> + + <string name="a11y_call_incoming_answer_description">Answer</string> + <string name="call_incoming_answer">Answer</string> + +</resources> diff --git a/java/com/android/incallui/answer/impl/answermethod/res/values/styles.xml b/java/com/android/incallui/answer/impl/answermethod/res/values/styles.xml new file mode 100644 index 000000000..fd3ca7ca0 --- /dev/null +++ b/java/com/android/incallui/answer/impl/answermethod/res/values/styles.xml @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <style name="Dialer.Incall.TextAppearance.Hint"> + <item name="android:textSize">14sp</item> + <item name="android:textStyle">italic</item> + </style> +</resources> diff --git a/java/com/android/incallui/answer/impl/answermethod/res/values/values.xml b/java/com/android/incallui/answer/impl/answermethod/res/values/values.xml new file mode 100644 index 000000000..43b2cd273 --- /dev/null +++ b/java/com/android/incallui/answer/impl/answermethod/res/values/values.xml @@ -0,0 +1,25 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2016 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 + --> + +<resources> + <color name="incoming_or_outgoing_call_screen_mask">@android:color/transparent</color> + <color name="call_hangup_background">#DF0000</color> + <color name="call_accept_background">#00C853</color> + <color name="incoming_answer_icon">#00C853</color> + <integer name="button_exit_fade_delay_ms">300</integer> + <bool name="two_button_show_button_labels">false</bool> +</resources> diff --git a/java/com/android/incallui/answer/impl/classifier/AccelerationClassifier.java b/java/com/android/incallui/answer/impl/classifier/AccelerationClassifier.java new file mode 100644 index 000000000..ac504444e --- /dev/null +++ b/java/com/android/incallui/answer/impl/classifier/AccelerationClassifier.java @@ -0,0 +1,99 @@ +/* + * Copyright (C) 2016 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.incallui.answer.impl.classifier; + +import android.util.ArrayMap; +import android.view.MotionEvent; +import java.util.Map; + +/** + * A classifier which looks at the speed and distance between successive points of a Stroke. It + * looks at two consecutive speeds between two points and calculates the ratio between them. The + * final result is the maximum of these values. It does the same for distances. If some speed or + * distance is equal to zero then the ratio between this and the next part is not calculated. To the + * duration of each part there is added one nanosecond so that it is always possible to calculate + * the speed of a part. + */ +class AccelerationClassifier extends StrokeClassifier { + private final Map<Stroke, Data> mStrokeMap = new ArrayMap<>(); + + public AccelerationClassifier(ClassifierData classifierData) { + mClassifierData = classifierData; + } + + @Override + public String getTag() { + return "ACC"; + } + + @Override + public void onTouchEvent(MotionEvent event) { + int action = event.getActionMasked(); + + if (action == MotionEvent.ACTION_DOWN) { + mStrokeMap.clear(); + } + + for (int i = 0; i < event.getPointerCount(); i++) { + Stroke stroke = mClassifierData.getStroke(event.getPointerId(i)); + Point point = stroke.getPoints().get(stroke.getPoints().size() - 1); + if (mStrokeMap.get(stroke) == null) { + mStrokeMap.put(stroke, new Data(point)); + } else { + mStrokeMap.get(stroke).addPoint(point); + } + } + } + + @Override + public float getFalseTouchEvaluation(Stroke stroke) { + Data data = mStrokeMap.get(stroke); + return 2 * SpeedRatioEvaluator.evaluate(data.maxSpeedRatio); + } + + private static class Data { + + static final float MILLIS_TO_NANOS = 1e6f; + + Point previousPoint; + float previousSpeed = 0; + float maxSpeedRatio = 0; + + public Data(Point point) { + previousPoint = point; + } + + public void addPoint(Point point) { + float distance = previousPoint.dist(point); + float duration = (float) (point.timeOffsetNano - previousPoint.timeOffsetNano + 1); + float speed = distance / duration; + + if (duration > 20 * MILLIS_TO_NANOS || duration < 5 * MILLIS_TO_NANOS) { + // reject this segment and ensure we won't use data about it in the next round. + previousSpeed = 0; + previousPoint = point; + return; + } + if (previousSpeed != 0.0f) { + maxSpeedRatio = Math.max(maxSpeedRatio, speed / previousSpeed); + } + + previousSpeed = speed; + previousPoint = point; + } + } +} diff --git a/java/com/android/incallui/answer/impl/classifier/AnglesClassifier.java b/java/com/android/incallui/answer/impl/classifier/AnglesClassifier.java new file mode 100644 index 000000000..dbfbcfc1c --- /dev/null +++ b/java/com/android/incallui/answer/impl/classifier/AnglesClassifier.java @@ -0,0 +1,193 @@ +/* + * Copyright (C) 2016 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.incallui.answer.impl.classifier; + +import android.util.ArrayMap; +import android.view.MotionEvent; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * A classifier which calculates the variance of differences between successive angles in a stroke. + * For each stroke it keeps its last three points. If some successive points are the same, it + * ignores the repetitions. If a new point is added, the classifier calculates the angle between the + * last three points. After that, it calculates the difference between this angle and the previously + * calculated angle. Then it calculates the variance of the differences from a stroke. To the + * differences there is artificially added value 0.0 and the difference between the first angle and + * PI (angles are in radians). It helps with strokes which have few points and punishes more strokes + * which are not smooth. + * + * <p>This classifier also tries to split the stroke into two parts in the place in which the + * biggest angle is. It calculates the angle variance of the two parts and sums them up. The reason + * the classifier is doing this, is because some human swipes at the beginning go for a moment in + * one direction and then they rapidly change direction for the rest of the stroke (like a tick). + * The final result is the minimum of angle variance of the whole stroke and the sum of angle + * variances of the two parts split up. The classifier tries the tick option only if the first part + * is shorter than the second part. + * + * <p>Additionally, the classifier classifies the angles as left angles (those angles which value is + * in [0.0, PI - ANGLE_DEVIATION) interval), straight angles ([PI - ANGLE_DEVIATION, PI + + * ANGLE_DEVIATION] interval) and right angles ((PI + ANGLE_DEVIATION, 2 * PI) interval) and then + * calculates the percentage of angles which are in the same direction (straight angles can be left + * angels or right angles) + */ +class AnglesClassifier extends StrokeClassifier { + private Map<Stroke, Data> mStrokeMap = new ArrayMap<>(); + + public AnglesClassifier(ClassifierData classifierData) { + mClassifierData = classifierData; + } + + @Override + public String getTag() { + return "ANG"; + } + + @Override + public void onTouchEvent(MotionEvent event) { + int action = event.getActionMasked(); + + if (action == MotionEvent.ACTION_DOWN) { + mStrokeMap.clear(); + } + + for (int i = 0; i < event.getPointerCount(); i++) { + Stroke stroke = mClassifierData.getStroke(event.getPointerId(i)); + + if (mStrokeMap.get(stroke) == null) { + mStrokeMap.put(stroke, new Data()); + } + mStrokeMap.get(stroke).addPoint(stroke.getPoints().get(stroke.getPoints().size() - 1)); + } + } + + @Override + public float getFalseTouchEvaluation(Stroke stroke) { + Data data = mStrokeMap.get(stroke); + return AnglesVarianceEvaluator.evaluate(data.getAnglesVariance()) + + AnglesPercentageEvaluator.evaluate(data.getAnglesPercentage()); + } + + private static class Data { + private static final float ANGLE_DEVIATION = (float) Math.PI / 20.0f; + private static final float MIN_MOVE_DIST_DP = .01f; + + private List<Point> mLastThreePoints = new ArrayList<>(); + private float mFirstAngleVariance; + private float mPreviousAngle; + private float mBiggestAngle; + private float mSumSquares; + private float mSecondSumSquares; + private float mSum; + private float mSecondSum; + private float mCount; + private float mSecondCount; + private float mFirstLength; + private float mLength; + private float mAnglesCount; + private float mLeftAngles; + private float mRightAngles; + private float mStraightAngles; + + public Data() { + mFirstAngleVariance = 0.0f; + mPreviousAngle = (float) Math.PI; + mBiggestAngle = 0.0f; + mSumSquares = mSecondSumSquares = 0.0f; + mSum = mSecondSum = 0.0f; + mCount = mSecondCount = 1.0f; + mLength = mFirstLength = 0.0f; + mAnglesCount = mLeftAngles = mRightAngles = mStraightAngles = 0.0f; + } + + public void addPoint(Point point) { + // Checking if the added point is different than the previously added point + // Repetitions and short distances are being ignored so that proper angles are calculated. + if (mLastThreePoints.isEmpty() + || (!mLastThreePoints.get(mLastThreePoints.size() - 1).equals(point) + && (mLastThreePoints.get(mLastThreePoints.size() - 1).dist(point) + > MIN_MOVE_DIST_DP))) { + if (!mLastThreePoints.isEmpty()) { + mLength += mLastThreePoints.get(mLastThreePoints.size() - 1).dist(point); + } + mLastThreePoints.add(point); + if (mLastThreePoints.size() == 4) { + mLastThreePoints.remove(0); + + float angle = + mLastThreePoints.get(1).getAngle(mLastThreePoints.get(0), mLastThreePoints.get(2)); + + mAnglesCount++; + if (angle < Math.PI - ANGLE_DEVIATION) { + mLeftAngles++; + } else if (angle <= Math.PI + ANGLE_DEVIATION) { + mStraightAngles++; + } else { + mRightAngles++; + } + + float difference = angle - mPreviousAngle; + + // If this is the biggest angle of the stroke so then we save the value of + // the angle variance so far and start to count the values for the angle + // variance of the second part. + if (mBiggestAngle < angle) { + mBiggestAngle = angle; + mFirstLength = mLength; + mFirstAngleVariance = getAnglesVariance(mSumSquares, mSum, mCount); + mSecondSumSquares = 0.0f; + mSecondSum = 0.0f; + mSecondCount = 1.0f; + } else { + mSecondSum += difference; + mSecondSumSquares += difference * difference; + mSecondCount += 1.0f; + } + + mSum += difference; + mSumSquares += difference * difference; + mCount += 1.0f; + mPreviousAngle = angle; + } + } + } + + public float getAnglesVariance(float sumSquares, float sum, float count) { + return sumSquares / count - (sum / count) * (sum / count); + } + + public float getAnglesVariance() { + float anglesVariance = getAnglesVariance(mSumSquares, mSum, mCount); + if (mFirstLength < mLength / 2f) { + anglesVariance = + Math.min( + anglesVariance, + mFirstAngleVariance + + getAnglesVariance(mSecondSumSquares, mSecondSum, mSecondCount)); + } + return anglesVariance; + } + + public float getAnglesPercentage() { + if (mAnglesCount == 0.0f) { + return 1.0f; + } + return (Math.max(mLeftAngles, mRightAngles) + mStraightAngles) / mAnglesCount; + } + } +} diff --git a/java/com/android/incallui/answer/impl/classifier/AnglesPercentageEvaluator.java b/java/com/android/incallui/answer/impl/classifier/AnglesPercentageEvaluator.java new file mode 100644 index 000000000..49a183596 --- /dev/null +++ b/java/com/android/incallui/answer/impl/classifier/AnglesPercentageEvaluator.java @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2015 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.incallui.answer.impl.classifier; + +class AnglesPercentageEvaluator { + public static float evaluate(float value) { + float evaluation = 0.0f; + if (value < 1.00) { + evaluation++; + } + if (value < 0.90) { + evaluation++; + } + if (value < 0.70) { + evaluation++; + } + return evaluation; + } +} diff --git a/java/com/android/incallui/answer/impl/classifier/AnglesVarianceEvaluator.java b/java/com/android/incallui/answer/impl/classifier/AnglesVarianceEvaluator.java new file mode 100644 index 000000000..db4de6a3b --- /dev/null +++ b/java/com/android/incallui/answer/impl/classifier/AnglesVarianceEvaluator.java @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2015 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.incallui.answer.impl.classifier; + +class AnglesVarianceEvaluator { + public static float evaluate(float value) { + float evaluation = 0.0f; + if (value > 0.05) { + evaluation++; + } + if (value > 0.10) { + evaluation++; + } + if (value > 0.20) { + evaluation++; + } + if (value > 0.40) { + evaluation++; + } + if (value > 0.80) { + evaluation++; + } + if (value > 1.50) { + evaluation++; + } + return evaluation; + } +} diff --git a/java/com/android/incallui/answer/impl/classifier/Classifier.java b/java/com/android/incallui/answer/impl/classifier/Classifier.java new file mode 100644 index 000000000..c6fbff327 --- /dev/null +++ b/java/com/android/incallui/answer/impl/classifier/Classifier.java @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2015 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.incallui.answer.impl.classifier; + +import android.hardware.SensorEvent; +import android.view.MotionEvent; + +/** An abstract class for classifiers for touch and sensor events. */ +abstract class Classifier { + + /** Contains all the information about touch events from which the classifier can query */ + protected ClassifierData mClassifierData; + + /** Informs the classifier that a new touch event has occurred */ + public void onTouchEvent(MotionEvent event) {} + + /** Informs the classifier that a sensor change occurred */ + public void onSensorChanged(SensorEvent event) {} + + public abstract String getTag(); +} diff --git a/java/com/android/incallui/answer/impl/classifier/ClassifierData.java b/java/com/android/incallui/answer/impl/classifier/ClassifierData.java new file mode 100644 index 000000000..ae07d27a0 --- /dev/null +++ b/java/com/android/incallui/answer/impl/classifier/ClassifierData.java @@ -0,0 +1,96 @@ +/* + * Copyright (C) 2015 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.incallui.answer.impl.classifier; + +import android.util.SparseArray; +import android.view.MotionEvent; +import java.util.ArrayList; +import java.util.concurrent.TimeUnit; + +/** + * Contains data which is used to classify interaction sequences on the lockscreen. It does, for + * example, provide information on the current touch state. + */ +class ClassifierData { + private SparseArray<Stroke> mCurrentStrokes = new SparseArray<>(); + private ArrayList<Stroke> mEndingStrokes = new ArrayList<>(); + private final float mDpi; + private final float mScreenHeight; + + public ClassifierData(float dpi, float screenHeight) { + mDpi = dpi; + mScreenHeight = screenHeight / dpi; + } + + public void update(MotionEvent event) { + mEndingStrokes.clear(); + int action = event.getActionMasked(); + if (action == MotionEvent.ACTION_DOWN) { + mCurrentStrokes.clear(); + } + + for (int i = 0; i < event.getPointerCount(); i++) { + int id = event.getPointerId(i); + if (mCurrentStrokes.get(id) == null) { + // TODO (keyboardr): See if there's a way to use event.getEventTimeNanos() instead + mCurrentStrokes.put( + id, new Stroke(TimeUnit.MILLISECONDS.toNanos(event.getEventTime()), mDpi)); + } + mCurrentStrokes + .get(id) + .addPoint( + event.getX(i), event.getY(i), TimeUnit.MILLISECONDS.toNanos(event.getEventTime())); + + if (action == MotionEvent.ACTION_UP + || action == MotionEvent.ACTION_CANCEL + || (action == MotionEvent.ACTION_POINTER_UP && i == event.getActionIndex())) { + mEndingStrokes.add(getStroke(id)); + } + } + } + + void cleanUp(MotionEvent event) { + mEndingStrokes.clear(); + int action = event.getActionMasked(); + for (int i = 0; i < event.getPointerCount(); i++) { + int id = event.getPointerId(i); + if (action == MotionEvent.ACTION_UP + || action == MotionEvent.ACTION_CANCEL + || (action == MotionEvent.ACTION_POINTER_UP && i == event.getActionIndex())) { + mCurrentStrokes.remove(id); + } + } + } + + /** @return the list of Strokes which are ending in the recently added MotionEvent */ + public ArrayList<Stroke> getEndingStrokes() { + return mEndingStrokes; + } + + /** + * @param id the id from MotionEvent + * @return the Stroke assigned to the id + */ + public Stroke getStroke(int id) { + return mCurrentStrokes.get(id); + } + + /** @return the height of the screen in inches */ + public float getScreenHeight() { + return mScreenHeight; + } +} diff --git a/java/com/android/incallui/answer/impl/classifier/DirectionClassifier.java b/java/com/android/incallui/answer/impl/classifier/DirectionClassifier.java new file mode 100644 index 000000000..068626859 --- /dev/null +++ b/java/com/android/incallui/answer/impl/classifier/DirectionClassifier.java @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2015 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.incallui.answer.impl.classifier; + +/** + * A classifier which looks at the general direction of a stroke and evaluates it depending on the + * type of action that takes place. + */ +public class DirectionClassifier extends StrokeClassifier { + public DirectionClassifier(ClassifierData classifierData) {} + + @Override + public String getTag() { + return "DIR"; + } + + @Override + public float getFalseTouchEvaluation(Stroke stroke) { + Point firstPoint = stroke.getPoints().get(0); + Point lastPoint = stroke.getPoints().get(stroke.getPoints().size() - 1); + return DirectionEvaluator.evaluate(lastPoint.x - firstPoint.x, lastPoint.y - firstPoint.y); + } +} diff --git a/java/com/android/incallui/answer/impl/classifier/DirectionEvaluator.java b/java/com/android/incallui/answer/impl/classifier/DirectionEvaluator.java new file mode 100644 index 000000000..cdc1cfe1e --- /dev/null +++ b/java/com/android/incallui/answer/impl/classifier/DirectionEvaluator.java @@ -0,0 +1,23 @@ +/* + * Copyright (C) 2015 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.incallui.answer.impl.classifier; + +class DirectionEvaluator { + public static float evaluate(float xDiff, float yDiff) { + return Math.abs(yDiff) < Math.abs(xDiff) ? 5.5f : 0.0f; + } +} diff --git a/java/com/android/incallui/answer/impl/classifier/DurationCountClassifier.java b/java/com/android/incallui/answer/impl/classifier/DurationCountClassifier.java new file mode 100644 index 000000000..0b9f1138d --- /dev/null +++ b/java/com/android/incallui/answer/impl/classifier/DurationCountClassifier.java @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2015 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.incallui.answer.impl.classifier; + +/** + * A classifier which looks at the ratio between the duration of the stroke and its number of + * points. + */ +class DurationCountClassifier extends StrokeClassifier { + public DurationCountClassifier(ClassifierData classifierData) {} + + @Override + public String getTag() { + return "DUR"; + } + + @Override + public float getFalseTouchEvaluation(Stroke stroke) { + return DurationCountEvaluator.evaluate(stroke.getDurationSeconds() / stroke.getCount()); + } +} diff --git a/java/com/android/incallui/answer/impl/classifier/DurationCountEvaluator.java b/java/com/android/incallui/answer/impl/classifier/DurationCountEvaluator.java new file mode 100644 index 000000000..5b232fe95 --- /dev/null +++ b/java/com/android/incallui/answer/impl/classifier/DurationCountEvaluator.java @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2015 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.incallui.answer.impl.classifier; + +class DurationCountEvaluator { + public static float evaluate(float value) { + float evaluation = 0.0f; + if (value < 0.0105) { + evaluation++; + } + if (value < 0.00909) { + evaluation++; + } + if (value < 0.00667) { + evaluation++; + } + if (value > 0.0333) { + evaluation++; + } + if (value > 0.0500) { + evaluation++; + } + return evaluation; + } +} diff --git a/java/com/android/incallui/answer/impl/classifier/EndPointLengthClassifier.java b/java/com/android/incallui/answer/impl/classifier/EndPointLengthClassifier.java new file mode 100644 index 000000000..95b317638 --- /dev/null +++ b/java/com/android/incallui/answer/impl/classifier/EndPointLengthClassifier.java @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2015 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.incallui.answer.impl.classifier; + +/** + * A classifier which looks at the distance between the first and the last point from the stroke. + */ +class EndPointLengthClassifier extends StrokeClassifier { + public EndPointLengthClassifier(ClassifierData classifierData) { + mClassifierData = classifierData; + } + + @Override + public String getTag() { + return "END_LNGTH"; + } + + @Override + public float getFalseTouchEvaluation(Stroke stroke) { + return EndPointLengthEvaluator.evaluate(stroke.getEndPointLength()); + } +} diff --git a/java/com/android/incallui/answer/impl/classifier/EndPointLengthEvaluator.java b/java/com/android/incallui/answer/impl/classifier/EndPointLengthEvaluator.java new file mode 100644 index 000000000..74bfffba4 --- /dev/null +++ b/java/com/android/incallui/answer/impl/classifier/EndPointLengthEvaluator.java @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2015 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.incallui.answer.impl.classifier; + +class EndPointLengthEvaluator { + public static float evaluate(float value) { + float evaluation = 0.0f; + if (value < 0.05) { + evaluation += 2.0f; + } + if (value < 0.1) { + evaluation += 2.0f; + } + if (value < 0.2) { + evaluation += 2.0f; + } + if (value < 0.3) { + evaluation += 2.0f; + } + if (value < 0.4) { + evaluation += 2.0f; + } + if (value < 0.5) { + evaluation += 2.0f; + } + return evaluation; + } +} diff --git a/java/com/android/incallui/answer/impl/classifier/EndPointRatioClassifier.java b/java/com/android/incallui/answer/impl/classifier/EndPointRatioClassifier.java new file mode 100644 index 000000000..01a35c126 --- /dev/null +++ b/java/com/android/incallui/answer/impl/classifier/EndPointRatioClassifier.java @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2015 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.incallui.answer.impl.classifier; + +/** + * A classifier which looks at the ratio between the total length covered by the stroke and the + * distance between the first and last point from this stroke. + */ +class EndPointRatioClassifier extends StrokeClassifier { + public EndPointRatioClassifier(ClassifierData classifierData) { + mClassifierData = classifierData; + } + + @Override + public String getTag() { + return "END_RTIO"; + } + + @Override + public float getFalseTouchEvaluation(Stroke stroke) { + float ratio; + if (stroke.getTotalLength() == 0.0f) { + ratio = 1.0f; + } else { + ratio = stroke.getEndPointLength() / stroke.getTotalLength(); + } + return EndPointRatioEvaluator.evaluate(ratio); + } +} diff --git a/java/com/android/incallui/answer/impl/classifier/EndPointRatioEvaluator.java b/java/com/android/incallui/answer/impl/classifier/EndPointRatioEvaluator.java new file mode 100644 index 000000000..1d64bea8e --- /dev/null +++ b/java/com/android/incallui/answer/impl/classifier/EndPointRatioEvaluator.java @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2015 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.incallui.answer.impl.classifier; + +class EndPointRatioEvaluator { + public static float evaluate(float value) { + float evaluation = 0.0f; + if (value < 0.85) { + evaluation++; + } + if (value < 0.75) { + evaluation++; + } + if (value < 0.65) { + evaluation++; + } + if (value < 0.55) { + evaluation++; + } + if (value < 0.45) { + evaluation++; + } + if (value < 0.35) { + evaluation++; + } + return evaluation; + } +} diff --git a/java/com/android/incallui/answer/impl/classifier/FalsingManager.java b/java/com/android/incallui/answer/impl/classifier/FalsingManager.java new file mode 100644 index 000000000..fdcc0a3f9 --- /dev/null +++ b/java/com/android/incallui/answer/impl/classifier/FalsingManager.java @@ -0,0 +1,140 @@ +/* + * Copyright (C) 2016 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.incallui.answer.impl.classifier; + +import android.content.Context; +import android.hardware.Sensor; +import android.hardware.SensorEvent; +import android.hardware.SensorEventListener; +import android.hardware.SensorManager; +import android.os.PowerManager; +import android.view.MotionEvent; +import android.view.accessibility.AccessibilityManager; + +/** + * When the phone is locked, listens to touch, sensor and phone events and sends them to + * HumanInteractionClassifier to determine if touches are coming from a human. + */ +public class FalsingManager implements SensorEventListener { + private static final int[] CLASSIFIER_SENSORS = + new int[] { + Sensor.TYPE_PROXIMITY, + }; + + private final SensorManager mSensorManager; + private final HumanInteractionClassifier mHumanInteractionClassifier; + private final AccessibilityManager mAccessibilityManager; + + private boolean mSessionActive = false; + private boolean mScreenOn; + + public FalsingManager(Context context) { + mSensorManager = context.getSystemService(SensorManager.class); + mAccessibilityManager = context.getSystemService(AccessibilityManager.class); + mHumanInteractionClassifier = new HumanInteractionClassifier(context); + mScreenOn = context.getSystemService(PowerManager.class).isInteractive(); + } + + /** Returns {@code true} iff the FalsingManager is enabled and able to classify touches */ + public boolean isEnabled() { + return mHumanInteractionClassifier.isEnabled(); + } + + /** + * Returns {@code true} iff the classifier determined that this is not a human interacting with + * the phone. + */ + public boolean isFalseTouch() { + // Touch exploration triggers false positives in the classifier and + // already sufficiently prevents false unlocks. + return !mAccessibilityManager.isTouchExplorationEnabled() + && mHumanInteractionClassifier.isFalseTouch(); + } + + /** + * Should be called when the screen turns on and the related Views become visible. This will start + * tracking changes if the manager is enabled. + */ + public void onScreenOn() { + mScreenOn = true; + sessionEntrypoint(); + } + + /** + * Should be called when the screen turns off or the related Views are no longer visible. This + * will cause the manager to stop tracking changes. + */ + public void onScreenOff() { + mScreenOn = false; + sessionExitpoint(); + } + + /** + * Should be called when a new touch event has been received and should be classified. + * + * @param event MotionEvent to be classified as human or false. + */ + public void onTouchEvent(MotionEvent event) { + if (mSessionActive) { + mHumanInteractionClassifier.onTouchEvent(event); + } + } + + @Override + public synchronized void onSensorChanged(SensorEvent event) { + mHumanInteractionClassifier.onSensorChanged(event); + } + + @Override + public void onAccuracyChanged(Sensor sensor, int accuracy) {} + + private boolean shouldSessionBeActive() { + return isEnabled() && mScreenOn; + } + + private boolean sessionEntrypoint() { + if (!mSessionActive && shouldSessionBeActive()) { + onSessionStart(); + return true; + } + return false; + } + + private void sessionExitpoint() { + if (mSessionActive && !shouldSessionBeActive()) { + mSessionActive = false; + mSensorManager.unregisterListener(this); + } + } + + private void onSessionStart() { + mSessionActive = true; + + if (mHumanInteractionClassifier.isEnabled()) { + registerSensors(CLASSIFIER_SENSORS); + } + } + + private void registerSensors(int[] sensors) { + for (int sensorType : sensors) { + Sensor s = mSensorManager.getDefaultSensor(sensorType); + if (s != null) { + mSensorManager.registerListener(this, s, SensorManager.SENSOR_DELAY_GAME); + } + } + } +} diff --git a/java/com/android/incallui/answer/impl/classifier/GestureClassifier.java b/java/com/android/incallui/answer/impl/classifier/GestureClassifier.java new file mode 100644 index 000000000..afd7ea0e7 --- /dev/null +++ b/java/com/android/incallui/answer/impl/classifier/GestureClassifier.java @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2015 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.incallui.answer.impl.classifier; + +/** + * An abstract class for classifiers which classify the whole gesture (all the strokes which + * occurred from DOWN event to UP/CANCEL event) + */ +abstract class GestureClassifier extends Classifier { + + /** + * @return a non-negative value which is used to determine whether the most recent gesture is a + * false interaction; the bigger the value the greater the chance that this a false + * interaction. + */ + public abstract float getFalseTouchEvaluation(); +} diff --git a/java/com/android/incallui/answer/impl/classifier/HistoryEvaluator.java b/java/com/android/incallui/answer/impl/classifier/HistoryEvaluator.java new file mode 100644 index 000000000..3f302c65f --- /dev/null +++ b/java/com/android/incallui/answer/impl/classifier/HistoryEvaluator.java @@ -0,0 +1,115 @@ +/* + * Copyright (C) 2015 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.incallui.answer.impl.classifier; + +import android.os.SystemClock; + +import java.util.ArrayList; + +/** + * Holds the evaluations for ended strokes and gestures. These values are decreased through time. + */ +class HistoryEvaluator { + private static final float INTERVAL = 50.0f; + private static final float HISTORY_FACTOR = 0.9f; + private static final float EPSILON = 1e-5f; + + private final ArrayList<Data> mStrokes = new ArrayList<>(); + private final ArrayList<Data> mGestureWeights = new ArrayList<>(); + private long mLastUpdate; + + public HistoryEvaluator() { + mLastUpdate = SystemClock.elapsedRealtime(); + } + + public void addStroke(float evaluation) { + decayValue(); + mStrokes.add(new Data(evaluation)); + } + + public void addGesture(float evaluation) { + decayValue(); + mGestureWeights.add(new Data(evaluation)); + } + + /** Calculates the weighted average of strokes and adds to it the weighted average of gestures */ + public float getEvaluation() { + return weightedAverage(mStrokes) + weightedAverage(mGestureWeights); + } + + private float weightedAverage(ArrayList<Data> list) { + float sumValue = 0.0f; + float sumWeight = 0.0f; + int size = list.size(); + for (int i = 0; i < size; i++) { + Data data = list.get(i); + sumValue += data.evaluation * data.weight; + sumWeight += data.weight; + } + + if (sumWeight == 0.0f) { + return 0.0f; + } + + return sumValue / sumWeight; + } + + private void decayValue() { + long time = SystemClock.elapsedRealtime(); + + if (time <= mLastUpdate) { + return; + } + + // All weights are multiplied by HISTORY_FACTOR after each INTERVAL milliseconds. + float factor = (float) Math.pow(HISTORY_FACTOR, (time - mLastUpdate) / INTERVAL); + + decayValue(mStrokes, factor); + decayValue(mGestureWeights, factor); + mLastUpdate = time; + } + + private void decayValue(ArrayList<Data> list, float factor) { + int size = list.size(); + for (int i = 0; i < size; i++) { + list.get(i).weight *= factor; + } + + // Removing evaluations with such small weights that they do not matter anymore + while (!list.isEmpty() && isZero(list.get(0).weight)) { + list.remove(0); + } + } + + private boolean isZero(float x) { + return x <= EPSILON && x >= -EPSILON; + } + + /** + * For each stroke it holds its initial value and the current weight. Initially the weight is set + * to 1.0 + */ + private static class Data { + public float evaluation; + public float weight; + + public Data(float evaluation) { + this.evaluation = evaluation; + weight = 1.0f; + } + } +} diff --git a/java/com/android/incallui/answer/impl/classifier/HumanInteractionClassifier.java b/java/com/android/incallui/answer/impl/classifier/HumanInteractionClassifier.java new file mode 100644 index 000000000..1d3d7ef22 --- /dev/null +++ b/java/com/android/incallui/answer/impl/classifier/HumanInteractionClassifier.java @@ -0,0 +1,142 @@ +/* + * Copyright (C) 2016 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.incallui.answer.impl.classifier; + +import android.content.Context; +import android.hardware.SensorEvent; +import android.util.DisplayMetrics; +import android.view.MotionEvent; +import com.android.dialer.common.ConfigProviderBindings; + +/** An classifier trying to determine whether it is a human interacting with the phone or not. */ +class HumanInteractionClassifier extends Classifier { + + private static final String CONFIG_ANSWER_FALSE_TOUCH_DETECTION_ENABLED = + "answer_false_touch_detection_enabled"; + + private final StrokeClassifier[] mStrokeClassifiers; + private final GestureClassifier[] mGestureClassifiers; + private final HistoryEvaluator mHistoryEvaluator; + private final boolean mEnabled; + + HumanInteractionClassifier(Context context) { + DisplayMetrics displayMetrics = context.getResources().getDisplayMetrics(); + + // If the phone is rotated to landscape, the calculations would be wrong if xdpi and ydpi + // were to be used separately. Due negligible differences in xdpi and ydpi we can just + // take the average. + // Note that xdpi and ydpi are the physical pixels per inch and are not affected by scaling. + float dpi = (displayMetrics.xdpi + displayMetrics.ydpi) / 2.0f; + mClassifierData = new ClassifierData(dpi, displayMetrics.heightPixels); + mHistoryEvaluator = new HistoryEvaluator(); + mEnabled = + ConfigProviderBindings.get(context) + .getBoolean(CONFIG_ANSWER_FALSE_TOUCH_DETECTION_ENABLED, true); + + mStrokeClassifiers = + new StrokeClassifier[] { + new AnglesClassifier(mClassifierData), + new SpeedClassifier(mClassifierData), + new DurationCountClassifier(mClassifierData), + new EndPointRatioClassifier(mClassifierData), + new EndPointLengthClassifier(mClassifierData), + new AccelerationClassifier(mClassifierData), + new SpeedAnglesClassifier(mClassifierData), + new LengthCountClassifier(mClassifierData), + new DirectionClassifier(mClassifierData) + }; + + mGestureClassifiers = + new GestureClassifier[] { + new PointerCountClassifier(mClassifierData), new ProximityClassifier(mClassifierData) + }; + } + + @Override + public void onTouchEvent(MotionEvent event) { + + // If the user is dragging down the notification, they might want to drag it down + // enough to see the content, read it for a while and then lift the finger to open + // the notification. This kind of motion scores very bad in the Classifier so the + // MotionEvents which are close to the current position of the finger are not + // sent to the classifiers until the finger moves far enough. When the finger if lifted + // up, the last MotionEvent which was far enough from the finger is set as the final + // MotionEvent and sent to the Classifiers. + addTouchEvent(event); + } + + private void addTouchEvent(MotionEvent event) { + mClassifierData.update(event); + + for (StrokeClassifier c : mStrokeClassifiers) { + c.onTouchEvent(event); + } + + for (GestureClassifier c : mGestureClassifiers) { + c.onTouchEvent(event); + } + + int size = mClassifierData.getEndingStrokes().size(); + for (int i = 0; i < size; i++) { + Stroke stroke = mClassifierData.getEndingStrokes().get(i); + float evaluation = 0.0f; + for (StrokeClassifier c : mStrokeClassifiers) { + float e = c.getFalseTouchEvaluation(stroke); + evaluation += e; + } + + mHistoryEvaluator.addStroke(evaluation); + } + + int action = event.getActionMasked(); + if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) { + float evaluation = 0.0f; + for (GestureClassifier c : mGestureClassifiers) { + float e = c.getFalseTouchEvaluation(); + evaluation += e; + } + mHistoryEvaluator.addGesture(evaluation); + } + + mClassifierData.cleanUp(event); + } + + @Override + public void onSensorChanged(SensorEvent event) { + for (Classifier c : mStrokeClassifiers) { + c.onSensorChanged(event); + } + + for (Classifier c : mGestureClassifiers) { + c.onSensorChanged(event); + } + } + + boolean isFalseTouch() { + float evaluation = mHistoryEvaluator.getEvaluation(); + return evaluation >= 5.0f; + } + + public boolean isEnabled() { + return mEnabled; + } + + @Override + public String getTag() { + return "HIC"; + } +} diff --git a/java/com/android/incallui/answer/impl/classifier/LengthCountClassifier.java b/java/com/android/incallui/answer/impl/classifier/LengthCountClassifier.java new file mode 100644 index 000000000..7dd2ab674 --- /dev/null +++ b/java/com/android/incallui/answer/impl/classifier/LengthCountClassifier.java @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2015 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.incallui.answer.impl.classifier; + +/** + * A classifier which looks at the ratio between the length of the stroke and its number of points. + * The number of points is subtracted by 2 because the UP event comes in with some delay and it + * should not influence the ratio and also strokes which are long and have a small number of points + * are punished more (these kind of strokes are usually bad ones and they tend to score well in + * other classifiers). + */ +class LengthCountClassifier extends StrokeClassifier { + public LengthCountClassifier(ClassifierData classifierData) {} + + @Override + public String getTag() { + return "LEN_CNT"; + } + + @Override + public float getFalseTouchEvaluation(Stroke stroke) { + return LengthCountEvaluator.evaluate( + stroke.getTotalLength() / Math.max(1.0f, stroke.getCount() - 2)); + } +} diff --git a/java/com/android/incallui/answer/impl/classifier/LengthCountEvaluator.java b/java/com/android/incallui/answer/impl/classifier/LengthCountEvaluator.java new file mode 100644 index 000000000..2a2225a00 --- /dev/null +++ b/java/com/android/incallui/answer/impl/classifier/LengthCountEvaluator.java @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2015 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.incallui.answer.impl.classifier; + +/** + * A classifier which looks at the ratio between the length of the stroke and its number of points. + */ +class LengthCountEvaluator { + public static float evaluate(float value) { + float evaluation = 0.0f; + if (value < 0.09) { + evaluation++; + } + if (value < 0.05) { + evaluation++; + } + if (value < 0.02) { + evaluation++; + } + if (value > 0.6) { + evaluation++; + } + if (value > 0.9) { + evaluation++; + } + if (value > 1.2) { + evaluation++; + } + return evaluation; + } +} diff --git a/java/com/android/incallui/answer/impl/classifier/Point.java b/java/com/android/incallui/answer/impl/classifier/Point.java new file mode 100644 index 000000000..5ea48b4ce --- /dev/null +++ b/java/com/android/incallui/answer/impl/classifier/Point.java @@ -0,0 +1,95 @@ +/* + * Copyright (C) 2015 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.incallui.answer.impl.classifier; + +class Point { + public float x; + public float y; + public long timeOffsetNano; + + public Point(float x, float y) { + this.x = x; + this.y = y; + this.timeOffsetNano = 0; + } + + public Point(float x, float y, long timeOffsetNano) { + this.x = x; + this.y = y; + this.timeOffsetNano = timeOffsetNano; + } + + @Override + public boolean equals(Object other) { + if (!(other instanceof Point)) { + return false; + } + Point otherPoint = ((Point) other); + return x == otherPoint.x && y == otherPoint.y; + } + + @Override + public int hashCode() { + int result = (x != +0.0f ? Float.floatToIntBits(x) : 0); + result = 31 * result + (y != +0.0f ? Float.floatToIntBits(y) : 0); + return result; + } + + public float dist(Point a) { + return (float) Math.hypot(a.x - x, a.y - y); + } + + /** + * Calculates the cross product of vec(this, a) and vec(this, b) where vec(x,y) is the vector from + * point x to point y + */ + public float crossProduct(Point a, Point b) { + return (a.x - x) * (b.y - y) - (a.y - y) * (b.x - x); + } + + /** + * Calculates the dot product of vec(this, a) and vec(this, b) where vec(x,y) is the vector from + * point x to point y + */ + public float dotProduct(Point a, Point b) { + return (a.x - x) * (b.x - x) + (a.y - y) * (b.y - y); + } + + /** + * Calculates the angle in radians created by points (a, this, b). If any two of these points are + * the same, the method will return 0.0f + * + * @return the angle in radians + */ + public float getAngle(Point a, Point b) { + float dist1 = dist(a); + float dist2 = dist(b); + + if (dist1 == 0.0f || dist2 == 0.0f) { + return 0.0f; + } + + float crossProduct = crossProduct(a, b); + float dotProduct = dotProduct(a, b); + float cos = Math.min(1.0f, Math.max(-1.0f, dotProduct / dist1 / dist2)); + float angle = (float) Math.acos(cos); + if (crossProduct < 0.0) { + angle = 2.0f * (float) Math.PI - angle; + } + return angle; + } +} diff --git a/java/com/android/incallui/answer/impl/classifier/PointerCountClassifier.java b/java/com/android/incallui/answer/impl/classifier/PointerCountClassifier.java new file mode 100644 index 000000000..070de6c9b --- /dev/null +++ b/java/com/android/incallui/answer/impl/classifier/PointerCountClassifier.java @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2015 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.incallui.answer.impl.classifier; + +import android.view.MotionEvent; + +/** A classifier which looks at the total number of traces in the whole gesture. */ +class PointerCountClassifier extends GestureClassifier { + private int mCount; + + public PointerCountClassifier(ClassifierData classifierData) { + mCount = 0; + } + + @Override + public String getTag() { + return "PTR_CNT"; + } + + @Override + public void onTouchEvent(MotionEvent event) { + int action = event.getActionMasked(); + + if (action == MotionEvent.ACTION_DOWN) { + mCount = 1; + } + + if (action == MotionEvent.ACTION_POINTER_DOWN) { + ++mCount; + } + } + + @Override + public float getFalseTouchEvaluation() { + return PointerCountEvaluator.evaluate(mCount); + } +} diff --git a/java/com/android/incallui/answer/impl/classifier/PointerCountEvaluator.java b/java/com/android/incallui/answer/impl/classifier/PointerCountEvaluator.java new file mode 100644 index 000000000..aa972da8c --- /dev/null +++ b/java/com/android/incallui/answer/impl/classifier/PointerCountEvaluator.java @@ -0,0 +1,23 @@ +/* + * Copyright (C) 2015 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.incallui.answer.impl.classifier; + +class PointerCountEvaluator { + public static float evaluate(int value) { + return (value - 1) * (value - 1); + } +} diff --git a/java/com/android/incallui/answer/impl/classifier/ProximityClassifier.java b/java/com/android/incallui/answer/impl/classifier/ProximityClassifier.java new file mode 100644 index 000000000..28701ea6d --- /dev/null +++ b/java/com/android/incallui/answer/impl/classifier/ProximityClassifier.java @@ -0,0 +1,97 @@ +/* + * Copyright (C) 2015 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.incallui.answer.impl.classifier; + +import android.hardware.Sensor; +import android.hardware.SensorEvent; +import android.view.MotionEvent; +import java.util.concurrent.TimeUnit; + +/** + * A classifier which looks at the proximity sensor during the gesture. It calculates the percentage + * the proximity sensor showing the near state during the whole gesture + */ +class ProximityClassifier extends GestureClassifier { + private long mGestureStartTimeNano; + private long mNearStartTimeNano; + private long mNearDuration; + private boolean mNear; + private float mAverageNear; + + public ProximityClassifier(ClassifierData classifierData) {} + + @Override + public String getTag() { + return "PROX"; + } + + @Override + public void onSensorChanged(SensorEvent event) { + if (event.sensor.getType() == Sensor.TYPE_PROXIMITY) { + update(event.values[0] < event.sensor.getMaximumRange(), event.timestamp); + } + } + + @Override + public void onTouchEvent(MotionEvent event) { + int action = event.getActionMasked(); + + if (action == MotionEvent.ACTION_DOWN) { + mGestureStartTimeNano = TimeUnit.MILLISECONDS.toNanos(event.getEventTime()); + mNearStartTimeNano = TimeUnit.MILLISECONDS.toNanos(event.getEventTime()); + mNearDuration = 0; + } + + if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) { + update(mNear, TimeUnit.MILLISECONDS.toNanos(event.getEventTime())); + long duration = TimeUnit.MILLISECONDS.toNanos(event.getEventTime()) - mGestureStartTimeNano; + + if (duration == 0) { + mAverageNear = mNear ? 1.0f : 0.0f; + } else { + mAverageNear = (float) mNearDuration / (float) duration; + } + } + } + + /** + * @param near is the sensor showing the near state right now + * @param timestampNano time of this event in nanoseconds + */ + private void update(boolean near, long timestampNano) { + // This if is necessary because MotionEvents and SensorEvents do not come in + // chronological order + if (timestampNano > mNearStartTimeNano) { + // if the state before was near then add the difference of the current time and + // mNearStartTimeNano to mNearDuration. + if (mNear) { + mNearDuration += timestampNano - mNearStartTimeNano; + } + + // if the new state is near, set mNearStartTimeNano equal to this moment. + if (near) { + mNearStartTimeNano = timestampNano; + } + } + mNear = near; + } + + @Override + public float getFalseTouchEvaluation() { + return ProximityEvaluator.evaluate(mAverageNear); + } +} diff --git a/java/com/android/incallui/answer/impl/classifier/ProximityEvaluator.java b/java/com/android/incallui/answer/impl/classifier/ProximityEvaluator.java new file mode 100644 index 000000000..14636c644 --- /dev/null +++ b/java/com/android/incallui/answer/impl/classifier/ProximityEvaluator.java @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2015 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.incallui.answer.impl.classifier; + +class ProximityEvaluator { + public static float evaluate(float value) { + float evaluation = 0.0f; + float threshold = 0.1f; + if (value >= threshold) { + evaluation += 2.0f; + } + return evaluation; + } +} diff --git a/java/com/android/incallui/answer/impl/classifier/SpeedAnglesClassifier.java b/java/com/android/incallui/answer/impl/classifier/SpeedAnglesClassifier.java new file mode 100644 index 000000000..36ae3ad7c --- /dev/null +++ b/java/com/android/incallui/answer/impl/classifier/SpeedAnglesClassifier.java @@ -0,0 +1,147 @@ +/* + * Copyright (C) 2015 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.incallui.answer.impl.classifier; + +import android.util.ArrayMap; +import android.view.MotionEvent; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * A classifier which for each point from a stroke, it creates a point on plane with coordinates + * (timeOffsetNano, distanceCoveredUpToThisPoint) (scaled by DURATION_SCALE and LENGTH_SCALE) and + * then it calculates the angle variance of these points like the class {@link AnglesClassifier} + * (without splitting it into two parts). The classifier ignores the last point of a stroke because + * the UP event comes in with some delay and this ruins the smoothness of this curve. Additionally, + * the classifier classifies calculates the percentage of angles which value is in [PI - + * ANGLE_DEVIATION, 2* PI) interval. The reason why the classifier does that is because the speed of + * a good stroke is most often increases, so most of these angels should be in this interval. + */ +class SpeedAnglesClassifier extends StrokeClassifier { + private Map<Stroke, Data> mStrokeMap = new ArrayMap<>(); + + public SpeedAnglesClassifier(ClassifierData classifierData) { + mClassifierData = classifierData; + } + + @Override + public String getTag() { + return "SPD_ANG"; + } + + @Override + public void onTouchEvent(MotionEvent event) { + int action = event.getActionMasked(); + + if (action == MotionEvent.ACTION_DOWN) { + mStrokeMap.clear(); + } + + for (int i = 0; i < event.getPointerCount(); i++) { + Stroke stroke = mClassifierData.getStroke(event.getPointerId(i)); + + if (mStrokeMap.get(stroke) == null) { + mStrokeMap.put(stroke, new Data()); + } + + if (action != MotionEvent.ACTION_UP + && action != MotionEvent.ACTION_CANCEL + && !(action == MotionEvent.ACTION_POINTER_UP && i == event.getActionIndex())) { + mStrokeMap.get(stroke).addPoint(stroke.getPoints().get(stroke.getPoints().size() - 1)); + } + } + } + + @Override + public float getFalseTouchEvaluation(Stroke stroke) { + Data data = mStrokeMap.get(stroke); + return SpeedVarianceEvaluator.evaluate(data.getAnglesVariance()) + + SpeedAnglesPercentageEvaluator.evaluate(data.getAnglesPercentage()); + } + + private static class Data { + private static final float DURATION_SCALE = 1e8f; + private static final float LENGTH_SCALE = 1.0f; + private static final float ANGLE_DEVIATION = (float) Math.PI / 10.0f; + + private List<Point> mLastThreePoints = new ArrayList<>(); + private Point mPreviousPoint; + private float mPreviousAngle; + private float mSumSquares; + private float mSum; + private float mCount; + private float mDist; + private float mAnglesCount; + private float mAcceleratingAngles; + + public Data() { + mPreviousPoint = null; + mPreviousAngle = (float) Math.PI; + mSumSquares = 0.0f; + mSum = 0.0f; + mCount = 1.0f; + mDist = 0.0f; + mAnglesCount = mAcceleratingAngles = 0.0f; + } + + public void addPoint(Point point) { + if (mPreviousPoint != null) { + mDist += mPreviousPoint.dist(point); + } + + mPreviousPoint = point; + Point speedPoint = + new Point((float) point.timeOffsetNano / DURATION_SCALE, mDist / LENGTH_SCALE); + + // Checking if the added point is different than the previously added point + // Repetitions are being ignored so that proper angles are calculated. + if (mLastThreePoints.isEmpty() + || !mLastThreePoints.get(mLastThreePoints.size() - 1).equals(speedPoint)) { + mLastThreePoints.add(speedPoint); + if (mLastThreePoints.size() == 4) { + mLastThreePoints.remove(0); + + float angle = + mLastThreePoints.get(1).getAngle(mLastThreePoints.get(0), mLastThreePoints.get(2)); + + mAnglesCount++; + if (angle >= (float) Math.PI - ANGLE_DEVIATION) { + mAcceleratingAngles++; + } + + float difference = angle - mPreviousAngle; + mSum += difference; + mSumSquares += difference * difference; + mCount += 1.0f; + mPreviousAngle = angle; + } + } + } + + public float getAnglesVariance() { + return mSumSquares / mCount - (mSum / mCount) * (mSum / mCount); + } + + public float getAnglesPercentage() { + if (mAnglesCount == 0.0f) { + return 1.0f; + } + return (mAcceleratingAngles) / mAnglesCount; + } + } +} diff --git a/java/com/android/incallui/answer/impl/classifier/SpeedAnglesPercentageEvaluator.java b/java/com/android/incallui/answer/impl/classifier/SpeedAnglesPercentageEvaluator.java new file mode 100644 index 000000000..5a8bc3556 --- /dev/null +++ b/java/com/android/incallui/answer/impl/classifier/SpeedAnglesPercentageEvaluator.java @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2015 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.incallui.answer.impl.classifier; + +class SpeedAnglesPercentageEvaluator { + public static float evaluate(float value) { + float evaluation = 0.0f; + if (value < 1.00) { + evaluation++; + } + if (value < 0.90) { + evaluation++; + } + if (value < 0.70) { + evaluation++; + } + return evaluation; + } +} diff --git a/java/com/android/incallui/answer/impl/classifier/SpeedClassifier.java b/java/com/android/incallui/answer/impl/classifier/SpeedClassifier.java new file mode 100644 index 000000000..f3ade3f49 --- /dev/null +++ b/java/com/android/incallui/answer/impl/classifier/SpeedClassifier.java @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2016 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.incallui.answer.impl.classifier; + +/** + * A classifier that looks at the speed of the stroke. It calculates the speed of a stroke in inches + * per second. + */ +class SpeedClassifier extends StrokeClassifier { + + public SpeedClassifier(ClassifierData classifierData) {} + + @Override + public String getTag() { + return "SPD"; + } + + @Override + public float getFalseTouchEvaluation(Stroke stroke) { + float duration = stroke.getDurationSeconds(); + if (duration == 0.0f) { + return SpeedEvaluator.evaluate(0.0f); + } + return SpeedEvaluator.evaluate(stroke.getTotalLength() / duration); + } +} diff --git a/java/com/android/incallui/answer/impl/classifier/SpeedEvaluator.java b/java/com/android/incallui/answer/impl/classifier/SpeedEvaluator.java new file mode 100644 index 000000000..4f9aace0e --- /dev/null +++ b/java/com/android/incallui/answer/impl/classifier/SpeedEvaluator.java @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2015 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.incallui.answer.impl.classifier; + +class SpeedEvaluator { + public static float evaluate(float value) { + float evaluation = 0.0f; + if (value < 4.0) { + evaluation++; + } + if (value < 2.2) { + evaluation++; + } + if (value > 35.0) { + evaluation++; + } + if (value > 50.0) { + evaluation++; + } + return evaluation; + } +} diff --git a/java/com/android/incallui/answer/impl/classifier/SpeedRatioEvaluator.java b/java/com/android/incallui/answer/impl/classifier/SpeedRatioEvaluator.java new file mode 100644 index 000000000..7ae111313 --- /dev/null +++ b/java/com/android/incallui/answer/impl/classifier/SpeedRatioEvaluator.java @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2015 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.incallui.answer.impl.classifier; + +class SpeedRatioEvaluator { + public static float evaluate(float value) { + float evaluation = 0.0f; + if (value == 0) { + return 0; + } + if (value <= 1.0) { + evaluation++; + } + if (value <= 0.5) { + evaluation++; + } + if (value > 9.0) { + evaluation++; + } + if (value > 18.0) { + evaluation++; + } + return evaluation; + } +} diff --git a/java/com/android/incallui/answer/impl/classifier/SpeedVarianceEvaluator.java b/java/com/android/incallui/answer/impl/classifier/SpeedVarianceEvaluator.java new file mode 100644 index 000000000..211650cbb --- /dev/null +++ b/java/com/android/incallui/answer/impl/classifier/SpeedVarianceEvaluator.java @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2015 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.incallui.answer.impl.classifier; + +class SpeedVarianceEvaluator { + public static float evaluate(float value) { + float evaluation = 0.0f; + if (value > 0.06) { + evaluation++; + } + if (value > 0.15) { + evaluation++; + } + if (value > 0.3) { + evaluation++; + } + if (value > 0.6) { + evaluation++; + } + return evaluation; + } +} diff --git a/java/com/android/incallui/answer/impl/classifier/Stroke.java b/java/com/android/incallui/answer/impl/classifier/Stroke.java new file mode 100644 index 000000000..c542d0f7c --- /dev/null +++ b/java/com/android/incallui/answer/impl/classifier/Stroke.java @@ -0,0 +1,72 @@ +/* + * Copyright (C) 2016 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.incallui.answer.impl.classifier; + +import java.util.ArrayList; + +/** + * Contains data about a stroke (a single trace, all the events from a given id from the + * DOWN/POINTER_DOWN event till the UP/POINTER_UP/CANCEL event.) + */ +class Stroke { + + private static final float NANOS_TO_SECONDS = 1e9f; + + private ArrayList<Point> mPoints = new ArrayList<>(); + private long mStartTimeNano; + private long mEndTimeNano; + private float mLength; + private final float mDpi; + + public Stroke(long eventTimeNano, float dpi) { + mDpi = dpi; + mStartTimeNano = mEndTimeNano = eventTimeNano; + } + + public void addPoint(float x, float y, long eventTimeNano) { + mEndTimeNano = eventTimeNano; + Point point = new Point(x / mDpi, y / mDpi, eventTimeNano - mStartTimeNano); + if (!mPoints.isEmpty()) { + mLength += mPoints.get(mPoints.size() - 1).dist(point); + } + mPoints.add(point); + } + + public int getCount() { + return mPoints.size(); + } + + public float getTotalLength() { + return mLength; + } + + public float getEndPointLength() { + return mPoints.get(0).dist(mPoints.get(mPoints.size() - 1)); + } + + public long getDurationNanos() { + return mEndTimeNano - mStartTimeNano; + } + + public float getDurationSeconds() { + return (float) getDurationNanos() / NANOS_TO_SECONDS; + } + + public ArrayList<Point> getPoints() { + return mPoints; + } +} diff --git a/java/com/android/incallui/answer/impl/classifier/StrokeClassifier.java b/java/com/android/incallui/answer/impl/classifier/StrokeClassifier.java new file mode 100644 index 000000000..8abd7e2ec --- /dev/null +++ b/java/com/android/incallui/answer/impl/classifier/StrokeClassifier.java @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2015 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.incallui.answer.impl.classifier; + +/** An abstract class for classifiers which classify each stroke separately. */ +abstract class StrokeClassifier extends Classifier { + + /** + * @param stroke the stroke for which the evaluation will be calculated + * @return a non-negative value which is used to determine whether this a false touch; the bigger + * the value the greater the chance that this a false touch + */ + public abstract float getFalseTouchEvaluation(Stroke stroke); +} diff --git a/java/com/android/incallui/answer/impl/hint/AndroidManifest.xml b/java/com/android/incallui/answer/impl/hint/AndroidManifest.xml new file mode 100644 index 000000000..b5fa6da8f --- /dev/null +++ b/java/com/android/incallui/answer/impl/hint/AndroidManifest.xml @@ -0,0 +1,13 @@ +<manifest + package="com.android.incallui.answer.impl.hint" + xmlns:android="http://schemas.android.com/apk/res/android"> + + <application> + <receiver android:name=".EventSecretCodeListener"> + <intent-filter> + <action android:name="android.provider.Telephony.SECRET_CODE" /> + <data android:scheme="android_secret_code" /> + </intent-filter> + </receiver> + </application> +</manifest> diff --git a/java/com/android/incallui/answer/impl/hint/AnswerHint.java b/java/com/android/incallui/answer/impl/hint/AnswerHint.java new file mode 100644 index 000000000..dd3b8228a --- /dev/null +++ b/java/com/android/incallui/answer/impl/hint/AnswerHint.java @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2016 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.incallui.answer.impl.hint; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +/** Interface to overlay a hint of how to answer the call. */ +public interface AnswerHint { + + /** + * Inflates the hint's layout into the container. + * + * <p>TODO: if the hint becomes more dependent on other UI elements of the AnswerFragment, + * should put put and hintText into another data structure. + */ + void onCreateView(LayoutInflater inflater, ViewGroup container, View puck, TextView hintText); + + /** Called when the puck bounce animation begins. */ + void onBounceStart(); + + /** + * Called when the bounce animation has ended (transitioned into other animations). The hint + * should reset itself. + */ + void onBounceEnd(); + + /** Called when the call is accepted or rejected through user interaction. */ + void onAnswered(); +} diff --git a/java/com/android/incallui/answer/impl/hint/AnswerHintFactory.java b/java/com/android/incallui/answer/impl/hint/AnswerHintFactory.java new file mode 100644 index 000000000..45395a71f --- /dev/null +++ b/java/com/android/incallui/answer/impl/hint/AnswerHintFactory.java @@ -0,0 +1,133 @@ +/* + * Copyright (C) 2016 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.incallui.answer.impl.hint; + +import android.content.Context; +import android.content.SharedPreferences; +import android.graphics.drawable.Drawable; +import android.os.Build; +import android.preference.PreferenceManager; +import android.support.annotation.NonNull; +import android.support.annotation.VisibleForTesting; +import com.android.dialer.common.Assert; +import com.android.dialer.common.ConfigProvider; +import com.android.dialer.common.ConfigProviderBindings; +import com.android.dialer.common.LogUtil; +import com.android.incallui.util.AccessibilityUtil; +import java.util.Calendar; + +/** + * Selects a AnswerHint to show. If there's no suitable hints {@link EmptyAnswerHint} will be used, + * which does nothing. + */ +public class AnswerHintFactory { + + private static final String CONFIG_ANSWER_HINT_ANSWERED_THRESHOLD_KEY = + "answer_hint_answered_threshold"; + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + static final String CONFIG_ANSWER_HINT_WHITELISTED_DEVICES_KEY = + "answer_hint_whitelisted_devices"; + // Most popular devices released before NDR1 is whitelisted. Their user are likely to have seen + // the legacy UI. + private static final String DEFAULT_WHITELISTED_DEVICES_CSV = + "/hammerhead//bullhead//angler//shamu//gm4g//gm4g_s//AQ4501//gce_x86_phone//gm4gtkc_s/" + + "/Sparkle_V//Mi-498//AQ4502//imobileiq2//A65//H940//m8_google//m0xx//A10//ctih220/" + + "/Mi438S//bacon/"; + + @VisibleForTesting + static final String ANSWERED_COUNT_PREFERENCE_KEY = "answer_hint_answered_count"; + + private final EventPayloadLoader eventPayloadLoader; + + public AnswerHintFactory(@NonNull EventPayloadLoader eventPayloadLoader) { + this.eventPayloadLoader = Assert.isNotNull(eventPayloadLoader); + } + + @NonNull + public AnswerHint create(Context context, long puckUpDuration, long puckUpDelay) { + + if (shouldShowAnswerHint( + context, + ConfigProviderBindings.get(context), + getDeviceProtectedPreferences(context), + Build.PRODUCT)) { + return new DotAnswerHint(context, puckUpDuration, puckUpDelay); + } + + // Display the event answer hint if the payload is available. + Drawable eventPayload = + eventPayloadLoader.loadPayload( + context, System.currentTimeMillis(), Calendar.getInstance().getTimeZone()); + if (eventPayload != null) { + return new EventAnswerHint(context, eventPayload, puckUpDuration, puckUpDelay); + } + + return new EmptyAnswerHint(); + } + + public static void increaseAnsweredCount(Context context) { + SharedPreferences sharedPreferences = getDeviceProtectedPreferences(context); + int answeredCount = sharedPreferences.getInt(ANSWERED_COUNT_PREFERENCE_KEY, 0); + sharedPreferences.edit().putInt(ANSWERED_COUNT_PREFERENCE_KEY, answeredCount + 1).apply(); + } + + @VisibleForTesting + static boolean shouldShowAnswerHint( + Context context, + ConfigProvider configProvider, + SharedPreferences sharedPreferences, + String device) { + if (AccessibilityUtil.isTouchExplorationEnabled(context)) { + return false; + } + // Devices that has the legacy dialer installed are whitelisted as they are likely to go through + // a UX change during updates. + if (!isDeviceWhitelisted(device, configProvider)) { + return false; + } + + // If the user has gone through the process a few times we can assume they have learnt the + // method. + int answeredCount = sharedPreferences.getInt(ANSWERED_COUNT_PREFERENCE_KEY, 0); + long threshold = configProvider.getLong(CONFIG_ANSWER_HINT_ANSWERED_THRESHOLD_KEY, 3); + LogUtil.i( + "AnswerHintFactory.shouldShowAnswerHint", + "answerCount: %d, threshold: %d", + answeredCount, + threshold); + return answeredCount < threshold; + } + + /** + * @param device should be the value of{@link Build#PRODUCT}. + * @param configProvider should provide a list of devices quoted with '/' concatenated to a + * string. + */ + private static boolean isDeviceWhitelisted(String device, ConfigProvider configProvider) { + return configProvider + .getString(CONFIG_ANSWER_HINT_WHITELISTED_DEVICES_KEY, DEFAULT_WHITELISTED_DEVICES_CSV) + .contains("/" + device + "/"); + } + + private static SharedPreferences getDeviceProtectedPreferences(Context context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { + return PreferenceManager.getDefaultSharedPreferences(context); + } + return PreferenceManager.getDefaultSharedPreferences( + context.createDeviceProtectedStorageContext()); + } +} diff --git a/java/com/android/incallui/answer/impl/hint/DotAnswerHint.java b/java/com/android/incallui/answer/impl/hint/DotAnswerHint.java new file mode 100644 index 000000000..394fe5808 --- /dev/null +++ b/java/com/android/incallui/answer/impl/hint/DotAnswerHint.java @@ -0,0 +1,283 @@ +/* + * Copyright (C) 2016 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.incallui.answer.impl.hint; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.AnimatorSet; +import android.animation.ObjectAnimator; +import android.content.Context; +import android.support.annotation.DimenRes; +import android.support.v4.view.animation.FastOutSlowInInterpolator; +import android.util.TypedValue; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.animation.Interpolator; +import android.view.animation.LinearInterpolator; +import android.widget.TextView; + +/** An Answer hint that uses a green swiping dot. */ +public class DotAnswerHint implements AnswerHint { + + private static final float ANSWER_HINT_SMALL_ALPHA = 0.8f; + private static final float ANSWER_HINT_MID_ALPHA = 0.5f; + private static final float ANSWER_HINT_LARGE_ALPHA = 0.2f; + + private static final long FADE_IN_DELAY_SCALE_MILLIS = 380; + private static final long FADE_IN_DURATION_SCALE_MILLIS = 200; + private static final long FADE_IN_DELAY_ALPHA_MILLIS = 340; + private static final long FADE_IN_DURATION_ALPHA_MILLIS = 50; + + private static final long SWIPE_UP_DURATION_ALPHA_MILLIS = 500; + + private static final long FADE_OUT_DELAY_SCALE_SMALL_MILLIS = 90; + private static final long FADE_OUT_DELAY_SCALE_MID_MILLIS = 70; + private static final long FADE_OUT_DELAY_SCALE_LARGE_MILLIS = 10; + private static final long FADE_OUT_DURATION_SCALE_MILLIS = 100; + private static final long FADE_OUT_DELAY_ALPHA_MILLIS = 130; + private static final long FADE_OUT_DURATION_ALPHA_MILLIS = 170; + + private final Context context; + private final long puckUpDurationMillis; + private final long puckUpDelayMillis; + + private View puck; + + private View answerHintSmall; + private View answerHintMid; + private View answerHintLarge; + private View answerHintContainer; + private AnimatorSet answerGestureHintAnim; + + public DotAnswerHint(Context context, long puckUpDurationMillis, long puckUpDelayMillis) { + this.context = context; + this.puckUpDurationMillis = puckUpDurationMillis; + this.puckUpDelayMillis = puckUpDelayMillis; + } + + @Override + public void onCreateView( + LayoutInflater inflater, ViewGroup container, View puck, TextView hintText) { + this.puck = puck; + View view = inflater.inflate(R.layout.dot_hint, container, true); + answerHintContainer = view.findViewById(R.id.answer_hint_container); + answerHintSmall = view.findViewById(R.id.answer_hint_small); + answerHintMid = view.findViewById(R.id.answer_hint_mid); + answerHintLarge = view.findViewById(R.id.answer_hint_large); + hintText.setTextSize( + TypedValue.COMPLEX_UNIT_PX, context.getResources().getDimension(R.dimen.hint_text_size)); + } + + @Override + public void onBounceStart() { + if (answerGestureHintAnim == null) { + answerGestureHintAnim = new AnimatorSet(); + answerHintContainer.setY(puck.getY() + getDimension(R.dimen.hint_initial_offset)); + + Animator fadeIn = createFadeIn(); + + Animator swipeUp = + ObjectAnimator.ofFloat( + answerHintContainer, + View.TRANSLATION_Y, + puck.getY() - getDimension(R.dimen.hint_offset)); + swipeUp.setInterpolator(new FastOutSlowInInterpolator()); + swipeUp.setDuration(SWIPE_UP_DURATION_ALPHA_MILLIS); + + Animator fadeOut = createFadeOut(); + + answerGestureHintAnim.play(fadeIn).after(puckUpDelayMillis); + answerGestureHintAnim.play(swipeUp).after(fadeIn); + // The fade out should start fading the alpha just as the puck is dropping. Scaling will start + // a bit earlier. + answerGestureHintAnim + .play(fadeOut) + .after(puckUpDelayMillis + puckUpDurationMillis - FADE_OUT_DELAY_ALPHA_MILLIS); + + fadeIn.addListener( + new AnimatorListenerAdapter() { + @Override + public void onAnimationStart(Animator animation) { + super.onAnimationStart(animation); + answerHintSmall.setAlpha(0); + answerHintSmall.setScaleX(1); + answerHintSmall.setScaleY(1); + answerHintMid.setAlpha(0); + answerHintMid.setScaleX(1); + answerHintMid.setScaleY(1); + answerHintLarge.setAlpha(0); + answerHintLarge.setScaleX(1); + answerHintLarge.setScaleY(1); + answerHintContainer.setY(puck.getY() + getDimension(R.dimen.hint_initial_offset)); + answerHintContainer.setVisibility(View.VISIBLE); + } + }); + } + + answerGestureHintAnim.start(); + } + + private Animator createFadeIn() { + AnimatorSet set = new AnimatorSet(); + set.play( + createFadeInScaleAndAlpha( + answerHintSmall, + R.dimen.hint_small_begin_size, + R.dimen.hint_small_end_size, + ANSWER_HINT_SMALL_ALPHA)) + .with( + createFadeInScaleAndAlpha( + answerHintMid, + R.dimen.hint_mid_begin_size, + R.dimen.hint_mid_end_size, + ANSWER_HINT_MID_ALPHA)) + .with( + createFadeInScaleAndAlpha( + answerHintLarge, + R.dimen.hint_large_begin_size, + R.dimen.hint_large_end_size, + ANSWER_HINT_LARGE_ALPHA)); + return set; + } + + private Animator createFadeInScaleAndAlpha( + View target, @DimenRes int beginSize, @DimenRes int endSize, float endAlpha) { + Animator scale = + createUniformScaleAnimator( + target, + getDimension(beginSize), + getDimension(beginSize), + getDimension(endSize), + FADE_IN_DURATION_SCALE_MILLIS, + FADE_IN_DELAY_SCALE_MILLIS, + new LinearInterpolator()); + Animator alpha = + createAlphaAnimator( + target, + 0f, + endAlpha, + FADE_IN_DURATION_ALPHA_MILLIS, + FADE_IN_DELAY_ALPHA_MILLIS, + new LinearInterpolator()); + AnimatorSet set = new AnimatorSet(); + set.play(scale).with(alpha); + return set; + } + + private Animator createFadeOut() { + AnimatorSet set = new AnimatorSet(); + set.play( + createFadeOutScaleAndAlpha( + answerHintSmall, + R.dimen.hint_small_begin_size, + R.dimen.hint_small_end_size, + FADE_OUT_DELAY_SCALE_SMALL_MILLIS, + ANSWER_HINT_SMALL_ALPHA)) + .with( + createFadeOutScaleAndAlpha( + answerHintMid, + R.dimen.hint_mid_begin_size, + R.dimen.hint_mid_end_size, + FADE_OUT_DELAY_SCALE_MID_MILLIS, + ANSWER_HINT_MID_ALPHA)) + .with( + createFadeOutScaleAndAlpha( + answerHintLarge, + R.dimen.hint_large_begin_size, + R.dimen.hint_large_end_size, + FADE_OUT_DELAY_SCALE_LARGE_MILLIS, + ANSWER_HINT_LARGE_ALPHA)); + return set; + } + + private Animator createFadeOutScaleAndAlpha( + View target, + @DimenRes int beginSize, + @DimenRes int endSize, + long scaleDelay, + float endAlpha) { + Animator scale = + createUniformScaleAnimator( + target, + getDimension(beginSize), + getDimension(endSize), + getDimension(beginSize), + FADE_OUT_DURATION_SCALE_MILLIS, + scaleDelay, + new LinearInterpolator()); + Animator alpha = + createAlphaAnimator( + target, + endAlpha, + 0.0f, + FADE_OUT_DURATION_ALPHA_MILLIS, + FADE_OUT_DELAY_ALPHA_MILLIS, + new LinearInterpolator()); + AnimatorSet set = new AnimatorSet(); + set.play(scale).with(alpha); + return set; + } + + @Override + public void onBounceEnd() { + if (answerGestureHintAnim != null) { + answerGestureHintAnim.end(); + answerGestureHintAnim = null; + answerHintContainer.setVisibility(View.GONE); + } + } + + @Override + public void onAnswered() { + AnswerHintFactory.increaseAnsweredCount(context); + } + + private float getDimension(@DimenRes int id) { + return context.getResources().getDimension(id); + } + + private static Animator createUniformScaleAnimator( + View target, + float original, + float begin, + float end, + long duration, + long delay, + Interpolator interpolator) { + float scaleBegin = begin / original; + float scaleEnd = end / original; + Animator scaleX = ObjectAnimator.ofFloat(target, View.SCALE_X, scaleBegin, scaleEnd); + Animator scaleY = ObjectAnimator.ofFloat(target, View.SCALE_Y, scaleBegin, scaleEnd); + scaleX.setDuration(duration); + scaleY.setDuration(duration); + scaleX.setInterpolator(interpolator); + scaleY.setInterpolator(interpolator); + AnimatorSet set = new AnimatorSet(); + set.play(scaleX).with(scaleY).after(delay); + return set; + } + + private static Animator createAlphaAnimator( + View target, float begin, float end, long duration, long delay, Interpolator interpolator) { + Animator alpha = ObjectAnimator.ofFloat(target, View.ALPHA, begin, end); + alpha.setDuration(duration); + alpha.setInterpolator(interpolator); + alpha.setStartDelay(delay); + return alpha; + } +} diff --git a/java/com/android/incallui/answer/impl/hint/EmptyAnswerHint.java b/java/com/android/incallui/answer/impl/hint/EmptyAnswerHint.java new file mode 100644 index 000000000..e52b4ee36 --- /dev/null +++ b/java/com/android/incallui/answer/impl/hint/EmptyAnswerHint.java @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2016 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.incallui.answer.impl.hint; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +/** Does nothing. Used to avoid null checks on AnswerHint. */ +public class EmptyAnswerHint implements AnswerHint { + + @Override + public void onCreateView( + LayoutInflater inflater, ViewGroup container, View puck, TextView hintText) {} + + @Override + public void onBounceStart() {} + + @Override + public void onBounceEnd() {} + + @Override + public void onAnswered() {} +} diff --git a/java/com/android/incallui/answer/impl/hint/EventAnswerHint.java b/java/com/android/incallui/answer/impl/hint/EventAnswerHint.java new file mode 100644 index 000000000..7ee327d50 --- /dev/null +++ b/java/com/android/incallui/answer/impl/hint/EventAnswerHint.java @@ -0,0 +1,235 @@ +/* + * Copyright (C) 2016 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.incallui.answer.impl.hint; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.AnimatorSet; +import android.animation.ObjectAnimator; +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.support.annotation.DimenRes; +import android.support.annotation.NonNull; +import android.support.v4.view.animation.FastOutSlowInInterpolator; +import android.util.TypedValue; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.animation.Interpolator; +import android.view.animation.LinearInterpolator; +import android.widget.ImageView; +import android.widget.TextView; +import com.android.dialer.common.Assert; + +/** + * An Answer hint that animates a {@link Drawable} payload with animation similar to {@link + * DotAnswerHint}. + */ +public final class EventAnswerHint implements AnswerHint { + + private static final long FADE_IN_DELAY_SCALE_MILLIS = 380; + private static final long FADE_IN_DURATION_SCALE_MILLIS = 200; + private static final long FADE_IN_DELAY_ALPHA_MILLIS = 340; + private static final long FADE_IN_DURATION_ALPHA_MILLIS = 50; + + private static final long SWIPE_UP_DURATION_ALPHA_MILLIS = 500; + + private static final long FADE_OUT_DELAY_SCALE_SMALL_MILLIS = 90; + private static final long FADE_OUT_DURATION_SCALE_MILLIS = 100; + private static final long FADE_OUT_DELAY_ALPHA_MILLIS = 130; + private static final long FADE_OUT_DURATION_ALPHA_MILLIS = 170; + + private static final float FADE_SCALE = 1.2f; + + private final Context context; + private final Drawable payload; + private final long puckUpDurationMillis; + private final long puckUpDelayMillis; + + private View puck; + private View payloadView; + private View answerHintContainer; + private AnimatorSet answerGestureHintAnim; + + public EventAnswerHint( + @NonNull Context context, + @NonNull Drawable payload, + long puckUpDurationMillis, + long puckUpDelayMillis) { + this.context = Assert.isNotNull(context); + this.payload = Assert.isNotNull(payload); + this.puckUpDurationMillis = puckUpDurationMillis; + this.puckUpDelayMillis = puckUpDelayMillis; + } + + @Override + public void onCreateView( + LayoutInflater inflater, ViewGroup container, View puck, TextView hintText) { + this.puck = puck; + View view = inflater.inflate(R.layout.event_hint, container, true); + answerHintContainer = view.findViewById(R.id.answer_hint_container); + payloadView = view.findViewById(R.id.payload); + hintText.setTextSize( + TypedValue.COMPLEX_UNIT_PX, context.getResources().getDimension(R.dimen.hint_text_size)); + ((ImageView) payloadView).setImageDrawable(payload); + } + + @Override + public void onBounceStart() { + if (answerGestureHintAnim == null) { + + answerGestureHintAnim = new AnimatorSet(); + answerHintContainer.setY(puck.getY() + getDimension(R.dimen.hint_initial_offset)); + + Animator fadeIn = createFadeIn(); + + Animator swipeUp = + ObjectAnimator.ofFloat( + answerHintContainer, + View.TRANSLATION_Y, + puck.getY() - getDimension(R.dimen.hint_offset)); + swipeUp.setInterpolator(new FastOutSlowInInterpolator()); + swipeUp.setDuration(SWIPE_UP_DURATION_ALPHA_MILLIS); + + Animator fadeOut = createFadeOut(); + + answerGestureHintAnim.play(fadeIn).after(puckUpDelayMillis); + answerGestureHintAnim.play(swipeUp).after(fadeIn); + // The fade out should start fading the alpha just as the puck is dropping. Scaling will start + // a bit earlier. + answerGestureHintAnim + .play(fadeOut) + .after(puckUpDelayMillis + puckUpDurationMillis - FADE_OUT_DELAY_ALPHA_MILLIS); + + fadeIn.addListener( + new AnimatorListenerAdapter() { + @Override + public void onAnimationStart(Animator animation) { + super.onAnimationStart(animation); + payloadView.setAlpha(0); + payloadView.setScaleX(1); + payloadView.setScaleY(1); + answerHintContainer.setY(puck.getY() + getDimension(R.dimen.hint_initial_offset)); + answerHintContainer.setVisibility(View.VISIBLE); + } + }); + } + + answerGestureHintAnim.start(); + } + + private Animator createFadeIn() { + AnimatorSet set = new AnimatorSet(); + set.play(createFadeInScaleAndAlpha(payloadView)); + return set; + } + + private static Animator createFadeInScaleAndAlpha(View target) { + Animator scale = + createUniformScaleAnimator( + target, + FADE_SCALE, + 1.0f, + FADE_IN_DURATION_SCALE_MILLIS, + FADE_IN_DELAY_SCALE_MILLIS, + new LinearInterpolator()); + Animator alpha = + createAlphaAnimator( + target, + 0f, + 1.0f, + FADE_IN_DURATION_ALPHA_MILLIS, + FADE_IN_DELAY_ALPHA_MILLIS, + new LinearInterpolator()); + AnimatorSet set = new AnimatorSet(); + set.play(scale).with(alpha); + return set; + } + + private Animator createFadeOut() { + AnimatorSet set = new AnimatorSet(); + set.play(createFadeOutScaleAndAlpha(payloadView, FADE_OUT_DELAY_SCALE_SMALL_MILLIS)); + return set; + } + + private static Animator createFadeOutScaleAndAlpha(View target, long scaleDelay) { + Animator scale = + createUniformScaleAnimator( + target, + 1.0f, + FADE_SCALE, + FADE_OUT_DURATION_SCALE_MILLIS, + scaleDelay, + new LinearInterpolator()); + Animator alpha = + createAlphaAnimator( + target, + 01.0f, + 0.0f, + FADE_OUT_DURATION_ALPHA_MILLIS, + FADE_OUT_DELAY_ALPHA_MILLIS, + new LinearInterpolator()); + AnimatorSet set = new AnimatorSet(); + set.play(scale).with(alpha); + return set; + } + + @Override + public void onBounceEnd() { + if (answerGestureHintAnim != null) { + answerGestureHintAnim.end(); + answerGestureHintAnim = null; + answerHintContainer.setVisibility(View.GONE); + } + } + + @Override + public void onAnswered() { + // Do nothing + } + + private float getDimension(@DimenRes int id) { + return context.getResources().getDimension(id); + } + + private static Animator createUniformScaleAnimator( + View target, + float scaleBegin, + float scaleEnd, + long duration, + long delay, + Interpolator interpolator) { + Animator scaleX = ObjectAnimator.ofFloat(target, View.SCALE_X, scaleBegin, scaleEnd); + Animator scaleY = ObjectAnimator.ofFloat(target, View.SCALE_Y, scaleBegin, scaleEnd); + scaleX.setDuration(duration); + scaleY.setDuration(duration); + scaleX.setInterpolator(interpolator); + scaleY.setInterpolator(interpolator); + AnimatorSet set = new AnimatorSet(); + set.play(scaleX).with(scaleY).after(delay); + return set; + } + + private static Animator createAlphaAnimator( + View target, float begin, float end, long duration, long delay, Interpolator interpolator) { + Animator alpha = ObjectAnimator.ofFloat(target, View.ALPHA, begin, end); + alpha.setDuration(duration); + alpha.setInterpolator(interpolator); + alpha.setStartDelay(delay); + return alpha; + } +} diff --git a/java/com/android/incallui/answer/impl/hint/EventPayloadLoader.java b/java/com/android/incallui/answer/impl/hint/EventPayloadLoader.java new file mode 100644 index 000000000..09e3bedf2 --- /dev/null +++ b/java/com/android/incallui/answer/impl/hint/EventPayloadLoader.java @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2016 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.incallui.answer.impl.hint; + +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import java.util.TimeZone; + +/** Loads a {@link Drawable} payload for the {@link EventAnswerHint} if it should be displayed. */ +public interface EventPayloadLoader { + @Nullable + Drawable loadPayload( + @NonNull Context context, long currentTimeUtcMillis, @NonNull TimeZone timeZone); +} diff --git a/java/com/android/incallui/answer/impl/hint/EventPayloadLoaderImpl.java b/java/com/android/incallui/answer/impl/hint/EventPayloadLoaderImpl.java new file mode 100644 index 000000000..bd8d73645 --- /dev/null +++ b/java/com/android/incallui/answer/impl/hint/EventPayloadLoaderImpl.java @@ -0,0 +1,118 @@ +/* + * Copyright (C) 2016 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.incallui.answer.impl.hint; + +import android.annotation.TargetApi; +import android.content.Context; +import android.content.SharedPreferences; +import android.graphics.BitmapFactory; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.os.Build.VERSION_CODES; +import android.preference.PreferenceManager; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.annotation.VisibleForTesting; +import com.android.dialer.common.Assert; +import com.android.dialer.common.ConfigProvider; +import com.android.dialer.common.ConfigProviderBindings; +import com.android.dialer.common.LogUtil; +import java.io.InputStream; +import java.util.TimeZone; +import javax.crypto.Cipher; +import javax.crypto.SecretKey; +import javax.crypto.SecretKeyFactory; +import javax.crypto.spec.PBEKeySpec; + +/** Decrypt the event payload to be shown if in a specific time range and the key is received. */ +@TargetApi(VERSION_CODES.M) +public final class EventPayloadLoaderImpl implements EventPayloadLoader { + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + static final String CONFIG_EVENT_KEY = "event_key"; + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + static final String CONFIG_EVENT_BINARY = "event_binary"; + + // Time is stored as a UTC UNIX timestamp in milliseconds, but interpreted as local time. + // For example, 946684800 (2000/1/1 00:00:00 @UTC) is the new year midnight at every timezone. + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + static final String CONFIG_EVENT_START_UTC_AS_LOCAL_MILLIS = "event_time_start_millis"; + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + static final String CONFIG_EVENT_TIME_END_UTC_AS_LOCAL_MILLIS = "event_time_end_millis"; + + @Override + @Nullable + public Drawable loadPayload( + @NonNull Context context, long currentTimeUtcMillis, @NonNull TimeZone timeZone) { + Assert.isNotNull(context); + Assert.isNotNull(timeZone); + ConfigProvider configProvider = ConfigProviderBindings.get(context); + + String pbeKey = configProvider.getString(CONFIG_EVENT_KEY, null); + if (pbeKey == null) { + return null; + } + long timeRangeStart = configProvider.getLong(CONFIG_EVENT_START_UTC_AS_LOCAL_MILLIS, 0); + long timeRangeEnd = configProvider.getLong(CONFIG_EVENT_TIME_END_UTC_AS_LOCAL_MILLIS, 0); + + String eventBinary = configProvider.getString(CONFIG_EVENT_BINARY, null); + if (eventBinary == null) { + return null; + } + + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(context); + if (!preferences.getBoolean( + EventSecretCodeListener.EVENT_ENABLED_WITH_SECRET_CODE_KEY, false)) { + long localTimestamp = currentTimeUtcMillis + timeZone.getRawOffset(); + + if (localTimestamp < timeRangeStart) { + return null; + } + + if (localTimestamp > timeRangeEnd) { + return null; + } + } + + // Use openssl aes-128-cbc -in <input> -out <output> -pass <PBEKey> to generate the asset + try (InputStream input = context.getAssets().open(eventBinary)) { + byte[] encryptedFile = new byte[input.available()]; + input.read(encryptedFile); + + Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding", "BC"); + + byte[] salt = new byte[8]; + System.arraycopy(encryptedFile, 8, salt, 0, 8); + SecretKey key = + SecretKeyFactory.getInstance("PBEWITHMD5AND128BITAES-CBC-OPENSSL", "BC") + .generateSecret(new PBEKeySpec(pbeKey.toCharArray(), salt, 100)); + cipher.init(Cipher.DECRYPT_MODE, key); + + byte[] decryptedFile = cipher.doFinal(encryptedFile, 16, encryptedFile.length - 16); + + return new BitmapDrawable( + context.getResources(), + BitmapFactory.decodeByteArray(decryptedFile, 0, decryptedFile.length)); + } catch (Exception e) { + // Avoid crashing dialer for any reason. + LogUtil.e("EventPayloadLoader.loadPayload", "error decrypting payload:", e); + return null; + } + } +} diff --git a/java/com/android/incallui/answer/impl/hint/EventSecretCodeListener.java b/java/com/android/incallui/answer/impl/hint/EventSecretCodeListener.java new file mode 100644 index 000000000..7cf4054a9 --- /dev/null +++ b/java/com/android/incallui/answer/impl/hint/EventSecretCodeListener.java @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2017 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.incallui.answer.impl.hint; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.preference.PreferenceManager; +import android.support.annotation.VisibleForTesting; +import android.text.TextUtils; +import android.widget.Toast; +import com.android.dialer.common.ConfigProviderBindings; +import com.android.dialer.common.LogUtil; +import com.android.dialer.logging.Logger; +import com.android.dialer.logging.nano.DialerImpression; + +/** + * Listen to the broadcast when the user dials "*#*#[number]#*#*" to toggle the event answer hint. + */ +public class EventSecretCodeListener extends BroadcastReceiver { + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + static final String CONFIG_EVENT_SECRET_CODE = "event_secret_code"; + + public static final String EVENT_ENABLED_WITH_SECRET_CODE_KEY = "event_enabled_with_secret_code"; + + @Override + public void onReceive(Context context, Intent intent) { + String host = intent.getData().getHost(); + String secretCode = + ConfigProviderBindings.get(context).getString(CONFIG_EVENT_SECRET_CODE, null); + if (secretCode == null) { + return; + } + if (!TextUtils.equals(secretCode, host)) { + return; + } + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(context); + boolean wasEnabled = preferences.getBoolean(EVENT_ENABLED_WITH_SECRET_CODE_KEY, false); + if (wasEnabled) { + preferences.edit().putBoolean(EVENT_ENABLED_WITH_SECRET_CODE_KEY, false).apply(); + Toast.makeText(context, R.string.event_deactivated, Toast.LENGTH_SHORT).show(); + Logger.get(context).logImpression(DialerImpression.Type.EVENT_ANSWER_HINT_DEACTIVATED); + LogUtil.i("EventSecretCodeListener.onReceive", "EventAnswerHint disabled"); + } else { + preferences.edit().putBoolean(EVENT_ENABLED_WITH_SECRET_CODE_KEY, true).apply(); + Toast.makeText(context, R.string.event_activated, Toast.LENGTH_SHORT).show(); + Logger.get(context).logImpression(DialerImpression.Type.EVENT_ANSWER_HINT_ACTIVATED); + LogUtil.i("EventSecretCodeListener.onReceive", "EventAnswerHint enabled"); + } + } +} diff --git a/java/com/android/incallui/answer/impl/hint/res/drawable/answer_hint_large.xml b/java/com/android/incallui/answer/impl/hint/res/drawable/answer_hint_large.xml new file mode 100644 index 000000000..f585ce5c9 --- /dev/null +++ b/java/com/android/incallui/answer/impl/hint/res/drawable/answer_hint_large.xml @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="utf-8"?> +<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval"> + <solid android:color="#00C853"/> +</shape>
\ No newline at end of file diff --git a/java/com/android/incallui/answer/impl/hint/res/drawable/answer_hint_mid.xml b/java/com/android/incallui/answer/impl/hint/res/drawable/answer_hint_mid.xml new file mode 100644 index 000000000..f585ce5c9 --- /dev/null +++ b/java/com/android/incallui/answer/impl/hint/res/drawable/answer_hint_mid.xml @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="utf-8"?> +<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval"> + <solid android:color="#00C853"/> +</shape>
\ No newline at end of file diff --git a/java/com/android/incallui/answer/impl/hint/res/drawable/answer_hint_small.xml b/java/com/android/incallui/answer/impl/hint/res/drawable/answer_hint_small.xml new file mode 100644 index 000000000..6a24d6a5f --- /dev/null +++ b/java/com/android/incallui/answer/impl/hint/res/drawable/answer_hint_small.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> + <shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval"> + <solid android:color="#00C853"/> + <stroke android:color="#00C853" android:width="2dp"/> + </shape>
\ No newline at end of file diff --git a/java/com/android/incallui/answer/impl/hint/res/layout/dot_hint.xml b/java/com/android/incallui/answer/impl/hint/res/layout/dot_hint.xml new file mode 100644 index 000000000..84b10e736 --- /dev/null +++ b/java/com/android/incallui/answer/impl/hint/res/layout/dot_hint.xml @@ -0,0 +1,30 @@ +<?xml version="1.0" encoding="utf-8"?> +<FrameLayout + xmlns:android="http://schemas.android.com/apk/res/android" + android:id="@+id/answer_hint_container" + android:layout_width="160dp" + android:layout_height="160dp" + android:layout_gravity="center_horizontal" + android:visibility="gone"> + <ImageView + android:id="@+id/answer_hint_large" + android:layout_width="@dimen/hint_large_begin_size" + android:layout_height="@dimen/hint_large_begin_size" + android:layout_gravity="center" + android:alpha="0" + android:src="@drawable/answer_hint_large"/> + <ImageView + android:id="@+id/answer_hint_mid" + android:layout_width="@dimen/hint_mid_begin_size" + android:layout_height="@dimen/hint_mid_begin_size" + android:src="@drawable/answer_hint_mid" + android:alpha="0" + android:layout_gravity="center"/> + <ImageView + android:id="@+id/answer_hint_small" + android:layout_width="@dimen/hint_small_begin_size" + android:layout_height="@dimen/hint_small_begin_size" + android:src="@drawable/answer_hint_small" + android:alpha="0" + android:layout_gravity="center" /> +</FrameLayout>
\ No newline at end of file diff --git a/java/com/android/incallui/answer/impl/hint/res/layout/event_hint.xml b/java/com/android/incallui/answer/impl/hint/res/layout/event_hint.xml new file mode 100644 index 000000000..d505014c1 --- /dev/null +++ b/java/com/android/incallui/answer/impl/hint/res/layout/event_hint.xml @@ -0,0 +1,36 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2016 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 + --> +<FrameLayout + xmlns:android="http://schemas.android.com/apk/res/android" + android:id="@+id/answer_hint_container" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:layout_gravity="center_horizontal" + android:clipChildren="false" + android:clipToPadding="false" + android:visibility="gone"> + <ImageView + android:id="@+id/payload" + android:layout_width="191dp" + android:layout_height="773dp" + android:layout_gravity="center" + android:alpha="0" + android:rotation="-30" + android:transformPivotY="90dp" + android:clipChildren="false" + android:clipToPadding="false"/> +</FrameLayout>
\ No newline at end of file diff --git a/java/com/android/incallui/answer/impl/hint/res/values/dimens.xml b/java/com/android/incallui/answer/impl/hint/res/values/dimens.xml new file mode 100644 index 000000000..d86084b74 --- /dev/null +++ b/java/com/android/incallui/answer/impl/hint/res/values/dimens.xml @@ -0,0 +1,12 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <dimen name="hint_text_size">18sp</dimen> + <dimen name="hint_initial_offset">-100dp</dimen> + <dimen name="hint_offset">300dp</dimen> + <dimen name="hint_small_begin_size">50dp</dimen> + <dimen name="hint_small_end_size">42dp</dimen> + <dimen name="hint_mid_begin_size">56dp</dimen> + <dimen name="hint_mid_end_size">64dp</dimen> + <dimen name="hint_large_begin_size">64dp</dimen> + <dimen name="hint_large_end_size">160dp</dimen> +</resources>
\ No newline at end of file diff --git a/java/com/android/incallui/answer/impl/hint/res/values/strings.xml b/java/com/android/incallui/answer/impl/hint/res/values/strings.xml new file mode 100644 index 000000000..d76021ae1 --- /dev/null +++ b/java/com/android/incallui/answer/impl/hint/res/values/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <string name="event_activated">Event Activated</string> + <string name="event_deactivated">Event Deactvated</string> +</resources>
\ No newline at end of file diff --git a/java/com/android/incallui/answer/impl/res/anim/incoming_unlocked_icon_entry.xml b/java/com/android/incallui/answer/impl/res/anim/incoming_unlocked_icon_entry.xml new file mode 100644 index 000000000..6490bbc5b --- /dev/null +++ b/java/com/android/incallui/answer/impl/res/anim/incoming_unlocked_icon_entry.xml @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="utf-8"?> +<set xmlns:android="http://schemas.android.com/apk/res/android" + android:ordering="together"> + <alpha + android:duration="583" + android:fromAlpha="0.0" + android:interpolator="@android:anim/accelerate_interpolator" + android:startOffset="167" + android:toAlpha="1.0"/> + <scale + android:duration="600" + android:fromXScale="0px" + android:fromYScale="0px" + android:interpolator="@android:anim/accelerate_interpolator" + android:pivotX="50%" + android:pivotY="50%" + android:toXScale="100%" + android:toYScale="100%"/> +</set> diff --git a/java/com/android/incallui/answer/impl/res/anim/incoming_unlocked_text_entry.xml b/java/com/android/incallui/answer/impl/res/anim/incoming_unlocked_text_entry.xml new file mode 100644 index 000000000..9d3195a79 --- /dev/null +++ b/java/com/android/incallui/answer/impl/res/anim/incoming_unlocked_text_entry.xml @@ -0,0 +1,9 @@ +<?xml version="1.0" encoding="utf-8"?> +<set xmlns:android="http://schemas.android.com/apk/res/android"> + <alpha + android:duration="583" + android:fromAlpha="0.0" + android:interpolator="@android:anim/accelerate_interpolator" + android:startOffset="167" + android:toAlpha="1.0"/> +</set> diff --git a/java/com/android/incallui/answer/impl/res/layout/fragment_avatar.xml b/java/com/android/incallui/answer/impl/res/layout/fragment_avatar.xml new file mode 100644 index 000000000..d656ceb4e --- /dev/null +++ b/java/com/android/incallui/answer/impl/res/layout/fragment_avatar.xml @@ -0,0 +1,26 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2016 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 + --> + + +<ImageView + xmlns:android="http://schemas.android.com/apk/res/android" + android:id="@id/contactgrid_avatar" + android:layout_width="@dimen/answer_avatar_size" + android:layout_height="@dimen/answer_avatar_size" + android:layout_marginTop="20dp" + android:layout_gravity="center_horizontal" + android:elevation="@dimen/answer_data_elevation"/> diff --git a/java/com/android/incallui/answer/impl/res/layout/fragment_custom_sms_dialog.xml b/java/com/android/incallui/answer/impl/res/layout/fragment_custom_sms_dialog.xml new file mode 100644 index 000000000..c36386ead --- /dev/null +++ b/java/com/android/incallui/answer/impl/res/layout/fragment_custom_sms_dialog.xml @@ -0,0 +1,14 @@ +<?xml version="1.0" encoding="utf-8"?> +<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" + android:orientation="vertical" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:paddingLeft="24dp" + android:paddingRight="24dp"> + + <EditText + android:id="@+id/custom_sms_input" + android:layout_width="match_parent" + android:layout_height="wrap_content"/> + +</FrameLayout>
\ No newline at end of file diff --git a/java/com/android/incallui/answer/impl/res/layout/fragment_incoming_call.xml b/java/com/android/incallui/answer/impl/res/layout/fragment_incoming_call.xml new file mode 100644 index 000000000..aa153dd4b --- /dev/null +++ b/java/com/android/incallui/answer/impl/res/layout/fragment_incoming_call.xml @@ -0,0 +1,152 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2016 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 + --> + +<com.android.incallui.answer.impl.AffordanceHolderLayout + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools" + android:id="@+id/incoming_container" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:clipChildren="false" + android:clipToPadding="false" + android:keepScreenOn="true"> + + <TextureView + android:id="@+id/incoming_preview_texture_view" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:importantForAccessibility="no" + android:visibility="gone"/> + + <View + android:id="@+id/incoming_preview_texture_view_overlay" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:background="@color/videocall_overlay_background_color" + android:visibility="gone"/> + + <FrameLayout + android:layout_width="match_parent" + android:layout_height="match_parent" + android:fitsSystemWindows="true"> + + <TextView + android:id="@+id/videocall_video_off" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center" + android:padding="64dp" + android:accessibilityTraversalBefore="@+id/videocall_speaker_button" + android:drawablePadding="8dp" + android:drawableTop="@drawable/quantum_ic_videocam_off_white_36" + android:gravity="center" + android:text="@string/call_incoming_video_is_off" + android:textAppearance="@style/Dialer.Incall.TextAppearance" + android:visibility="gone"/> + + <LinearLayout + android:id="@+id/incall_contact_grid" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:layout_marginTop="24dp" + android:clipChildren="false" + android:clipToPadding="false" + android:gravity="top|center_horizontal" + android:orientation="vertical"> + + <include + android:id="@id/contactgrid_top_row" + layout="@layout/incall_contactgrid_top_row" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginBottom="8dp" + android:layout_marginStart="24dp" + android:layout_marginEnd="24dp"/> + + <!-- We have to keep deprecated singleLine to allow long text being truncated with ellipses. + b/31396406 --> + <com.android.incallui.autoresizetext.AutoResizeTextView + android:id="@id/contactgrid_contact_name" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginBottom="8dp" + android:layout_marginStart="24dp" + android:layout_marginEnd="24dp" + android:singleLine="true" + android:textAppearance="@style/Dialer.Incall.TextAppearance.Large" + android:textSize="@dimen/answer_contact_name_text_size" + app:autoResizeText_minTextSize="@dimen/answer_contact_name_min_size" + tools:ignore="Deprecated" + tools:text="Jake Peralta"/> + + <include + android:id="@id/contactgrid_bottom_row" + layout="@layout/incall_contactgrid_bottom_row" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginStart="24dp" + android:layout_marginEnd="24dp"/> + + <TextView + android:id="@+id/incall_important_call_badge" + android:layout_width="wrap_content" + android:layout_height="48dp" + android:layout_marginTop="4dp" + android:layout_marginBottom="@dimen/answer_importance_margin_bottom" + android:elevation="@dimen/answer_data_elevation" + android:gravity="center" + android:singleLine="true" + android:text="@string/call_incoming_important" + android:textAllCaps="true" + android:textAppearance="@style/Dialer.Incall.TextAppearance" + android:textColor="@android:color/black"/> + + <FrameLayout + android:id="@+id/incall_location_holder" + android:layout_width="match_parent" + android:layout_height="wrap_content"/> + + <FrameLayout + android:id="@+id/incall_data_container" + android:layout_width="match_parent" + android:layout_height="@dimen/answer_data_size" + android:clipChildren="false" + android:clipToPadding="false"/> + + </LinearLayout> + + <FrameLayout + android:id="@+id/answer_method_container" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:clipChildren="false" + android:clipToPadding="false"/> + + </FrameLayout> + + <com.android.incallui.answer.impl.affordance.SwipeButtonView + android:id="@+id/incoming_secondary_button" + android:layout_width="56dp" + android:layout_height="56dp" + android:layout_gravity="bottom|start" + android:scaleType="center" + android:src="@drawable/quantum_ic_message_white_24" + android:visibility="invisible" + tools:visibility="visible"/> + +</com.android.incallui.answer.impl.AffordanceHolderLayout> diff --git a/java/com/android/incallui/answer/impl/res/values-h240dp/dimens.xml b/java/com/android/incallui/answer/impl/res/values-h240dp/dimens.xml new file mode 100644 index 000000000..ca384ef8d --- /dev/null +++ b/java/com/android/incallui/answer/impl/res/values-h240dp/dimens.xml @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2016 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 + --> + +<resources> + <dimen name="answer_contact_name_text_size">36sp</dimen> + <dimen name="answer_contact_name_min_size">32sp</dimen> +</resources> diff --git a/java/com/android/incallui/answer/impl/res/values-h300dp/dimens.xml b/java/com/android/incallui/answer/impl/res/values-h300dp/dimens.xml new file mode 100644 index 000000000..fdecbb7bf --- /dev/null +++ b/java/com/android/incallui/answer/impl/res/values-h300dp/dimens.xml @@ -0,0 +1,20 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2016 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 + --> + +<resources> + <dimen name="answer_contact_name_text_size">54sp</dimen> +</resources> diff --git a/java/com/android/incallui/answer/impl/res/values-h480dp/dimens.xml b/java/com/android/incallui/answer/impl/res/values-h480dp/dimens.xml new file mode 100644 index 000000000..5dc3f2ac5 --- /dev/null +++ b/java/com/android/incallui/answer/impl/res/values-h480dp/dimens.xml @@ -0,0 +1,22 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2016 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 + --> +<resources> + <dimen name="answer_data_size">150dp</dimen> + <dimen name="answer_avatar_size">100dp</dimen> + <dimen name="answer_importance_margin_bottom">8dp</dimen> + <bool name="answer_important_call_allowed">true</bool> +</resources> diff --git a/java/com/android/incallui/answer/impl/res/values-h540dp/dimens.xml b/java/com/android/incallui/answer/impl/res/values-h540dp/dimens.xml new file mode 100644 index 000000000..69716e0bd --- /dev/null +++ b/java/com/android/incallui/answer/impl/res/values-h540dp/dimens.xml @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2016 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 + --> +<resources> + <dimen name="answer_data_size">258dp</dimen> + <dimen name="answer_avatar_size">172dp</dimen> + <dimen name="answer_importance_margin_bottom">8dp</dimen> +</resources> diff --git a/java/com/android/incallui/answer/impl/res/values/dimens.xml b/java/com/android/incallui/answer/impl/res/values/dimens.xml new file mode 100644 index 000000000..c48b68f93 --- /dev/null +++ b/java/com/android/incallui/answer/impl/res/values/dimens.xml @@ -0,0 +1,25 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2016 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 + --> + +<resources> + <dimen name="answer_contact_name_text_size">24sp</dimen> + <dimen name="answer_contact_name_min_size">24sp</dimen> + <dimen name="answer_data_size">0dp</dimen> + <dimen name="answer_avatar_size">0dp</dimen> + <dimen name="answer_importance_margin_bottom">0dp</dimen> + <bool name="answer_important_call_allowed">false</bool> +</resources> diff --git a/java/com/android/incallui/answer/impl/res/values/strings.xml b/java/com/android/incallui/answer/impl/res/values/strings.xml new file mode 100644 index 000000000..7fc91fce4 --- /dev/null +++ b/java/com/android/incallui/answer/impl/res/values/strings.xml @@ -0,0 +1,26 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <string name="call_incoming_swipe_to_decline_with_message">Swipe from icon to decline with message</string> + <string name="call_incoming_swipe_to_answer_video_as_audio">Swipe from icon to answer as an audio call</string> + <string name="call_incoming_message_custom">Write your own…</string> + <string name="call_incoming_audio_handset">Handset</string> + <string name="call_incoming_audio_speakerphone">Speakerphone</string> + <!-- "Respond via SMS" option that lets you compose a custom response. [CHAR LIMIT=30] --> + <string name="call_incoming_respond_via_sms_custom_message">Write your own…</string> + <!-- "Custom Message" Cancel alert dialog button --> + <string name="call_incoming_custom_message_cancel">Cancel</string> + <!-- "Custom Message" Send alert dialog button --> + <string name="call_incoming_custom_message_send">Send</string> + <string name="a11y_incoming_call_reject_with_sms">Reject this call with a message</string> + <string name="a11y_incoming_call_answer_video_as_audio">Answer as audio call</string> + <string name="a11y_description_incoming_call_reject_with_sms">Reject with message</string> + <string name="a11y_description_incoming_call_answer_video_as_audio">Answer as audio call</string> + + <!-- Text indicates the video local camera is off. [CHAR LIMIT=40] --> + <string name="call_incoming_video_is_off">Video is off</string> + + <!-- Voice prompt of swipe gesture when accessibility is turned on. --> + <string description="The message announced to accessibility assistance on incoming call." + name="a11y_incoming_call_swipe_gesture_prompt">Two finger swipe up to answer. Two finger swipe down to decline.</string> + <string name="call_incoming_important">Important call</string> +</resources> diff --git a/java/com/android/incallui/answer/impl/utils/FlingAnimationUtils.java b/java/com/android/incallui/answer/impl/utils/FlingAnimationUtils.java new file mode 100644 index 000000000..3acb2a205 --- /dev/null +++ b/java/com/android/incallui/answer/impl/utils/FlingAnimationUtils.java @@ -0,0 +1,293 @@ +/* + * Copyright (C) 2016 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.incallui.answer.impl.utils; + +import android.animation.Animator; +import android.content.Context; +import android.view.ViewPropertyAnimator; +import android.view.animation.Interpolator; +import android.view.animation.PathInterpolator; + +/** Utility class to calculate general fling animation when the finger is released. */ +public class FlingAnimationUtils { + + private static final float LINEAR_OUT_SLOW_IN_X2 = 0.35f; + private static final float LINEAR_OUT_FASTER_IN_X2 = 0.5f; + private static final float LINEAR_OUT_FASTER_IN_Y2_MIN = 0.4f; + private static final float LINEAR_OUT_FASTER_IN_Y2_MAX = 0.5f; + private static final float MIN_VELOCITY_DP_PER_SECOND = 250; + private static final float HIGH_VELOCITY_DP_PER_SECOND = 3000; + + /** Crazy math. http://en.wikipedia.org/wiki/B%C3%A9zier_curve */ + private static final float LINEAR_OUT_SLOW_IN_START_GRADIENT = 1.0f / LINEAR_OUT_SLOW_IN_X2; + + private Interpolator linearOutSlowIn; + + private float minVelocityPxPerSecond; + private float maxLengthSeconds; + private float highVelocityPxPerSecond; + + private AnimatorProperties mAnimatorProperties = new AnimatorProperties(); + + public FlingAnimationUtils(Context ctx, float maxLengthSeconds) { + this.maxLengthSeconds = maxLengthSeconds; + linearOutSlowIn = new PathInterpolator(0, 0, LINEAR_OUT_SLOW_IN_X2, 1); + minVelocityPxPerSecond = + MIN_VELOCITY_DP_PER_SECOND * ctx.getResources().getDisplayMetrics().density; + highVelocityPxPerSecond = + HIGH_VELOCITY_DP_PER_SECOND * ctx.getResources().getDisplayMetrics().density; + } + + /** + * Applies the interpolator and length to the animator, such that the fling animation is + * consistent with the finger motion. + * + * @param animator the animator to apply + * @param currValue the current value + * @param endValue the end value of the animator + * @param velocity the current velocity of the motion + */ + public void apply(Animator animator, float currValue, float endValue, float velocity) { + apply(animator, currValue, endValue, velocity, Math.abs(endValue - currValue)); + } + + /** + * Applies the interpolator and length to the animator, such that the fling animation is + * consistent with the finger motion. + * + * @param animator the animator to apply + * @param currValue the current value + * @param endValue the end value of the animator + * @param velocity the current velocity of the motion + */ + public void apply( + ViewPropertyAnimator animator, float currValue, float endValue, float velocity) { + apply(animator, currValue, endValue, velocity, Math.abs(endValue - currValue)); + } + + /** + * Applies the interpolator and length to the animator, such that the fling animation is + * consistent with the finger motion. + * + * @param animator the animator to apply + * @param currValue the current value + * @param endValue the end value of the animator + * @param velocity the current velocity of the motion + * @param maxDistance the maximum distance for this interaction; the maximum animation length gets + * multiplied by the ratio between the actual distance and this value + */ + public void apply( + Animator animator, float currValue, float endValue, float velocity, float maxDistance) { + AnimatorProperties properties = getProperties(currValue, endValue, velocity, maxDistance); + animator.setDuration(properties.duration); + animator.setInterpolator(properties.interpolator); + } + + /** + * Applies the interpolator and length to the animator, such that the fling animation is + * consistent with the finger motion. + * + * @param animator the animator to apply + * @param currValue the current value + * @param endValue the end value of the animator + * @param velocity the current velocity of the motion + * @param maxDistance the maximum distance for this interaction; the maximum animation length gets + * multiplied by the ratio between the actual distance and this value + */ + public void apply( + ViewPropertyAnimator animator, + float currValue, + float endValue, + float velocity, + float maxDistance) { + AnimatorProperties properties = getProperties(currValue, endValue, velocity, maxDistance); + animator.setDuration(properties.duration); + animator.setInterpolator(properties.interpolator); + } + + private AnimatorProperties getProperties( + float currValue, float endValue, float velocity, float maxDistance) { + float maxLengthSeconds = + (float) (this.maxLengthSeconds * Math.sqrt(Math.abs(endValue - currValue) / maxDistance)); + float diff = Math.abs(endValue - currValue); + float velAbs = Math.abs(velocity); + float durationSeconds = LINEAR_OUT_SLOW_IN_START_GRADIENT * diff / velAbs; + if (durationSeconds <= maxLengthSeconds) { + mAnimatorProperties.interpolator = linearOutSlowIn; + } else if (velAbs >= minVelocityPxPerSecond) { + + // Cross fade between fast-out-slow-in and linear interpolator with current velocity. + durationSeconds = maxLengthSeconds; + VelocityInterpolator velocityInterpolator = + new VelocityInterpolator(durationSeconds, velAbs, diff); + mAnimatorProperties.interpolator = + new InterpolatorInterpolator(velocityInterpolator, linearOutSlowIn, linearOutSlowIn); + } else { + + // Just use a normal interpolator which doesn't take the velocity into account. + durationSeconds = maxLengthSeconds; + mAnimatorProperties.interpolator = Interpolators.FAST_OUT_SLOW_IN; + } + mAnimatorProperties.duration = (long) (durationSeconds * 1000); + return mAnimatorProperties; + } + + /** + * Applies the interpolator and length to the animator, such that the fling animation is + * consistent with the finger motion for the case when the animation is making something + * disappear. + * + * @param animator the animator to apply + * @param currValue the current value + * @param endValue the end value of the animator + * @param velocity the current velocity of the motion + * @param maxDistance the maximum distance for this interaction; the maximum animation length gets + * multiplied by the ratio between the actual distance and this value + */ + public void applyDismissing( + Animator animator, float currValue, float endValue, float velocity, float maxDistance) { + AnimatorProperties properties = + getDismissingProperties(currValue, endValue, velocity, maxDistance); + animator.setDuration(properties.duration); + animator.setInterpolator(properties.interpolator); + } + + /** + * Applies the interpolator and length to the animator, such that the fling animation is + * consistent with the finger motion for the case when the animation is making something + * disappear. + * + * @param animator the animator to apply + * @param currValue the current value + * @param endValue the end value of the animator + * @param velocity the current velocity of the motion + * @param maxDistance the maximum distance for this interaction; the maximum animation length gets + * multiplied by the ratio between the actual distance and this value + */ + public void applyDismissing( + ViewPropertyAnimator animator, + float currValue, + float endValue, + float velocity, + float maxDistance) { + AnimatorProperties properties = + getDismissingProperties(currValue, endValue, velocity, maxDistance); + animator.setDuration(properties.duration); + animator.setInterpolator(properties.interpolator); + } + + private AnimatorProperties getDismissingProperties( + float currValue, float endValue, float velocity, float maxDistance) { + float maxLengthSeconds = + (float) + (this.maxLengthSeconds * Math.pow(Math.abs(endValue - currValue) / maxDistance, 0.5f)); + float diff = Math.abs(endValue - currValue); + float velAbs = Math.abs(velocity); + float y2 = calculateLinearOutFasterInY2(velAbs); + + float startGradient = y2 / LINEAR_OUT_FASTER_IN_X2; + Interpolator mLinearOutFasterIn = new PathInterpolator(0, 0, LINEAR_OUT_FASTER_IN_X2, y2); + float durationSeconds = startGradient * diff / velAbs; + if (durationSeconds <= maxLengthSeconds) { + mAnimatorProperties.interpolator = mLinearOutFasterIn; + } else if (velAbs >= minVelocityPxPerSecond) { + + // Cross fade between linear-out-faster-in and linear interpolator with current + // velocity. + durationSeconds = maxLengthSeconds; + VelocityInterpolator velocityInterpolator = + new VelocityInterpolator(durationSeconds, velAbs, diff); + InterpolatorInterpolator superInterpolator = + new InterpolatorInterpolator(velocityInterpolator, mLinearOutFasterIn, linearOutSlowIn); + mAnimatorProperties.interpolator = superInterpolator; + } else { + + // Just use a normal interpolator which doesn't take the velocity into account. + durationSeconds = maxLengthSeconds; + mAnimatorProperties.interpolator = Interpolators.FAST_OUT_LINEAR_IN; + } + mAnimatorProperties.duration = (long) (durationSeconds * 1000); + return mAnimatorProperties; + } + + /** + * Calculates the y2 control point for a linear-out-faster-in path interpolator depending on the + * velocity. The faster the velocity, the more "linear" the interpolator gets. + * + * @param velocity the velocity of the gesture. + * @return the y2 control point for a cubic bezier path interpolator + */ + private float calculateLinearOutFasterInY2(float velocity) { + float t = + (velocity - minVelocityPxPerSecond) / (highVelocityPxPerSecond - minVelocityPxPerSecond); + t = Math.max(0, Math.min(1, t)); + return (1 - t) * LINEAR_OUT_FASTER_IN_Y2_MIN + t * LINEAR_OUT_FASTER_IN_Y2_MAX; + } + + /** @return the minimum velocity a gesture needs to have to be considered a fling */ + public float getMinVelocityPxPerSecond() { + return minVelocityPxPerSecond; + } + + /** An interpolator which interpolates two interpolators with an interpolator. */ + private static final class InterpolatorInterpolator implements Interpolator { + + private Interpolator mInterpolator1; + private Interpolator mInterpolator2; + private Interpolator mCrossfader; + + InterpolatorInterpolator( + Interpolator interpolator1, Interpolator interpolator2, Interpolator crossfader) { + mInterpolator1 = interpolator1; + mInterpolator2 = interpolator2; + mCrossfader = crossfader; + } + + @Override + public float getInterpolation(float input) { + float t = mCrossfader.getInterpolation(input); + return (1 - t) * mInterpolator1.getInterpolation(input) + + t * mInterpolator2.getInterpolation(input); + } + } + + /** An interpolator which interpolates with a fixed velocity. */ + private static final class VelocityInterpolator implements Interpolator { + + private float mDurationSeconds; + private float mVelocity; + private float mDiff; + + private VelocityInterpolator(float durationSeconds, float velocity, float diff) { + mDurationSeconds = durationSeconds; + mVelocity = velocity; + mDiff = diff; + } + + @Override + public float getInterpolation(float input) { + float time = input * mDurationSeconds; + return time * mVelocity / mDiff; + } + } + + private static class AnimatorProperties { + + Interpolator interpolator; + long duration; + } +} diff --git a/java/com/android/incallui/answer/impl/utils/Interpolators.java b/java/com/android/incallui/answer/impl/utils/Interpolators.java new file mode 100644 index 000000000..efc68f78a --- /dev/null +++ b/java/com/android/incallui/answer/impl/utils/Interpolators.java @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2016 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.incallui.answer.impl.utils; + +import android.view.animation.Interpolator; +import android.view.animation.PathInterpolator; + +/** + * Common interpolators used in answer methods. + */ +public class Interpolators { + + public static final Interpolator FAST_OUT_LINEAR_IN = new PathInterpolator(0.4f, 0f, 1f, 1f); + public static final Interpolator LINEAR_OUT_SLOW_IN = new PathInterpolator(0f, 0f, 0.2f, 1f); + public static final Interpolator FAST_OUT_SLOW_IN = new PathInterpolator(0.4f, 0f, 0.2f, 1f); +} |