diff options
Diffstat (limited to 'java/com/android/incallui')
476 files changed, 39486 insertions, 0 deletions
diff --git a/java/com/android/incallui/AccelerometerListener.java b/java/com/android/incallui/AccelerometerListener.java new file mode 100644 index 000000000..01f884354 --- /dev/null +++ b/java/com/android/incallui/AccelerometerListener.java @@ -0,0 +1,173 @@ +/* + * Copyright (C) 2009 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; + +import android.content.Context; +import android.hardware.Sensor; +import android.hardware.SensorEvent; +import android.hardware.SensorEventListener; +import android.hardware.SensorManager; +import android.os.Handler; +import android.os.Message; +import android.util.Log; + +/** + * This class is used to listen to the accelerometer to monitor the orientation of the phone. The + * client of this class is notified when the orientation changes between horizontal and vertical. + */ +public class AccelerometerListener { + + // Device orientation + public static final int ORIENTATION_UNKNOWN = 0; + public static final int ORIENTATION_VERTICAL = 1; + public static final int ORIENTATION_HORIZONTAL = 2; + private static final String TAG = "AccelerometerListener"; + private static final boolean DEBUG = true; + private static final boolean VDEBUG = false; + private static final int ORIENTATION_CHANGED = 1234; + private static final int VERTICAL_DEBOUNCE = 100; + private static final int HORIZONTAL_DEBOUNCE = 500; + private static final double VERTICAL_ANGLE = 50.0; + private SensorManager mSensorManager; + private Sensor mSensor; + // mOrientation is the orientation value most recently reported to the client. + private int mOrientation; + // mPendingOrientation is the latest orientation computed based on the sensor value. + // This is sent to the client after a rebounce delay, at which point it is copied to + // mOrientation. + private int mPendingOrientation; + private OrientationListener mListener; + Handler mHandler = + new Handler() { + @Override + public void handleMessage(Message msg) { + switch (msg.what) { + case ORIENTATION_CHANGED: + synchronized (this) { + mOrientation = mPendingOrientation; + if (DEBUG) { + Log.d( + TAG, + "orientation: " + + (mOrientation == ORIENTATION_HORIZONTAL + ? "horizontal" + : (mOrientation == ORIENTATION_VERTICAL ? "vertical" : "unknown"))); + } + if (mListener != null) { + mListener.orientationChanged(mOrientation); + } + } + break; + } + } + }; + SensorEventListener mSensorListener = + new SensorEventListener() { + @Override + public void onSensorChanged(SensorEvent event) { + onSensorEvent(event.values[0], event.values[1], event.values[2]); + } + + @Override + public void onAccuracyChanged(Sensor sensor, int accuracy) { + // ignore + } + }; + + public AccelerometerListener(Context context) { + mSensorManager = (SensorManager) context.getSystemService(Context.SENSOR_SERVICE); + mSensor = mSensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER); + } + + public void setListener(OrientationListener listener) { + mListener = listener; + } + + public void enable(boolean enable) { + if (DEBUG) { + Log.d(TAG, "enable(" + enable + ")"); + } + synchronized (this) { + if (enable) { + mOrientation = ORIENTATION_UNKNOWN; + mPendingOrientation = ORIENTATION_UNKNOWN; + mSensorManager.registerListener( + mSensorListener, mSensor, SensorManager.SENSOR_DELAY_NORMAL); + } else { + mSensorManager.unregisterListener(mSensorListener); + mHandler.removeMessages(ORIENTATION_CHANGED); + } + } + } + + private void setOrientation(int orientation) { + synchronized (this) { + if (mPendingOrientation == orientation) { + // Pending orientation has not changed, so do nothing. + return; + } + + // Cancel any pending messages. + // We will either start a new timer or cancel alltogether + // if the orientation has not changed. + mHandler.removeMessages(ORIENTATION_CHANGED); + + if (mOrientation != orientation) { + // Set timer to send an event if the orientation has changed since its + // previously reported value. + mPendingOrientation = orientation; + final Message m = mHandler.obtainMessage(ORIENTATION_CHANGED); + // set delay to our debounce timeout + int delay = (orientation == ORIENTATION_VERTICAL ? VERTICAL_DEBOUNCE : HORIZONTAL_DEBOUNCE); + mHandler.sendMessageDelayed(m, delay); + } else { + // no message is pending + mPendingOrientation = ORIENTATION_UNKNOWN; + } + } + } + + private void onSensorEvent(double x, double y, double z) { + if (VDEBUG) { + Log.d(TAG, "onSensorEvent(" + x + ", " + y + ", " + z + ")"); + } + + // If some values are exactly zero, then likely the sensor is not powered up yet. + // ignore these events to avoid false horizontal positives. + if (x == 0.0 || y == 0.0 || z == 0.0) { + return; + } + + // magnitude of the acceleration vector projected onto XY plane + final double xy = Math.hypot(x, y); + // compute the vertical angle + double angle = Math.atan2(xy, z); + // convert to degrees + angle = angle * 180.0 / Math.PI; + final int orientation = + (angle > VERTICAL_ANGLE ? ORIENTATION_VERTICAL : ORIENTATION_HORIZONTAL); + if (VDEBUG) { + Log.d(TAG, "angle: " + angle + " orientation: " + orientation); + } + setOrientation(orientation); + } + + public interface OrientationListener { + + void orientationChanged(int orientation); + } +} diff --git a/java/com/android/incallui/AndroidManifest.xml b/java/com/android/incallui/AndroidManifest.xml new file mode 100644 index 000000000..276b47a5e --- /dev/null +++ b/java/com/android/incallui/AndroidManifest.xml @@ -0,0 +1,121 @@ +<!-- + ~ 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 + --> + +<manifest xmlns:android="http://schemas.android.com/apk/res/android" + package="com.android.incallui"> + + <uses-sdk + android:minSdkVersion="23" + android:targetSdkVersion="25"/> + + <uses-permission android:name="android.permission.CONTROL_INCALL_EXPERIENCE"/> + <!-- We use this to disable the status bar buttons of home, back and recent + during an incoming call. By doing so this allows us to not show the user + is viewing the activity in full screen alert, on a fresh system/factory + reset state of the app. --> + <uses-permission android:name="android.permission.STATUS_BAR"/> + <uses-permission android:name="android.permission.CAMERA"/> + <!-- Warning: setting the required boolean to true would prevent installation of Dialer on + devices which do not support a camera. --> + <uses-feature + android:name="android.hardware.camera.any" + android:required="false"/> + + <!-- Testing location --> + <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/> + + <!-- Set android:taskAffinity="com.android.incallui" for all activities to ensure proper + navigation. Otherwise system could bring up DialtactsActivity instead, e.g. when user unmerge a + call. + Set taskAffinity for application is not working because it will be merged and the result is + that all activities here still have same taskAffinity as activities under dialer. --> + <application> + <meta-data android:name="android.telephony.hide_voicemail_settings_menu" + android:value="true"/> + <activity + android:directBootAware="true" + android:excludeFromRecents="true" + android:exported="false" + android:label="@string/phoneAppLabel" + android:taskAffinity="com.android.incallui" + android:launchMode="singleInstance" + android:name="com.android.incallui.InCallActivity" + android:resizeableActivity="true" + android:screenOrientation="nosensor" + android:theme="@style/Theme.InCallScreen"> + </activity> + + <activity + android:directBootAware="true" + android:excludeFromRecents="true" + android:noHistory="true" + android:exported="false" + android:label="@string/manageConferenceLabel" + android:taskAffinity="com.android.incallui" + android:launchMode="singleTask" + android:name="com.android.incallui.ManageConferenceActivity" + android:resizeableActivity="true" + android:theme="@style/Theme.InCallScreen.ManageConference"/> + + <service + android:directBootAware="true" + android:exported="true" + android:name="com.android.incallui.InCallServiceImpl" + android:permission="android.permission.BIND_INCALL_SERVICE"> + <meta-data + android:name="android.telecom.IN_CALL_SERVICE_UI" + android:value="true"/> + <meta-data + android:name="android.telecom.IN_CALL_SERVICE_RINGING" + android:value="false"/> + <meta-data + android:name="android.telecom.INCLUDE_EXTERNAL_CALLS" + android:value="true"/> + + <intent-filter> + <action android:name="android.telecom.InCallService"/> + </intent-filter> + </service> + + <!-- + Comments for attributes in SpamNotificationActivity: + taskAffinity="" -> Open the dialog without opening the dialer app behind it + noHistory="true" -> Navigating away finishes activity + excludeFromRecents="true" -> Don't show in "recent apps" screen + --> + <activity + android:excludeFromRecents="true" + android:exported="false" + android:name="com.android.incallui.spam.SpamNotificationActivity" + android:noHistory="true" + android:taskAffinity="" + android:theme="@style/AfterCallNotificationTheme"> + </activity> + + <service + android:exported="false" + android:name="com.android.incallui.spam.SpamNotificationService"/> + + <!-- BroadcastReceiver for receiving Intents from Notification mechanism. --> + <receiver + android:directBootAware="true" + android:exported="false" + android:name="com.android.incallui.NotificationBroadcastReceiver"/> + + </application> + +</manifest> + diff --git a/java/com/android/incallui/AnswerScreenPresenter.java b/java/com/android/incallui/AnswerScreenPresenter.java new file mode 100644 index 000000000..a21876b2b --- /dev/null +++ b/java/com/android/incallui/AnswerScreenPresenter.java @@ -0,0 +1,110 @@ +/* + * 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; + +import android.content.Context; +import android.support.annotation.FloatRange; +import android.support.annotation.NonNull; +import android.support.v4.os.UserManagerCompat; +import com.android.dialer.common.Assert; +import com.android.dialer.common.LogUtil; +import com.android.incallui.answer.protocol.AnswerScreen; +import com.android.incallui.answer.protocol.AnswerScreenDelegate; +import com.android.incallui.answerproximitysensor.AnswerProximitySensor; +import com.android.incallui.answerproximitysensor.PseudoScreenState; +import com.android.incallui.call.DialerCall; + +/** Manages changes for an incoming call screen. */ +public class AnswerScreenPresenter + implements AnswerScreenDelegate, DialerCall.CannedTextResponsesLoadedListener { + @NonNull private final Context context; + @NonNull private final AnswerScreen answerScreen; + @NonNull private final DialerCall call; + + public AnswerScreenPresenter( + @NonNull Context context, @NonNull AnswerScreen answerScreen, @NonNull DialerCall call) { + LogUtil.i("AnswerScreenPresenter.constructor", null); + this.context = Assert.isNotNull(context); + this.answerScreen = Assert.isNotNull(answerScreen); + this.call = Assert.isNotNull(call); + if (isSmsResponseAllowed(call)) { + answerScreen.setTextResponses(call.getCannedSmsResponses()); + } + call.addCannedTextResponsesLoadedListener(this); + + PseudoScreenState pseudoScreenState = InCallPresenter.getInstance().getPseudoScreenState(); + if (AnswerProximitySensor.shouldUse(context, call)) { + new AnswerProximitySensor(context, call, pseudoScreenState); + } else { + pseudoScreenState.setOn(true); + } + } + + @Override + public void onAnswerScreenUnready() { + call.removeCannedTextResponsesLoadedListener(this); + } + + @Override + public void onDismissDialog() { + InCallPresenter.getInstance().onDismissDialog(); + } + + @Override + public void onRejectCallWithMessage(String message) { + call.reject(true /* rejectWithMessage */, message); + onDismissDialog(); + } + + @Override + public void onAnswer(int videoState) { + if (answerScreen.isVideoUpgradeRequest()) { + call.acceptUpgradeRequest(videoState); + } else { + call.answer(videoState); + } + } + + @Override + public void onReject() { + if (answerScreen.isVideoUpgradeRequest()) { + call.declineUpgradeRequest(); + } else { + call.reject(false /* rejectWithMessage */, null); + } + } + + @Override + public void onCannedTextResponsesLoaded(DialerCall call) { + if (isSmsResponseAllowed(call)) { + answerScreen.setTextResponses(call.getCannedSmsResponses()); + } + } + + @Override + public void updateWindowBackgroundColor(@FloatRange(from = -1f, to = 1.0f) float progress) { + InCallActivity activity = (InCallActivity) answerScreen.getAnswerScreenFragment().getActivity(); + if (activity != null) { + activity.updateWindowBackgroundColor(progress); + } + } + + private boolean isSmsResponseAllowed(DialerCall call) { + return UserManagerCompat.isUserUnlocked(context) + && call.can(android.telecom.Call.Details.CAPABILITY_RESPOND_VIA_TEXT); + } +} diff --git a/java/com/android/incallui/AnswerScreenPresenterStub.java b/java/com/android/incallui/AnswerScreenPresenterStub.java new file mode 100644 index 000000000..fc47bf5b0 --- /dev/null +++ b/java/com/android/incallui/AnswerScreenPresenterStub.java @@ -0,0 +1,44 @@ +/* + * 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; + +import android.support.annotation.FloatRange; +import com.android.incallui.answer.protocol.AnswerScreenDelegate; + +/** + * Stub implementation of the answer screen delegate. Used to keep the answer fragment visible when + * no call exists. + */ +public class AnswerScreenPresenterStub implements AnswerScreenDelegate { + @Override + public void onAnswerScreenUnready() {} + + @Override + public void onDismissDialog() {} + + @Override + public void onRejectCallWithMessage(String message) {} + + @Override + public void onAnswer(int videoState) {} + + @Override + public void onReject() {} + + @Override + public void updateWindowBackgroundColor(@FloatRange(from = -1f, to = 1.0f) float progress) {} +} diff --git a/java/com/android/incallui/AudioModeProvider.java b/java/com/android/incallui/AudioModeProvider.java new file mode 100644 index 000000000..698db0ab9 --- /dev/null +++ b/java/com/android/incallui/AudioModeProvider.java @@ -0,0 +1,69 @@ +/* + * Copyright (C) 2013 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; + +import android.telecom.CallAudioState; +import java.util.ArrayList; +import java.util.List; + +/** Proxy class for getting and setting the audio mode. */ +public class AudioModeProvider { + private static final int SUPPORTED_AUDIO_ROUTE_ALL = + CallAudioState.ROUTE_EARPIECE + | CallAudioState.ROUTE_BLUETOOTH + | CallAudioState.ROUTE_WIRED_HEADSET + | CallAudioState.ROUTE_SPEAKER; + + private static final AudioModeProvider instance = new AudioModeProvider(); + private final List<AudioModeListener> listeners = new ArrayList<>(); + private CallAudioState audioState = + new CallAudioState(false, CallAudioState.ROUTE_EARPIECE, SUPPORTED_AUDIO_ROUTE_ALL); + + public static AudioModeProvider getInstance() { + return instance; + } + + public void onAudioStateChanged(CallAudioState audioState) { + if (!this.audioState.equals(audioState)) { + this.audioState = audioState; + for (AudioModeListener listener : listeners) { + listener.onAudioStateChanged(audioState); + } + } + } + + public void addListener(AudioModeListener listener) { + if (!listeners.contains(listener)) { + listeners.add(listener); + listener.onAudioStateChanged(audioState); + } + } + + public void removeListener(AudioModeListener listener) { + listeners.remove(listener); + } + + public CallAudioState getAudioState() { + return audioState; + } + + /** Notified on changes to audio mode. */ + public interface AudioModeListener { + + void onAudioStateChanged(CallAudioState audioState); + } +} diff --git a/java/com/android/incallui/Bindings.java b/java/com/android/incallui/Bindings.java new file mode 100644 index 000000000..4f142ff96 --- /dev/null +++ b/java/com/android/incallui/Bindings.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; + +import android.content.Context; +import com.android.incallui.bindings.InCallUiBindings; +import com.android.incallui.bindings.InCallUiBindingsFactory; +import com.android.incallui.bindings.InCallUiBindingsStub; +import java.util.Objects; + +/** Accessor for the in call UI bindings. */ +public class Bindings { + + private static InCallUiBindings instance; + + private Bindings() {} + + public static InCallUiBindings get(Context context) { + Objects.requireNonNull(context); + if (instance != null) { + return instance; + } + + Context application = context.getApplicationContext(); + if (application instanceof InCallUiBindingsFactory) { + instance = ((InCallUiBindingsFactory) application).newInCallUiBindings(); + } + + if (instance == null) { + instance = new InCallUiBindingsStub(); + } + return instance; + } + + public static void setForTesting(InCallUiBindings testInstance) { + instance = testInstance; + } +} diff --git a/java/com/android/incallui/CallButtonPresenter.java b/java/com/android/incallui/CallButtonPresenter.java new file mode 100644 index 000000000..d6f4cddc9 --- /dev/null +++ b/java/com/android/incallui/CallButtonPresenter.java @@ -0,0 +1,515 @@ +/* + * Copyright (C) 2013 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; + +import android.content.Context; +import android.os.Build; +import android.os.Bundle; +import android.support.v4.app.Fragment; +import android.support.v4.os.UserManagerCompat; +import android.telecom.CallAudioState; +import android.telecom.InCallService.VideoCall; +import android.telecom.VideoProfile; +import com.android.contacts.common.compat.CallCompat; +import com.android.dialer.common.Assert; +import com.android.dialer.common.LogUtil; +import com.android.dialer.compat.SdkVersionOverride; +import com.android.dialer.logging.Logger; +import com.android.dialer.logging.nano.DialerImpression; +import com.android.incallui.AudioModeProvider.AudioModeListener; +import com.android.incallui.InCallCameraManager.Listener; +import com.android.incallui.InCallPresenter.CanAddCallListener; +import com.android.incallui.InCallPresenter.InCallDetailsListener; +import com.android.incallui.InCallPresenter.InCallState; +import com.android.incallui.InCallPresenter.InCallStateListener; +import com.android.incallui.InCallPresenter.IncomingCallListener; +import com.android.incallui.call.CallList; +import com.android.incallui.call.DialerCall; +import com.android.incallui.call.TelecomAdapter; +import com.android.incallui.call.VideoUtils; +import com.android.incallui.incall.protocol.InCallButtonIds; +import com.android.incallui.incall.protocol.InCallButtonUi; +import com.android.incallui.incall.protocol.InCallButtonUiDelegate; + +/** Logic for call buttons. */ +public class CallButtonPresenter + implements InCallStateListener, + AudioModeListener, + IncomingCallListener, + InCallDetailsListener, + CanAddCallListener, + Listener, + InCallButtonUiDelegate { + + private static final String KEY_AUTOMATICALLY_MUTED = "incall_key_automatically_muted"; + private static final String KEY_PREVIOUS_MUTE_STATE = "incall_key_previous_mute_state"; + + private final Context mContext; + private InCallButtonUi mInCallButtonUi; + private DialerCall mCall; + private boolean mAutomaticallyMuted = false; + private boolean mPreviousMuteState = false; + private boolean isInCallButtonUiReady; + + public CallButtonPresenter(Context context) { + mContext = context.getApplicationContext(); + } + + @Override + public void onInCallButtonUiReady(InCallButtonUi ui) { + Assert.checkState(!isInCallButtonUiReady); + mInCallButtonUi = ui; + AudioModeProvider.getInstance().addListener(this); + + // register for call state changes last + final InCallPresenter inCallPresenter = InCallPresenter.getInstance(); + inCallPresenter.addListener(this); + inCallPresenter.addIncomingCallListener(this); + inCallPresenter.addDetailsListener(this); + inCallPresenter.addCanAddCallListener(this); + inCallPresenter.getInCallCameraManager().addCameraSelectionListener(this); + + // Update the buttons state immediately for the current call + onStateChange(InCallState.NO_CALLS, inCallPresenter.getInCallState(), CallList.getInstance()); + isInCallButtonUiReady = true; + } + + @Override + public void onInCallButtonUiUnready() { + Assert.checkState(isInCallButtonUiReady); + mInCallButtonUi = null; + InCallPresenter.getInstance().removeListener(this); + AudioModeProvider.getInstance().removeListener(this); + InCallPresenter.getInstance().removeIncomingCallListener(this); + InCallPresenter.getInstance().removeDetailsListener(this); + InCallPresenter.getInstance().getInCallCameraManager().removeCameraSelectionListener(this); + InCallPresenter.getInstance().removeCanAddCallListener(this); + isInCallButtonUiReady = false; + } + + @Override + public void onStateChange(InCallState oldState, InCallState newState, CallList callList) { + if (newState == InCallState.OUTGOING) { + mCall = callList.getOutgoingCall(); + } else if (newState == InCallState.INCALL) { + mCall = callList.getActiveOrBackgroundCall(); + + // When connected to voice mail, automatically shows the dialpad. + // (On previous releases we showed it when in-call shows up, before waiting for + // OUTGOING. We may want to do that once we start showing "Voice mail" label on + // the dialpad too.) + if (oldState == InCallState.OUTGOING && mCall != null) { + if (CallerInfoUtils.isVoiceMailNumber(mContext, mCall) && getActivity() != null) { + getActivity().showDialpadFragment(true /* show */, true /* animate */); + } + } + } else if (newState == InCallState.INCOMING) { + if (getActivity() != null) { + getActivity().showDialpadFragment(false /* show */, true /* animate */); + } + mCall = callList.getIncomingCall(); + } else { + mCall = null; + } + updateUi(newState, mCall); + } + + /** + * Updates the user interface in response to a change in the details of a call. Currently handles + * changes to the call buttons in response to a change in the details for a call. This is + * important to ensure changes to the active call are reflected in the available buttons. + * + * @param call The active call. + * @param details The call details. + */ + @Override + public void onDetailsChanged(DialerCall call, android.telecom.Call.Details details) { + // Only update if the changes are for the currently active call + if (mInCallButtonUi != null && call != null && call.equals(mCall)) { + updateButtonsState(call); + } + } + + @Override + public void onIncomingCall(InCallState oldState, InCallState newState, DialerCall call) { + onStateChange(oldState, newState, CallList.getInstance()); + } + + @Override + public void onCanAddCallChanged(boolean canAddCall) { + if (mInCallButtonUi != null && mCall != null) { + updateButtonsState(mCall); + } + } + + @Override + public void onAudioStateChanged(CallAudioState audioState) { + if (mInCallButtonUi != null) { + mInCallButtonUi.setAudioState(audioState); + } + } + + @Override + public CallAudioState getCurrentAudioState() { + return AudioModeProvider.getInstance().getAudioState(); + } + + @Override + public void setAudioRoute(int route) { + LogUtil.i( + "CallButtonPresenter.setAudioRoute", + "sending new audio route: " + CallAudioState.audioRouteToString(route)); + TelecomAdapter.getInstance().setAudioRoute(route); + } + + /** Function assumes that bluetooth is not supported. */ + @Override + public void toggleSpeakerphone() { + // This function should not be called if bluetooth is available. + CallAudioState audioState = getCurrentAudioState(); + if (0 != (CallAudioState.ROUTE_BLUETOOTH & audioState.getSupportedRouteMask())) { + // It's clear the UI is wrong, so update the supported mode once again. + LogUtil.e( + "CallButtonPresenter", "toggling speakerphone not allowed when bluetooth supported."); + mInCallButtonUi.setAudioState(audioState); + return; + } + + int newRoute; + if (audioState.getRoute() == CallAudioState.ROUTE_SPEAKER) { + newRoute = CallAudioState.ROUTE_WIRED_OR_EARPIECE; + Logger.get(mContext) + .logCallImpression( + DialerImpression.Type.IN_CALL_SCREEN_TURN_ON_WIRED_OR_EARPIECE, + mCall.getUniqueCallId(), + mCall.getTimeAddedMs()); + } else { + newRoute = CallAudioState.ROUTE_SPEAKER; + Logger.get(mContext) + .logCallImpression( + DialerImpression.Type.IN_CALL_SCREEN_TURN_ON_SPEAKERPHONE, + mCall.getUniqueCallId(), + mCall.getTimeAddedMs()); + } + + setAudioRoute(newRoute); + } + + @Override + public void muteClicked(boolean checked) { + LogUtil.v("CallButtonPresenter", "turning on mute: " + checked); + TelecomAdapter.getInstance().mute(checked); + } + + @Override + public void holdClicked(boolean checked) { + if (mCall == null) { + return; + } + if (checked) { + LogUtil.i("CallButtonPresenter", "putting the call on hold: " + mCall); + mCall.hold(); + } else { + LogUtil.i("CallButtonPresenter", "removing the call from hold: " + mCall); + mCall.unhold(); + } + } + + @Override + public void swapClicked() { + if (mCall == null) { + return; + } + + LogUtil.i("CallButtonPresenter", "swapping the call: " + mCall); + TelecomAdapter.getInstance().swap(mCall.getId()); + } + + @Override + public void mergeClicked() { + TelecomAdapter.getInstance().merge(mCall.getId()); + } + + @Override + public void addCallClicked() { + // Automatically mute the current call + mAutomaticallyMuted = true; + mPreviousMuteState = AudioModeProvider.getInstance().getAudioState().isMuted(); + // Simulate a click on the mute button + muteClicked(true); + TelecomAdapter.getInstance().addCall(); + } + + @Override + public void showDialpadClicked(boolean checked) { + LogUtil.v("CallButtonPresenter", "show dialpad " + String.valueOf(checked)); + getActivity().showDialpadFragment(checked /* show */, true /* animate */); + } + + @Override + public void changeToVideoClicked() { + VideoCall videoCall = mCall.getVideoCall(); + if (videoCall == null) { + return; + } + int currVideoState = mCall.getVideoState(); + int currUnpausedVideoState = VideoUtils.getUnPausedVideoState(currVideoState); + currUnpausedVideoState |= VideoProfile.STATE_BIDIRECTIONAL; + + VideoProfile videoProfile = new VideoProfile(currUnpausedVideoState); + videoCall.sendSessionModifyRequest(videoProfile); + mCall.setSessionModificationState( + DialerCall.SESSION_MODIFICATION_STATE_WAITING_FOR_UPGRADE_TO_VIDEO_RESPONSE); + } + + @Override + public void onEndCallClicked() { + LogUtil.i("CallButtonPresenter.onEndCallClicked", "call: " + mCall); + if (mCall != null) { + mCall.disconnect(); + } + } + + @Override + public void showAudioRouteSelector() { + mInCallButtonUi.showAudioRouteSelector(); + } + + /** + * Switches the camera between the front-facing and back-facing camera. + * + * @param useFrontFacingCamera True if we should switch to using the front-facing camera, or false + * if we should switch to using the back-facing camera. + */ + @Override + public void switchCameraClicked(boolean useFrontFacingCamera) { + InCallCameraManager cameraManager = InCallPresenter.getInstance().getInCallCameraManager(); + cameraManager.setUseFrontFacingCamera(useFrontFacingCamera); + + VideoCall videoCall = mCall.getVideoCall(); + if (videoCall == null) { + return; + } + + String cameraId = cameraManager.getActiveCameraId(); + if (cameraId != null) { + final int cameraDir = + cameraManager.isUsingFrontFacingCamera() + ? DialerCall.VideoSettings.CAMERA_DIRECTION_FRONT_FACING + : DialerCall.VideoSettings.CAMERA_DIRECTION_BACK_FACING; + mCall.getVideoSettings().setCameraDir(cameraDir); + videoCall.setCamera(cameraId); + videoCall.requestCameraCapabilities(); + } + } + + @Override + public void toggleCameraClicked() { + LogUtil.i("CallButtonPresenter.toggleCameraClicked", ""); + switchCameraClicked( + !InCallPresenter.getInstance().getInCallCameraManager().isUsingFrontFacingCamera()); + } + + /** + * Stop or start client's video transmission. + * + * @param pause True if pausing the local user's video, or false if starting the local user's + * video. + */ + @Override + public void pauseVideoClicked(boolean pause) { + LogUtil.i("CallButtonPresenter.pauseVideoClicked", "%s", pause ? "pause" : "unpause"); + VideoCall videoCall = mCall.getVideoCall(); + if (videoCall == null) { + return; + } + + int currUnpausedVideoState = VideoUtils.getUnPausedVideoState(mCall.getVideoState()); + if (pause) { + videoCall.setCamera(null); + VideoProfile videoProfile = + new VideoProfile(currUnpausedVideoState & ~VideoProfile.STATE_TX_ENABLED); + videoCall.sendSessionModifyRequest(videoProfile); + } else { + InCallCameraManager cameraManager = InCallPresenter.getInstance().getInCallCameraManager(); + videoCall.setCamera(cameraManager.getActiveCameraId()); + VideoProfile videoProfile = + new VideoProfile(currUnpausedVideoState | VideoProfile.STATE_TX_ENABLED); + videoCall.sendSessionModifyRequest(videoProfile); + mCall.setSessionModificationState(DialerCall.SESSION_MODIFICATION_STATE_WAITING_FOR_RESPONSE); + } + + mInCallButtonUi.setVideoPaused(pause); + mInCallButtonUi.enableButton(InCallButtonIds.BUTTON_PAUSE_VIDEO, false); + } + + private void updateUi(InCallState state, DialerCall call) { + LogUtil.v("CallButtonPresenter", "updating call UI for call: ", call); + + if (mInCallButtonUi == null) { + return; + } + + if (call != null) { + mInCallButtonUi.updateInCallButtonUiColors(); + } + + final boolean isEnabled = + state.isConnectingOrConnected() && !state.isIncoming() && call != null; + mInCallButtonUi.setEnabled(isEnabled); + + if (call == null) { + return; + } + + updateButtonsState(call); + } + + /** + * Updates the buttons applicable for the UI. + * + * @param call The active call. + */ + private void updateButtonsState(DialerCall call) { + LogUtil.v("CallButtonPresenter.updateButtonsState", ""); + final boolean isVideo = VideoUtils.isVideoCall(call); + + // Common functionality (audio, hold, etc). + // Show either HOLD or SWAP, but not both. If neither HOLD or SWAP is available: + // (1) If the device normally can hold, show HOLD in a disabled state. + // (2) If the device doesn't have the concept of hold/swap, remove the button. + final boolean showSwap = call.can(android.telecom.Call.Details.CAPABILITY_SWAP_CONFERENCE); + final boolean showHold = + !showSwap + && call.can(android.telecom.Call.Details.CAPABILITY_SUPPORT_HOLD) + && call.can(android.telecom.Call.Details.CAPABILITY_HOLD); + final boolean isCallOnHold = call.getState() == DialerCall.State.ONHOLD; + + final boolean showAddCall = + TelecomAdapter.getInstance().canAddCall() && UserManagerCompat.isUserUnlocked(mContext); + final boolean showMerge = call.can(android.telecom.Call.Details.CAPABILITY_MERGE_CONFERENCE); + final boolean showUpgradeToVideo = !isVideo && hasVideoCallCapabilities(call); + final boolean showDowngradeToAudio = isVideo && isDowngradeToAudioSupported(call); + final boolean showMute = call.can(android.telecom.Call.Details.CAPABILITY_MUTE); + + final boolean hasCameraPermission = + isVideo && VideoUtils.hasCameraPermissionAndAllowedByUser(mContext); + // Disabling local video doesn't seem to work when dialing. See b/30256571. + final boolean showPauseVideo = + isVideo + && call.getState() != DialerCall.State.DIALING + && call.getState() != DialerCall.State.CONNECTING; + + mInCallButtonUi.showButton(InCallButtonIds.BUTTON_AUDIO, true); + mInCallButtonUi.showButton(InCallButtonIds.BUTTON_SWAP, showSwap); + mInCallButtonUi.showButton(InCallButtonIds.BUTTON_HOLD, showHold); + mInCallButtonUi.setHold(isCallOnHold); + mInCallButtonUi.showButton(InCallButtonIds.BUTTON_MUTE, showMute); + mInCallButtonUi.showButton(InCallButtonIds.BUTTON_ADD_CALL, true); + mInCallButtonUi.enableButton(InCallButtonIds.BUTTON_ADD_CALL, showAddCall); + mInCallButtonUi.showButton(InCallButtonIds.BUTTON_UPGRADE_TO_VIDEO, showUpgradeToVideo); + mInCallButtonUi.showButton(InCallButtonIds.BUTTON_DOWNGRADE_TO_AUDIO, showDowngradeToAudio); + mInCallButtonUi.showButton( + InCallButtonIds.BUTTON_SWITCH_CAMERA, isVideo && hasCameraPermission); + mInCallButtonUi.showButton(InCallButtonIds.BUTTON_PAUSE_VIDEO, showPauseVideo); + if (isVideo) { + mInCallButtonUi.setVideoPaused( + !VideoUtils.isTransmissionEnabled(call) || !hasCameraPermission); + } + mInCallButtonUi.showButton(InCallButtonIds.BUTTON_DIALPAD, true); + mInCallButtonUi.showButton(InCallButtonIds.BUTTON_MERGE, showMerge); + + mInCallButtonUi.updateButtonStates(); + } + + private boolean hasVideoCallCapabilities(DialerCall call) { + if (SdkVersionOverride.getSdkVersion(Build.VERSION_CODES.M) >= Build.VERSION_CODES.M) { + return call.can(android.telecom.Call.Details.CAPABILITY_SUPPORTS_VT_LOCAL_TX) + && call.can(android.telecom.Call.Details.CAPABILITY_SUPPORTS_VT_REMOTE_RX); + } + // In L, this single flag represents both video transmitting and receiving capabilities + return call.can(android.telecom.Call.Details.CAPABILITY_SUPPORTS_VT_LOCAL_TX); + } + + /** + * Determines if downgrading from a video call to an audio-only call is supported. In order to + * support downgrade to audio, the SDK version must be >= N and the call should NOT have the + * {@link android.telecom.Call.Details#CAPABILITY_CANNOT_DOWNGRADE_VIDEO_TO_AUDIO}. + * + * @param call The call. + * @return {@code true} if downgrading to an audio-only call from a video call is supported. + */ + private boolean isDowngradeToAudioSupported(DialerCall call) { + return !call.can(CallCompat.Details.CAPABILITY_CANNOT_DOWNGRADE_VIDEO_TO_AUDIO); + } + + @Override + public void refreshMuteState() { + // Restore the previous mute state + if (mAutomaticallyMuted + && AudioModeProvider.getInstance().getAudioState().isMuted() != mPreviousMuteState) { + if (mInCallButtonUi == null) { + return; + } + muteClicked(mPreviousMuteState); + } + mAutomaticallyMuted = false; + } + + @Override + public void onSaveInstanceState(Bundle outState) { + outState.putBoolean(KEY_AUTOMATICALLY_MUTED, mAutomaticallyMuted); + outState.putBoolean(KEY_PREVIOUS_MUTE_STATE, mPreviousMuteState); + } + + @Override + public void onRestoreInstanceState(Bundle savedInstanceState) { + mAutomaticallyMuted = + savedInstanceState.getBoolean(KEY_AUTOMATICALLY_MUTED, mAutomaticallyMuted); + mPreviousMuteState = savedInstanceState.getBoolean(KEY_PREVIOUS_MUTE_STATE, mPreviousMuteState); + } + + @Override + public void onCameraPermissionGranted() { + if (mCall != null) { + updateButtonsState(mCall); + } + } + + @Override + public void onActiveCameraSelectionChanged(boolean isUsingFrontFacingCamera) { + if (mInCallButtonUi == null) { + return; + } + mInCallButtonUi.setCameraSwitched(!isUsingFrontFacingCamera); + } + + @Override + public Context getContext() { + return mContext; + } + + private InCallActivity getActivity() { + if (mInCallButtonUi != null) { + Fragment fragment = mInCallButtonUi.getInCallButtonUiFragment(); + if (fragment != null) { + return (InCallActivity) fragment.getActivity(); + } + } + return null; + } +} diff --git a/java/com/android/incallui/CallCardPresenter.java b/java/com/android/incallui/CallCardPresenter.java new file mode 100644 index 000000000..930775772 --- /dev/null +++ b/java/com/android/incallui/CallCardPresenter.java @@ -0,0 +1,1110 @@ +/* + * Copyright (C) 2013 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; + +import static com.android.contacts.common.compat.CallCompat.Details.PROPERTY_ENTERPRISE_CALL; + +import android.Manifest; +import android.app.Application; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageManager; +import android.graphics.drawable.Drawable; +import android.hardware.display.DisplayManager; +import android.os.BatteryManager; +import android.os.Handler; +import android.support.annotation.Nullable; +import android.support.v4.app.Fragment; +import android.support.v4.content.ContextCompat; +import android.telecom.Call.Details; +import android.telecom.StatusHints; +import android.telecom.TelecomManager; +import android.text.TextUtils; +import android.view.Display; +import android.view.View; +import android.view.accessibility.AccessibilityEvent; +import android.view.accessibility.AccessibilityManager; +import com.android.contacts.common.ContactsUtils; +import com.android.contacts.common.preference.ContactsPreferences; +import com.android.contacts.common.util.ContactDisplayUtils; +import com.android.dialer.common.Assert; +import com.android.dialer.common.ConfigProviderBindings; +import com.android.dialer.common.LogUtil; +import com.android.dialer.enrichedcall.EnrichedCallManager; +import com.android.dialer.enrichedcall.Session; +import com.android.dialer.multimedia.MultimediaData; +import com.android.incallui.ContactInfoCache.ContactCacheEntry; +import com.android.incallui.ContactInfoCache.ContactInfoCacheCallback; +import com.android.incallui.InCallPresenter.InCallDetailsListener; +import com.android.incallui.InCallPresenter.InCallEventListener; +import com.android.incallui.InCallPresenter.InCallState; +import com.android.incallui.InCallPresenter.InCallStateListener; +import com.android.incallui.InCallPresenter.IncomingCallListener; +import com.android.incallui.call.CallList; +import com.android.incallui.call.DialerCall; +import com.android.incallui.call.DialerCall.SessionModificationState; +import com.android.incallui.call.DialerCallListener; +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.PrimaryCallState; +import com.android.incallui.incall.protocol.PrimaryInfo; +import com.android.incallui.incall.protocol.SecondaryInfo; +import java.lang.ref.WeakReference; + +/** + * Controller for the Call Card Fragment. This class listens for changes to InCallState and passes + * it along to the fragment. + */ +public class CallCardPresenter + implements InCallStateListener, + IncomingCallListener, + InCallDetailsListener, + InCallEventListener, + InCallScreenDelegate, + DialerCallListener, + EnrichedCallManager.StateChangedListener { + + /** + * Amount of time to wait before sending an announcement via the accessibility manager. When the + * call state changes to an outgoing or incoming state for the first time, the UI can often be + * changing due to call updates or contact lookup. This allows the UI to settle to a stable state + * to ensure that the correct information is announced. + */ + private static final long ACCESSIBILITY_ANNOUNCEMENT_DELAY_MILLIS = 500; + + /** Flag to allow the user's current location to be shown during emergency calls. */ + private static final String CONFIG_ENABLE_EMERGENCY_LOCATION = "config_enable_emergency_location"; + + private static final boolean CONFIG_ENABLE_EMERGENCY_LOCATION_DEFAULT = true; + + /** + * Make it possible to not get location during an emergency call if the battery is too low, since + * doing so could trigger gps and thus potentially cause the phone to die in the middle of the + * call. + */ + private static final String CONFIG_MIN_BATTERY_PERCENT_FOR_EMERGENCY_LOCATION = + "min_battery_percent_for_emergency_location"; + + private static final long CONFIG_MIN_BATTERY_PERCENT_FOR_EMERGENCY_LOCATION_DEFAULT = 10; + + private final Context mContext; + private final Handler handler = new Handler(); + + private DialerCall mPrimary; + private DialerCall mSecondary; + private ContactCacheEntry mPrimaryContactInfo; + private ContactCacheEntry mSecondaryContactInfo; + @Nullable private ContactsPreferences mContactsPreferences; + private boolean mIsFullscreen = false; + private InCallScreen mInCallScreen; + private boolean isInCallScreenReady; + private boolean shouldSendAccessibilityEvent; + private final String locationModule = null; + private final Runnable sendAccessibilityEventRunnable = + new Runnable() { + @Override + public void run() { + shouldSendAccessibilityEvent = !sendAccessibilityEvent(mContext, getUi()); + LogUtil.i( + "CallCardPresenter.sendAccessibilityEventRunnable", + "still should send: %b", + shouldSendAccessibilityEvent); + if (!shouldSendAccessibilityEvent) { + handler.removeCallbacks(this); + } + } + }; + + public CallCardPresenter(Context context) { + LogUtil.i("CallCardController.constructor", null); + mContext = Assert.isNotNull(context).getApplicationContext(); + } + + private static boolean hasCallSubject(DialerCall call) { + return !TextUtils.isEmpty(call.getCallSubject()); + } + + @Override + public void onInCallScreenDelegateInit(InCallScreen inCallScreen) { + Assert.isNotNull(inCallScreen); + mInCallScreen = inCallScreen; + mContactsPreferences = ContactsPreferencesFactory.newContactsPreferences(mContext); + + // Call may be null if disconnect happened already. + DialerCall call = CallList.getInstance().getFirstCall(); + if (call != null) { + mPrimary = call; + if (shouldShowNoteSentToast(mPrimary)) { + mInCallScreen.showNoteSentToast(); + } + call.addListener(this); + + // start processing lookups right away. + if (!call.isConferenceCall()) { + startContactInfoSearch(call, true, call.getState() == DialerCall.State.INCOMING); + } else { + updateContactEntry(null, true); + } + } + + onStateChange(null, InCallPresenter.getInstance().getInCallState(), CallList.getInstance()); + } + + @Override + public void onInCallScreenReady() { + LogUtil.i("CallCardController.onInCallScreenReady", null); + Assert.checkState(!isInCallScreenReady); + if (mContactsPreferences != null) { + mContactsPreferences.refreshValue(ContactsPreferences.DISPLAY_ORDER_KEY); + } + + EnrichedCallManager.Accessor.getInstance(((Application) mContext)) + .registerStateChangedListener(this); + + // Contact search may have completed before ui is ready. + if (mPrimaryContactInfo != null) { + updatePrimaryDisplayInfo(); + } + + // Register for call state changes last + InCallPresenter.getInstance().addListener(this); + InCallPresenter.getInstance().addIncomingCallListener(this); + InCallPresenter.getInstance().addDetailsListener(this); + InCallPresenter.getInstance().addInCallEventListener(this); + isInCallScreenReady = true; + } + + @Override + public void onInCallScreenUnready() { + LogUtil.i("CallCardController.onInCallScreenUnready", null); + Assert.checkState(isInCallScreenReady); + + EnrichedCallManager.Accessor.getInstance(((Application) mContext)) + .unregisterStateChangedListener(this); + // stop getting call state changes + InCallPresenter.getInstance().removeListener(this); + InCallPresenter.getInstance().removeIncomingCallListener(this); + InCallPresenter.getInstance().removeDetailsListener(this); + InCallPresenter.getInstance().removeInCallEventListener(this); + if (mPrimary != null) { + mPrimary.removeListener(this); + } + + mPrimary = null; + mPrimaryContactInfo = null; + mSecondaryContactInfo = null; + isInCallScreenReady = false; + } + + @Override + public void onIncomingCall(InCallState oldState, InCallState newState, DialerCall call) { + // same logic should happen as with onStateChange() + onStateChange(oldState, newState, CallList.getInstance()); + } + + @Override + public void onStateChange(InCallState oldState, InCallState newState, CallList callList) { + LogUtil.v("CallCardPresenter.onStateChange", "" + newState); + if (mInCallScreen == null) { + return; + } + + DialerCall primary = null; + DialerCall secondary = null; + + if (newState == InCallState.INCOMING) { + primary = callList.getIncomingCall(); + } else if (newState == InCallState.PENDING_OUTGOING || newState == InCallState.OUTGOING) { + primary = callList.getOutgoingCall(); + if (primary == null) { + primary = callList.getPendingOutgoingCall(); + } + + // getCallToDisplay doesn't go through outgoing or incoming calls. It will return the + // highest priority call to display as the secondary call. + secondary = getCallToDisplay(callList, null, true); + } else if (newState == InCallState.INCALL) { + primary = getCallToDisplay(callList, null, false); + secondary = getCallToDisplay(callList, primary, true); + } + + LogUtil.v("CallCardPresenter.onStateChange", "primary call: " + primary); + LogUtil.v("CallCardPresenter.onStateChange", "secondary call: " + secondary); + + final boolean primaryChanged = + !(DialerCall.areSame(mPrimary, primary) && DialerCall.areSameNumber(mPrimary, primary)); + final boolean secondaryChanged = + !(DialerCall.areSame(mSecondary, secondary) + && DialerCall.areSameNumber(mSecondary, secondary)); + + mSecondary = secondary; + DialerCall previousPrimary = mPrimary; + mPrimary = primary; + + if (mPrimary != null) { + InCallPresenter.getInstance().onForegroundCallChanged(mPrimary); + mInCallScreen.updateInCallScreenColors(); + } + + if (primaryChanged && shouldShowNoteSentToast(primary)) { + mInCallScreen.showNoteSentToast(); + } + + // Refresh primary call information if either: + // 1. Primary call changed. + // 2. The call's ability to manage conference has changed. + if (shouldRefreshPrimaryInfo(primaryChanged)) { + // primary call has changed + if (previousPrimary != null) { + previousPrimary.removeListener(this); + } + mPrimary.addListener(this); + + mPrimaryContactInfo = + ContactInfoCache.buildCacheEntryFromCall( + mContext, mPrimary, mPrimary.getState() == DialerCall.State.INCOMING); + updatePrimaryDisplayInfo(); + maybeStartSearch(mPrimary, true); + maybeClearSessionModificationState(mPrimary); + } + + if (previousPrimary != null && mPrimary == null) { + previousPrimary.removeListener(this); + } + + if (mSecondary == null) { + // Secondary call may have ended. Update the ui. + mSecondaryContactInfo = null; + updateSecondaryDisplayInfo(); + } else if (secondaryChanged) { + // secondary call has changed + mSecondaryContactInfo = + ContactInfoCache.buildCacheEntryFromCall( + mContext, mSecondary, mSecondary.getState() == DialerCall.State.INCOMING); + updateSecondaryDisplayInfo(); + maybeStartSearch(mSecondary, false); + maybeClearSessionModificationState(mSecondary); + } + + // Set the call state + int callState = DialerCall.State.IDLE; + if (mPrimary != null) { + callState = mPrimary.getState(); + updatePrimaryCallState(); + } else { + getUi().setCallState(PrimaryCallState.createEmptyPrimaryCallState()); + } + + maybeShowManageConferenceCallButton(); + + // Hide the end call button instantly if we're receiving an incoming call. + getUi() + .setEndCallButtonEnabled( + shouldShowEndCallButton(mPrimary, callState), + callState != DialerCall.State.INCOMING /* animate */); + + maybeSendAccessibilityEvent(oldState, newState, primaryChanged); + } + + @Override + public void onDetailsChanged(DialerCall call, Details details) { + updatePrimaryCallState(); + + if (call.can(Details.CAPABILITY_MANAGE_CONFERENCE) + != details.can(Details.CAPABILITY_MANAGE_CONFERENCE)) { + maybeShowManageConferenceCallButton(); + } + } + + @Override + public void onDialerCallDisconnect() {} + + @Override + public void onDialerCallUpdate() { + // No-op; specific call updates handled elsewhere. + } + + @Override + public void onWiFiToLteHandover() {} + + @Override + public void onHandoverToWifiFailure() {} + + /** Handles a change to the child number by refreshing the primary call info. */ + @Override + public void onDialerCallChildNumberChange() { + LogUtil.v("CallCardPresenter.onDialerCallChildNumberChange", ""); + + if (mPrimary == null) { + return; + } + updatePrimaryDisplayInfo(); + } + + /** Handles a change to the last forwarding number by refreshing the primary call info. */ + @Override + public void onDialerCallLastForwardedNumberChange() { + LogUtil.v("CallCardPresenter.onDialerCallLastForwardedNumberChange", ""); + + if (mPrimary == null) { + return; + } + updatePrimaryDisplayInfo(); + updatePrimaryCallState(); + } + + @Override + public void onDialerCallUpgradeToVideo() {} + + /** + * Handles a change to the session modification state for a call. + * + * @param sessionModificationState The new session modification state. + */ + @Override + public void onDialerCallSessionModificationStateChange( + @SessionModificationState int sessionModificationState) { + LogUtil.v( + "CallCardPresenter.onDialerCallSessionModificationStateChange", + "state: " + sessionModificationState); + + if (mPrimary == null) { + return; + } + getUi() + .setEndCallButtonEnabled( + sessionModificationState + != DialerCall.SESSION_MODIFICATION_STATE_RECEIVED_UPGRADE_TO_VIDEO_REQUEST, + true /* shouldAnimate */); + updatePrimaryCallState(); + } + + @Override + public void onEnrichedCallStateChanged() { + LogUtil.enterBlock("CallCardPresenter.onEnrichedCallStateChanged"); + updatePrimaryDisplayInfo(); + } + + private boolean shouldRefreshPrimaryInfo(boolean primaryChanged) { + if (mPrimary == null) { + return false; + } + return primaryChanged + || mInCallScreen.isManageConferenceVisible() != shouldShowManageConference(); + } + + private void updatePrimaryCallState() { + if (getUi() != null && mPrimary != null) { + boolean isWorkCall = + mPrimary.hasProperty(PROPERTY_ENTERPRISE_CALL) + || (mPrimaryContactInfo != null + && mPrimaryContactInfo.userType == ContactsUtils.USER_TYPE_WORK); + boolean isHdAudioCall = + isPrimaryCallActive() && mPrimary.hasProperty(Details.PROPERTY_HIGH_DEF_AUDIO); + // Check for video state change and update the visibility of the contact photo. The contact + // photo is hidden when the incoming video surface is shown. + // The contact photo visibility can also change in setPrimary(). + boolean shouldShowContactPhoto = + !VideoCallPresenter.showIncomingVideo(mPrimary.getVideoState(), mPrimary.getState()); + getUi() + .setCallState( + new PrimaryCallState( + mPrimary.getState(), + mPrimary.getVideoState(), + mPrimary.getSessionModificationState(), + mPrimary.getDisconnectCause(), + getConnectionLabel(), + getCallStateIcon(), + getGatewayNumber(), + shouldShowCallSubject(mPrimary) ? mPrimary.getCallSubject() : null, + mPrimary.getCallbackNumber(), + mPrimary.hasProperty(Details.PROPERTY_WIFI), + mPrimary.isConferenceCall(), + isWorkCall, + isHdAudioCall, + !TextUtils.isEmpty(mPrimary.getLastForwardedNumber()), + shouldShowContactPhoto, + mPrimary.getConnectTimeMillis(), + CallerInfoUtils.isVoiceMailNumber(mContext, mPrimary), + mPrimary.isRemotelyHeld())); + + InCallActivity activity = + (InCallActivity) (mInCallScreen.getInCallScreenFragment().getActivity()); + if (activity != null) { + activity.onPrimaryCallStateChanged(); + } + } + } + + /** Only show the conference call button if we can manage the conference. */ + private void maybeShowManageConferenceCallButton() { + getUi().showManageConferenceCallButton(shouldShowManageConference()); + } + + /** + * Determines if the manage conference button should be visible, based on the current primary + * call. + * + * @return {@code True} if the manage conference button should be visible. + */ + private boolean shouldShowManageConference() { + if (mPrimary == null) { + return false; + } + + return mPrimary.can(android.telecom.Call.Details.CAPABILITY_MANAGE_CONFERENCE) + && !mIsFullscreen; + } + + @Override + public void onCallStateButtonClicked() { + Intent broadcastIntent = Bindings.get(mContext).getCallStateButtonBroadcastIntent(mContext); + if (broadcastIntent != null) { + LogUtil.v( + "CallCardPresenter.onCallStateButtonClicked", + "sending call state button broadcast: " + broadcastIntent); + mContext.sendBroadcast(broadcastIntent, Manifest.permission.READ_PHONE_STATE); + } + } + + @Override + public void onManageConferenceClicked() { + InCallActivity activity = + (InCallActivity) (mInCallScreen.getInCallScreenFragment().getActivity()); + activity.showConferenceFragment(true); + } + + @Override + public void onShrinkAnimationComplete() { + InCallPresenter.getInstance().onShrinkAnimationComplete(); + } + + @Override + public Drawable getDefaultContactPhotoDrawable() { + return ContactInfoCache.getInstance(mContext).getDefaultContactPhotoDrawable(); + } + + private void maybeStartSearch(DialerCall call, boolean isPrimary) { + // no need to start search for conference calls which show generic info. + if (call != null && !call.isConferenceCall()) { + startContactInfoSearch(call, isPrimary, call.getState() == DialerCall.State.INCOMING); + } + } + + private void maybeClearSessionModificationState(DialerCall call) { + @SessionModificationState int state = call.getSessionModificationState(); + if (state != DialerCall.SESSION_MODIFICATION_STATE_NO_REQUEST + && state != DialerCall.SESSION_MODIFICATION_STATE_RECEIVED_UPGRADE_TO_VIDEO_REQUEST) { + LogUtil.i("CallCardPresenter.maybeClearSessionModificationState", "clearing state"); + call.setSessionModificationState(DialerCall.SESSION_MODIFICATION_STATE_NO_REQUEST); + } + } + + /** Starts a query for more contact data for the save primary and secondary calls. */ + private void startContactInfoSearch( + final DialerCall call, final boolean isPrimary, boolean isIncoming) { + final ContactInfoCache cache = ContactInfoCache.getInstance(mContext); + + cache.findInfo(call, isIncoming, new ContactLookupCallback(this, isPrimary)); + } + + private void onContactInfoComplete(String callId, ContactCacheEntry entry, boolean isPrimary) { + final boolean entryMatchesExistingCall = + (isPrimary && mPrimary != null && TextUtils.equals(callId, mPrimary.getId())) + || (!isPrimary && mSecondary != null && TextUtils.equals(callId, mSecondary.getId())); + if (entryMatchesExistingCall) { + updateContactEntry(entry, isPrimary); + } else { + LogUtil.e( + "CallCardPresenter.onContactInfoComplete", + "dropping stale contact lookup info for " + callId); + } + + final DialerCall call = CallList.getInstance().getCallById(callId); + if (call != null) { + call.getLogState().contactLookupResult = entry.contactLookupResult; + } + if (entry.contactUri != null) { + CallerInfoUtils.sendViewNotification(mContext, entry.contactUri); + } + } + + private void onImageLoadComplete(String callId, ContactCacheEntry entry) { + if (getUi() == null) { + return; + } + + if (entry.photo != null) { + if (mPrimary != null && callId.equals(mPrimary.getId())) { + updateContactEntry(entry, true /* isPrimary */); + } else if (mSecondary != null && callId.equals(mSecondary.getId())) { + updateContactEntry(entry, false /* isPrimary */); + } + } + } + + private void updateContactEntry(ContactCacheEntry entry, boolean isPrimary) { + if (isPrimary) { + mPrimaryContactInfo = entry; + updatePrimaryDisplayInfo(); + } else { + mSecondaryContactInfo = entry; + updateSecondaryDisplayInfo(); + } + } + + /** + * Get the highest priority call to display. Goes through the calls and chooses which to return + * based on priority of which type of call to display to the user. Callers can use the "ignore" + * feature to get the second best call by passing a previously found primary call as ignore. + * + * @param ignore A call to ignore if found. + */ + private DialerCall getCallToDisplay( + CallList callList, DialerCall ignore, boolean skipDisconnected) { + // Active calls come second. An active call always gets precedent. + DialerCall retval = callList.getActiveCall(); + if (retval != null && retval != ignore) { + return retval; + } + + // Sometimes there is intemediate state that two calls are in active even one is about + // to be on hold. + retval = callList.getSecondActiveCall(); + if (retval != null && retval != ignore) { + return retval; + } + + // Disconnected calls get primary position if there are no active calls + // to let user know quickly what call has disconnected. Disconnected + // calls are very short lived. + if (!skipDisconnected) { + retval = callList.getDisconnectingCall(); + if (retval != null && retval != ignore) { + return retval; + } + retval = callList.getDisconnectedCall(); + if (retval != null && retval != ignore) { + return retval; + } + } + + // Then we go to background call (calls on hold) + retval = callList.getBackgroundCall(); + if (retval != null && retval != ignore) { + return retval; + } + + // Lastly, we go to a second background call. + retval = callList.getSecondBackgroundCall(); + + return retval; + } + + private void updatePrimaryDisplayInfo() { + if (mInCallScreen == null) { + // TODO: May also occur if search result comes back after ui is destroyed. Look into + // removing that case completely. + LogUtil.v( + "CallCardPresenter.updatePrimaryDisplayInfo", + "updatePrimaryDisplayInfo called but ui is null!"); + return; + } + + if (mPrimary == null) { + // Clear the primary display info. + mInCallScreen.setPrimary(PrimaryInfo.createEmptyPrimaryInfo()); + return; + } + + // Hide the contact photo if we are in a video call and the incoming video surface is + // showing. + boolean showContactPhoto = + !VideoCallPresenter.showIncomingVideo(mPrimary.getVideoState(), mPrimary.getState()); + + // DialerCall placed through a work phone account. + boolean hasWorkCallProperty = mPrimary.hasProperty(PROPERTY_ENTERPRISE_CALL); + + Session enrichedCallSession = + mPrimary.getNumber() == null + ? null + : EnrichedCallManager.Accessor.getInstance(((Application) mContext)) + .getSession(mPrimary.getNumber()); + MultimediaData enrichedCallMultimediaData = + enrichedCallSession == null ? null : enrichedCallSession.getMultimediaData(); + + if (mPrimary.isConferenceCall()) { + LogUtil.v( + "CallCardPresenter.updatePrimaryDisplayInfo", + "update primary display info for conference call."); + + mInCallScreen.setPrimary( + new PrimaryInfo( + null /* number */, + getConferenceString(mPrimary), + false /* nameIsNumber */, + null /* location */, + null /* label */, + getConferencePhoto(mPrimary), + ContactPhotoType.DEFAULT_PLACEHOLDER, + false /* isSipCall */, + showContactPhoto, + hasWorkCallProperty, + false /* isSpam */, + false /* answeringDisconnectsOngoingCall */, + shouldShowLocation(), + null /* contactInfoLookupKey */, + null /* enrichedCallMultimediaData */)); + } else if (mPrimaryContactInfo != null) { + LogUtil.v( + "CallCardPresenter.updatePrimaryDisplayInfo", + "update primary display info for " + mPrimaryContactInfo); + + String name = getNameForCall(mPrimaryContactInfo); + String number; + + boolean isChildNumberShown = !TextUtils.isEmpty(mPrimary.getChildNumber()); + boolean isForwardedNumberShown = !TextUtils.isEmpty(mPrimary.getLastForwardedNumber()); + boolean isCallSubjectShown = shouldShowCallSubject(mPrimary); + + if (isCallSubjectShown) { + number = null; + } else if (isChildNumberShown) { + number = mContext.getString(R.string.child_number, mPrimary.getChildNumber()); + } else if (isForwardedNumberShown) { + // Use last forwarded number instead of second line, if present. + number = mPrimary.getLastForwardedNumber(); + } else { + number = mPrimaryContactInfo.number; + } + + boolean nameIsNumber = name != null && name.equals(mPrimaryContactInfo.number); + // DialerCall with caller that is a work contact. + boolean isWorkContact = (mPrimaryContactInfo.userType == ContactsUtils.USER_TYPE_WORK); + mInCallScreen.setPrimary( + new PrimaryInfo( + number, + name, + nameIsNumber, + mPrimaryContactInfo.location, + isChildNumberShown || isCallSubjectShown ? null : mPrimaryContactInfo.label, + mPrimaryContactInfo.photo, + mPrimaryContactInfo.photoType, + mPrimaryContactInfo.isSipCall, + showContactPhoto, + hasWorkCallProperty || isWorkContact, + mPrimary.isSpam(), + mPrimary.answeringDisconnectsForegroundVideoCall(), + shouldShowLocation(), + mPrimaryContactInfo.lookupKey, + enrichedCallMultimediaData)); + } else { + // Clear the primary display info. + mInCallScreen.setPrimary(PrimaryInfo.createEmptyPrimaryInfo()); + } + + mInCallScreen.showLocationUi(null); + } + + private boolean shouldShowLocation() { + if (isOutgoingEmergencyCall(mPrimary)) { + LogUtil.i("CallCardPresenter.shouldShowLocation", "new emergency call"); + return true; + } else if (isIncomingEmergencyCall(mPrimary)) { + LogUtil.i("CallCardPresenter.shouldShowLocation", "potential emergency callback"); + return true; + } else if (isIncomingEmergencyCall(mSecondary)) { + LogUtil.i("CallCardPresenter.shouldShowLocation", "has potential emergency callback"); + return true; + } + return false; + } + + private static boolean isOutgoingEmergencyCall(@Nullable DialerCall call) { + return call != null && !call.isIncoming() && call.isEmergencyCall(); + } + + private static boolean isIncomingEmergencyCall(@Nullable DialerCall call) { + return call != null && call.isIncoming() && call.isPotentialEmergencyCallback(); + } + + private boolean hasLocationPermission() { + return ContextCompat.checkSelfPermission(mContext, Manifest.permission.ACCESS_FINE_LOCATION) + == PackageManager.PERMISSION_GRANTED; + } + + private boolean isBatteryTooLowForEmergencyLocation() { + Intent batteryStatus = + mContext.registerReceiver(null, new IntentFilter(Intent.ACTION_BATTERY_CHANGED)); + int status = batteryStatus.getIntExtra(BatteryManager.EXTRA_STATUS, -1); + if (status == BatteryManager.BATTERY_STATUS_CHARGING + || status == BatteryManager.BATTERY_STATUS_FULL) { + // Plugged in or full battery + return false; + } + int level = batteryStatus.getIntExtra(BatteryManager.EXTRA_LEVEL, -1); + int scale = batteryStatus.getIntExtra(BatteryManager.EXTRA_SCALE, -1); + float batteryPercent = (100f * level) / scale; + long threshold = + ConfigProviderBindings.get(mContext) + .getLong( + CONFIG_MIN_BATTERY_PERCENT_FOR_EMERGENCY_LOCATION, + CONFIG_MIN_BATTERY_PERCENT_FOR_EMERGENCY_LOCATION_DEFAULT); + LogUtil.i( + "CallCardPresenter.isBatteryTooLowForEmergencyLocation", + "percent charged: " + batteryPercent + ", min required charge: " + threshold); + return batteryPercent < threshold; + } + + private void updateSecondaryDisplayInfo() { + if (mInCallScreen == null) { + return; + } + + if (mSecondary == null) { + // Clear the secondary display info. + mInCallScreen.setSecondary(SecondaryInfo.createEmptySecondaryInfo(mIsFullscreen)); + return; + } + + if (mSecondary.isConferenceCall()) { + mInCallScreen.setSecondary( + new SecondaryInfo( + true /* show */, + getConferenceString(mSecondary), + false /* nameIsNumber */, + null /* label */, + mSecondary.getCallProviderLabel(), + true /* isConference */, + mSecondary.isVideoCall(), + mIsFullscreen)); + } else if (mSecondaryContactInfo != null) { + LogUtil.v("CallCardPresenter.updateSecondaryDisplayInfo", "" + mSecondaryContactInfo); + String name = getNameForCall(mSecondaryContactInfo); + boolean nameIsNumber = name != null && name.equals(mSecondaryContactInfo.number); + mInCallScreen.setSecondary( + new SecondaryInfo( + true /* show */, + name, + nameIsNumber, + mSecondaryContactInfo.label, + mSecondary.getCallProviderLabel(), + false /* isConference */, + mSecondary.isVideoCall(), + mIsFullscreen)); + } else { + // Clear the secondary display info. + mInCallScreen.setSecondary(SecondaryInfo.createEmptySecondaryInfo(mIsFullscreen)); + } + } + + /** Returns the gateway number for any existing outgoing call. */ + private String getGatewayNumber() { + if (hasOutgoingGatewayCall()) { + return DialerCall.getNumberFromHandle(mPrimary.getGatewayInfo().getGatewayAddress()); + } + return null; + } + + /** + * Returns the label (line of text above the number/name) for any given call. For example, + * "calling via [Account/Google Voice]" for outgoing calls. + */ + private String getConnectionLabel() { + if (ContextCompat.checkSelfPermission(mContext, Manifest.permission.READ_PHONE_STATE) + != PackageManager.PERMISSION_GRANTED) { + return null; + } + StatusHints statusHints = mPrimary.getStatusHints(); + if (statusHints != null && !TextUtils.isEmpty(statusHints.getLabel())) { + return statusHints.getLabel().toString(); + } + + if (hasOutgoingGatewayCall() && getUi() != null) { + // Return the label for the gateway app on outgoing calls. + final PackageManager pm = mContext.getPackageManager(); + try { + ApplicationInfo info = + pm.getApplicationInfo(mPrimary.getGatewayInfo().getGatewayProviderPackageName(), 0); + return pm.getApplicationLabel(info).toString(); + } catch (PackageManager.NameNotFoundException e) { + LogUtil.e("CallCardPresenter.getConnectionLabel", "gateway Application Not Found.", e); + return null; + } + } + return mPrimary.getCallProviderLabel(); + } + + private Drawable getCallStateIcon() { + // Return connection icon if one exists. + StatusHints statusHints = mPrimary.getStatusHints(); + if (statusHints != null && statusHints.getIcon() != null) { + Drawable icon = statusHints.getIcon().loadDrawable(mContext); + if (icon != null) { + return icon; + } + } + + return null; + } + + private boolean hasOutgoingGatewayCall() { + // We only display the gateway information while STATE_DIALING so return false for any other + // call state. + // TODO: mPrimary can be null because this is called from updatePrimaryDisplayInfo which + // is also called after a contact search completes (call is not present yet). Split the + // UI update so it can receive independent updates. + if (mPrimary == null) { + return false; + } + return DialerCall.State.isDialing(mPrimary.getState()) + && mPrimary.getGatewayInfo() != null + && !mPrimary.getGatewayInfo().isEmpty(); + } + + /** Gets the name to display for the call. */ + String getNameForCall(ContactCacheEntry contactInfo) { + String preferredName = + ContactDisplayUtils.getPreferredDisplayName( + contactInfo.namePrimary, contactInfo.nameAlternative, mContactsPreferences); + if (TextUtils.isEmpty(preferredName)) { + return contactInfo.number; + } + return preferredName; + } + + /** Gets the number to display for a call. */ + String getNumberForCall(ContactCacheEntry contactInfo) { + // If the name is empty, we use the number for the name...so don't show a second + // number in the number field + String preferredName = + ContactDisplayUtils.getPreferredDisplayName( + contactInfo.namePrimary, contactInfo.nameAlternative, mContactsPreferences); + if (TextUtils.isEmpty(preferredName)) { + return contactInfo.location; + } + return contactInfo.number; + } + + @Override + public void onSecondaryInfoClicked() { + if (mSecondary == null) { + LogUtil.e( + "CallCardPresenter.onSecondaryInfoClicked", + "secondary info clicked but no secondary call."); + return; + } + + LogUtil.i( + "CallCardPresenter.onSecondaryInfoClicked", "swapping call to foreground: " + mSecondary); + mSecondary.unhold(); + } + + @Override + public void onEndCallClicked() { + LogUtil.i("CallCardPresenter.onEndCallClicked", "disconnecting call: " + mPrimary); + if (mPrimary != null) { + mPrimary.disconnect(); + } + } + + /** + * Handles a change to the fullscreen mode of the in-call UI. + * + * @param isFullscreenMode {@code True} if the in-call UI is entering full screen mode. + */ + @Override + public void onFullscreenModeChanged(boolean isFullscreenMode) { + mIsFullscreen = isFullscreenMode; + if (mInCallScreen == null) { + return; + } + maybeShowManageConferenceCallButton(); + } + + private boolean isPrimaryCallActive() { + return mPrimary != null && mPrimary.getState() == DialerCall.State.ACTIVE; + } + + private String getConferenceString(DialerCall call) { + boolean isGenericConference = call.hasProperty(Details.PROPERTY_GENERIC_CONFERENCE); + LogUtil.v("CallCardPresenter.getConferenceString", "" + isGenericConference); + + final int resId = + isGenericConference ? R.string.generic_conference_call_name : R.string.conference_call_name; + return mContext.getResources().getString(resId); + } + + private Drawable getConferencePhoto(DialerCall call) { + boolean isGenericConference = call.hasProperty(Details.PROPERTY_GENERIC_CONFERENCE); + LogUtil.v("CallCardPresenter.getConferencePhoto", "" + isGenericConference); + + final int resId = isGenericConference ? R.drawable.img_phone : R.drawable.img_conference; + Drawable photo = mContext.getResources().getDrawable(resId); + photo.setAutoMirrored(true); + return photo; + } + + private boolean shouldShowEndCallButton(DialerCall primary, int callState) { + if (primary == null) { + return false; + } + if ((!DialerCall.State.isConnectingOrConnected(callState) + && callState != DialerCall.State.DISCONNECTING + && callState != DialerCall.State.DISCONNECTED) + || callState == DialerCall.State.INCOMING) { + return false; + } + if (mPrimary.getSessionModificationState() + == DialerCall.SESSION_MODIFICATION_STATE_RECEIVED_UPGRADE_TO_VIDEO_REQUEST) { + return false; + } + return true; + } + + @Override + public void onInCallScreenResumed() { + if (shouldSendAccessibilityEvent) { + handler.postDelayed(sendAccessibilityEventRunnable, ACCESSIBILITY_ANNOUNCEMENT_DELAY_MILLIS); + } + } + + static boolean sendAccessibilityEvent(Context context, InCallScreen inCallScreen) { + AccessibilityManager am = + (AccessibilityManager) context.getSystemService(Context.ACCESSIBILITY_SERVICE); + if (!am.isEnabled()) { + LogUtil.w("CallCardPresenter.sendAccessibilityEvent", "accessibility is off"); + return false; + } + if (inCallScreen == null) { + LogUtil.w("CallCardPresenter.sendAccessibilityEvent", "incallscreen is null"); + return false; + } + Fragment fragment = inCallScreen.getInCallScreenFragment(); + if (fragment == null || fragment.getView() == null || fragment.getView().getParent() == null) { + LogUtil.w("CallCardPresenter.sendAccessibilityEvent", "fragment/view/parent is null"); + return false; + } + + DisplayManager displayManager = + (DisplayManager) context.getSystemService(Context.DISPLAY_SERVICE); + Display display = displayManager.getDisplay(Display.DEFAULT_DISPLAY); + boolean screenIsOn = display.getState() == Display.STATE_ON; + LogUtil.d("CallCardPresenter.sendAccessibilityEvent", "screen is on: %b", screenIsOn); + if (!screenIsOn) { + return false; + } + + AccessibilityEvent event = AccessibilityEvent.obtain(AccessibilityEvent.TYPE_ANNOUNCEMENT); + inCallScreen.dispatchPopulateAccessibilityEvent(event); + View view = inCallScreen.getInCallScreenFragment().getView(); + view.getParent().requestSendAccessibilityEvent(view, event); + return true; + } + + private void maybeSendAccessibilityEvent( + InCallState oldState, final InCallState newState, boolean primaryChanged) { + shouldSendAccessibilityEvent = false; + if (mContext == null) { + return; + } + final AccessibilityManager am = + (AccessibilityManager) mContext.getSystemService(Context.ACCESSIBILITY_SERVICE); + if (!am.isEnabled()) { + return; + } + // Announce the current call if it's new incoming/outgoing call or primary call is changed + // due to switching calls between two ongoing calls (one is on hold). + if ((oldState != InCallState.OUTGOING && newState == InCallState.OUTGOING) + || (oldState != InCallState.INCOMING && newState == InCallState.INCOMING) + || primaryChanged) { + LogUtil.i( + "CallCardPresenter.maybeSendAccessibilityEvent", "schedule accessibility announcement"); + shouldSendAccessibilityEvent = true; + handler.postDelayed(sendAccessibilityEventRunnable, ACCESSIBILITY_ANNOUNCEMENT_DELAY_MILLIS); + } + } + + /** + * Determines whether the call subject should be visible on the UI. For the call subject to be + * visible, the call has to be in an incoming or waiting state, and the subject must not be empty. + * + * @param call The call. + * @return {@code true} if the subject should be shown, {@code false} otherwise. + */ + private boolean shouldShowCallSubject(DialerCall call) { + if (call == null) { + return false; + } + + boolean isIncomingOrWaiting = + mPrimary.getState() == DialerCall.State.INCOMING + || mPrimary.getState() == DialerCall.State.CALL_WAITING; + return isIncomingOrWaiting + && !TextUtils.isEmpty(call.getCallSubject()) + && call.getNumberPresentation() == TelecomManager.PRESENTATION_ALLOWED + && call.isCallSubjectSupported(); + } + + /** + * Determines whether the "note sent" toast should be shown. It should be shown for a new outgoing + * call with a subject. + * + * @param call The call + * @return {@code true} if the toast should be shown, {@code false} otherwise. + */ + private boolean shouldShowNoteSentToast(DialerCall call) { + return call != null + && hasCallSubject(call) + && (call.getState() == DialerCall.State.DIALING + || call.getState() == DialerCall.State.CONNECTING); + } + + private InCallScreen getUi() { + return mInCallScreen; + } + + public static class ContactLookupCallback implements ContactInfoCacheCallback { + + private final WeakReference<CallCardPresenter> mCallCardPresenter; + private final boolean mIsPrimary; + + public ContactLookupCallback(CallCardPresenter callCardPresenter, boolean isPrimary) { + mCallCardPresenter = new WeakReference<CallCardPresenter>(callCardPresenter); + mIsPrimary = isPrimary; + } + + @Override + public void onContactInfoComplete(String callId, ContactCacheEntry entry) { + CallCardPresenter presenter = mCallCardPresenter.get(); + if (presenter != null) { + presenter.onContactInfoComplete(callId, entry, mIsPrimary); + } + } + + @Override + public void onImageLoadComplete(String callId, ContactCacheEntry entry) { + CallCardPresenter presenter = mCallCardPresenter.get(); + if (presenter != null) { + presenter.onImageLoadComplete(callId, entry); + } + } + } +} diff --git a/java/com/android/incallui/CallerInfo.java b/java/com/android/incallui/CallerInfo.java new file mode 100644 index 000000000..473bb8f4e --- /dev/null +++ b/java/com/android/incallui/CallerInfo.java @@ -0,0 +1,573 @@ +/* + * Copyright (C) 2006 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; + +import android.content.Context; +import android.database.Cursor; +import android.graphics.Bitmap; +import android.graphics.drawable.Drawable; +import android.net.Uri; +import android.os.Build.VERSION; +import android.os.Build.VERSION_CODES; +import android.provider.ContactsContract; +import android.provider.ContactsContract.CommonDataKinds.Phone; +import android.provider.ContactsContract.Contacts; +import android.provider.ContactsContract.Data; +import android.provider.ContactsContract.PhoneLookup; +import android.provider.ContactsContract.RawContacts; +import android.support.annotation.RequiresApi; +import android.telephony.PhoneNumberUtils; +import android.text.TextUtils; +import com.android.contacts.common.ContactsUtils; +import com.android.contacts.common.ContactsUtils.UserType; +import com.android.contacts.common.util.TelephonyManagerUtils; +import com.android.dialer.phonenumbercache.ContactInfoHelper; +import com.android.dialer.phonenumbercache.PhoneLookupUtil; +import com.android.dialer.phonenumberutil.PhoneNumberHelper; + +/** + * Looks up caller information for the given phone number. This is intermediate data and should NOT + * be used by any UI. + */ +public class CallerInfo { + + private static final String TAG = "CallerInfo"; + + // We should always use this projection starting from N onward. + @RequiresApi(VERSION_CODES.N) + private static final String[] DEFAULT_PHONELOOKUP_PROJECTION = + new String[] { + PhoneLookup.CONTACT_ID, + PhoneLookup.DISPLAY_NAME, + PhoneLookup.LOOKUP_KEY, + PhoneLookup.NUMBER, + PhoneLookup.NORMALIZED_NUMBER, + PhoneLookup.LABEL, + PhoneLookup.TYPE, + PhoneLookup.PHOTO_URI, + PhoneLookup.CUSTOM_RINGTONE, + PhoneLookup.SEND_TO_VOICEMAIL + }; + + // In pre-N, contact id is stored in {@link PhoneLookup._ID} in non-sip query. + private static final String[] BACKWARD_COMPATIBLE_NON_SIP_DEFAULT_PHONELOOKUP_PROJECTION = + new String[] { + PhoneLookup._ID, + PhoneLookup.DISPLAY_NAME, + PhoneLookup.LOOKUP_KEY, + PhoneLookup.NUMBER, + PhoneLookup.NORMALIZED_NUMBER, + PhoneLookup.LABEL, + PhoneLookup.TYPE, + PhoneLookup.PHOTO_URI, + PhoneLookup.CUSTOM_RINGTONE, + PhoneLookup.SEND_TO_VOICEMAIL + }; + /** + * Please note that, any one of these member variables can be null, and any accesses to them + * should be prepared to handle such a case. + * + * <p>Also, it is implied that phoneNumber is more often populated than name is, (think of calls + * being dialed/received using numbers where names are not known to the device), so phoneNumber + * should serve as a dependable fallback when name is unavailable. + * + * <p>One other detail here is that this CallerInfo object reflects information found on a + * connection, it is an OUTPUT that serves mainly to display information to the user. In no way is + * this object used as input to make a connection, so we can choose to display whatever + * human-readable text makes sense to the user for a connection. This is especially relevant for + * the phone number field, since it is the one field that is most likely exposed to the user. + * + * <p>As an example: 1. User dials "911" 2. Device recognizes that this is an emergency number 3. + * We use the "Emergency Number" string instead of "911" in the phoneNumber field. + * + * <p>What we're really doing here is treating phoneNumber as an essential field here, NOT name. + * We're NOT always guaranteed to have a name for a connection, but the number should be + * displayable. + */ + public String name; + + public String nameAlternative; + public String phoneNumber; + public String normalizedNumber; + public String forwardingNumber; + public String geoDescription; + public String cnapName; + public int numberPresentation; + public int namePresentation; + public boolean contactExists; + public String phoneLabel; + /* Split up the phoneLabel into number type and label name */ + public int numberType; + public String numberLabel; + public int photoResource; + // Contact ID, which will be 0 if a contact comes from the corp CP2. + public long contactIdOrZero; + public String lookupKeyOrNull; + public boolean needUpdate; + public Uri contactRefUri; + public @UserType long userType; + /** + * Contact display photo URI. If a contact has no display photo but a thumbnail, it'll be the + * thumbnail URI instead. + */ + public Uri contactDisplayPhotoUri; + // fields to hold individual contact preference data, + // including the send to voicemail flag and the ringtone + // uri reference. + public Uri contactRingtoneUri; + public boolean shouldSendToVoicemail; + /** + * Drawable representing the caller image. This is essentially a cache for the image data tied + * into the connection / callerinfo object. + * + * <p>This might be a high resolution picture which is more suitable for full-screen image view + * than for smaller icons used in some kinds of notifications. + * + * <p>The {@link #isCachedPhotoCurrent} flag indicates if the image data needs to be reloaded. + */ + public Drawable cachedPhoto; + /** + * Bitmap representing the caller image which has possibly lower resolution than {@link + * #cachedPhoto} and thus more suitable for icons (like notification icons). + * + * <p>In usual cases this is just down-scaled image of {@link #cachedPhoto}. If the down-scaling + * fails, this will just become null. + * + * <p>The {@link #isCachedPhotoCurrent} flag indicates if the image data needs to be reloaded. + */ + public Bitmap cachedPhotoIcon; + /** + * Boolean which indicates if {@link #cachedPhoto} and {@link #cachedPhotoIcon} is fresh enough. + * If it is false, those images aren't pointing to valid objects. + */ + public boolean isCachedPhotoCurrent; + /** + * String which holds the call subject sent as extra from the lower layers for this call. This is + * used to display the no-caller ID reason for restricted/unknown number presentation. + */ + public String callSubject; + + private boolean mIsEmergency; + private boolean mIsVoiceMail; + + public CallerInfo() { + // TODO: Move all the basic initialization here? + mIsEmergency = false; + mIsVoiceMail = false; + userType = ContactsUtils.USER_TYPE_CURRENT; + } + + public static String[] getDefaultPhoneLookupProjection(Uri phoneLookupUri) { + if (VERSION.SDK_INT >= VERSION_CODES.N) { + return DEFAULT_PHONELOOKUP_PROJECTION; + } + // Pre-N + boolean isSip = + phoneLookupUri.getBooleanQueryParameter( + ContactsContract.PhoneLookup.QUERY_PARAMETER_SIP_ADDRESS, false); + return (isSip) + ? DEFAULT_PHONELOOKUP_PROJECTION + : BACKWARD_COMPATIBLE_NON_SIP_DEFAULT_PHONELOOKUP_PROJECTION; + } + + /** + * getCallerInfo given a Cursor. + * + * @param context the context used to retrieve string constants + * @param contactRef the URI to attach to this CallerInfo object + * @param cursor the first object in the cursor is used to build the CallerInfo object. + * @return the CallerInfo which contains the caller id for the given number. The returned + * CallerInfo is null if no number is supplied. + */ + public static CallerInfo getCallerInfo(Context context, Uri contactRef, Cursor cursor) { + CallerInfo info = new CallerInfo(); + info.photoResource = 0; + info.phoneLabel = null; + info.numberType = 0; + info.numberLabel = null; + info.cachedPhoto = null; + info.isCachedPhotoCurrent = false; + info.contactExists = false; + info.userType = ContactsUtils.USER_TYPE_CURRENT; + + Log.v(TAG, "getCallerInfo() based on cursor..."); + + if (cursor != null) { + if (cursor.moveToFirst()) { + // TODO: photo_id is always available but not taken + // care of here. Maybe we should store it in the + // CallerInfo object as well. + + long contactId = 0L; + int columnIndex; + + // Look for the name + columnIndex = cursor.getColumnIndex(PhoneLookup.DISPLAY_NAME); + if (columnIndex != -1) { + info.name = cursor.getString(columnIndex); + } + + // Look for the number + columnIndex = cursor.getColumnIndex(PhoneLookup.NUMBER); + if (columnIndex != -1) { + info.phoneNumber = cursor.getString(columnIndex); + } + + // Look for the normalized number + columnIndex = cursor.getColumnIndex(PhoneLookup.NORMALIZED_NUMBER); + if (columnIndex != -1) { + info.normalizedNumber = cursor.getString(columnIndex); + } + + // Look for the label/type combo + columnIndex = cursor.getColumnIndex(PhoneLookup.LABEL); + if (columnIndex != -1) { + int typeColumnIndex = cursor.getColumnIndex(PhoneLookup.TYPE); + if (typeColumnIndex != -1) { + info.numberType = cursor.getInt(typeColumnIndex); + info.numberLabel = cursor.getString(columnIndex); + info.phoneLabel = + Phone.getTypeLabel(context.getResources(), info.numberType, info.numberLabel) + .toString(); + } + } + + // cache the lookup key for later use to create lookup URIs + columnIndex = cursor.getColumnIndex(PhoneLookup.LOOKUP_KEY); + if (columnIndex != -1) { + info.lookupKeyOrNull = cursor.getString(columnIndex); + } + + // Look for the person_id. + columnIndex = getColumnIndexForPersonId(contactRef, cursor); + if (columnIndex != -1) { + contactId = cursor.getLong(columnIndex); + // QuickContacts in M doesn't support enterprise contact id + if (contactId != 0 + && (VERSION.SDK_INT >= VERSION_CODES.N + || !Contacts.isEnterpriseContactId(contactId))) { + info.contactIdOrZero = contactId; + Log.v(TAG, "==> got info.contactIdOrZero: " + info.contactIdOrZero); + } + } else { + // No valid columnIndex, so we can't look up person_id. + Log.v(TAG, "Couldn't find contactId column for " + contactRef); + // Watch out: this means that anything that depends on + // person_id will be broken (like contact photo lookups in + // the in-call UI, for example.) + } + + // Display photo URI. + columnIndex = cursor.getColumnIndex(PhoneLookup.PHOTO_URI); + if ((columnIndex != -1) && (cursor.getString(columnIndex) != null)) { + info.contactDisplayPhotoUri = Uri.parse(cursor.getString(columnIndex)); + } else { + info.contactDisplayPhotoUri = null; + } + + // look for the custom ringtone, create from the string stored + // in the database. + columnIndex = cursor.getColumnIndex(PhoneLookup.CUSTOM_RINGTONE); + if ((columnIndex != -1) && (cursor.getString(columnIndex) != null)) { + if (TextUtils.isEmpty(cursor.getString(columnIndex))) { + // make it consistent with frameworks/base/.../CallerInfo.java + info.contactRingtoneUri = Uri.EMPTY; + } else { + info.contactRingtoneUri = Uri.parse(cursor.getString(columnIndex)); + } + } else { + info.contactRingtoneUri = null; + } + + // look for the send to voicemail flag, set it to true only + // under certain circumstances. + columnIndex = cursor.getColumnIndex(PhoneLookup.SEND_TO_VOICEMAIL); + info.shouldSendToVoicemail = (columnIndex != -1) && ((cursor.getInt(columnIndex)) == 1); + info.contactExists = true; + + // Determine userType by directoryId and contactId + final String directory = + contactRef == null + ? null + : contactRef.getQueryParameter(ContactsContract.DIRECTORY_PARAM_KEY); + Long directoryId = null; + if (directory != null) { + try { + directoryId = Long.parseLong(directory); + } catch (NumberFormatException e) { + // do nothing + } + } + info.userType = ContactsUtils.determineUserType(directoryId, contactId); + + info.nameAlternative = + ContactInfoHelper.lookUpDisplayNameAlternative( + context, info.lookupKeyOrNull, info.userType, directoryId); + } + cursor.close(); + } + + info.needUpdate = false; + info.name = normalize(info.name); + info.contactRefUri = contactRef; + + return info; + } + + /** + * getCallerInfo given a URI, look up in the call-log database for the uri unique key. + * + * @param context the context used to get the ContentResolver + * @param contactRef the URI used to lookup caller id + * @return the CallerInfo which contains the caller id for the given number. The returned + * CallerInfo is null if no number is supplied. + */ + private static CallerInfo getCallerInfo(Context context, Uri contactRef) { + + return getCallerInfo( + context, + contactRef, + context.getContentResolver().query(contactRef, null, null, null, null)); + } + + /** + * Performs another lookup if previous lookup fails and it's a SIP call and the peer's username is + * all numeric. Look up the username as it could be a PSTN number in the contact database. + * + * @param context the query context + * @param number the original phone number, could be a SIP URI + * @param previousResult the result of previous lookup + * @return previousResult if it's not the case + */ + static CallerInfo doSecondaryLookupIfNecessary( + Context context, String number, CallerInfo previousResult) { + if (!previousResult.contactExists && PhoneNumberHelper.isUriNumber(number)) { + String username = PhoneNumberHelper.getUsernameFromUriNumber(number); + if (PhoneNumberUtils.isGlobalPhoneNumber(username)) { + previousResult = + getCallerInfo( + context, + Uri.withAppendedPath( + PhoneLookup.ENTERPRISE_CONTENT_FILTER_URI, Uri.encode(username))); + } + } + return previousResult; + } + + // Accessors + + private static String normalize(String s) { + if (s == null || s.length() > 0) { + return s; + } else { + return null; + } + } + + /** + * Returns the column index to use to find the "person_id" field in the specified cursor, based on + * the contact URI that was originally queried. + * + * <p>This is a helper function for the getCallerInfo() method that takes a Cursor. Looking up the + * person_id is nontrivial (compared to all the other CallerInfo fields) since the column we need + * to use depends on what query we originally ran. + * + * <p>Watch out: be sure to not do any database access in this method, since it's run from the UI + * thread (see comments below for more info.) + * + * @return the columnIndex to use (with cursor.getLong()) to get the person_id, or -1 if we + * couldn't figure out what colum to use. + * <p>TODO: Add a unittest for this method. (This is a little tricky to test, since we'll need + * a live contacts database to test against, preloaded with at least some phone numbers and + * SIP addresses. And we'll probably have to hardcode the column indexes we expect, so the + * test might break whenever the contacts schema changes. But we can at least make sure we + * handle all the URI patterns we claim to, and that the mime types match what we expect...) + */ + private static int getColumnIndexForPersonId(Uri contactRef, Cursor cursor) { + // TODO: This is pretty ugly now, see bug 2269240 for + // more details. The column to use depends upon the type of URL: + // - content://com.android.contacts/data/phones ==> use the "contact_id" column + // - content://com.android.contacts/phone_lookup ==> use the "_ID" column + // - content://com.android.contacts/data ==> use the "contact_id" column + // If it's none of the above, we leave columnIndex=-1 which means + // that the person_id field will be left unset. + // + // The logic here *used* to be based on the mime type of contactRef + // (for example Phone.CONTENT_ITEM_TYPE would tell us to use the + // RawContacts.CONTACT_ID column). But looking up the mime type requires + // a call to context.getContentResolver().getType(contactRef), which + // isn't safe to do from the UI thread since it can cause an ANR if + // the contacts provider is slow or blocked (like during a sync.) + // + // So instead, figure out the column to use for person_id by just + // looking at the URI itself. + + Log.v(TAG, "- getColumnIndexForPersonId: contactRef URI = '" + contactRef + "'..."); + // Warning: Do not enable the following logging (due to ANR risk.) + // if (VDBG) Rlog.v(TAG, "- MIME type: " + // + context.getContentResolver().getType(contactRef)); + + String url = contactRef.toString(); + String columnName = null; + if (url.startsWith("content://com.android.contacts/data/phones")) { + // Direct lookup in the Phone table. + // MIME type: Phone.CONTENT_ITEM_TYPE (= "vnd.android.cursor.item/phone_v2") + Log.v(TAG, "'data/phones' URI; using RawContacts.CONTACT_ID"); + columnName = RawContacts.CONTACT_ID; + } else if (url.startsWith("content://com.android.contacts/data")) { + // Direct lookup in the Data table. + // MIME type: Data.CONTENT_TYPE (= "vnd.android.cursor.dir/data") + Log.v(TAG, "'data' URI; using Data.CONTACT_ID"); + // (Note Data.CONTACT_ID and RawContacts.CONTACT_ID are equivalent.) + columnName = Data.CONTACT_ID; + } else if (url.startsWith("content://com.android.contacts/phone_lookup")) { + // Lookup in the PhoneLookup table, which provides "fuzzy matching" + // for phone numbers. + // MIME type: PhoneLookup.CONTENT_TYPE (= "vnd.android.cursor.dir/phone_lookup") + Log.v(TAG, "'phone_lookup' URI; using PhoneLookup._ID"); + columnName = PhoneLookupUtil.getContactIdColumnNameForUri(contactRef); + } else { + Log.v(TAG, "Unexpected prefix for contactRef '" + url + "'"); + } + int columnIndex = (columnName != null) ? cursor.getColumnIndex(columnName) : -1; + Log.v( + TAG, + "==> Using column '" + + columnName + + "' (columnIndex = " + + columnIndex + + ") for person_id lookup..."); + return columnIndex; + } + + /** @return true if the caller info is an emergency number. */ + public boolean isEmergencyNumber() { + return mIsEmergency; + } + + /** @return true if the caller info is a voicemail number. */ + public boolean isVoiceMailNumber() { + return mIsVoiceMail; + } + + /** + * Mark this CallerInfo as an emergency call. + * + * @param context To lookup the localized 'Emergency Number' string. + * @return this instance. + */ + /* package */ CallerInfo markAsEmergency(Context context) { + name = context.getString(R.string.emergency_call_dialog_number_for_display); + phoneNumber = null; + + photoResource = R.drawable.img_phone; + mIsEmergency = true; + return this; + } + + /** + * Mark this CallerInfo as a voicemail call. The voicemail label is obtained from the telephony + * manager. Caller must hold the READ_PHONE_STATE permission otherwise the phoneNumber will be set + * to null. + * + * @return this instance. + */ + /* package */ CallerInfo markAsVoiceMail(Context context) { + mIsVoiceMail = true; + + try { + // For voicemail calls, we display the voice mail tag + // instead of the real phone number in the "number" + // field. + name = TelephonyManagerUtils.getVoiceMailAlphaTag(context); + phoneNumber = null; + } catch (SecurityException se) { + // Should never happen: if this process does not have + // permission to retrieve VM tag, it should not have + // permission to retrieve VM number and would not call + // this method. + // Leave phoneNumber untouched. + Log.e(TAG, "Cannot access VoiceMail.", se); + } + // TODO: There is no voicemail picture? + // FIXME: FIND ANOTHER ICON + // photoResource = android.R.drawable.badge_voicemail; + return this; + } + + /** + * Updates this CallerInfo's geoDescription field, based on the raw phone number in the + * phoneNumber field. + * + * <p>(Note that the various getCallerInfo() methods do *not* set the geoDescription + * automatically; you need to call this method explicitly to get it.) + * + * @param context the context used to look up the current locale / country + * @param fallbackNumber if this CallerInfo's phoneNumber field is empty, this specifies a + * fallback number to use instead. + */ + public void updateGeoDescription(Context context, String fallbackNumber) { + String number = TextUtils.isEmpty(phoneNumber) ? fallbackNumber : phoneNumber; + geoDescription = PhoneNumberHelper.getGeoDescription(context, number); + } + + /** @return a string debug representation of this instance. */ + @Override + public String toString() { + // Warning: never check in this file with VERBOSE_DEBUG = true + // because that will result in PII in the system log. + final boolean VERBOSE_DEBUG = false; + + if (VERBOSE_DEBUG) { + return new StringBuilder(384) + .append(super.toString() + " { ") + .append("\nname: " + name) + .append("\nphoneNumber: " + phoneNumber) + .append("\nnormalizedNumber: " + normalizedNumber) + .append("\forwardingNumber: " + forwardingNumber) + .append("\ngeoDescription: " + geoDescription) + .append("\ncnapName: " + cnapName) + .append("\nnumberPresentation: " + numberPresentation) + .append("\nnamePresentation: " + namePresentation) + .append("\ncontactExists: " + contactExists) + .append("\nphoneLabel: " + phoneLabel) + .append("\nnumberType: " + numberType) + .append("\nnumberLabel: " + numberLabel) + .append("\nphotoResource: " + photoResource) + .append("\ncontactIdOrZero: " + contactIdOrZero) + .append("\nneedUpdate: " + needUpdate) + .append("\ncontactRefUri: " + contactRefUri) + .append("\ncontactRingtoneUri: " + contactRingtoneUri) + .append("\ncontactDisplayPhotoUri: " + contactDisplayPhotoUri) + .append("\nshouldSendToVoicemail: " + shouldSendToVoicemail) + .append("\ncachedPhoto: " + cachedPhoto) + .append("\nisCachedPhotoCurrent: " + isCachedPhotoCurrent) + .append("\nemergency: " + mIsEmergency) + .append("\nvoicemail: " + mIsVoiceMail) + .append("\nuserType: " + userType) + .append(" }") + .toString(); + } else { + return new StringBuilder(128) + .append(super.toString() + " { ") + .append("name " + ((name == null) ? "null" : "non-null")) + .append(", phoneNumber " + ((phoneNumber == null) ? "null" : "non-null")) + .append(" }") + .toString(); + } + } +} diff --git a/java/com/android/incallui/CallerInfoAsyncQuery.java b/java/com/android/incallui/CallerInfoAsyncQuery.java new file mode 100644 index 000000000..f8d7ac65a --- /dev/null +++ b/java/com/android/incallui/CallerInfoAsyncQuery.java @@ -0,0 +1,638 @@ +/* + * Copyright (C) 2006 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; + +import android.Manifest; +import android.annotation.TargetApi; +import android.content.AsyncQueryHandler; +import android.content.ContentResolver; +import android.content.Context; +import android.database.Cursor; +import android.database.SQLException; +import android.net.Uri; +import android.os.Build.VERSION; +import android.os.Build.VERSION_CODES; +import android.os.Handler; +import android.os.Looper; +import android.os.Message; +import android.provider.ContactsContract; +import android.provider.ContactsContract.Directory; +import android.support.annotation.MainThread; +import android.support.annotation.RequiresPermission; +import android.support.annotation.WorkerThread; +import android.telephony.PhoneNumberUtils; +import android.text.TextUtils; +import com.android.contacts.common.compat.DirectoryCompat; +import com.android.dialer.phonenumbercache.CachedNumberLookupService; +import com.android.dialer.phonenumbercache.CachedNumberLookupService.CachedContactInfo; +import com.android.dialer.phonenumbercache.ContactInfoHelper; +import com.android.dialer.phonenumbercache.PhoneNumberCache; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Arrays; + +/** + * Helper class to make it easier to run asynchronous caller-id lookup queries. + * + * @see CallerInfo + */ +@TargetApi(VERSION_CODES.M) +public class CallerInfoAsyncQuery { + + /** Interface for a CallerInfoAsyncQueryHandler result return. */ + public interface OnQueryCompleteListener { + + /** Called when the query is complete. */ + @MainThread + void onQueryComplete(int token, Object cookie, CallerInfo ci); + + /** Called when data is loaded. Must be called in worker thread. */ + @WorkerThread + void onDataLoaded(int token, Object cookie, CallerInfo ci); + } + + private static final boolean DBG = false; + private static final String LOG_TAG = "CallerInfoAsyncQuery"; + + private static final int EVENT_NEW_QUERY = 1; + private static final int EVENT_ADD_LISTENER = 2; + private static final int EVENT_EMERGENCY_NUMBER = 3; + private static final int EVENT_VOICEMAIL_NUMBER = 4; + // If the CallerInfo query finds no contacts, should we use the + // PhoneNumberOfflineGeocoder to look up a "geo description"? + // (TODO: This could become a flag in config.xml if it ever needs to be + // configured on a per-product basis.) + private static final boolean ENABLE_UNKNOWN_NUMBER_GEO_DESCRIPTION = true; + /* Directory lookup related code - START */ + private static final String[] DIRECTORY_PROJECTION = new String[] {Directory._ID}; + + /** Private constructor for factory methods. */ + private CallerInfoAsyncQuery() {} + + @RequiresPermission(Manifest.permission.READ_CONTACTS) + public static void startQuery( + final int token, + final Context context, + final CallerInfo info, + final OnQueryCompleteListener listener, + final Object cookie) { + Log.d(LOG_TAG, "##### CallerInfoAsyncQuery startContactProviderQuery()... #####"); + Log.d(LOG_TAG, "- number: " + info.phoneNumber); + Log.d(LOG_TAG, "- cookie: " + cookie); + + OnQueryCompleteListener contactsProviderQueryCompleteListener = + new OnQueryCompleteListener() { + @Override + public void onQueryComplete(int token, Object cookie, CallerInfo ci) { + Log.d(LOG_TAG, "contactsProviderQueryCompleteListener done"); + // If there are no other directory queries, make sure that the listener is + // notified of this result. see b/27621628 + if ((ci != null && ci.contactExists) + || !startOtherDirectoriesQuery(token, context, info, listener, cookie)) { + if (listener != null && ci != null) { + listener.onQueryComplete(token, cookie, ci); + } + } + } + + @Override + public void onDataLoaded(int token, Object cookie, CallerInfo ci) { + listener.onDataLoaded(token, cookie, ci); + } + }; + startDefaultDirectoryQuery(token, context, info, contactsProviderQueryCompleteListener, cookie); + } + + // Private methods + private static void startDefaultDirectoryQuery( + int token, + Context context, + CallerInfo info, + OnQueryCompleteListener listener, + Object cookie) { + // Construct the URI object and query params, and start the query. + Uri uri = ContactInfoHelper.getContactInfoLookupUri(info.phoneNumber); + startQueryInternal(token, context, info, listener, cookie, uri); + } + + /** + * Factory method to start the query based on a CallerInfo object. + * + * <p>Note: if the number contains an "@" character we treat it as a SIP address, and look it up + * directly in the Data table rather than using the PhoneLookup table. TODO: But eventually we + * should expose two separate methods, one for numbers and one for SIP addresses, and then have + * PhoneUtils.startGetCallerInfo() decide which one to call based on the phone type of the + * incoming connection. + */ + private static void startQueryInternal( + int token, + Context context, + CallerInfo info, + OnQueryCompleteListener listener, + Object cookie, + Uri contactRef) { + if (DBG) { + Log.d(LOG_TAG, "==> contactRef: " + sanitizeUriToString(contactRef)); + } + + if ((context == null) || (contactRef == null)) { + throw new QueryPoolException("Bad context or query uri."); + } + CallerInfoAsyncQueryHandler handler = new CallerInfoAsyncQueryHandler(context, contactRef); + + //create cookieWrapper, start query + CookieWrapper cw = new CookieWrapper(); + cw.listener = listener; + cw.cookie = cookie; + cw.number = info.phoneNumber; + + // check to see if these are recognized numbers, and use shortcuts if we can. + if (PhoneNumberUtils.isLocalEmergencyNumber(context, info.phoneNumber)) { + cw.event = EVENT_EMERGENCY_NUMBER; + } else if (info.isVoiceMailNumber()) { + cw.event = EVENT_VOICEMAIL_NUMBER; + } else { + cw.event = EVENT_NEW_QUERY; + } + + String[] proejection = CallerInfo.getDefaultPhoneLookupProjection(contactRef); + handler.startQuery( + token, + cw, // cookie + contactRef, // uri + proejection, // projection + null, // selection + null, // selectionArgs + null); // orderBy + } + + // Return value indicates if listener was notified. + private static boolean startOtherDirectoriesQuery( + int token, + Context context, + CallerInfo info, + OnQueryCompleteListener listener, + Object cookie) { + long[] directoryIds = getDirectoryIds(context); + int size = directoryIds.length; + if (size == 0) { + return false; + } + + DirectoryQueryCompleteListenerFactory listenerFactory = + new DirectoryQueryCompleteListenerFactory(context, size, listener); + + // The current implementation of multiple async query runs in single handler thread + // in AsyncQueryHandler. + // intermediateListener.onQueryComplete is also called from the same caller thread. + // TODO(b/26019872): use thread pool instead of single thread. + for (int i = 0; i < size; i++) { + long directoryId = directoryIds[i]; + Uri uri = ContactInfoHelper.getContactInfoLookupUri(info.phoneNumber, directoryId); + if (DBG) { + Log.d(LOG_TAG, "directoryId: " + directoryId + " uri: " + uri); + } + OnQueryCompleteListener intermediateListener = listenerFactory.newListener(directoryId); + startQueryInternal(token, context, info, intermediateListener, cookie, uri); + } + return true; + } + + private static long[] getDirectoryIds(Context context) { + ArrayList<Long> results = new ArrayList<>(); + + Uri uri = Directory.CONTENT_URI; + if (VERSION.SDK_INT >= VERSION_CODES.N) { + uri = Uri.withAppendedPath(ContactsContract.AUTHORITY_URI, "directories_enterprise"); + } + + ContentResolver cr = context.getContentResolver(); + Cursor cursor = cr.query(uri, DIRECTORY_PROJECTION, null, null, null); + addDirectoryIdsFromCursor(cursor, results); + + long[] result = new long[results.size()]; + for (int i = 0; i < results.size(); i++) { + result[i] = results.get(i); + } + return result; + } + + private static void addDirectoryIdsFromCursor(Cursor cursor, ArrayList<Long> results) { + if (cursor != null) { + int idIndex = cursor.getColumnIndex(Directory._ID); + while (cursor.moveToNext()) { + long id = cursor.getLong(idIndex); + if (DirectoryCompat.isRemoteDirectoryId(id)) { + results.add(id); + } + } + cursor.close(); + } + } + + private static String sanitizeUriToString(Uri uri) { + if (uri != null) { + String uriString = uri.toString(); + int indexOfLastSlash = uriString.lastIndexOf('/'); + if (indexOfLastSlash > 0) { + return uriString.substring(0, indexOfLastSlash) + "/xxxxxxx"; + } else { + return uriString; + } + } else { + return ""; + } + } + + /** Wrap the cookie from the WorkerArgs with additional information needed by our classes. */ + private static final class CookieWrapper { + + public OnQueryCompleteListener listener; + public Object cookie; + public int event; + public String number; + } + /* Directory lookup related code - END */ + + /** Simple exception used to communicate problems with the query pool. */ + public static class QueryPoolException extends SQLException { + + public QueryPoolException(String error) { + super(error); + } + } + + private static final class DirectoryQueryCompleteListenerFactory { + + private final OnQueryCompleteListener mListener; + private final Context mContext; + // Make sure listener to be called once and only once + private int mCount; + private boolean mIsListenerCalled; + + DirectoryQueryCompleteListenerFactory( + Context context, int size, OnQueryCompleteListener listener) { + mCount = size; + mListener = listener; + mIsListenerCalled = false; + mContext = context; + } + + private void onDirectoryQueryComplete( + int token, Object cookie, CallerInfo ci, long directoryId) { + boolean shouldCallListener = false; + synchronized (this) { + mCount = mCount - 1; + if (!mIsListenerCalled && (ci.contactExists || mCount == 0)) { + mIsListenerCalled = true; + shouldCallListener = true; + } + } + + // Don't call callback in synchronized block because mListener.onQueryComplete may + // take long time to complete + if (shouldCallListener && mListener != null) { + addCallerInfoIntoCache(ci, directoryId); + mListener.onQueryComplete(token, cookie, ci); + } + } + + private void addCallerInfoIntoCache(CallerInfo ci, long directoryId) { + CachedNumberLookupService cachedNumberLookupService = + PhoneNumberCache.get(mContext).getCachedNumberLookupService(); + if (ci.contactExists && cachedNumberLookupService != null) { + // 1. Cache caller info + CachedContactInfo cachedContactInfo = + CallerInfoUtils.buildCachedContactInfo(cachedNumberLookupService, ci); + String directoryLabel = mContext.getString(R.string.directory_search_label); + cachedContactInfo.setDirectorySource(directoryLabel, directoryId); + cachedNumberLookupService.addContact(mContext, cachedContactInfo); + + // 2. Cache photo + if (ci.contactDisplayPhotoUri != null && ci.normalizedNumber != null) { + try (InputStream in = + mContext.getContentResolver().openInputStream(ci.contactDisplayPhotoUri)) { + if (in != null) { + cachedNumberLookupService.addPhoto(mContext, ci.normalizedNumber, in); + } + } catch (IOException e) { + Log.e(LOG_TAG, "failed to fetch directory contact photo", e); + } + } + } + } + + public OnQueryCompleteListener newListener(long directoryId) { + return new DirectoryQueryCompleteListener(directoryId); + } + + private class DirectoryQueryCompleteListener implements OnQueryCompleteListener { + + private final long mDirectoryId; + + DirectoryQueryCompleteListener(long directoryId) { + mDirectoryId = directoryId; + } + + @Override + public void onDataLoaded(int token, Object cookie, CallerInfo ci) { + mListener.onDataLoaded(token, cookie, ci); + } + + @Override + public void onQueryComplete(int token, Object cookie, CallerInfo ci) { + onDirectoryQueryComplete(token, cookie, ci, mDirectoryId); + } + } + } + + /** Our own implementation of the AsyncQueryHandler. */ + private static class CallerInfoAsyncQueryHandler extends AsyncQueryHandler { + + /** + * The information relevant to each CallerInfo query. Each query may have multiple listeners, so + * each AsyncCursorInfo is associated with 2 or more CookieWrapper objects in the queue (one + * with a new query event, and one with a end event, with 0 or more additional listeners in + * between). + */ + private Context mQueryContext; + + private Uri mQueryUri; + private CallerInfo mCallerInfo; + + /** Asynchronous query handler class for the contact / callerinfo object. */ + private CallerInfoAsyncQueryHandler(Context context, Uri contactRef) { + super(context.getContentResolver()); + this.mQueryContext = context; + this.mQueryUri = contactRef; + } + + @Override + public void startQuery( + int token, + Object cookie, + Uri uri, + String[] projection, + String selection, + String[] selectionArgs, + String orderBy) { + if (DBG) { + // Show stack trace with the arguments. + Log.d( + LOG_TAG, + "InCall: startQuery: url=" + + uri + + " projection=[" + + Arrays.toString(projection) + + "]" + + " selection=" + + selection + + " " + + " args=[" + + Arrays.toString(selectionArgs) + + "]", + new RuntimeException("STACKTRACE")); + } + super.startQuery(token, cookie, uri, projection, selection, selectionArgs, orderBy); + } + + @Override + protected Handler createHandler(Looper looper) { + return new CallerInfoWorkerHandler(looper); + } + + /** + * Overrides onQueryComplete from AsyncQueryHandler. + * + * <p>This method takes into account the state of this class; we construct the CallerInfo object + * only once for each set of listeners. When the query thread has done its work and calls this + * method, we inform the remaining listeners in the queue, until we're out of listeners. Once we + * get the message indicating that we should expect no new listeners for this CallerInfo object, + * we release the AsyncCursorInfo back into the pool. + */ + @Override + protected void onQueryComplete(int token, Object cookie, Cursor cursor) { + Log.d(this, "##### onQueryComplete() ##### query complete for token: " + token); + + CookieWrapper cw = (CookieWrapper) cookie; + + if (cw.listener != null) { + Log.d( + this, + "notifying listener: " + + cw.listener.getClass().toString() + + " for token: " + + token + + mCallerInfo); + cw.listener.onQueryComplete(token, cw.cookie, mCallerInfo); + } + mQueryContext = null; + mQueryUri = null; + mCallerInfo = null; + } + + protected void updateData(int token, Object cookie, Cursor cursor) { + try { + Log.d(this, "##### updateData() ##### for token: " + token); + + //get the cookie and notify the listener. + CookieWrapper cw = (CookieWrapper) cookie; + if (cw == null) { + // Normally, this should never be the case for calls originating + // from within this code. + // However, if there is any code that calls this method, we should + // check the parameters to make sure they're viable. + Log.d(this, "Cookie is null, ignoring onQueryComplete() request."); + return; + } + + // check the token and if needed, create the callerinfo object. + if (mCallerInfo == null) { + if ((mQueryContext == null) || (mQueryUri == null)) { + throw new QueryPoolException( + "Bad context or query uri, or CallerInfoAsyncQuery already released."); + } + + // adjust the callerInfo data as needed, and only if it was set from the + // initial query request. + // Change the callerInfo number ONLY if it is an emergency number or the + // voicemail number, and adjust other data (including photoResource) + // accordingly. + if (cw.event == EVENT_EMERGENCY_NUMBER) { + // Note we're setting the phone number here (refer to javadoc + // comments at the top of CallerInfo class). + mCallerInfo = new CallerInfo().markAsEmergency(mQueryContext); + } else if (cw.event == EVENT_VOICEMAIL_NUMBER) { + mCallerInfo = new CallerInfo().markAsVoiceMail(mQueryContext); + } else { + mCallerInfo = CallerInfo.getCallerInfo(mQueryContext, mQueryUri, cursor); + Log.d(this, "==> Got mCallerInfo: " + mCallerInfo); + + CallerInfo newCallerInfo = + CallerInfo.doSecondaryLookupIfNecessary(mQueryContext, cw.number, mCallerInfo); + if (newCallerInfo != mCallerInfo) { + mCallerInfo = newCallerInfo; + Log.d(this, "#####async contact look up with numeric username" + mCallerInfo); + } + + // Final step: look up the geocoded description. + if (ENABLE_UNKNOWN_NUMBER_GEO_DESCRIPTION) { + // Note we do this only if we *don't* have a valid name (i.e. if + // no contacts matched the phone number of the incoming call), + // since that's the only case where the incoming-call UI cares + // about this field. + // + // (TODO: But if we ever want the UI to show the geoDescription + // even when we *do* match a contact, we'll need to either call + // updateGeoDescription() unconditionally here, or possibly add a + // new parameter to CallerInfoAsyncQuery.startQuery() to force + // the geoDescription field to be populated.) + + if (TextUtils.isEmpty(mCallerInfo.name)) { + // Actually when no contacts match the incoming phone number, + // the CallerInfo object is totally blank here (i.e. no name + // *or* phoneNumber). So we need to pass in cw.number as + // a fallback number. + mCallerInfo.updateGeoDescription(mQueryContext, cw.number); + } + } + + // Use the number entered by the user for display. + if (!TextUtils.isEmpty(cw.number)) { + mCallerInfo.phoneNumber = cw.number; + } + } + + Log.d(this, "constructing CallerInfo object for token: " + token); + + if (cw.listener != null) { + cw.listener.onDataLoaded(token, cw.cookie, mCallerInfo); + } + } + + } finally { + // The cursor may have been closed in CallerInfo.getCallerInfo() + if (cursor != null && !cursor.isClosed()) { + cursor.close(); + } + } + } + + /** + * Our own query worker thread. + * + * <p>This thread handles the messages enqueued in the looper. The normal sequence of events is + * that a new query shows up in the looper queue, followed by 0 or more add listener requests, + * and then an end request. Of course, these requests can be interlaced with requests from other + * tokens, but is irrelevant to this handler since the handler has no state. + * + * <p>Note that we depend on the queue to keep things in order; in other words, the looper queue + * must be FIFO with respect to input from the synchronous startQuery calls and output to this + * handleMessage call. + * + * <p>This use of the queue is required because CallerInfo objects may be accessed multiple + * times before the query is complete. All accesses (listeners) must be queued up and informed + * in order when the query is complete. + */ + protected class CallerInfoWorkerHandler extends WorkerHandler { + + public CallerInfoWorkerHandler(Looper looper) { + super(looper); + } + + @Override + public void handleMessage(Message msg) { + WorkerArgs args = (WorkerArgs) msg.obj; + CookieWrapper cw = (CookieWrapper) args.cookie; + + if (cw == null) { + // Normally, this should never be the case for calls originating + // from within this code. + // However, if there is any code that this Handler calls (such as in + // super.handleMessage) that DOES place unexpected messages on the + // queue, then we need pass these messages on. + Log.d( + this, + "Unexpected command (CookieWrapper is null): " + + msg.what + + " ignored by CallerInfoWorkerHandler, passing onto parent."); + + super.handleMessage(msg); + } else { + Log.d( + this, + "Processing event: " + + cw.event + + " token (arg1): " + + msg.arg1 + + " command: " + + msg.what + + " query URI: " + + sanitizeUriToString(args.uri)); + + switch (cw.event) { + case EVENT_NEW_QUERY: + final ContentResolver resolver = mQueryContext.getContentResolver(); + + // This should never happen. + if (resolver == null) { + Log.e(this, "Content Resolver is null!"); + return; + } + //start the sql command. + Cursor cursor; + try { + cursor = + resolver.query( + args.uri, + args.projection, + args.selection, + args.selectionArgs, + args.orderBy); + // Calling getCount() causes the cursor window to be filled, + // which will make the first access on the main thread a lot faster. + if (cursor != null) { + cursor.getCount(); + } + } catch (Exception e) { + Log.e(this, "Exception thrown during handling EVENT_ARG_QUERY", e); + cursor = null; + } + + args.result = cursor; + updateData(msg.arg1, cw, cursor); + break; + + // shortcuts to avoid query for recognized numbers. + case EVENT_EMERGENCY_NUMBER: + case EVENT_VOICEMAIL_NUMBER: + case EVENT_ADD_LISTENER: + updateData(msg.arg1, cw, (Cursor) args.result); + break; + default: + } + Message reply = args.handler.obtainMessage(msg.what); + reply.obj = args; + reply.arg1 = msg.arg1; + + reply.sendToTarget(); + } + } + } + } +} diff --git a/java/com/android/incallui/CallerInfoUtils.java b/java/com/android/incallui/CallerInfoUtils.java new file mode 100644 index 000000000..9f57fba65 --- /dev/null +++ b/java/com/android/incallui/CallerInfoUtils.java @@ -0,0 +1,279 @@ +/* + * 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; + +import android.Manifest.permission; +import android.content.Context; +import android.content.Loader; +import android.content.Loader.OnLoadCompleteListener; +import android.content.pm.PackageManager; +import android.net.Uri; +import android.support.v4.content.ContextCompat; +import android.telecom.PhoneAccount; +import android.telecom.TelecomManager; +import android.text.TextUtils; +import com.android.contacts.common.model.Contact; +import com.android.contacts.common.model.ContactLoader; +import com.android.dialer.common.LogUtil; +import com.android.dialer.phonenumbercache.CachedNumberLookupService; +import com.android.dialer.phonenumbercache.CachedNumberLookupService.CachedContactInfo; +import com.android.dialer.phonenumbercache.ContactInfo; +import com.android.dialer.phonenumberutil.PhoneNumberHelper; +import com.android.dialer.telecom.TelecomUtil; +import com.android.dialer.util.PermissionsUtil; +import com.android.incallui.call.DialerCall; +import java.util.Arrays; + +/** Utility methods for contact and caller info related functionality */ +public class CallerInfoUtils { + + private static final String TAG = CallerInfoUtils.class.getSimpleName(); + + private static final int QUERY_TOKEN = -1; + + public CallerInfoUtils() {} + + /** + * This is called to get caller info for a call. This will return a CallerInfo object immediately + * based off information in the call, but more information is returned to the + * OnQueryCompleteListener (which contains information about the phone number label, user's name, + * etc). + */ + public static CallerInfo getCallerInfoForCall( + Context context, + DialerCall call, + Object cookie, + CallerInfoAsyncQuery.OnQueryCompleteListener listener) { + CallerInfo info = buildCallerInfo(context, call); + + // TODO: Have phoneapp send a Uri when it knows the contact that triggered this call. + + if (info.numberPresentation == TelecomManager.PRESENTATION_ALLOWED) { + if (PermissionsUtil.hasContactsPermissions(context)) { + // Start the query with the number provided from the call. + LogUtil.d( + "CallerInfoUtils.getCallerInfoForCall", + "Actually starting CallerInfoAsyncQuery.startQuery()..."); + + //noinspection MissingPermission + CallerInfoAsyncQuery.startQuery(QUERY_TOKEN, context, info, listener, cookie); + } else { + LogUtil.w( + "CallerInfoUtils.getCallerInfoForCall", + "Dialer doesn't have permission to read contacts." + + " Not calling CallerInfoAsyncQuery.startQuery()."); + } + } + return info; + } + + public static CallerInfo buildCallerInfo(Context context, DialerCall call) { + CallerInfo info = new CallerInfo(); + + // Store CNAP information retrieved from the Connection (we want to do this + // here regardless of whether the number is empty or not). + info.cnapName = call.getCnapName(); + info.name = info.cnapName; + info.numberPresentation = call.getNumberPresentation(); + info.namePresentation = call.getCnapNamePresentation(); + info.callSubject = call.getCallSubject(); + + String number = call.getNumber(); + if (!TextUtils.isEmpty(number)) { + // Don't split it if it's a SIP number. + if (!PhoneNumberHelper.isUriNumber(number)) { + final String[] numbers = number.split("&"); + number = numbers[0]; + if (numbers.length > 1) { + info.forwardingNumber = numbers[1]; + } + number = modifyForSpecialCnapCases(context, info, number, info.numberPresentation); + } + info.phoneNumber = number; + } + + // Because the InCallUI is immediately launched before the call is connected, occasionally + // a voicemail call will be passed to InCallUI as a "voicemail:" URI without a number. + // This call should still be handled as a voicemail call. + if ((call.getHandle() != null + && PhoneAccount.SCHEME_VOICEMAIL.equals(call.getHandle().getScheme())) + || isVoiceMailNumber(context, call)) { + info.markAsVoiceMail(context); + } + + ContactInfoCache.getInstance(context).maybeInsertCnapInformationIntoCache(context, call, info); + + return info; + } + + /** + * Creates a new {@link CachedContactInfo} from a {@link CallerInfo} + * + * @param lookupService the {@link CachedNumberLookupService} used to build a new {@link + * CachedContactInfo} + * @param {@link CallerInfo} object + * @return a CachedContactInfo object created from this CallerInfo + * @throws NullPointerException if lookupService or ci are null + */ + public static CachedContactInfo buildCachedContactInfo( + CachedNumberLookupService lookupService, CallerInfo ci) { + ContactInfo info = new ContactInfo(); + info.name = ci.name; + info.type = ci.numberType; + info.label = ci.phoneLabel; + info.number = ci.phoneNumber; + info.normalizedNumber = ci.normalizedNumber; + info.photoUri = ci.contactDisplayPhotoUri; + info.userType = ci.userType; + + CachedContactInfo cacheInfo = lookupService.buildCachedContactInfo(info); + cacheInfo.setLookupKey(ci.lookupKeyOrNull); + return cacheInfo; + } + + public static boolean isVoiceMailNumber(Context context, DialerCall call) { + if (ContextCompat.checkSelfPermission(context, permission.READ_PHONE_STATE) + != PackageManager.PERMISSION_GRANTED) { + return false; + } + return TelecomUtil.isVoicemailNumber(context, call.getAccountHandle(), call.getNumber()); + } + + /** + * Handles certain "corner cases" for CNAP. When we receive weird phone numbers from the network + * to indicate different number presentations, convert them to expected number and presentation + * values within the CallerInfo object. + * + * @param number number we use to verify if we are in a corner case + * @param presentation presentation value used to verify if we are in a corner case + * @return the new String that should be used for the phone number + */ + /* package */ + static String modifyForSpecialCnapCases( + Context context, CallerInfo ci, String number, int presentation) { + // Obviously we return number if ci == null, but still return number if + // number == null, because in these cases the correct string will still be + // displayed/logged after this function returns based on the presentation value. + if (ci == null || number == null) { + return number; + } + + LogUtil.d( + "CallerInfoUtils.modifyForSpecialCnapCases", + "modifyForSpecialCnapCases: initially, number=" + + toLogSafePhoneNumber(number) + + ", presentation=" + + presentation + + " ci " + + ci); + + // "ABSENT NUMBER" is a possible value we could get from the network as the + // phone number, so if this happens, change it to "Unknown" in the CallerInfo + // and fix the presentation to be the same. + final String[] absentNumberValues = context.getResources().getStringArray(R.array.absent_num); + if (Arrays.asList(absentNumberValues).contains(number) + && presentation == TelecomManager.PRESENTATION_ALLOWED) { + number = context.getString(R.string.unknown); + ci.numberPresentation = TelecomManager.PRESENTATION_UNKNOWN; + } + + // Check for other special "corner cases" for CNAP and fix them similarly. Corner + // cases only apply if we received an allowed presentation from the network, so check + // if we think we have an allowed presentation, or if the CallerInfo presentation doesn't + // match the presentation passed in for verification (meaning we changed it previously + // because it's a corner case and we're being called from a different entry point). + if (ci.numberPresentation == TelecomManager.PRESENTATION_ALLOWED + || (ci.numberPresentation != presentation + && presentation == TelecomManager.PRESENTATION_ALLOWED)) { + // For all special strings, change number & numberPrentation. + if (isCnapSpecialCaseRestricted(number)) { + number = PhoneNumberHelper.getDisplayNameForRestrictedNumber(context).toString(); + ci.numberPresentation = TelecomManager.PRESENTATION_RESTRICTED; + } else if (isCnapSpecialCaseUnknown(number)) { + number = context.getString(R.string.unknown); + ci.numberPresentation = TelecomManager.PRESENTATION_UNKNOWN; + } + LogUtil.d( + "CallerInfoUtils.modifyForSpecialCnapCases", + "SpecialCnap: number=" + + toLogSafePhoneNumber(number) + + "; presentation now=" + + ci.numberPresentation); + } + LogUtil.d( + "CallerInfoUtils.modifyForSpecialCnapCases", + "returning number string=" + toLogSafePhoneNumber(number)); + return number; + } + + private static boolean isCnapSpecialCaseRestricted(String n) { + return n.equals("PRIVATE") || n.equals("P") || n.equals("RES") || n.equals("PRIVATENUMBER"); + } + + private static boolean isCnapSpecialCaseUnknown(String n) { + return n.equals("UNAVAILABLE") || n.equals("UNKNOWN") || n.equals("UNA") || n.equals("U"); + } + + /* package */ + static String toLogSafePhoneNumber(String number) { + // For unknown number, log empty string. + if (number == null) { + return ""; + } + + // Todo: Figure out an equivalent for VDBG + if (false) { + // When VDBG is true we emit PII. + return number; + } + + // Do exactly same thing as Uri#toSafeString() does, which will enable us to compare + // sanitized phone numbers. + StringBuilder builder = new StringBuilder(); + for (int i = 0; i < number.length(); i++) { + char c = number.charAt(i); + if (c == '-' || c == '@' || c == '.' || c == '&') { + builder.append(c); + } else { + builder.append('x'); + } + } + return builder.toString(); + } + + /** + * Send a notification using a {@link ContactLoader} to inform the sync adapter that we are + * viewing a particular contact, so that it can download the high-res photo. + */ + public static void sendViewNotification(Context context, Uri contactUri) { + final ContactLoader loader = + new ContactLoader(context, contactUri, true /* postViewNotification */); + loader.registerListener( + 0, + new OnLoadCompleteListener<Contact>() { + @Override + public void onLoadComplete(Loader<Contact> loader, Contact contact) { + try { + loader.reset(); + } catch (RuntimeException e) { + LogUtil.e("CallerInfoUtils.onLoadComplete", "Error resetting loader", e); + } + } + }); + loader.startLoading(); + } +} diff --git a/java/com/android/incallui/ConferenceManagerFragment.java b/java/com/android/incallui/ConferenceManagerFragment.java new file mode 100644 index 000000000..8696bb8ec --- /dev/null +++ b/java/com/android/incallui/ConferenceManagerFragment.java @@ -0,0 +1,106 @@ +/* + * Copyright (C) 2013 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; + +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ListView; +import com.android.contacts.common.ContactPhotoManager; +import com.android.dialer.logging.Logger; +import com.android.dialer.logging.nano.ScreenEvent; +import com.android.incallui.ConferenceManagerPresenter.ConferenceManagerUi; +import com.android.incallui.baseui.BaseFragment; +import com.android.incallui.call.CallList; +import com.android.incallui.call.DialerCall; +import java.util.List; + +/** Fragment that allows the user to manage a conference call. */ +public class ConferenceManagerFragment + extends BaseFragment<ConferenceManagerPresenter, ConferenceManagerUi> + implements ConferenceManagerPresenter.ConferenceManagerUi { + + private ListView mConferenceParticipantList; + private ContactPhotoManager mContactPhotoManager; + private ConferenceParticipantListAdapter mConferenceParticipantListAdapter; + + @Override + public ConferenceManagerPresenter createPresenter() { + return new ConferenceManagerPresenter(); + } + + @Override + public ConferenceManagerPresenter.ConferenceManagerUi getUi() { + return this; + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + if (savedInstanceState != null) { + Logger.get(getContext()).logScreenView(ScreenEvent.Type.CONFERENCE_MANAGEMENT, getActivity()); + } + } + + @Override + public View onCreateView( + LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + final View parent = inflater.inflate(R.layout.conference_manager_fragment, container, false); + + mConferenceParticipantList = (ListView) parent.findViewById(R.id.participantList); + mContactPhotoManager = ContactPhotoManager.getInstance(getActivity().getApplicationContext()); + + return parent; + } + + @Override + public void onResume() { + super.onResume(); + final CallList calls = CallList.getInstance(); + getPresenter().init(calls); + // Request focus on the list of participants for accessibility purposes. This ensures + // that once the list of participants is shown, the first participant is announced. + mConferenceParticipantList.requestFocus(); + } + + @Override + public void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + } + + @Override + public boolean isFragmentVisible() { + return isVisible(); + } + + @Override + public void update(List<DialerCall> participants, boolean parentCanSeparate) { + if (mConferenceParticipantListAdapter == null) { + mConferenceParticipantListAdapter = + new ConferenceParticipantListAdapter(mConferenceParticipantList, mContactPhotoManager); + + mConferenceParticipantList.setAdapter(mConferenceParticipantListAdapter); + } + mConferenceParticipantListAdapter.updateParticipants(participants, parentCanSeparate); + } + + @Override + public void refreshCall(DialerCall call) { + mConferenceParticipantListAdapter.refreshCall(call); + } +} diff --git a/java/com/android/incallui/ConferenceManagerPresenter.java b/java/com/android/incallui/ConferenceManagerPresenter.java new file mode 100644 index 000000000..226741dcd --- /dev/null +++ b/java/com/android/incallui/ConferenceManagerPresenter.java @@ -0,0 +1,139 @@ +/* + * Copyright (C) 2013 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; + +import com.android.incallui.ConferenceManagerPresenter.ConferenceManagerUi; +import com.android.incallui.InCallPresenter.InCallDetailsListener; +import com.android.incallui.InCallPresenter.InCallState; +import com.android.incallui.InCallPresenter.InCallStateListener; +import com.android.incallui.InCallPresenter.IncomingCallListener; +import com.android.incallui.baseui.Presenter; +import com.android.incallui.baseui.Ui; +import com.android.incallui.call.CallList; +import com.android.incallui.call.DialerCall; +import java.util.ArrayList; +import java.util.List; + +/** Logic for call buttons. */ +public class ConferenceManagerPresenter extends Presenter<ConferenceManagerUi> + implements InCallStateListener, InCallDetailsListener, IncomingCallListener { + + @Override + public void onUiReady(ConferenceManagerUi ui) { + super.onUiReady(ui); + + // register for call state changes last + InCallPresenter.getInstance().addListener(this); + InCallPresenter.getInstance().addIncomingCallListener(this); + } + + @Override + public void onUiUnready(ConferenceManagerUi ui) { + super.onUiUnready(ui); + + InCallPresenter.getInstance().removeListener(this); + InCallPresenter.getInstance().removeIncomingCallListener(this); + } + + @Override + public void onStateChange(InCallState oldState, InCallState newState, CallList callList) { + if (getUi().isFragmentVisible()) { + Log.v(this, "onStateChange" + newState); + if (newState == InCallState.INCALL) { + final DialerCall call = callList.getActiveOrBackgroundCall(); + if (call != null && call.isConferenceCall()) { + Log.v( + this, "Number of existing calls is " + String.valueOf(call.getChildCallIds().size())); + update(callList); + } else { + InCallPresenter.getInstance().showConferenceCallManager(false); + } + } else { + InCallPresenter.getInstance().showConferenceCallManager(false); + } + } + } + + @Override + public void onDetailsChanged(DialerCall call, android.telecom.Call.Details details) { + boolean canDisconnect = + details.can(android.telecom.Call.Details.CAPABILITY_DISCONNECT_FROM_CONFERENCE); + boolean canSeparate = + details.can(android.telecom.Call.Details.CAPABILITY_SEPARATE_FROM_CONFERENCE); + + if (call.can(android.telecom.Call.Details.CAPABILITY_DISCONNECT_FROM_CONFERENCE) + != canDisconnect + || call.can(android.telecom.Call.Details.CAPABILITY_SEPARATE_FROM_CONFERENCE) + != canSeparate) { + getUi().refreshCall(call); + } + + if (!details.can(android.telecom.Call.Details.CAPABILITY_MANAGE_CONFERENCE)) { + InCallPresenter.getInstance().showConferenceCallManager(false); + } + } + + @Override + public void onIncomingCall(InCallState oldState, InCallState newState, DialerCall call) { + // When incoming call exists, set conference ui invisible. + if (getUi().isFragmentVisible()) { + Log.d(this, "onIncomingCall()... Conference ui is showing, hide it."); + InCallPresenter.getInstance().showConferenceCallManager(false); + } + } + + public void init(CallList callList) { + update(callList); + } + + /** + * Updates the conference participant adapter. + * + * @param callList The callList. + */ + private void update(CallList callList) { + // callList is non null, but getActiveOrBackgroundCall() may return null + final DialerCall currentCall = callList.getActiveOrBackgroundCall(); + if (currentCall == null) { + return; + } + + ArrayList<DialerCall> calls = new ArrayList<>(currentCall.getChildCallIds().size()); + for (String callerId : currentCall.getChildCallIds()) { + calls.add(callList.getCallById(callerId)); + } + + Log.d(this, "Number of calls is " + String.valueOf(calls.size())); + + // Users can split out a call from the conference call if either the active call or the + // holding call is empty. If both are filled, users can not split out another call. + final boolean hasActiveCall = (callList.getActiveCall() != null); + final boolean hasHoldingCall = (callList.getBackgroundCall() != null); + boolean canSeparate = !(hasActiveCall && hasHoldingCall); + + getUi().update(calls, canSeparate); + } + + public interface ConferenceManagerUi extends Ui { + + boolean isFragmentVisible(); + + void update(List<DialerCall> participants, boolean parentCanSeparate); + + void refreshCall(DialerCall call); + } +} diff --git a/java/com/android/incallui/ConferenceParticipantListAdapter.java b/java/com/android/incallui/ConferenceParticipantListAdapter.java new file mode 100644 index 000000000..72c0fcd20 --- /dev/null +++ b/java/com/android/incallui/ConferenceParticipantListAdapter.java @@ -0,0 +1,523 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.android.incallui; + +import android.content.Context; +import android.net.Uri; +import android.support.annotation.Nullable; +import android.support.v4.util.ArrayMap; +import android.text.BidiFormatter; +import android.text.TextDirectionHeuristics; +import android.text.TextUtils; +import android.util.ArraySet; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.BaseAdapter; +import android.widget.ImageView; +import android.widget.ListView; +import android.widget.TextView; +import com.android.contacts.common.ContactPhotoManager; +import com.android.contacts.common.ContactPhotoManager.DefaultImageRequest; +import com.android.contacts.common.compat.PhoneNumberUtilsCompat; +import com.android.contacts.common.preference.ContactsPreferences; +import com.android.contacts.common.util.ContactDisplayUtils; +import com.android.dialer.common.LogUtil; +import com.android.incallui.ContactInfoCache.ContactCacheEntry; +import com.android.incallui.call.CallList; +import com.android.incallui.call.DialerCall; +import java.lang.ref.WeakReference; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** Adapter for a ListView containing conference call participant information. */ +public class ConferenceParticipantListAdapter extends BaseAdapter { + + /** The ListView containing the participant information. */ + private final ListView mListView; + /** Hashmap to make accessing participant info by call Id faster. */ + private final Map<String, ParticipantInfo> mParticipantsByCallId = new ArrayMap<>(); + /** ContactsPreferences used to lookup displayName preferences */ + @Nullable private final ContactsPreferences mContactsPreferences; + /** Contact photo manager to retrieve cached contact photo information. */ + private final ContactPhotoManager mContactPhotoManager; + /** Listener used to handle tap of the "disconnect' button for a participant. */ + private View.OnClickListener mDisconnectListener = + new View.OnClickListener() { + @Override + public void onClick(View view) { + DialerCall call = getCallFromView(view); + LogUtil.i( + "ConferenceParticipantListAdapter.mDisconnectListener.onClick", "call: " + call); + if (call != null) { + call.disconnect(); + } + } + }; + /** Listener used to handle tap of the "separate' button for a participant. */ + private View.OnClickListener mSeparateListener = + new View.OnClickListener() { + @Override + public void onClick(View view) { + DialerCall call = getCallFromView(view); + LogUtil.i("ConferenceParticipantListAdapter.mSeparateListener.onClick", "call: " + call); + if (call != null) { + call.splitFromConference(); + } + } + }; + /** The conference participants to show in the ListView. */ + private List<ParticipantInfo> mConferenceParticipants = new ArrayList<>(); + /** {@code True} if the conference parent supports separating calls from the conference. */ + private boolean mParentCanSeparate; + + /** + * Creates an instance of the ConferenceParticipantListAdapter. + * + * @param listView The listview. + * @param contactPhotoManager The contact photo manager, used to load contact photos. + */ + public ConferenceParticipantListAdapter( + ListView listView, ContactPhotoManager contactPhotoManager) { + + mListView = listView; + mContactsPreferences = ContactsPreferencesFactory.newContactsPreferences(getContext()); + mContactPhotoManager = contactPhotoManager; + } + + /** + * Updates the adapter with the new conference participant information provided. + * + * @param conferenceParticipants The list of conference participants. + * @param parentCanSeparate {@code True} if the parent supports separating calls from the + * conference. + */ + public void updateParticipants( + List<DialerCall> conferenceParticipants, boolean parentCanSeparate) { + if (mContactsPreferences != null) { + mContactsPreferences.refreshValue(ContactsPreferences.DISPLAY_ORDER_KEY); + mContactsPreferences.refreshValue(ContactsPreferences.SORT_ORDER_KEY); + } + mParentCanSeparate = parentCanSeparate; + updateParticipantInfo(conferenceParticipants); + } + + /** + * Determines the number of participants in the conference. + * + * @return The number of participants. + */ + @Override + public int getCount() { + return mConferenceParticipants.size(); + } + + /** + * Retrieves an item from the list of participants. + * + * @param position Position of the item whose data we want within the adapter's data set. + * @return The {@link ParticipantInfo}. + */ + @Override + public Object getItem(int position) { + return mConferenceParticipants.get(position); + } + + /** + * Retreives the adapter-specific item id for an item at a specified position. + * + * @param position The position of the item within the adapter's data set whose row id we want. + * @return The item id. + */ + @Override + public long getItemId(int position) { + return position; + } + + /** + * Refreshes call information for the call passed in. + * + * @param call The new call information. + */ + public void refreshCall(DialerCall call) { + String callId = call.getId(); + + if (mParticipantsByCallId.containsKey(callId)) { + ParticipantInfo participantInfo = mParticipantsByCallId.get(callId); + participantInfo.setCall(call); + refreshView(callId); + } + } + + private Context getContext() { + return mListView.getContext(); + } + + /** + * Attempts to refresh the view for the specified call ID. This ensures the contact info and photo + * loaded from cache are updated. + * + * @param callId The call id. + */ + private void refreshView(String callId) { + int first = mListView.getFirstVisiblePosition(); + int last = mListView.getLastVisiblePosition(); + + for (int position = 0; position <= last - first; position++) { + View view = mListView.getChildAt(position); + String rowCallId = (String) view.getTag(); + if (rowCallId.equals(callId)) { + getView(position + first, view, mListView); + break; + } + } + } + + /** + * Creates or populates an existing conference participant row. + * + * @param position The position of the item within the adapter's data set of the item whose view + * we want. + * @param convertView The old view to reuse, if possible. + * @param parent The parent that this view will eventually be attached to + * @return The populated view. + */ + @Override + public View getView(int position, View convertView, ViewGroup parent) { + // Make sure we have a valid convertView to start with + final View result = + convertView == null + ? LayoutInflater.from(parent.getContext()) + .inflate(R.layout.caller_in_conference, parent, false) + : convertView; + + ParticipantInfo participantInfo = mConferenceParticipants.get(position); + DialerCall call = participantInfo.getCall(); + ContactCacheEntry contactCache = participantInfo.getContactCacheEntry(); + + final ContactInfoCache cache = ContactInfoCache.getInstance(getContext()); + + // If a cache lookup has not yet been performed to retrieve the contact information and + // photo, do it now. + if (!participantInfo.isCacheLookupComplete()) { + cache.findInfo( + participantInfo.getCall(), + participantInfo.getCall().getState() == DialerCall.State.INCOMING, + new ContactLookupCallback(this)); + } + + boolean thisRowCanSeparate = + mParentCanSeparate + && call.can(android.telecom.Call.Details.CAPABILITY_SEPARATE_FROM_CONFERENCE); + boolean thisRowCanDisconnect = + call.can(android.telecom.Call.Details.CAPABILITY_DISCONNECT_FROM_CONFERENCE); + + setCallerInfoForRow( + result, + contactCache.namePrimary, + ContactDisplayUtils.getPreferredDisplayName( + contactCache.namePrimary, contactCache.nameAlternative, mContactsPreferences), + contactCache.number, + contactCache.label, + contactCache.lookupKey, + contactCache.displayPhotoUri, + thisRowCanSeparate, + thisRowCanDisconnect); + + // Tag the row in the conference participant list with the call id to make it easier to + // find calls when contact cache information is loaded. + result.setTag(call.getId()); + + return result; + } + + /** + * Replaces the contact info for a participant and triggers a refresh of the UI. + * + * @param callId The call id. + * @param entry The new contact info. + */ + /* package */ void updateContactInfo(String callId, ContactCacheEntry entry) { + if (mParticipantsByCallId.containsKey(callId)) { + ParticipantInfo participantInfo = mParticipantsByCallId.get(callId); + participantInfo.setContactCacheEntry(entry); + participantInfo.setCacheLookupComplete(true); + refreshView(callId); + } + } + + /** + * Sets the caller information for a row in the conference participant list. + * + * @param view The view to set the details on. + * @param callerName The participant's name. + * @param callerNumber The participant's phone number. + * @param callerNumberType The participant's phone number typ.e + * @param lookupKey The lookup key for the participant (for photo lookup). + * @param photoUri The URI of the contact photo. + * @param thisRowCanSeparate {@code True} if this participant can separate from the conference. + * @param thisRowCanDisconnect {@code True} if this participant can be disconnected. + */ + private void setCallerInfoForRow( + View view, + String callerName, + String preferredName, + String callerNumber, + String callerNumberType, + String lookupKey, + Uri photoUri, + boolean thisRowCanSeparate, + boolean thisRowCanDisconnect) { + + final ImageView photoView = (ImageView) view.findViewById(R.id.callerPhoto); + final TextView nameTextView = (TextView) view.findViewById(R.id.conferenceCallerName); + final TextView numberTextView = (TextView) view.findViewById(R.id.conferenceCallerNumber); + final TextView numberTypeTextView = + (TextView) view.findViewById(R.id.conferenceCallerNumberType); + final View endButton = view.findViewById(R.id.conferenceCallerDisconnect); + final View separateButton = view.findViewById(R.id.conferenceCallerSeparate); + + endButton.setVisibility(thisRowCanDisconnect ? View.VISIBLE : View.GONE); + if (thisRowCanDisconnect) { + endButton.setOnClickListener(mDisconnectListener); + } else { + endButton.setOnClickListener(null); + } + + separateButton.setVisibility(thisRowCanSeparate ? View.VISIBLE : View.GONE); + if (thisRowCanSeparate) { + separateButton.setOnClickListener(mSeparateListener); + } else { + separateButton.setOnClickListener(null); + } + + DefaultImageRequest imageRequest = + (photoUri != null) + ? null + : new DefaultImageRequest(callerName, lookupKey, true /* isCircularPhoto */); + + mContactPhotoManager.loadDirectoryPhoto(photoView, photoUri, false, true, imageRequest); + + // set the caller name + nameTextView.setText(preferredName); + + // set the caller number in subscript, or make the field disappear. + if (TextUtils.isEmpty(callerNumber)) { + numberTextView.setVisibility(View.GONE); + numberTypeTextView.setVisibility(View.GONE); + } else { + numberTextView.setVisibility(View.VISIBLE); + numberTextView.setText( + PhoneNumberUtilsCompat.createTtsSpannable( + BidiFormatter.getInstance().unicodeWrap(callerNumber, TextDirectionHeuristics.LTR))); + numberTypeTextView.setVisibility(View.VISIBLE); + numberTypeTextView.setText(callerNumberType); + } + } + + /** + * Updates the participant info list which is bound to the ListView. Stores the call and contact + * info for all entries. The list is sorted alphabetically by participant name. + * + * @param conferenceParticipants The calls which make up the conference participants. + */ + private void updateParticipantInfo(List<DialerCall> conferenceParticipants) { + final ContactInfoCache cache = ContactInfoCache.getInstance(getContext()); + boolean newParticipantAdded = false; + Set<String> newCallIds = new ArraySet<>(conferenceParticipants.size()); + + // Update or add conference participant info. + for (DialerCall call : conferenceParticipants) { + String callId = call.getId(); + newCallIds.add(callId); + ContactCacheEntry contactCache = cache.getInfo(callId); + if (contactCache == null) { + contactCache = + ContactInfoCache.buildCacheEntryFromCall( + getContext(), call, call.getState() == DialerCall.State.INCOMING); + } + + if (mParticipantsByCallId.containsKey(callId)) { + ParticipantInfo participantInfo = mParticipantsByCallId.get(callId); + participantInfo.setCall(call); + participantInfo.setContactCacheEntry(contactCache); + } else { + newParticipantAdded = true; + ParticipantInfo participantInfo = new ParticipantInfo(call, contactCache); + mConferenceParticipants.add(participantInfo); + mParticipantsByCallId.put(call.getId(), participantInfo); + } + } + + // Remove any participants that no longer exist. + Iterator<Map.Entry<String, ParticipantInfo>> it = mParticipantsByCallId.entrySet().iterator(); + while (it.hasNext()) { + Map.Entry<String, ParticipantInfo> entry = it.next(); + String existingCallId = entry.getKey(); + if (!newCallIds.contains(existingCallId)) { + ParticipantInfo existingInfo = entry.getValue(); + mConferenceParticipants.remove(existingInfo); + it.remove(); + } + } + + if (newParticipantAdded) { + // Sort the list of participants by contact name. + sortParticipantList(); + } + notifyDataSetChanged(); + } + + /** Sorts the participant list by contact name. */ + private void sortParticipantList() { + Collections.sort( + mConferenceParticipants, + new Comparator<ParticipantInfo>() { + @Override + public int compare(ParticipantInfo p1, ParticipantInfo p2) { + // Contact names might be null, so replace with empty string. + ContactCacheEntry c1 = p1.getContactCacheEntry(); + String p1Name = + ContactDisplayUtils.getPreferredSortName( + c1.namePrimary, c1.nameAlternative, mContactsPreferences); + p1Name = p1Name != null ? p1Name : ""; + + ContactCacheEntry c2 = p2.getContactCacheEntry(); + String p2Name = + ContactDisplayUtils.getPreferredSortName( + c2.namePrimary, c2.nameAlternative, mContactsPreferences); + p2Name = p2Name != null ? p2Name : ""; + + return p1Name.compareToIgnoreCase(p2Name); + } + }); + } + + private DialerCall getCallFromView(View view) { + View parent = (View) view.getParent(); + String callId = (String) parent.getTag(); + return CallList.getInstance().getCallById(callId); + } + + /** + * Callback class used when making requests to the {@link ContactInfoCache} to resolve contact + * info and contact photos for conference participants. + */ + public static class ContactLookupCallback implements ContactInfoCache.ContactInfoCacheCallback { + + private final WeakReference<ConferenceParticipantListAdapter> mListAdapter; + + public ContactLookupCallback(ConferenceParticipantListAdapter listAdapter) { + mListAdapter = new WeakReference<>(listAdapter); + } + + /** + * Called when contact info has been resolved. + * + * @param callId The call id. + * @param entry The new contact information. + */ + @Override + public void onContactInfoComplete(String callId, ContactCacheEntry entry) { + update(callId, entry); + } + + /** + * Called when contact photo has been loaded into the cache. + * + * @param callId The call id. + * @param entry The new contact information. + */ + @Override + public void onImageLoadComplete(String callId, ContactCacheEntry entry) { + update(callId, entry); + } + + /** + * Updates the contact information for a participant. + * + * @param callId The call id. + * @param entry The new contact information. + */ + private void update(String callId, ContactCacheEntry entry) { + ConferenceParticipantListAdapter listAdapter = mListAdapter.get(); + if (listAdapter != null) { + listAdapter.updateContactInfo(callId, entry); + } + } + } + + /** + * Internal class which represents a participant. Includes a reference to the {@link DialerCall} + * and the corresponding {@link ContactCacheEntry} for the participant. + */ + private static class ParticipantInfo { + + private DialerCall mCall; + private ContactCacheEntry mContactCacheEntry; + private boolean mCacheLookupComplete = false; + + public ParticipantInfo(DialerCall call, ContactCacheEntry contactCacheEntry) { + mCall = call; + mContactCacheEntry = contactCacheEntry; + } + + public DialerCall getCall() { + return mCall; + } + + public void setCall(DialerCall call) { + mCall = call; + } + + public ContactCacheEntry getContactCacheEntry() { + return mContactCacheEntry; + } + + public void setContactCacheEntry(ContactCacheEntry entry) { + mContactCacheEntry = entry; + } + + public boolean isCacheLookupComplete() { + return mCacheLookupComplete; + } + + public void setCacheLookupComplete(boolean cacheLookupComplete) { + mCacheLookupComplete = cacheLookupComplete; + } + + @Override + public boolean equals(Object o) { + if (o instanceof ParticipantInfo) { + ParticipantInfo p = (ParticipantInfo) o; + return Objects.equals(p.getCall().getId(), mCall.getId()); + } + return false; + } + + @Override + public int hashCode() { + return mCall.getId().hashCode(); + } + } +} diff --git a/java/com/android/incallui/ContactInfoCache.java b/java/com/android/incallui/ContactInfoCache.java new file mode 100644 index 000000000..4d4d94a17 --- /dev/null +++ b/java/com/android/incallui/ContactInfoCache.java @@ -0,0 +1,759 @@ +/* + * Copyright (C) 2013 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; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.media.RingtoneManager; +import android.net.Uri; +import android.os.AsyncTask; +import android.os.Build.VERSION; +import android.os.Build.VERSION_CODES; +import android.provider.ContactsContract; +import android.provider.ContactsContract.CommonDataKinds.Phone; +import android.provider.ContactsContract.Contacts; +import android.provider.ContactsContract.DisplayNameSources; +import android.support.annotation.AnyThread; +import android.support.annotation.MainThread; +import android.support.annotation.NonNull; +import android.support.annotation.WorkerThread; +import android.support.v4.os.UserManagerCompat; +import android.telecom.TelecomManager; +import android.text.TextUtils; +import android.util.ArrayMap; +import android.util.ArraySet; +import com.android.contacts.common.ContactsUtils; +import com.android.dialer.common.Assert; +import com.android.dialer.logging.nano.ContactLookupResult; +import com.android.dialer.phonenumbercache.CachedNumberLookupService; +import com.android.dialer.phonenumbercache.CachedNumberLookupService.CachedContactInfo; +import com.android.dialer.phonenumbercache.ContactInfo; +import com.android.dialer.phonenumbercache.PhoneNumberCache; +import com.android.dialer.phonenumberutil.PhoneNumberHelper; +import com.android.dialer.util.MoreStrings; +import com.android.incallui.CallerInfoAsyncQuery.OnQueryCompleteListener; +import com.android.incallui.ContactsAsyncHelper.OnImageLoadCompleteListener; +import com.android.incallui.bindings.PhoneNumberService; +import com.android.incallui.call.DialerCall; +import com.android.incallui.incall.protocol.ContactPhotoType; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import org.json.JSONException; +import org.json.JSONObject; + +/** + * Class responsible for querying Contact Information for DialerCall objects. Can perform + * asynchronous requests to the Contact Provider for information as well as respond synchronously + * for any data that it currently has cached from previous queries. This class always gets called + * from the UI thread so it does not need thread protection. + */ +public class ContactInfoCache implements OnImageLoadCompleteListener { + + private static final String TAG = ContactInfoCache.class.getSimpleName(); + private static final int TOKEN_UPDATE_PHOTO_FOR_CALL_STATE = 0; + private static ContactInfoCache sCache = null; + private final Context mContext; + private final PhoneNumberService mPhoneNumberService; + // Cache info map needs to be thread-safe since it could be modified by both main thread and + // worker thread. + private final Map<String, ContactCacheEntry> mInfoMap = new ConcurrentHashMap<>(); + private final Map<String, Set<ContactInfoCacheCallback>> mCallBacks = new ArrayMap<>(); + private Drawable mDefaultContactPhotoDrawable; + private Drawable mConferencePhotoDrawable; + + private ContactInfoCache(Context context) { + mContext = context; + mPhoneNumberService = Bindings.get(context).newPhoneNumberService(context); + } + + public static synchronized ContactInfoCache getInstance(Context mContext) { + if (sCache == null) { + sCache = new ContactInfoCache(mContext.getApplicationContext()); + } + return sCache; + } + + public static ContactCacheEntry buildCacheEntryFromCall( + Context context, DialerCall call, boolean isIncoming) { + final ContactCacheEntry entry = new ContactCacheEntry(); + + // TODO: get rid of caller info. + final CallerInfo info = CallerInfoUtils.buildCallerInfo(context, call); + ContactInfoCache.populateCacheEntry( + context, info, entry, call.getNumberPresentation(), isIncoming); + return entry; + } + + /** Populate a cache entry from a call (which got converted into a caller info). */ + public static void populateCacheEntry( + @NonNull Context context, + @NonNull CallerInfo info, + @NonNull ContactCacheEntry cce, + int presentation, + boolean isIncoming) { + Objects.requireNonNull(info); + String displayName = null; + String displayNumber = null; + String displayLocation = null; + String label = null; + boolean isSipCall = false; + + // It appears that there is a small change in behaviour with the + // PhoneUtils' startGetCallerInfo whereby if we query with an + // empty number, we will get a valid CallerInfo object, but with + // fields that are all null, and the isTemporary boolean input + // parameter as true. + + // In the past, we would see a NULL callerinfo object, but this + // ends up causing null pointer exceptions elsewhere down the + // line in other cases, so we need to make this fix instead. It + // appears that this was the ONLY call to PhoneUtils + // .getCallerInfo() that relied on a NULL CallerInfo to indicate + // an unknown contact. + + // Currently, info.phoneNumber may actually be a SIP address, and + // if so, it might sometimes include the "sip:" prefix. That + // prefix isn't really useful to the user, though, so strip it off + // if present. (For any other URI scheme, though, leave the + // prefix alone.) + // TODO: It would be cleaner for CallerInfo to explicitly support + // SIP addresses instead of overloading the "phoneNumber" field. + // Then we could remove this hack, and instead ask the CallerInfo + // for a "user visible" form of the SIP address. + String number = info.phoneNumber; + + if (!TextUtils.isEmpty(number)) { + isSipCall = PhoneNumberHelper.isUriNumber(number); + if (number.startsWith("sip:")) { + number = number.substring(4); + } + } + + if (TextUtils.isEmpty(info.name)) { + // No valid "name" in the CallerInfo, so fall back to + // something else. + // (Typically, we promote the phone number up to the "name" slot + // onscreen, and possibly display a descriptive string in the + // "number" slot.) + if (TextUtils.isEmpty(number)) { + // No name *or* number! Display a generic "unknown" string + // (or potentially some other default based on the presentation.) + displayName = getPresentationString(context, presentation, info.callSubject); + Log.d(TAG, " ==> no name *or* number! displayName = " + displayName); + } else if (presentation != TelecomManager.PRESENTATION_ALLOWED) { + // This case should never happen since the network should never send a phone # + // AND a restricted presentation. However we leave it here in case of weird + // network behavior + displayName = getPresentationString(context, presentation, info.callSubject); + Log.d(TAG, " ==> presentation not allowed! displayName = " + displayName); + } else if (!TextUtils.isEmpty(info.cnapName)) { + // No name, but we do have a valid CNAP name, so use that. + displayName = info.cnapName; + info.name = info.cnapName; + displayNumber = PhoneNumberHelper.formatNumber(number, context); + Log.d( + TAG, + " ==> cnapName available: displayName '" + + displayName + + "', displayNumber '" + + displayNumber + + "'"); + } else { + // No name; all we have is a number. This is the typical + // case when an incoming call doesn't match any contact, + // or if you manually dial an outgoing number using the + // dialpad. + displayNumber = PhoneNumberHelper.formatNumber(number, context); + + // Display a geographical description string if available + // (but only for incoming calls.) + if (isIncoming) { + // TODO (CallerInfoAsyncQuery cleanup): Fix the CallerInfo + // query to only do the geoDescription lookup in the first + // place for incoming calls. + displayLocation = info.geoDescription; // may be null + Log.d(TAG, "Geodescrption: " + info.geoDescription); + } + + Log.d( + TAG, + " ==> no name; falling back to number:" + + " displayNumber '" + + Log.pii(displayNumber) + + "', displayLocation '" + + displayLocation + + "'"); + } + } else { + // We do have a valid "name" in the CallerInfo. Display that + // in the "name" slot, and the phone number in the "number" slot. + if (presentation != TelecomManager.PRESENTATION_ALLOWED) { + // This case should never happen since the network should never send a name + // AND a restricted presentation. However we leave it here in case of weird + // network behavior + displayName = getPresentationString(context, presentation, info.callSubject); + Log.d( + TAG, + " ==> valid name, but presentation not allowed!" + " displayName = " + displayName); + } else { + // Causes cce.namePrimary to be set as info.name below. CallCardPresenter will + // later determine whether to use the name or nameAlternative when presenting + displayName = info.name; + cce.nameAlternative = info.nameAlternative; + displayNumber = PhoneNumberHelper.formatNumber(number, context); + label = info.phoneLabel; + Log.d( + TAG, + " ==> name is present in CallerInfo: displayName '" + + displayName + + "', displayNumber '" + + displayNumber + + "'"); + } + } + + cce.namePrimary = displayName; + cce.number = displayNumber; + cce.location = displayLocation; + cce.label = label; + cce.isSipCall = isSipCall; + cce.userType = info.userType; + + if (info.contactExists) { + cce.contactLookupResult = ContactLookupResult.Type.LOCAL_CONTACT; + } + } + + /** Gets name strings based on some special presentation modes and the associated custom label. */ + private static String getPresentationString( + Context context, int presentation, String customLabel) { + String name = context.getString(R.string.unknown); + if (!TextUtils.isEmpty(customLabel) + && ((presentation == TelecomManager.PRESENTATION_UNKNOWN) + || (presentation == TelecomManager.PRESENTATION_RESTRICTED))) { + name = customLabel; + return name; + } else { + if (presentation == TelecomManager.PRESENTATION_RESTRICTED) { + name = PhoneNumberHelper.getDisplayNameForRestrictedNumber(context).toString(); + } else if (presentation == TelecomManager.PRESENTATION_PAYPHONE) { + name = context.getString(R.string.payphone); + } + } + return name; + } + + public ContactCacheEntry getInfo(String callId) { + return mInfoMap.get(callId); + } + + public void maybeInsertCnapInformationIntoCache( + Context context, final DialerCall call, final CallerInfo info) { + final CachedNumberLookupService cachedNumberLookupService = + PhoneNumberCache.get(context).getCachedNumberLookupService(); + if (!UserManagerCompat.isUserUnlocked(context)) { + Log.i(TAG, "User locked, not inserting cnap info into cache"); + return; + } + if (cachedNumberLookupService == null + || TextUtils.isEmpty(info.cnapName) + || mInfoMap.get(call.getId()) != null) { + return; + } + final Context applicationContext = context.getApplicationContext(); + Log.i(TAG, "Found contact with CNAP name - inserting into cache"); + new AsyncTask<Void, Void, Void>() { + @Override + protected Void doInBackground(Void... params) { + ContactInfo contactInfo = new ContactInfo(); + CachedContactInfo cacheInfo = cachedNumberLookupService.buildCachedContactInfo(contactInfo); + cacheInfo.setSource(CachedContactInfo.SOURCE_TYPE_CNAP, "CNAP", 0); + contactInfo.name = info.cnapName; + contactInfo.number = call.getNumber(); + contactInfo.type = ContactsContract.CommonDataKinds.Phone.TYPE_MAIN; + try { + final JSONObject contactRows = + new JSONObject() + .put( + Phone.CONTENT_ITEM_TYPE, + new JSONObject() + .put(Phone.NUMBER, contactInfo.number) + .put(Phone.TYPE, Phone.TYPE_MAIN)); + final String jsonString = + new JSONObject() + .put(Contacts.DISPLAY_NAME, contactInfo.name) + .put(Contacts.DISPLAY_NAME_SOURCE, DisplayNameSources.STRUCTURED_NAME) + .put(Contacts.CONTENT_ITEM_TYPE, contactRows) + .toString(); + cacheInfo.setLookupKey(jsonString); + } catch (JSONException e) { + Log.w(TAG, "Creation of lookup key failed when caching CNAP information"); + } + cachedNumberLookupService.addContact(applicationContext, cacheInfo); + return null; + } + }.execute(); + } + + /** + * Requests contact data for the DialerCall object passed in. Returns the data through callback. + * If callback is null, no response is made, however the query is still performed and cached. + * + * @param callback The function to call back when the call is found. Can be null. + */ + @MainThread + public void findInfo( + @NonNull final DialerCall call, + final boolean isIncoming, + @NonNull ContactInfoCacheCallback callback) { + Assert.isMainThread(); + Objects.requireNonNull(callback); + + final String callId = call.getId(); + final ContactCacheEntry cacheEntry = mInfoMap.get(callId); + Set<ContactInfoCacheCallback> callBacks = mCallBacks.get(callId); + + // If we have a previously obtained intermediate result return that now + if (cacheEntry != null) { + Log.d( + TAG, + "Contact lookup. In memory cache hit; lookup " + + (callBacks == null ? "complete" : "still running")); + callback.onContactInfoComplete(callId, cacheEntry); + // If no other callbacks are in flight, we're done. + if (callBacks == null) { + return; + } + } + + // If the entry already exists, add callback + if (callBacks != null) { + callBacks.add(callback); + return; + } + Log.d(TAG, "Contact lookup. In memory cache miss; searching provider."); + // New lookup + callBacks = new ArraySet<>(); + callBacks.add(callback); + mCallBacks.put(callId, callBacks); + + /** + * Performs a query for caller information. Save any immediate data we get from the query. An + * asynchronous query may also be made for any data that we do not already have. Some queries, + * such as those for voicemail and emergency call information, will not perform an additional + * asynchronous query. + */ + final CallerInfo callerInfo = + CallerInfoUtils.getCallerInfoForCall( + mContext, + call, + new DialerCallCookieWrapper(callId, call.getNumberPresentation()), + new FindInfoCallback(isIncoming)); + + updateCallerInfoInCacheOnAnyThread( + callId, call.getNumberPresentation(), callerInfo, isIncoming, false); + sendInfoNotifications(callId, mInfoMap.get(callId)); + } + + @AnyThread + private void updateCallerInfoInCacheOnAnyThread( + String callId, + int numberPresentation, + CallerInfo callerInfo, + boolean isIncoming, + boolean didLocalLookup) { + int presentationMode = numberPresentation; + if (callerInfo.contactExists + || callerInfo.isEmergencyNumber() + || callerInfo.isVoiceMailNumber()) { + presentationMode = TelecomManager.PRESENTATION_ALLOWED; + } + + synchronized (mInfoMap) { + ContactCacheEntry cacheEntry = mInfoMap.get(callId); + // Ensure we always have a cacheEntry. Replace the existing entry if + // it has no name or if we found a local contact. + if (cacheEntry == null + || TextUtils.isEmpty(cacheEntry.namePrimary) + || callerInfo.contactExists) { + cacheEntry = buildEntry(mContext, callerInfo, presentationMode, isIncoming); + mInfoMap.put(callId, cacheEntry); + } + if (didLocalLookup) { + // Before issuing a request for more data from other services, we only check that the + // contact wasn't found in the local DB. We don't check the if the cache entry already + // has a name because we allow overriding cnap data with data from other services. + if (!callerInfo.contactExists && mPhoneNumberService != null) { + Log.d(TAG, "Contact lookup. Local contacts miss, checking remote"); + final PhoneNumberServiceListener listener = new PhoneNumberServiceListener(callId); + mPhoneNumberService.getPhoneNumberInfo(cacheEntry.number, listener, listener, isIncoming); + } else if (cacheEntry.displayPhotoUri != null) { + Log.d(TAG, "Contact lookup. Local contact found, starting image load"); + // Load the image with a callback to update the image state. + // When the load is finished, onImageLoadComplete() will be called. + cacheEntry.hasPhotoToLoad = true; + ContactsAsyncHelper.startObtainPhotoAsync( + TOKEN_UPDATE_PHOTO_FOR_CALL_STATE, + mContext, + cacheEntry.displayPhotoUri, + ContactInfoCache.this, + callId); + } + } + } + } + + /** + * Implemented for ContactsAsyncHelper.OnImageLoadCompleteListener interface. Update contact photo + * when image is loaded in worker thread. + */ + @WorkerThread + @Override + public void onImageLoaded(int token, Drawable photo, Bitmap photoIcon, Object cookie) { + Assert.isWorkerThread(); + loadImage(photo, photoIcon, cookie); + } + + private void loadImage(Drawable photo, Bitmap photoIcon, Object cookie) { + Log.d(this, "Image load complete with context: ", mContext); + // TODO: may be nice to update the image view again once the newer one + // is available on contacts database. + String callId = (String) cookie; + ContactCacheEntry entry = mInfoMap.get(callId); + + if (entry == null) { + Log.e(this, "Image Load received for empty search entry."); + clearCallbacks(callId); + return; + } + + Log.d(this, "setting photo for entry: ", entry); + + // Conference call icons are being handled in CallCardPresenter. + if (photo != null) { + Log.v(this, "direct drawable: ", photo); + entry.photo = photo; + entry.photoType = ContactPhotoType.CONTACT; + } else if (photoIcon != null) { + Log.v(this, "photo icon: ", photoIcon); + entry.photo = new BitmapDrawable(mContext.getResources(), photoIcon); + entry.photoType = ContactPhotoType.CONTACT; + } else { + Log.v(this, "unknown photo"); + entry.photo = null; + entry.photoType = ContactPhotoType.DEFAULT_PLACEHOLDER; + } + } + + /** + * Implemented for ContactsAsyncHelper.OnImageLoadCompleteListener interface. make sure that the + * call state is reflected after the image is loaded. + */ + @MainThread + @Override + public void onImageLoadComplete(int token, Drawable photo, Bitmap photoIcon, Object cookie) { + Assert.isMainThread(); + String callId = (String) cookie; + ContactCacheEntry entry = mInfoMap.get(callId); + sendImageNotifications(callId, entry); + + clearCallbacks(callId); + } + + /** Blows away the stored cache values. */ + public void clearCache() { + mInfoMap.clear(); + mCallBacks.clear(); + } + + private ContactCacheEntry buildEntry( + Context context, CallerInfo info, int presentation, boolean isIncoming) { + final ContactCacheEntry cce = new ContactCacheEntry(); + populateCacheEntry(context, info, cce, presentation, isIncoming); + + // This will only be true for emergency numbers + if (info.photoResource != 0) { + cce.photo = context.getResources().getDrawable(info.photoResource); + } else if (info.isCachedPhotoCurrent) { + if (info.cachedPhoto != null) { + cce.photo = info.cachedPhoto; + cce.photoType = ContactPhotoType.CONTACT; + } else { + cce.photo = getDefaultContactPhotoDrawable(); + cce.photoType = ContactPhotoType.DEFAULT_PLACEHOLDER; + } + } else if (info.contactDisplayPhotoUri == null) { + cce.photo = getDefaultContactPhotoDrawable(); + cce.photoType = ContactPhotoType.DEFAULT_PLACEHOLDER; + } else { + cce.displayPhotoUri = info.contactDisplayPhotoUri; + cce.photo = null; + } + + // Support any contact id in N because QuickContacts in N starts supporting enterprise + // contact id + if (info.lookupKeyOrNull != null + && (VERSION.SDK_INT >= VERSION_CODES.N || info.contactIdOrZero != 0)) { + cce.lookupUri = Contacts.getLookupUri(info.contactIdOrZero, info.lookupKeyOrNull); + } else { + Log.v(TAG, "lookup key is null or contact ID is 0 on M. Don't create a lookup uri."); + cce.lookupUri = null; + } + + cce.lookupKey = info.lookupKeyOrNull; + cce.contactRingtoneUri = info.contactRingtoneUri; + if (cce.contactRingtoneUri == null || Uri.EMPTY.equals(cce.contactRingtoneUri)) { + cce.contactRingtoneUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE); + } + + return cce; + } + + /** Sends the updated information to call the callbacks for the entry. */ + private void sendInfoNotifications(String callId, ContactCacheEntry entry) { + final Set<ContactInfoCacheCallback> callBacks = mCallBacks.get(callId); + if (callBacks != null) { + for (ContactInfoCacheCallback callBack : callBacks) { + callBack.onContactInfoComplete(callId, entry); + } + } + } + + private void sendImageNotifications(String callId, ContactCacheEntry entry) { + final Set<ContactInfoCacheCallback> callBacks = mCallBacks.get(callId); + if (callBacks != null && entry.photo != null) { + for (ContactInfoCacheCallback callBack : callBacks) { + callBack.onImageLoadComplete(callId, entry); + } + } + } + + private void clearCallbacks(String callId) { + mCallBacks.remove(callId); + } + + public Drawable getDefaultContactPhotoDrawable() { + if (mDefaultContactPhotoDrawable == null) { + mDefaultContactPhotoDrawable = + mContext.getResources().getDrawable(R.drawable.img_no_image_automirrored); + } + return mDefaultContactPhotoDrawable; + } + + public Drawable getConferenceDrawable() { + if (mConferencePhotoDrawable == null) { + mConferencePhotoDrawable = + mContext.getResources().getDrawable(R.drawable.img_conference_automirrored); + } + return mConferencePhotoDrawable; + } + + /** Callback interface for the contact query. */ + public interface ContactInfoCacheCallback { + + void onContactInfoComplete(String callId, ContactCacheEntry entry); + + void onImageLoadComplete(String callId, ContactCacheEntry entry); + } + + /** This is cached contact info, which should be the ONLY info used by UI. */ + public static class ContactCacheEntry { + + public String namePrimary; + public String nameAlternative; + public String number; + public String location; + public String label; + public Drawable photo; + @ContactPhotoType public int photoType; + public boolean isSipCall; + // Note in cache entry whether this is a pending async loading action to know whether to + // wait for its callback or not. + public boolean hasPhotoToLoad; + /** This will be used for the "view" notification. */ + public Uri contactUri; + /** Either a display photo or a thumbnail URI. */ + public Uri displayPhotoUri; + + public Uri lookupUri; // Sent to NotificationMananger + public String lookupKey; + public int contactLookupResult = ContactLookupResult.Type.NOT_FOUND; + public long userType = ContactsUtils.USER_TYPE_CURRENT; + public Uri contactRingtoneUri; + + @Override + public String toString() { + return "ContactCacheEntry{" + + "name='" + + MoreStrings.toSafeString(namePrimary) + + '\'' + + ", nameAlternative='" + + MoreStrings.toSafeString(nameAlternative) + + '\'' + + ", number='" + + MoreStrings.toSafeString(number) + + '\'' + + ", location='" + + MoreStrings.toSafeString(location) + + '\'' + + ", label='" + + label + + '\'' + + ", photo=" + + photo + + ", isSipCall=" + + isSipCall + + ", contactUri=" + + contactUri + + ", displayPhotoUri=" + + displayPhotoUri + + ", contactLookupResult=" + + contactLookupResult + + ", userType=" + + userType + + ", contactRingtoneUri=" + + contactRingtoneUri + + '}'; + } + } + + private static final class DialerCallCookieWrapper { + public final String callId; + public final int numberPresentation; + + public DialerCallCookieWrapper(String callId, int numberPresentation) { + this.callId = callId; + this.numberPresentation = numberPresentation; + } + } + + private class FindInfoCallback implements OnQueryCompleteListener { + + private final boolean mIsIncoming; + + public FindInfoCallback(boolean isIncoming) { + mIsIncoming = isIncoming; + } + + @Override + public void onDataLoaded(int token, Object cookie, CallerInfo ci) { + Assert.isWorkerThread(); + DialerCallCookieWrapper cw = (DialerCallCookieWrapper) cookie; + updateCallerInfoInCacheOnAnyThread(cw.callId, cw.numberPresentation, ci, mIsIncoming, true); + } + + @Override + public void onQueryComplete(int token, Object cookie, CallerInfo callerInfo) { + Assert.isMainThread(); + DialerCallCookieWrapper cw = (DialerCallCookieWrapper) cookie; + String callId = cw.callId; + ContactCacheEntry cacheEntry = mInfoMap.get(callId); + // This may happen only when InCallPresenter attempt to cleanup. + if (cacheEntry == null) { + Log.w(TAG, "Contact lookup done, but cache entry is not found."); + clearCallbacks(callId); + return; + } + sendInfoNotifications(callId, cacheEntry); + if (!cacheEntry.hasPhotoToLoad) { + if (callerInfo.contactExists) { + Log.d(TAG, "Contact lookup done. Local contact found, no image."); + } else { + Log.d( + TAG, + "Contact lookup done. Local contact not found and" + + " no remote lookup service available."); + } + clearCallbacks(callId); + } + } + } + + class PhoneNumberServiceListener + implements PhoneNumberService.NumberLookupListener, PhoneNumberService.ImageLookupListener { + + private final String mCallId; + + PhoneNumberServiceListener(String callId) { + mCallId = callId; + } + + @Override + public void onPhoneNumberInfoComplete(final PhoneNumberService.PhoneNumberInfo info) { + // If we got a miss, this is the end of the lookup pipeline, + // so clear the callbacks and return. + if (info == null) { + Log.d(TAG, "Contact lookup done. Remote contact not found."); + clearCallbacks(mCallId); + return; + } + + ContactCacheEntry entry = new ContactCacheEntry(); + entry.namePrimary = info.getDisplayName(); + entry.number = info.getNumber(); + entry.contactLookupResult = info.getLookupSource(); + final int type = info.getPhoneType(); + final String label = info.getPhoneLabel(); + if (type == Phone.TYPE_CUSTOM) { + entry.label = label; + } else { + final CharSequence typeStr = Phone.getTypeLabel(mContext.getResources(), type, label); + entry.label = typeStr == null ? null : typeStr.toString(); + } + synchronized (mInfoMap) { + final ContactCacheEntry oldEntry = mInfoMap.get(mCallId); + if (oldEntry != null) { + // Location is only obtained from local lookup so persist + // the value for remote lookups. Once we have a name this + // field is no longer used; it is persisted here in case + // the UI is ever changed to use it. + entry.location = oldEntry.location; + // Contact specific ringtone is obtained from local lookup. + entry.contactRingtoneUri = oldEntry.contactRingtoneUri; + } + + // If no image and it's a business, switch to using the default business avatar. + if (info.getImageUrl() == null && info.isBusiness()) { + Log.d(TAG, "Business has no image. Using default."); + entry.photo = mContext.getResources().getDrawable(R.drawable.img_business); + entry.photoType = ContactPhotoType.BUSINESS; + } + + mInfoMap.put(mCallId, entry); + } + sendInfoNotifications(mCallId, entry); + + entry.hasPhotoToLoad = info.getImageUrl() != null; + + // If there is no image then we should not expect another callback. + if (!entry.hasPhotoToLoad) { + // We're done, so clear callbacks + clearCallbacks(mCallId); + } + } + + @Override + public void onImageFetchComplete(Bitmap bitmap) { + loadImage(null, bitmap, mCallId); + onImageLoadComplete(TOKEN_UPDATE_PHOTO_FOR_CALL_STATE, null, bitmap, mCallId); + } + } +} diff --git a/java/com/android/incallui/ContactsAsyncHelper.java b/java/com/android/incallui/ContactsAsyncHelper.java new file mode 100644 index 000000000..08ff74d0e --- /dev/null +++ b/java/com/android/incallui/ContactsAsyncHelper.java @@ -0,0 +1,269 @@ +/* + * Copyright (C) 2008 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; + +import android.app.Notification; +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.net.Uri; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.Looper; +import android.os.Message; +import android.support.annotation.MainThread; +import android.support.annotation.WorkerThread; +import java.io.IOException; +import java.io.InputStream; + +/** Helper class for loading contacts photo asynchronously. */ +public class ContactsAsyncHelper { + + /** Interface for a WorkerHandler result return. */ + public interface OnImageLoadCompleteListener { + + /** + * Called when the image load is complete. Must be called in main thread. + * + * @param token Integer passed in {@link ContactsAsyncHelper#startObtainPhotoAsync(int, Context, + * Uri, OnImageLoadCompleteListener, Object)}. + * @param photo Drawable object obtained by the async load. + * @param photoIcon Bitmap object obtained by the async load. + * @param cookie Object passed in {@link ContactsAsyncHelper#startObtainPhotoAsync(int, Context, + * Uri, OnImageLoadCompleteListener, Object)}. Can be null iff. the original cookie is null. + */ + @MainThread + void onImageLoadComplete(int token, Drawable photo, Bitmap photoIcon, Object cookie); + + /** Called when image is loaded to udpate data. Must be called in worker thread. */ + @WorkerThread + void onImageLoaded(int token, Drawable photo, Bitmap photoIcon, Object cookie); + } + + // constants + private static final int EVENT_LOAD_IMAGE = 1; + /** Handler run on a worker thread to load photo asynchronously. */ + private static Handler sThreadHandler; + /** For forcing the system to call its constructor */ + @SuppressWarnings("unused") + private static ContactsAsyncHelper sInstance; + + static { + sInstance = new ContactsAsyncHelper(); + } + + private final Handler mResultHandler = + /** A handler that handles message to call listener notifying UI change on main thread. */ + new Handler(Looper.getMainLooper()) { + @Override + public void handleMessage(Message msg) { + WorkerArgs args = (WorkerArgs) msg.obj; + switch (msg.arg1) { + case EVENT_LOAD_IMAGE: + if (args.listener != null) { + Log.d( + this, + "Notifying listener: " + + args.listener.toString() + + " image: " + + args.displayPhotoUri + + " completed"); + args.listener.onImageLoadComplete( + msg.what, args.photo, args.photoIcon, args.cookie); + } + break; + default: + } + } + }; + + /** Private constructor for static class */ + private ContactsAsyncHelper() { + HandlerThread thread = new HandlerThread("ContactsAsyncWorker"); + thread.start(); + sThreadHandler = new WorkerHandler(thread.getLooper()); + } + + /** + * Starts an asynchronous image load. After finishing the load, {@link + * OnImageLoadCompleteListener#onImageLoadComplete(int, Drawable, Bitmap, Object)} will be called. + * + * @param token Arbitrary integer which will be returned as the first argument of {@link + * OnImageLoadCompleteListener#onImageLoadComplete(int, Drawable, Bitmap, Object)} + * @param context Context object used to do the time-consuming operation. + * @param displayPhotoUri Uri to be used to fetch the photo + * @param listener Callback object which will be used when the asynchronous load is done. Can be + * null, which means only the asynchronous load is done while there's no way to obtain the + * loaded photos. + * @param cookie Arbitrary object the caller wants to remember, which will become the fourth + * argument of {@link OnImageLoadCompleteListener#onImageLoadComplete(int, Drawable, Bitmap, + * Object)}. Can be null, at which the callback will also has null for the argument. + */ + public static final void startObtainPhotoAsync( + int token, + Context context, + Uri displayPhotoUri, + OnImageLoadCompleteListener listener, + Object cookie) { + // in case the source caller info is null, the URI will be null as well. + // just update using the placeholder image in this case. + if (displayPhotoUri == null) { + Log.e("startObjectPhotoAsync", "Uri is missing"); + return; + } + + // Added additional Cookie field in the callee to handle arguments + // sent to the callback function. + + // setup arguments + WorkerArgs args = new WorkerArgs(); + args.cookie = cookie; + args.context = context; + args.displayPhotoUri = displayPhotoUri; + args.listener = listener; + + // setup message arguments + Message msg = sThreadHandler.obtainMessage(token); + msg.arg1 = EVENT_LOAD_IMAGE; + msg.obj = args; + + Log.d( + "startObjectPhotoAsync", + "Begin loading image: " + args.displayPhotoUri + ", displaying default image for now."); + + // notify the thread to begin working + sThreadHandler.sendMessage(msg); + } + + private static final class WorkerArgs { + + public Context context; + public Uri displayPhotoUri; + public Drawable photo; + public Bitmap photoIcon; + public Object cookie; + public OnImageLoadCompleteListener listener; + } + + /** Thread worker class that handles the task of opening the stream and loading the images. */ + private class WorkerHandler extends Handler { + + public WorkerHandler(Looper looper) { + super(looper); + } + + @Override + public void handleMessage(Message msg) { + WorkerArgs args = (WorkerArgs) msg.obj; + + switch (msg.arg1) { + case EVENT_LOAD_IMAGE: + InputStream inputStream = null; + try { + try { + inputStream = args.context.getContentResolver().openInputStream(args.displayPhotoUri); + } catch (Exception e) { + Log.e(this, "Error opening photo input stream", e); + } + + if (inputStream != null) { + args.photo = Drawable.createFromStream(inputStream, args.displayPhotoUri.toString()); + + // This assumes Drawable coming from contact database is usually + // BitmapDrawable and thus we can have (down)scaled version of it. + args.photoIcon = getPhotoIconWhenAppropriate(args.context, args.photo); + + Log.d( + ContactsAsyncHelper.this, + "Loading image: " + + msg.arg1 + + " token: " + + msg.what + + " image URI: " + + args.displayPhotoUri); + } else { + args.photo = null; + args.photoIcon = null; + Log.d( + ContactsAsyncHelper.this, + "Problem with image: " + + msg.arg1 + + " token: " + + msg.what + + " image URI: " + + args.displayPhotoUri + + ", using default image."); + } + if (args.listener != null) { + args.listener.onImageLoaded(msg.what, args.photo, args.photoIcon, args.cookie); + } + } finally { + if (inputStream != null) { + try { + inputStream.close(); + } catch (IOException e) { + Log.e(this, "Unable to close input stream.", e); + } + } + } + break; + default: + } + + // send the reply to the enclosing class. + Message reply = ContactsAsyncHelper.this.mResultHandler.obtainMessage(msg.what); + reply.arg1 = msg.arg1; + reply.obj = msg.obj; + reply.sendToTarget(); + } + + /** + * Returns a Bitmap object suitable for {@link Notification}'s large icon. This might return + * null when the given Drawable isn't BitmapDrawable, or if the system fails to create a scaled + * Bitmap for the Drawable. + */ + private Bitmap getPhotoIconWhenAppropriate(Context context, Drawable photo) { + if (!(photo instanceof BitmapDrawable)) { + return null; + } + int iconSize = context.getResources().getDimensionPixelSize(R.dimen.notification_icon_size); + Bitmap orgBitmap = ((BitmapDrawable) photo).getBitmap(); + int orgWidth = orgBitmap.getWidth(); + int orgHeight = orgBitmap.getHeight(); + int longerEdge = orgWidth > orgHeight ? orgWidth : orgHeight; + // We want downscaled one only when the original icon is too big. + if (longerEdge > iconSize) { + float ratio = ((float) longerEdge) / iconSize; + int newWidth = (int) (orgWidth / ratio); + int newHeight = (int) (orgHeight / ratio); + // If the longer edge is much longer than the shorter edge, the latter may + // become 0 which will cause a crash. + if (newWidth <= 0 || newHeight <= 0) { + Log.w(this, "Photo icon's width or height become 0."); + return null; + } + + // It is sure ratio >= 1.0f in any case and thus the newly created Bitmap + // should be smaller than the original. + return Bitmap.createScaledBitmap(orgBitmap, newWidth, newHeight, true); + } else { + return orgBitmap; + } + } + } +} diff --git a/java/com/android/incallui/ContactsPreferencesFactory.java b/java/com/android/incallui/ContactsPreferencesFactory.java new file mode 100644 index 000000000..429de7bc9 --- /dev/null +++ b/java/com/android/incallui/ContactsPreferencesFactory.java @@ -0,0 +1,56 @@ +/* + * 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; + +import android.content.Context; +import android.support.annotation.Nullable; +import android.support.v4.os.UserManagerCompat; +import com.android.contacts.common.preference.ContactsPreferences; + +/** Factory class for {@link ContactsPreferences}. */ +public class ContactsPreferencesFactory { + + private static boolean sUseTestInstance; + private static ContactsPreferences sTestInstance; + + /** + * Creates a new {@link ContactsPreferences} object if possible. + * + * @param context the context to use when creating the ContactsPreferences. + * @return a new ContactsPreferences object or {@code null} if the user is locked. + */ + @Nullable + public static ContactsPreferences newContactsPreferences(Context context) { + if (sUseTestInstance) { + return sTestInstance; + } + if (UserManagerCompat.isUserUnlocked(context)) { + return new ContactsPreferences(context); + } + return null; + } + + /** + * Sets the instance to be returned by all calls to {@link #newContactsPreferences(Context)}. + * + * @param testInstance the instance to return. + */ + static void setTestInstance(ContactsPreferences testInstance) { + sUseTestInstance = true; + sTestInstance = testInstance; + } +} diff --git a/java/com/android/incallui/DialpadFragment.java b/java/com/android/incallui/DialpadFragment.java new file mode 100644 index 000000000..7f494aa61 --- /dev/null +++ b/java/com/android/incallui/DialpadFragment.java @@ -0,0 +1,461 @@ +/* + * Copyright (C) 2013 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; + +import android.content.Context; +import android.os.Bundle; +import android.text.Editable; +import android.text.method.DialerKeyListener; +import android.util.ArrayMap; +import android.util.AttributeSet; +import android.view.KeyEvent; +import android.view.LayoutInflater; +import android.view.View; +import android.view.View.OnClickListener; +import android.view.View.OnKeyListener; +import android.view.ViewGroup; +import android.widget.EditText; +import android.widget.LinearLayout; +import android.widget.TextView; +import com.android.contacts.common.compat.PhoneNumberUtilsCompat; +import com.android.dialer.dialpadview.DialpadKeyButton; +import com.android.dialer.dialpadview.DialpadKeyButton.OnPressedListener; +import com.android.dialer.dialpadview.DialpadView; +import com.android.incallui.DialpadPresenter.DialpadUi; +import com.android.incallui.baseui.BaseFragment; +import java.util.Map; + +/** Fragment for call control buttons */ +public class DialpadFragment extends BaseFragment<DialpadPresenter, DialpadUi> + implements DialpadUi, OnKeyListener, OnClickListener, OnPressedListener { + + /** Hash Map to map a view id to a character */ + private static final Map<Integer, Character> mDisplayMap = new ArrayMap<>(); + + /** Set up the static maps */ + static { + // Map the buttons to the display characters + mDisplayMap.put(R.id.one, '1'); + mDisplayMap.put(R.id.two, '2'); + mDisplayMap.put(R.id.three, '3'); + mDisplayMap.put(R.id.four, '4'); + mDisplayMap.put(R.id.five, '5'); + mDisplayMap.put(R.id.six, '6'); + mDisplayMap.put(R.id.seven, '7'); + mDisplayMap.put(R.id.eight, '8'); + mDisplayMap.put(R.id.nine, '9'); + mDisplayMap.put(R.id.zero, '0'); + mDisplayMap.put(R.id.pound, '#'); + mDisplayMap.put(R.id.star, '*'); + } + + private final int[] mButtonIds = + new int[] { + R.id.zero, + R.id.one, + R.id.two, + R.id.three, + R.id.four, + R.id.five, + R.id.six, + R.id.seven, + R.id.eight, + R.id.nine, + R.id.star, + R.id.pound + }; + private EditText mDtmfDialerField; + // KeyListener used with the "dialpad digits" EditText widget. + private DTMFKeyListener mDialerKeyListener; + private DialpadView mDialpadView; + private int mCurrentTextColor; + + @Override + public void onClick(View v) { + if (v.getId() == R.id.dialpad_back) { + getActivity().onBackPressed(); + } + } + + @Override + public boolean onKey(View v, int keyCode, KeyEvent event) { + Log.d(this, "onKey: keyCode " + keyCode + ", view " + v); + + if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER || keyCode == KeyEvent.KEYCODE_ENTER) { + int viewId = v.getId(); + if (mDisplayMap.containsKey(viewId)) { + switch (event.getAction()) { + case KeyEvent.ACTION_DOWN: + if (event.getRepeatCount() == 0) { + getPresenter().processDtmf(mDisplayMap.get(viewId)); + } + break; + case KeyEvent.ACTION_UP: + getPresenter().stopDtmf(); + break; + } + // do not return true [handled] here, since we want the + // press / click animation to be handled by the framework. + } + } + return false; + } + + @Override + public DialpadPresenter createPresenter() { + return new DialpadPresenter(); + } + + @Override + public DialpadPresenter.DialpadUi getUi() { + return this; + } + + // TODO Adds hardware keyboard listener + + @Override + public View onCreateView( + LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + final View parent = inflater.inflate(R.layout.incall_dialpad_fragment, container, false); + mDialpadView = (DialpadView) parent.findViewById(R.id.dialpad_view); + mDialpadView.setCanDigitsBeEdited(false); + mDialpadView.setBackgroundResource(R.color.incall_dialpad_background); + mDtmfDialerField = (EditText) parent.findViewById(R.id.digits); + if (mDtmfDialerField != null) { + mDialerKeyListener = new DTMFKeyListener(); + mDtmfDialerField.setKeyListener(mDialerKeyListener); + // remove the long-press context menus that support + // the edit (copy / paste / select) functions. + mDtmfDialerField.setLongClickable(false); + mDtmfDialerField.setElegantTextHeight(false); + configureKeypadListeners(); + } + View backButton = mDialpadView.findViewById(R.id.dialpad_back); + backButton.setVisibility(View.VISIBLE); + backButton.setOnClickListener(this); + + return parent; + } + + @Override + public void onResume() { + super.onResume(); + updateColors(); + } + + public void updateColors() { + int textColor = InCallPresenter.getInstance().getThemeColorManager().getPrimaryColor(); + + if (mCurrentTextColor == textColor) { + return; + } + + DialpadKeyButton dialpadKey; + for (int i = 0; i < mButtonIds.length; i++) { + dialpadKey = (DialpadKeyButton) mDialpadView.findViewById(mButtonIds[i]); + ((TextView) dialpadKey.findViewById(R.id.dialpad_key_number)).setTextColor(textColor); + } + + mCurrentTextColor = textColor; + } + + @Override + public void onDestroyView() { + mDialerKeyListener = null; + super.onDestroyView(); + } + + /** + * Getter for Dialpad text. + * + * @return String containing current Dialpad EditText text. + */ + public String getDtmfText() { + return mDtmfDialerField.getText().toString(); + } + + /** + * Sets the Dialpad text field with some text. + * + * @param text Text to set Dialpad EditText to. + */ + public void setDtmfText(String text) { + mDtmfDialerField.setText(PhoneNumberUtilsCompat.createTtsSpannable(text)); + } + + @Override + public void setVisible(boolean on) { + if (on) { + getView().setVisibility(View.VISIBLE); + } else { + getView().setVisibility(View.INVISIBLE); + } + } + + /** Starts the slide up animation for the Dialpad keys when the Dialpad is revealed. */ + public void animateShowDialpad() { + final DialpadView dialpadView = (DialpadView) getView().findViewById(R.id.dialpad_view); + dialpadView.animateShow(); + } + + @Override + public void appendDigitsToField(char digit) { + if (mDtmfDialerField != null) { + // TODO: maybe *don't* manually append this digit if + // mDialpadDigits is focused and this key came from the HW + // keyboard, since in that case the EditText field will + // get the key event directly and automatically appends + // whetever the user types. + // (Or, a cleaner fix would be to just make mDialpadDigits + // *not* handle HW key presses. That seems to be more + // complicated than just setting focusable="false" on it, + // though.) + mDtmfDialerField.getText().append(digit); + } + } + + /** Called externally (from InCallScreen) to play a DTMF Tone. */ + /* package */ boolean onDialerKeyDown(KeyEvent event) { + Log.d(this, "Notifying dtmf key down."); + if (mDialerKeyListener != null) { + return mDialerKeyListener.onKeyDown(event); + } else { + return false; + } + } + + /** Called externally (from InCallScreen) to cancel the last DTMF Tone played. */ + public boolean onDialerKeyUp(KeyEvent event) { + Log.d(this, "Notifying dtmf key up."); + if (mDialerKeyListener != null) { + return mDialerKeyListener.onKeyUp(event); + } else { + return false; + } + } + + private void configureKeypadListeners() { + DialpadKeyButton dialpadKey; + for (int i = 0; i < mButtonIds.length; i++) { + dialpadKey = (DialpadKeyButton) mDialpadView.findViewById(mButtonIds[i]); + dialpadKey.setOnKeyListener(this); + dialpadKey.setOnClickListener(this); + dialpadKey.setOnPressedListener(this); + } + } + + @Override + public void onPressed(View view, boolean pressed) { + if (pressed && mDisplayMap.containsKey(view.getId())) { + Log.d(this, "onPressed: " + pressed + " " + mDisplayMap.get(view.getId())); + getPresenter().processDtmf(mDisplayMap.get(view.getId())); + } + if (!pressed) { + Log.d(this, "onPressed: " + pressed); + getPresenter().stopDtmf(); + } + } + + /** + * LinearLayout with getter and setter methods for the translationY property using floats, for + * animation purposes. + */ + public static class DialpadSlidingLinearLayout extends LinearLayout { + + public DialpadSlidingLinearLayout(Context context) { + super(context); + } + + public DialpadSlidingLinearLayout(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public DialpadSlidingLinearLayout(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + } + + public float getYFraction() { + final int height = getHeight(); + if (height == 0) { + return 0; + } + return getTranslationY() / height; + } + + public void setYFraction(float yFraction) { + setTranslationY(yFraction * getHeight()); + } + } + + /** + * Our own key listener, specialized for dealing with DTMF codes. 1. Ignore the backspace since it + * is irrelevant. 2. Allow ONLY valid DTMF characters to generate a tone and be sent as a DTMF + * code. 3. All other remaining characters are handled by the superclass. + * + * <p>This code is purely here to handle events from the hardware keyboard while the DTMF dialpad + * is up. + */ + private class DTMFKeyListener extends DialerKeyListener { + + /** + * Overrides the characters used in {@link DialerKeyListener#CHARACTERS} These are the valid + * dtmf characters. + */ + public final char[] DTMF_CHARACTERS = + new char[] {'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '#', '*'}; + + private DTMFKeyListener() { + super(); + } + + /** Overriden to return correct DTMF-dialable characters. */ + @Override + protected char[] getAcceptedChars() { + return DTMF_CHARACTERS; + } + + /** special key listener ignores backspace. */ + @Override + public boolean backspace(View view, Editable content, int keyCode, KeyEvent event) { + return false; + } + + /** + * Overriden so that with each valid button press, we start sending a dtmf code and play a local + * dtmf tone. + */ + @Override + public boolean onKeyDown(View view, Editable content, int keyCode, KeyEvent event) { + // if (DBG) log("DTMFKeyListener.onKeyDown, keyCode " + keyCode + ", view " + view); + + // find the character + char c = (char) lookup(event, content); + + // if not a long press, and parent onKeyDown accepts the input + if (event.getRepeatCount() == 0 && super.onKeyDown(view, content, keyCode, event)) { + + boolean keyOK = ok(getAcceptedChars(), c); + + // if the character is a valid dtmf code, start playing the tone and send the + // code. + if (keyOK) { + Log.d(this, "DTMFKeyListener reading '" + c + "' from input."); + getPresenter().processDtmf(c); + } else { + Log.d(this, "DTMFKeyListener rejecting '" + c + "' from input."); + } + return true; + } + return false; + } + + /** + * Overriden so that with each valid button up, we stop sending a dtmf code and the dtmf tone. + */ + @Override + public boolean onKeyUp(View view, Editable content, int keyCode, KeyEvent event) { + // if (DBG) log("DTMFKeyListener.onKeyUp, keyCode " + keyCode + ", view " + view); + + super.onKeyUp(view, content, keyCode, event); + + // find the character + char c = (char) lookup(event, content); + + boolean keyOK = ok(getAcceptedChars(), c); + + if (keyOK) { + Log.d(this, "Stopping the tone for '" + c + "'"); + getPresenter().stopDtmf(); + return true; + } + + return false; + } + + /** Handle individual keydown events when we DO NOT have an Editable handy. */ + public boolean onKeyDown(KeyEvent event) { + char c = lookup(event); + Log.d(this, "DTMFKeyListener.onKeyDown: event '" + c + "'"); + + // if not a long press, and parent onKeyDown accepts the input + if (event.getRepeatCount() == 0 && c != 0) { + // if the character is a valid dtmf code, start playing the tone and send the + // code. + if (ok(getAcceptedChars(), c)) { + Log.d(this, "DTMFKeyListener reading '" + c + "' from input."); + getPresenter().processDtmf(c); + return true; + } else { + Log.d(this, "DTMFKeyListener rejecting '" + c + "' from input."); + } + } + return false; + } + + /** + * Handle individual keyup events. + * + * @param event is the event we are trying to stop. If this is null, then we just force-stop the + * last tone without checking if the event is an acceptable dialer event. + */ + public boolean onKeyUp(KeyEvent event) { + if (event == null) { + //the below piece of code sends stopDTMF event unnecessarily even when a null event + //is received, hence commenting it. + /*if (DBG) log("Stopping the last played tone."); + stopTone();*/ + return true; + } + + char c = lookup(event); + Log.d(this, "DTMFKeyListener.onKeyUp: event '" + c + "'"); + + // TODO: stopTone does not take in character input, we may want to + // consider checking for this ourselves. + if (ok(getAcceptedChars(), c)) { + Log.d(this, "Stopping the tone for '" + c + "'"); + getPresenter().stopDtmf(); + return true; + } + + return false; + } + + /** + * Find the Dialer Key mapped to this event. + * + * @return The char value of the input event, otherwise 0 if no matching character was found. + */ + private char lookup(KeyEvent event) { + // This code is similar to {@link DialerKeyListener#lookup(KeyEvent, Spannable) lookup} + int meta = event.getMetaState(); + int number = event.getNumber(); + + if (!((meta & (KeyEvent.META_ALT_ON | KeyEvent.META_SHIFT_ON)) == 0) || (number == 0)) { + int match = event.getMatch(getAcceptedChars(), meta); + number = (match != 0) ? match : number; + } + + return (char) number; + } + + /** Check to see if the keyEvent is dialable. */ + boolean isKeyEventAcceptable(KeyEvent event) { + return (ok(getAcceptedChars(), lookup(event))); + } + } +} diff --git a/java/com/android/incallui/DialpadPresenter.java b/java/com/android/incallui/DialpadPresenter.java new file mode 100644 index 000000000..7a784c279 --- /dev/null +++ b/java/com/android/incallui/DialpadPresenter.java @@ -0,0 +1,91 @@ +/* + * Copyright (C) 2013 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; + +import android.telephony.PhoneNumberUtils; +import com.android.incallui.DialpadPresenter.DialpadUi; +import com.android.incallui.baseui.Presenter; +import com.android.incallui.baseui.Ui; +import com.android.incallui.call.CallList; +import com.android.incallui.call.DialerCall; +import com.android.incallui.call.TelecomAdapter; + +/** Logic for call buttons. */ +public class DialpadPresenter extends Presenter<DialpadUi> + implements InCallPresenter.InCallStateListener { + + private DialerCall mCall; + + @Override + public void onUiReady(DialpadUi ui) { + super.onUiReady(ui); + InCallPresenter.getInstance().addListener(this); + mCall = CallList.getInstance().getOutgoingOrActive(); + } + + @Override + public void onUiUnready(DialpadUi ui) { + super.onUiUnready(ui); + InCallPresenter.getInstance().removeListener(this); + } + + @Override + public void onStateChange( + InCallPresenter.InCallState oldState, + InCallPresenter.InCallState newState, + CallList callList) { + mCall = callList.getOutgoingOrActive(); + Log.d(this, "DialpadPresenter mCall = " + mCall); + } + + /** + * Processes the specified digit as a DTMF key, by playing the appropriate DTMF tone, and + * appending the digit to the EditText field that displays the DTMF digits sent so far. + */ + public final void processDtmf(char c) { + Log.d(this, "Processing dtmf key " + c); + // if it is a valid key, then update the display and send the dtmf tone. + if (PhoneNumberUtils.is12Key(c) && mCall != null) { + Log.d(this, "updating display and sending dtmf tone for '" + c + "'"); + + // Append this key to the "digits" widget. + DialpadUi dialpadUi = getUi(); + if (dialpadUi != null) { + dialpadUi.appendDigitsToField(c); + } + // Plays the tone through Telecom. + TelecomAdapter.getInstance().playDtmfTone(mCall.getId(), c); + } else { + Log.d(this, "ignoring dtmf request for '" + c + "'"); + } + } + + /** Stops the local tone based on the phone type. */ + public void stopDtmf() { + if (mCall != null) { + Log.d(this, "stopping remote tone"); + TelecomAdapter.getInstance().stopDtmfTone(mCall.getId()); + } + } + + public interface DialpadUi extends Ui { + + void setVisible(boolean on); + + void appendDigitsToField(char digit); + } +} diff --git a/java/com/android/incallui/ExternalCallNotifier.java b/java/com/android/incallui/ExternalCallNotifier.java new file mode 100644 index 000000000..466e12a6d --- /dev/null +++ b/java/com/android/incallui/ExternalCallNotifier.java @@ -0,0 +1,465 @@ +/* + * 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; + +import android.annotation.TargetApi; +import android.app.Notification; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.drawable.BitmapDrawable; +import android.net.Uri; +import android.os.Build.VERSION_CODES; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.telecom.Call; +import android.telecom.PhoneAccount; +import android.telecom.VideoProfile; +import android.text.BidiFormatter; +import android.text.TextDirectionHeuristics; +import android.text.TextUtils; +import android.util.ArrayMap; +import com.android.contacts.common.ContactsUtils; +import com.android.contacts.common.compat.CallCompat; +import com.android.contacts.common.preference.ContactsPreferences; +import com.android.contacts.common.util.BitmapUtil; +import com.android.contacts.common.util.ContactDisplayUtils; +import com.android.incallui.call.DialerCall; +import com.android.incallui.call.DialerCallDelegate; +import com.android.incallui.call.ExternalCallList; +import com.android.incallui.latencyreport.LatencyReport; +import com.android.incallui.util.TelecomCallUtil; +import java.util.Map; + +/** + * Handles the display of notifications for "external calls". + * + * <p>External calls are a representation of a call which is in progress on the user's other device + * (e.g. another phone, or a watch). + */ +public class ExternalCallNotifier implements ExternalCallList.ExternalCallListener { + + /** Tag used with the notification manager to uniquely identify external call notifications. */ + private static final String NOTIFICATION_TAG = "EXTERNAL_CALL"; + + private static final int SUMMARY_ID = -1; + private final Context mContext; + private final ContactInfoCache mContactInfoCache; + private Map<Call, NotificationInfo> mNotifications = new ArrayMap<>(); + private int mNextUniqueNotificationId; + private ContactsPreferences mContactsPreferences; + private boolean mShowingSummary; + + /** Initializes a new instance of the external call notifier. */ + public ExternalCallNotifier( + @NonNull Context context, @NonNull ContactInfoCache contactInfoCache) { + mContext = context; + mContactsPreferences = ContactsPreferencesFactory.newContactsPreferences(mContext); + mContactInfoCache = contactInfoCache; + } + + /** + * Handles the addition of a new external call by showing a new notification. Triggered by {@link + * CallList#onCallAdded(android.telecom.Call)}. + */ + @Override + public void onExternalCallAdded(android.telecom.Call call) { + Log.i(this, "onExternalCallAdded " + call); + if (mNotifications.containsKey(call)) { + throw new IllegalArgumentException(); + } + NotificationInfo info = new NotificationInfo(call, mNextUniqueNotificationId++); + mNotifications.put(call, info); + + showNotifcation(info); + } + + /** + * Handles the removal of an external call by hiding its associated notification. Triggered by + * {@link CallList#onCallRemoved(android.telecom.Call)}. + */ + @Override + public void onExternalCallRemoved(android.telecom.Call call) { + Log.i(this, "onExternalCallRemoved " + call); + + dismissNotification(call); + } + + /** Handles updates to an external call. */ + @Override + public void onExternalCallUpdated(Call call) { + if (!mNotifications.containsKey(call)) { + throw new IllegalArgumentException(); + } + postNotification(mNotifications.get(call)); + } + + @Override + public void onExternalCallPulled(Call call) { + // no-op; if an external call is pulled, it will be removed via onExternalCallRemoved. + } + + /** + * Initiates a call pull given a notification ID. + * + * @param notificationId The notification ID associated with the external call which is to be + * pulled. + */ + @TargetApi(VERSION_CODES.N_MR1) + public void pullExternalCall(int notificationId) { + for (NotificationInfo info : mNotifications.values()) { + if (info.getNotificationId() == notificationId + && CallCompat.canPullExternalCall(info.getCall())) { + info.getCall().pullExternalCall(); + return; + } + } + } + + /** + * Shows a notification for a new external call. Performs a contact cache lookup to find any + * associated photo and information for the call. + */ + private void showNotifcation(final NotificationInfo info) { + // We make a call to the contact info cache to query for supplemental data to what the + // call provides. This includes the contact name and photo. + // This callback will always get called immediately and synchronously with whatever data + // it has available, and may make a subsequent call later (same thread) if it had to + // call into the contacts provider for more data. + DialerCall dialerCall = + new DialerCall( + mContext, + new DialerCallDelegateStub(), + info.getCall(), + new LatencyReport(), + false /* registerCallback */); + + mContactInfoCache.findInfo( + dialerCall, + false /* isIncoming */, + new ContactInfoCache.ContactInfoCacheCallback() { + @Override + public void onContactInfoComplete( + String callId, ContactInfoCache.ContactCacheEntry entry) { + + // Ensure notification still exists as the external call could have been + // removed during async contact info lookup. + if (mNotifications.containsKey(info.getCall())) { + saveContactInfo(info, entry); + } + } + + @Override + public void onImageLoadComplete(String callId, ContactInfoCache.ContactCacheEntry entry) { + + // Ensure notification still exists as the external call could have been + // removed during async contact info lookup. + if (mNotifications.containsKey(info.getCall())) { + savePhoto(info, entry); + } + } + }); + } + + /** Dismisses a notification for an external call. */ + private void dismissNotification(Call call) { + if (!mNotifications.containsKey(call)) { + throw new IllegalArgumentException(); + } + + NotificationManager notificationManager = + (NotificationManager) mContext.getSystemService(Context.NOTIFICATION_SERVICE); + notificationManager.cancel(NOTIFICATION_TAG, mNotifications.get(call).getNotificationId()); + + mNotifications.remove(call); + + if (mShowingSummary && mNotifications.size() <= 1) { + // Where a summary notification is showing and there is now not enough notifications to + // necessitate a summary, cancel the summary. + notificationManager.cancel(NOTIFICATION_TAG, SUMMARY_ID); + mShowingSummary = false; + + // If there is still a single call requiring a notification, re-post the notification as a + // standalone notification without a summary notification. + if (mNotifications.size() == 1) { + postNotification(mNotifications.values().iterator().next()); + } + } + } + + /** + * Attempts to build a large icon to use for the notification based on the contact info and post + * the updated notification to the notification manager. + */ + private void savePhoto(NotificationInfo info, ContactInfoCache.ContactCacheEntry entry) { + Bitmap largeIcon = getLargeIconToDisplay(mContext, entry, info.getCall()); + if (largeIcon != null) { + largeIcon = getRoundedIcon(mContext, largeIcon); + } + info.setLargeIcon(largeIcon); + postNotification(info); + } + + /** + * Builds and stores the contact information the notification will display and posts the updated + * notification to the notification manager. + */ + private void saveContactInfo(NotificationInfo info, ContactInfoCache.ContactCacheEntry entry) { + info.setContentTitle(getContentTitle(mContext, mContactsPreferences, entry, info.getCall())); + info.setPersonReference(getPersonReference(entry, info.getCall())); + postNotification(info); + } + + /** Rebuild an existing or show a new notification given {@link NotificationInfo}. */ + private void postNotification(NotificationInfo info) { + Notification.Builder builder = new Notification.Builder(mContext); + // Set notification as ongoing since calls are long-running versus a point-in-time notice. + builder.setOngoing(true); + // Make the notification prioritized over the other normal notifications. + builder.setPriority(Notification.PRIORITY_HIGH); + builder.setGroup(NOTIFICATION_TAG); + + boolean isVideoCall = VideoProfile.isVideo(info.getCall().getDetails().getVideoState()); + // Set the content ("Ongoing call on another device") + builder.setContentText( + mContext.getString( + isVideoCall + ? R.string.notification_external_video_call + : R.string.notification_external_call)); + builder.setSmallIcon(R.drawable.quantum_ic_call_white_24); + builder.setContentTitle(info.getContentTitle()); + builder.setLargeIcon(info.getLargeIcon()); + builder.setColor(mContext.getResources().getColor(R.color.dialer_theme_color)); + builder.addPerson(info.getPersonReference()); + + // Where the external call supports being transferred to the local device, add an action + // to the notification to initiate the call pull process. + if (CallCompat.canPullExternalCall(info.getCall())) { + + Intent intent = + new Intent( + NotificationBroadcastReceiver.ACTION_PULL_EXTERNAL_CALL, + null, + mContext, + NotificationBroadcastReceiver.class); + intent.putExtra( + NotificationBroadcastReceiver.EXTRA_NOTIFICATION_ID, info.getNotificationId()); + builder.addAction( + new Notification.Action.Builder( + R.drawable.quantum_ic_call_white_24, + mContext.getString( + isVideoCall + ? R.string.notification_take_video_call + : R.string.notification_take_call), + PendingIntent.getBroadcast(mContext, info.getNotificationId(), intent, 0)) + .build()); + } + + /** + * This builder is used for the notification shown when the device is locked and the user has + * set their notification settings to 'hide sensitive content' {@see + * Notification.Builder#setPublicVersion}. + */ + Notification.Builder publicBuilder = new Notification.Builder(mContext); + publicBuilder.setSmallIcon(R.drawable.quantum_ic_call_white_24); + publicBuilder.setColor(mContext.getResources().getColor(R.color.dialer_theme_color)); + + builder.setPublicVersion(publicBuilder.build()); + Notification notification = builder.build(); + + NotificationManager notificationManager = + (NotificationManager) mContext.getSystemService(Context.NOTIFICATION_SERVICE); + notificationManager.notify(NOTIFICATION_TAG, info.getNotificationId(), notification); + + if (!mShowingSummary && mNotifications.size() > 1) { + // If the number of notifications shown is > 1, and we're not already showing a group summary, + // build one now. This will ensure the like notifications are grouped together. + + Notification.Builder summary = new Notification.Builder(mContext); + // Set notification as ongoing since calls are long-running versus a point-in-time notice. + summary.setOngoing(true); + // Make the notification prioritized over the other normal notifications. + summary.setPriority(Notification.PRIORITY_HIGH); + summary.setGroup(NOTIFICATION_TAG); + summary.setGroupSummary(true); + summary.setSmallIcon(R.drawable.quantum_ic_call_white_24); + notificationManager.notify(NOTIFICATION_TAG, SUMMARY_ID, summary.build()); + mShowingSummary = true; + } + } + + /** + * Finds a large icon to display in a notification for a call. For conference calls, a conference + * call icon is used, otherwise if contact info is specified, the user's contact photo or avatar + * is used. + * + * @param context The context. + * @param contactInfo The contact cache info. + * @param call The call. + * @return The large icon to use for the notification. + */ + private @Nullable Bitmap getLargeIconToDisplay( + Context context, ContactInfoCache.ContactCacheEntry contactInfo, android.telecom.Call call) { + + Bitmap largeIcon = null; + if (call.getDetails().hasProperty(android.telecom.Call.Details.PROPERTY_CONFERENCE) + && !call.getDetails() + .hasProperty(android.telecom.Call.Details.PROPERTY_GENERIC_CONFERENCE)) { + + largeIcon = BitmapFactory.decodeResource(context.getResources(), R.drawable.img_conference); + } + if (contactInfo.photo != null && (contactInfo.photo instanceof BitmapDrawable)) { + largeIcon = ((BitmapDrawable) contactInfo.photo).getBitmap(); + } + return largeIcon; + } + + /** + * Given a bitmap, returns a rounded version of the icon suitable for display in a notification. + * + * @param context The context. + * @param bitmap The bitmap to round. + * @return The rounded bitmap. + */ + private @Nullable Bitmap getRoundedIcon(Context context, @Nullable Bitmap bitmap) { + if (bitmap == null) { + return null; + } + final int height = + (int) context.getResources().getDimension(android.R.dimen.notification_large_icon_height); + final int width = + (int) context.getResources().getDimension(android.R.dimen.notification_large_icon_width); + return BitmapUtil.getRoundedBitmap(bitmap, width, height); + } + + /** + * Builds a notification content title for a call. If the call is a conference call, it is + * identified as such. Otherwise an attempt is made to show an associated contact name or phone + * number. + * + * @param context The context. + * @param contactsPreferences Contacts preferences, used to determine the preferred formatting for + * contact names. + * @param contactInfo The contact info which was looked up in the contact cache. + * @param call The call to generate a title for. + * @return The content title. + */ + private @Nullable String getContentTitle( + Context context, + @Nullable ContactsPreferences contactsPreferences, + ContactInfoCache.ContactCacheEntry contactInfo, + android.telecom.Call call) { + + if (call.getDetails().hasProperty(android.telecom.Call.Details.PROPERTY_CONFERENCE) + && !call.getDetails() + .hasProperty(android.telecom.Call.Details.PROPERTY_GENERIC_CONFERENCE)) { + + return context.getResources().getString(R.string.conference_call_name); + } + + String preferredName = + ContactDisplayUtils.getPreferredDisplayName( + contactInfo.namePrimary, contactInfo.nameAlternative, contactsPreferences); + if (TextUtils.isEmpty(preferredName)) { + return TextUtils.isEmpty(contactInfo.number) + ? null + : BidiFormatter.getInstance() + .unicodeWrap(contactInfo.number, TextDirectionHeuristics.LTR); + } + return preferredName; + } + + /** + * Gets a "person reference" for a notification, used by the system to determine whether the + * notification should be allowed past notification interruption filters. + * + * @param contactInfo The contact info from cache. + * @param call The call. + * @return the person reference. + */ + private String getPersonReference(ContactInfoCache.ContactCacheEntry contactInfo, Call call) { + + String number = TelecomCallUtil.getNumber(call); + // Query {@link Contacts#CONTENT_LOOKUP_URI} directly with work lookup key is not allowed. + // So, do not pass {@link Contacts#CONTENT_LOOKUP_URI} to NotificationManager to avoid + // NotificationManager using it. + if (contactInfo.lookupUri != null && contactInfo.userType != ContactsUtils.USER_TYPE_WORK) { + return contactInfo.lookupUri.toString(); + } else if (!TextUtils.isEmpty(number)) { + return Uri.fromParts(PhoneAccount.SCHEME_TEL, number, null).toString(); + } + return ""; + } + + private static class DialerCallDelegateStub implements DialerCallDelegate { + + @Override + public DialerCall getDialerCallFromTelecomCall(Call telecomCall) { + return null; + } + } + + /** Represents a call and associated cached notification data. */ + private static class NotificationInfo { + + @NonNull private final Call mCall; + private final int mNotificationId; + @Nullable private String mContentTitle; + @Nullable private Bitmap mLargeIcon; + @Nullable private String mPersonReference; + + public NotificationInfo(@NonNull Call call, int notificationId) { + mCall = call; + mNotificationId = notificationId; + } + + public Call getCall() { + return mCall; + } + + public int getNotificationId() { + return mNotificationId; + } + + public @Nullable String getContentTitle() { + return mContentTitle; + } + + public void setContentTitle(@Nullable String contentTitle) { + mContentTitle = contentTitle; + } + + public @Nullable Bitmap getLargeIcon() { + return mLargeIcon; + } + + public void setLargeIcon(@Nullable Bitmap largeIcon) { + mLargeIcon = largeIcon; + } + + public @Nullable String getPersonReference() { + return mPersonReference; + } + + public void setPersonReference(@Nullable String personReference) { + mPersonReference = personReference; + } + } +} diff --git a/java/com/android/incallui/InCallActivity.java b/java/com/android/incallui/InCallActivity.java new file mode 100644 index 000000000..307415916 --- /dev/null +++ b/java/com/android/incallui/InCallActivity.java @@ -0,0 +1,756 @@ +/* + * 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; + +import android.content.Context; +import android.content.Intent; +import android.graphics.drawable.GradientDrawable; +import android.graphics.drawable.GradientDrawable.Orientation; +import android.os.Bundle; +import android.support.annotation.ColorInt; +import android.support.annotation.FloatRange; +import android.support.annotation.Nullable; +import android.support.v4.app.FragmentManager; +import android.support.v4.app.FragmentTransaction; +import android.support.v4.graphics.ColorUtils; +import android.telecom.DisconnectCause; +import android.view.KeyEvent; +import android.view.MenuItem; +import android.view.MotionEvent; +import android.view.View; +import com.android.dialer.common.LogUtil; +import com.android.dialer.compat.ActivityCompat; +import com.android.dialer.logging.Logger; +import com.android.dialer.logging.nano.ScreenEvent; +import com.android.incallui.answer.bindings.AnswerBindings; +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.answerproximitysensor.PseudoScreenState; +import com.android.incallui.call.CallList; +import com.android.incallui.call.DialerCall; +import com.android.incallui.call.DialerCall.State; +import com.android.incallui.call.VideoUtils; +import com.android.incallui.incall.bindings.InCallBindings; +import com.android.incallui.incall.protocol.InCallButtonUiDelegate; +import com.android.incallui.incall.protocol.InCallButtonUiDelegateFactory; +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.video.bindings.VideoBindings; +import com.android.incallui.video.protocol.VideoCallScreen; +import com.android.incallui.video.protocol.VideoCallScreenDelegate; +import com.android.incallui.video.protocol.VideoCallScreenDelegateFactory; + +/** Version of {@link InCallActivity} that shows the new UI */ +public class InCallActivity extends TransactionSafeFragmentActivity + implements AnswerScreenDelegateFactory, + InCallScreenDelegateFactory, + InCallButtonUiDelegateFactory, + VideoCallScreenDelegateFactory, + PseudoScreenState.StateChangedListener { + + private static final String TAG_IN_CALL_SCREEN = "tag_in_call_screen"; + private static final String TAG_ANSWER_SCREEN = "tag_answer_screen"; + private static final String TAG_VIDEO_CALL_SCREEN = "tag_video_call_screen"; + + private static final String DID_SHOW_ANSWER_SCREEN_KEY = "did_show_answer_screen"; + private static final String DID_SHOW_IN_CALL_SCREEN_KEY = "did_show_in_call_screen"; + private static final String DID_SHOW_VIDEO_CALL_SCREEN_KEY = "did_show_video_call_screen"; + + private final InCallActivityCommon common; + private boolean didShowAnswerScreen; + private boolean didShowInCallScreen; + private boolean didShowVideoCallScreen; + private int[] backgroundDrawableColors; + private GradientDrawable backgroundDrawable; + private boolean isVisible; + private View pseudoBlackScreenOverlay; + private boolean touchDownWhenPseudoScreenOff; + private boolean isInShowMainInCallFragment; + private boolean needDismissPendingDialogs; + + public InCallActivity() { + common = new InCallActivityCommon(this); + } + + public static Intent getIntent( + Context context, + boolean showDialpad, + boolean newOutgoingCall, + boolean isVideoCall, + boolean isForFullScreen) { + Intent intent = new Intent(Intent.ACTION_MAIN, null); + intent.setFlags(Intent.FLAG_ACTIVITY_NO_USER_ACTION | Intent.FLAG_ACTIVITY_NEW_TASK); + intent.setClass(context, InCallActivity.class); + InCallActivityCommon.setIntentExtras(intent, showDialpad, newOutgoingCall, isForFullScreen); + return intent; + } + + @Override + protected void onResumeFragments() { + super.onResumeFragments(); + if (needDismissPendingDialogs) { + dismissPendingDialogs(); + } + } + + @Override + protected void onCreate(Bundle icicle) { + LogUtil.i("InCallActivity.onCreate", ""); + super.onCreate(icicle); + + if (icicle != null) { + didShowAnswerScreen = icicle.getBoolean(DID_SHOW_ANSWER_SCREEN_KEY); + didShowInCallScreen = icicle.getBoolean(DID_SHOW_IN_CALL_SCREEN_KEY); + didShowVideoCallScreen = icicle.getBoolean(DID_SHOW_VIDEO_CALL_SCREEN_KEY); + } + + common.onCreate(icicle); + + getWindow() + .getDecorView() + .setSystemUiVisibility( + View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION); + + pseudoBlackScreenOverlay = findViewById(R.id.psuedo_black_screen_overlay); + } + + @Override + protected void onSaveInstanceState(Bundle out) { + LogUtil.i("InCallActivity.onSaveInstanceState", ""); + common.onSaveInstanceState(out); + out.putBoolean(DID_SHOW_ANSWER_SCREEN_KEY, didShowAnswerScreen); + out.putBoolean(DID_SHOW_IN_CALL_SCREEN_KEY, didShowInCallScreen); + out.putBoolean(DID_SHOW_VIDEO_CALL_SCREEN_KEY, didShowVideoCallScreen); + super.onSaveInstanceState(out); + isVisible = false; + } + + @Override + protected void onStart() { + LogUtil.i("InCallActivity.onStart", ""); + super.onStart(); + isVisible = true; + showMainInCallFragment(); + common.onStart(); + if (ActivityCompat.isInMultiWindowMode(this) + && !getResources().getBoolean(R.bool.incall_dialpad_allowed)) { + // Hide the dialpad because there may not be enough room + showDialpadFragment(false, false); + } + } + + @Override + protected void onResume() { + LogUtil.i("InCallActivity.onResume", ""); + super.onResume(); + common.onResume(); + PseudoScreenState pseudoScreenState = InCallPresenter.getInstance().getPseudoScreenState(); + pseudoScreenState.addListener(this); + onPseudoScreenStateChanged(pseudoScreenState.isOn()); + } + + /** onPause is guaranteed to be called when the InCallActivity goes in the background. */ + @Override + protected void onPause() { + LogUtil.i("InCallActivity.onPause", ""); + super.onPause(); + common.onPause(); + InCallPresenter.getInstance().getPseudoScreenState().removeListener(this); + } + + @Override + protected void onStop() { + LogUtil.i("InCallActivity.onStop", ""); + super.onStop(); + common.onStop(); + isVisible = false; + } + + @Override + protected void onDestroy() { + LogUtil.i("InCallActivity.onDestroy", ""); + super.onDestroy(); + common.onDestroy(); + } + + @Override + public void finish() { + if (shouldCloseActivityOnFinish()) { + super.finish(); + } + } + + private boolean shouldCloseActivityOnFinish() { + if (!isVisible()) { + LogUtil.i( + "InCallActivity.shouldCloseActivityOnFinish", + "allowing activity to be closed because it's not visible"); + return true; + } + + if (common.hasPendingDialogs()) { + LogUtil.i( + "InCallActivity.shouldCloseActivityOnFinish", "dialog is visible, not closing activity"); + return false; + } + + AnswerScreen answerScreen = getAnswerScreen(); + if (answerScreen != null && answerScreen.hasPendingDialogs()) { + LogUtil.i( + "InCallActivity.shouldCloseActivityOnFinish", + "answer screen dialog is visible, not closing activity"); + return false; + } + + LogUtil.i( + "InCallActivity.shouldCloseActivityOnFinish", + "activity is visible and has no dialogs, allowing activity to close"); + return true; + } + + @Override + protected void onNewIntent(Intent intent) { + LogUtil.i("InCallActivity.onNewIntent", ""); + common.onNewIntent(intent); + + // If the screen is off, we need to make sure it gets turned on for incoming calls. + // This normally works just fine thanks to FLAG_TURN_SCREEN_ON but that only works + // when the activity is first created. Therefore, to ensure the screen is turned on + // for the call waiting case, we recreate() the current activity. There should be no jank from + // this since the screen is already off and will remain so until our new activity is up. + if (!isVisible()) { + LogUtil.i("InCallActivity.onNewIntent", "Restarting InCallActivity to force screen on."); + recreate(); + } + } + + @Override + public void onBackPressed() { + LogUtil.i("InCallActivity.onBackPressed", ""); + if (!common.onBackPressed(didShowInCallScreen || didShowVideoCallScreen)) { + super.onBackPressed(); + } + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + LogUtil.i("InCallActivity.onOptionsItemSelected", "item: " + item); + if (item.getItemId() == android.R.id.home) { + onBackPressed(); + return true; + } + return super.onOptionsItemSelected(item); + } + + @Override + public boolean onKeyUp(int keyCode, KeyEvent event) { + if (common.onKeyUp(keyCode, event)) { + return true; + } + return super.onKeyUp(keyCode, event); + } + + @Override + public boolean onKeyDown(int keyCode, KeyEvent event) { + if (common.onKeyDown(keyCode, event)) { + return true; + } + return super.onKeyDown(keyCode, event); + } + + public boolean isInCallScreenAnimating() { + return false; + } + + public void showConferenceFragment(boolean show) { + if (show) { + startActivity(new Intent(this, ManageConferenceActivity.class)); + } + } + + public boolean showDialpadFragment(boolean show, boolean animate) { + boolean didChange = common.showDialpadFragment(show, animate); + if (didChange) { + // Note: onInCallScreenDialpadVisibilityChange is called here to ensure that the dialpad FAB + // repositions itself. + getInCallScreen().onInCallScreenDialpadVisibilityChange(show); + } + return didChange; + } + + public boolean isDialpadVisible() { + return common.isDialpadVisible(); + } + + public void onForegroundCallChanged(DialerCall newForegroundCall) { + common.updateTaskDescription(); + if (didShowAnswerScreen && newForegroundCall != null) { + if (newForegroundCall.getState() == State.DISCONNECTED + || newForegroundCall.getState() == State.IDLE) { + LogUtil.i( + "InCallActivity.onForegroundCallChanged", + "rejecting incoming call, not updating " + "window background color"); + } + } else { + LogUtil.v("InCallActivity.onForegroundCallChanged", "resetting background color"); + updateWindowBackgroundColor(0); + } + } + + public void updateWindowBackgroundColor(@FloatRange(from = -1f, to = 1.0f) float progress) { + ThemeColorManager themeColorManager = InCallPresenter.getInstance().getThemeColorManager(); + @ColorInt int top; + @ColorInt int middle; + @ColorInt int bottom; + @ColorInt int gray = 0x66000000; + + if (ActivityCompat.isInMultiWindowMode(this)) { + top = themeColorManager.getBackgroundColorSolid(); + middle = themeColorManager.getBackgroundColorSolid(); + bottom = themeColorManager.getBackgroundColorSolid(); + } else { + top = themeColorManager.getBackgroundColorTop(); + middle = themeColorManager.getBackgroundColorMiddle(); + bottom = themeColorManager.getBackgroundColorBottom(); + } + + if (progress < 0) { + float correctedProgress = Math.abs(progress); + top = ColorUtils.blendARGB(top, gray, correctedProgress); + middle = ColorUtils.blendARGB(middle, gray, correctedProgress); + bottom = ColorUtils.blendARGB(bottom, gray, correctedProgress); + } + + boolean backgroundDirty = false; + if (backgroundDrawable == null) { + backgroundDrawableColors = new int[] {top, middle, bottom}; + backgroundDrawable = new GradientDrawable(Orientation.TOP_BOTTOM, backgroundDrawableColors); + backgroundDirty = true; + } else { + if (backgroundDrawableColors[0] != top) { + backgroundDrawableColors[0] = top; + backgroundDirty = true; + } + if (backgroundDrawableColors[1] != middle) { + backgroundDrawableColors[1] = middle; + backgroundDirty = true; + } + if (backgroundDrawableColors[2] != bottom) { + backgroundDrawableColors[2] = bottom; + backgroundDirty = true; + } + if (backgroundDirty) { + backgroundDrawable.setColors(backgroundDrawableColors); + } + } + + if (backgroundDirty) { + getWindow().setBackgroundDrawable(backgroundDrawable); + } + } + + public boolean isVisible() { + return isVisible; + } + + public boolean getCallCardFragmentVisible() { + return didShowInCallScreen || didShowVideoCallScreen; + } + + public void dismissKeyguard(boolean dismiss) { + common.dismissKeyguard(dismiss); + } + + public void showPostCharWaitDialog(String callId, String chars) { + common.showPostCharWaitDialog(callId, chars); + } + + public void maybeShowErrorDialogOnDisconnect(DisconnectCause disconnectCause) { + common.maybeShowErrorDialogOnDisconnect(disconnectCause); + } + + public void dismissPendingDialogs() { + if (isVisible) { + LogUtil.i("InCallActivity.dismissPendingDialogs", ""); + common.dismissPendingDialogs(); + AnswerScreen answerScreen = getAnswerScreen(); + if (answerScreen != null) { + answerScreen.dismissPendingDialogs(); + } + needDismissPendingDialogs = false; + } else { + // The activity is not visible and onSaveInstanceState may have been called so defer the + // dismissing action. + LogUtil.i( + "InCallActivity.dismissPendingDialogs", "defer actions since activity is not visible"); + needDismissPendingDialogs = true; + } + } + + private void enableInCallOrientationEventListener(boolean enable) { + common.enableInCallOrientationEventListener(enable); + } + + public void setExcludeFromRecents(boolean exclude) { + common.setExcludeFromRecents(exclude); + } + + public void onResolveIntent( + DialerCall outgoingCall, boolean isNewOutgoingCall, boolean didShowAccountSelectionDialog) { + if (didShowAccountSelectionDialog) { + hideMainInCallFragment(); + } + } + + @Nullable + public FragmentManager getDialpadFragmentManager() { + InCallScreen inCallScreen = getInCallScreen(); + if (inCallScreen != null) { + return inCallScreen.getInCallScreenFragment().getChildFragmentManager(); + } + return null; + } + + public int getDialpadContainerId() { + return getInCallScreen().getAnswerAndDialpadContainerResourceId(); + } + + @Override + public AnswerScreenDelegate newAnswerScreenDelegate(AnswerScreen answerScreen) { + DialerCall call = CallList.getInstance().getCallById(answerScreen.getCallId()); + if (call == null) { + // This is a work around for a bug where we attempt to create a new delegate after the call + // has already been removed. An example of when this can happen is: + // 1. incoming video call in landscape mode + // 2. remote party hangs up + // 3. activity switches from landscape to portrait + // At step #3 the answer fragment will try to create a new answer delegate but the call won't + // exist. In this case we'll simply return a stub delegate that does nothing. This is ok + // because this new state is transient and the activity will be destroyed soon. + LogUtil.i("InCallActivity.onPrimaryCallStateChanged", "call doesn't exist, using stub"); + return new AnswerScreenPresenterStub(); + } else { + return new AnswerScreenPresenter( + this, answerScreen, CallList.getInstance().getCallById(answerScreen.getCallId())); + } + } + + @Override + public InCallScreenDelegate newInCallScreenDelegate() { + return new CallCardPresenter(this); + } + + @Override + public InCallButtonUiDelegate newInCallButtonUiDelegate() { + return new CallButtonPresenter(this); + } + + @Override + public VideoCallScreenDelegate newVideoCallScreenDelegate() { + return new VideoCallPresenter(); + } + + public void onPrimaryCallStateChanged() { + LogUtil.i("InCallActivity.onPrimaryCallStateChanged", ""); + showMainInCallFragment(); + } + + public void onWiFiToLteHandover(DialerCall call) { + common.showWifiToLteHandoverToast(call); + } + + public void onHandoverToWifiFailed(DialerCall call) { + common.showWifiFailedDialog(call); + } + + public void setAllowOrientationChange(boolean allowOrientationChange) { + if (!allowOrientationChange) { + setRequestedOrientation(InCallOrientationEventListener.ACTIVITY_PREFERENCE_DISALLOW_ROTATION); + } else { + setRequestedOrientation(InCallOrientationEventListener.ACTIVITY_PREFERENCE_ALLOW_ROTATION); + } + enableInCallOrientationEventListener(allowOrientationChange); + } + + private void hideMainInCallFragment() { + LogUtil.i("InCallActivity.hideMainInCallFragment", ""); + if (didShowInCallScreen || didShowVideoCallScreen) { + FragmentTransaction transaction = getSupportFragmentManager().beginTransaction(); + hideInCallScreenFragment(transaction); + hideVideoCallScreenFragment(transaction); + transaction.commitAllowingStateLoss(); + getSupportFragmentManager().executePendingTransactions(); + } + } + + private void showMainInCallFragment() { + // If the activity's onStart method hasn't been called yet then defer doing any work. + if (!isVisible) { + LogUtil.i("InCallActivity.showMainInCallFragment", "not visible yet/anymore"); + return; + } + + // Don't let this be reentrant. + if (isInShowMainInCallFragment) { + LogUtil.i("InCallActivity.showMainInCallFragment", "already in method, bailing"); + return; + } + + isInShowMainInCallFragment = true; + ShouldShowAnswerUiResult shouldShowAnswerUi = getShouldShowAnswerUi(); + boolean shouldShowVideoUi = getShouldShowVideoUi(); + LogUtil.i( + "InCallActivity.showMainInCallFragment", + "shouldShowAnswerUi: %b, shouldShowVideoUi: %b, " + + "didShowAnswerScreen: %b, didShowInCallScreen: %b, didShowVideoCallScreen: %b", + shouldShowAnswerUi.shouldShow, + shouldShowVideoUi, + didShowAnswerScreen, + didShowInCallScreen, + didShowVideoCallScreen); + // Only video call ui allows orientation change. + setAllowOrientationChange(shouldShowVideoUi); + + FragmentTransaction transaction = getSupportFragmentManager().beginTransaction(); + boolean didChangeInCall; + boolean didChangeVideo; + boolean didChangeAnswer; + if (shouldShowAnswerUi.shouldShow) { + didChangeInCall = hideInCallScreenFragment(transaction); + didChangeVideo = hideVideoCallScreenFragment(transaction); + didChangeAnswer = showAnswerScreenFragment(transaction, shouldShowAnswerUi.call); + } else if (shouldShowVideoUi) { + didChangeInCall = hideInCallScreenFragment(transaction); + didChangeVideo = showVideoCallScreenFragment(transaction); + didChangeAnswer = hideAnswerScreenFragment(transaction); + } else { + didChangeInCall = showInCallScreenFragment(transaction); + didChangeVideo = hideVideoCallScreenFragment(transaction); + didChangeAnswer = hideAnswerScreenFragment(transaction); + } + + if (didChangeInCall || didChangeVideo || didChangeAnswer) { + transaction.commitNow(); + Logger.get(this).logScreenView(ScreenEvent.Type.INCALL, this); + } + isInShowMainInCallFragment = false; + } + + private ShouldShowAnswerUiResult getShouldShowAnswerUi() { + DialerCall call = CallList.getInstance().getIncomingCall(); + if (call != null) { + LogUtil.i("InCallActivity.getShouldShowAnswerUi", "found incoming call"); + return new ShouldShowAnswerUiResult(true, call); + } + + call = CallList.getInstance().getVideoUpgradeRequestCall(); + if (call != null) { + LogUtil.i("InCallActivity.getShouldShowAnswerUi", "found video upgrade request"); + return new ShouldShowAnswerUiResult(true, call); + } + + // Check if we're showing the answer screen and the call is disconnected. If this condition is + // true then we won't switch from the answer UI to the in call UI. This prevents flicker when + // the user rejects an incoming call. + call = CallList.getInstance().getFirstCall(); + if (call == null) { + call = CallList.getInstance().getBackgroundCall(); + } + if (didShowAnswerScreen && (call == null || call.getState() == State.DISCONNECTED)) { + LogUtil.i("InCallActivity.getShouldShowAnswerUi", "found disconnecting incoming call"); + return new ShouldShowAnswerUiResult(true, call); + } + + return new ShouldShowAnswerUiResult(false, null); + } + + private boolean getShouldShowVideoUi() { + DialerCall call = CallList.getInstance().getFirstCall(); + if (call == null) { + LogUtil.i("InCallActivity.getShouldShowVideoUi", "null call"); + return false; + } + + if (VideoUtils.isVideoCall(call)) { + LogUtil.i("InCallActivity.getShouldShowVideoUi", "found video call"); + return true; + } + + if (VideoUtils.hasSentVideoUpgradeRequest(call)) { + LogUtil.i("InCallActivity.getShouldShowVideoUi", "upgrading to video"); + return true; + } + + return false; + } + + private boolean showAnswerScreenFragment(FragmentTransaction transaction, DialerCall call) { + // When rejecting a call the active call can become null in which case we should continue + // showing the answer screen. + if (didShowAnswerScreen && call == null) { + return false; + } + + boolean isVideoUpgradeRequest = VideoUtils.hasReceivedVideoUpgradeRequest(call); + int videoState = isVideoUpgradeRequest ? call.getRequestedVideoState() : call.getVideoState(); + + // Check if we're already showing an answer screen for this call. + if (didShowAnswerScreen) { + AnswerScreen answerScreen = getAnswerScreen(); + if (answerScreen.getCallId().equals(call.getId()) + && answerScreen.getVideoState() == videoState + && answerScreen.isVideoUpgradeRequest() == isVideoUpgradeRequest) { + return false; + } + LogUtil.i( + "InCallActivity.showAnswerScreenFragment", + "answer fragment exists but arguments do not match"); + hideAnswerScreenFragment(transaction); + } + + // Show a new answer screen. + AnswerScreen answerScreen = + AnswerBindings.createAnswerScreen(call.getId(), videoState, isVideoUpgradeRequest); + transaction.add(R.id.main, answerScreen.getAnswerScreenFragment(), TAG_ANSWER_SCREEN); + + Logger.get(this).logScreenView(ScreenEvent.Type.INCOMING_CALL, this); + didShowAnswerScreen = true; + return true; + } + + private boolean hideAnswerScreenFragment(FragmentTransaction transaction) { + if (!didShowAnswerScreen) { + return false; + } + AnswerScreen answerScreen = getAnswerScreen(); + if (answerScreen != null) { + transaction.remove(answerScreen.getAnswerScreenFragment()); + } + + didShowAnswerScreen = false; + return true; + } + + private boolean showInCallScreenFragment(FragmentTransaction transaction) { + if (didShowInCallScreen) { + return false; + } + InCallScreen inCallScreen = getInCallScreen(); + if (inCallScreen == null) { + inCallScreen = InCallBindings.createInCallScreen(); + transaction.add(R.id.main, inCallScreen.getInCallScreenFragment(), TAG_IN_CALL_SCREEN); + } else { + transaction.show(inCallScreen.getInCallScreenFragment()); + } + Logger.get(this).logScreenView(ScreenEvent.Type.INCALL, this); + didShowInCallScreen = true; + return true; + } + + private boolean hideInCallScreenFragment(FragmentTransaction transaction) { + if (!didShowInCallScreen) { + return false; + } + InCallScreen inCallScreen = getInCallScreen(); + if (inCallScreen != null) { + transaction.hide(inCallScreen.getInCallScreenFragment()); + } + didShowInCallScreen = false; + return true; + } + + private boolean showVideoCallScreenFragment(FragmentTransaction transaction) { + if (didShowVideoCallScreen) { + return false; + } + + VideoCallScreen videoCallScreen = VideoBindings.createVideoCallScreen(); + transaction.add(R.id.main, videoCallScreen.getVideoCallScreenFragment(), TAG_VIDEO_CALL_SCREEN); + + Logger.get(this).logScreenView(ScreenEvent.Type.INCALL, this); + didShowVideoCallScreen = true; + return true; + } + + private boolean hideVideoCallScreenFragment(FragmentTransaction transaction) { + if (!didShowVideoCallScreen) { + return false; + } + VideoCallScreen videoCallScreen = getVideoCallScreen(); + if (videoCallScreen != null) { + transaction.remove(videoCallScreen.getVideoCallScreenFragment()); + } + didShowVideoCallScreen = false; + return true; + } + + AnswerScreen getAnswerScreen() { + return (AnswerScreen) getSupportFragmentManager().findFragmentByTag(TAG_ANSWER_SCREEN); + } + + InCallScreen getInCallScreen() { + return (InCallScreen) getSupportFragmentManager().findFragmentByTag(TAG_IN_CALL_SCREEN); + } + + VideoCallScreen getVideoCallScreen() { + return (VideoCallScreen) getSupportFragmentManager().findFragmentByTag(TAG_VIDEO_CALL_SCREEN); + } + + @Override + public void onPseudoScreenStateChanged(boolean isOn) { + LogUtil.i("InCallActivity.onPseudoScreenStateChanged", "isOn: " + isOn); + pseudoBlackScreenOverlay.setVisibility(isOn ? View.GONE : View.VISIBLE); + } + + /** + * For some touch related issue, turning off the screen can be faked by drawing a black view over + * the activity. All touch events started when the screen is "off" is rejected. + * + * @see PseudoScreenState + */ + @Override + public boolean dispatchTouchEvent(MotionEvent event) { + // Reject any gesture that started when the screen is in the fake off state. + if (touchDownWhenPseudoScreenOff) { + if (event.getAction() == MotionEvent.ACTION_UP) { + touchDownWhenPseudoScreenOff = false; + } + return true; + } + // Reject all touch event when the screen is in the fake off state. + if (!InCallPresenter.getInstance().getPseudoScreenState().isOn()) { + if (event.getAction() == MotionEvent.ACTION_DOWN) { + touchDownWhenPseudoScreenOff = true; + LogUtil.i("InCallActivity.dispatchTouchEvent", "touchDownWhenPseudoScreenOff"); + } + return true; + } + return super.dispatchTouchEvent(event); + } + + private static class ShouldShowAnswerUiResult { + public final boolean shouldShow; + public final DialerCall call; + + ShouldShowAnswerUiResult(boolean shouldShow, DialerCall call) { + this.shouldShow = shouldShow; + this.call = call; + } + } +} diff --git a/java/com/android/incallui/InCallActivityCommon.java b/java/com/android/incallui/InCallActivityCommon.java new file mode 100644 index 000000000..a2467dd72 --- /dev/null +++ b/java/com/android/incallui/InCallActivityCommon.java @@ -0,0 +1,820 @@ +/* + * 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; + +import android.app.ActivityManager; +import android.app.ActivityManager.AppTask; +import android.app.ActivityManager.TaskDescription; +import android.app.AlertDialog; +import android.app.Dialog; +import android.app.DialogFragment; +import android.content.Context; +import android.content.DialogInterface; +import android.content.DialogInterface.OnCancelListener; +import android.content.DialogInterface.OnDismissListener; +import android.content.Intent; +import android.content.res.Configuration; +import android.content.res.Resources; +import android.os.Bundle; +import android.support.annotation.IntDef; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v4.app.Fragment; +import android.support.v4.app.FragmentManager; +import android.support.v4.app.FragmentTransaction; +import android.support.v4.content.res.ResourcesCompat; +import android.telecom.DisconnectCause; +import android.telecom.PhoneAccountHandle; +import android.text.TextUtils; +import android.util.Pair; +import android.view.KeyEvent; +import android.view.View; +import android.view.Window; +import android.view.WindowManager; +import android.view.animation.Animation; +import android.view.animation.AnimationUtils; +import android.widget.CheckBox; +import android.widget.Toast; +import com.android.contacts.common.widget.SelectPhoneAccountDialogFragment; +import com.android.contacts.common.widget.SelectPhoneAccountDialogFragment.SelectPhoneAccountListener; +import com.android.dialer.animation.AnimUtils; +import com.android.dialer.animation.AnimationListenerAdapter; +import com.android.dialer.common.LogUtil; +import com.android.dialer.compat.CompatUtils; +import com.android.dialer.logging.Logger; +import com.android.dialer.logging.nano.ScreenEvent; +import com.android.dialer.util.ViewUtil; +import com.android.incallui.call.CallList; +import com.android.incallui.call.DialerCall; +import com.android.incallui.call.DialerCall.State; +import com.android.incallui.call.TelecomAdapter; +import com.android.incallui.wifi.EnableWifiCallingPrompt; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.ArrayList; +import java.util.List; + +/** Shared functionality between the new and old in call activity. */ +public class InCallActivityCommon { + + private static final String INTENT_EXTRA_SHOW_DIALPAD = "InCallActivity.show_dialpad"; + private static final String INTENT_EXTRA_NEW_OUTGOING_CALL = "InCallActivity.new_outgoing_call"; + private static final String INTENT_EXTRA_FOR_FULL_SCREEN = + "InCallActivity.for_full_screen_intent"; + + private static final String DIALPAD_TEXT_KEY = "InCallActivity.dialpad_text"; + + private static final String TAG_SELECT_ACCOUNT_FRAGMENT = "tag_select_account_fragment"; + private static final String TAG_DIALPAD_FRAGMENT = "tag_dialpad_fragment"; + + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + DIALPAD_REQUEST_NONE, + DIALPAD_REQUEST_SHOW, + DIALPAD_REQUEST_HIDE, + }) + @interface DialpadRequestType {} + + private static final int DIALPAD_REQUEST_NONE = 1; + private static final int DIALPAD_REQUEST_SHOW = 2; + private static final int DIALPAD_REQUEST_HIDE = 3; + + private final InCallActivity inCallActivity; + private boolean dismissKeyguard; + private boolean showPostCharWaitDialogOnResume; + private String showPostCharWaitDialogCallId; + private String showPostCharWaitDialogChars; + private Dialog dialog; + private InCallOrientationEventListener inCallOrientationEventListener; + private Animation dialpadSlideInAnimation; + private Animation dialpadSlideOutAnimation; + private boolean animateDialpadOnShow; + private String dtmfTextToPreopulate; + @DialpadRequestType private int showDialpadRequest = DIALPAD_REQUEST_NONE; + + private SelectPhoneAccountListener selectAccountListener = + new SelectPhoneAccountListener() { + @Override + public void onPhoneAccountSelected( + PhoneAccountHandle selectedAccountHandle, boolean setDefault, String callId) { + DialerCall call = CallList.getInstance().getCallById(callId); + LogUtil.i( + "InCallActivityCommon.SelectPhoneAccountListener.onPhoneAccountSelected", + "call: " + call); + if (call != null) { + call.phoneAccountSelected(selectedAccountHandle, setDefault); + } + } + + @Override + public void onDialogDismissed(String callId) { + DialerCall call = CallList.getInstance().getCallById(callId); + LogUtil.i( + "InCallActivityCommon.SelectPhoneAccountListener.onDialogDismissed", + "disconnecting call: " + call); + if (call != null) { + call.disconnect(); + } + } + }; + + public static void setIntentExtras( + Intent intent, boolean showDialpad, boolean newOutgoingCall, boolean isForFullScreen) { + if (showDialpad) { + intent.putExtra(INTENT_EXTRA_SHOW_DIALPAD, true); + } + intent.putExtra(INTENT_EXTRA_NEW_OUTGOING_CALL, newOutgoingCall); + intent.putExtra(INTENT_EXTRA_FOR_FULL_SCREEN, isForFullScreen); + } + + public InCallActivityCommon(InCallActivity inCallActivity) { + this.inCallActivity = inCallActivity; + } + + public void onCreate(Bundle icicle) { + // set this flag so this activity will stay in front of the keyguard + // Have the WindowManager filter out touch events that are "too fat". + int flags = + WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED + | WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON + | WindowManager.LayoutParams.FLAG_IGNORE_CHEEK_PRESSES; + + inCallActivity.getWindow().addFlags(flags); + + inCallActivity.setContentView(R.layout.incall_screen); + + internalResolveIntent(inCallActivity.getIntent()); + + boolean isLandscape = + inCallActivity.getResources().getConfiguration().orientation + == Configuration.ORIENTATION_LANDSCAPE; + boolean isRtl = ViewUtil.isRtl(); + + if (isLandscape) { + dialpadSlideInAnimation = + AnimationUtils.loadAnimation( + inCallActivity, isRtl ? R.anim.dialpad_slide_in_left : R.anim.dialpad_slide_in_right); + dialpadSlideOutAnimation = + AnimationUtils.loadAnimation( + inCallActivity, + isRtl ? R.anim.dialpad_slide_out_left : R.anim.dialpad_slide_out_right); + } else { + dialpadSlideInAnimation = + AnimationUtils.loadAnimation(inCallActivity, R.anim.dialpad_slide_in_bottom); + dialpadSlideOutAnimation = + AnimationUtils.loadAnimation(inCallActivity, R.anim.dialpad_slide_out_bottom); + } + + dialpadSlideInAnimation.setInterpolator(AnimUtils.EASE_IN); + dialpadSlideOutAnimation.setInterpolator(AnimUtils.EASE_OUT); + + dialpadSlideOutAnimation.setAnimationListener( + new AnimationListenerAdapter() { + @Override + public void onAnimationEnd(Animation animation) { + performHideDialpadFragment(); + } + }); + + if (icicle != null) { + // If the dialpad was shown before, set variables indicating it should be shown and + // populated with the previous DTMF text. The dialpad is actually shown and populated + // in onResume() to ensure the hosting fragment has been inflated and is ready to receive it. + if (icicle.containsKey(INTENT_EXTRA_SHOW_DIALPAD)) { + boolean showDialpad = icicle.getBoolean(INTENT_EXTRA_SHOW_DIALPAD); + showDialpadRequest = showDialpad ? DIALPAD_REQUEST_SHOW : DIALPAD_REQUEST_HIDE; + animateDialpadOnShow = false; + } + dtmfTextToPreopulate = icicle.getString(DIALPAD_TEXT_KEY); + + SelectPhoneAccountDialogFragment dialogFragment = + (SelectPhoneAccountDialogFragment) + inCallActivity.getFragmentManager().findFragmentByTag(TAG_SELECT_ACCOUNT_FRAGMENT); + if (dialogFragment != null) { + dialogFragment.setListener(selectAccountListener); + } + } + + inCallOrientationEventListener = new InCallOrientationEventListener(inCallActivity); + } + + public void onSaveInstanceState(Bundle out) { + // TODO: The dialpad fragment should handle this as part of its own state + out.putBoolean(INTENT_EXTRA_SHOW_DIALPAD, isDialpadVisible()); + DialpadFragment dialpadFragment = getDialpadFragment(); + if (dialpadFragment != null) { + out.putString(DIALPAD_TEXT_KEY, dialpadFragment.getDtmfText()); + } + } + + public void onStart() { + // setting activity should be last thing in setup process + InCallPresenter.getInstance().setActivity(inCallActivity); + enableInCallOrientationEventListener( + inCallActivity.getRequestedOrientation() + == InCallOrientationEventListener.ACTIVITY_PREFERENCE_ALLOW_ROTATION); + + InCallPresenter.getInstance().onActivityStarted(); + } + + public void onResume() { + if (InCallPresenter.getInstance().isReadyForTearDown()) { + LogUtil.i( + "InCallActivityCommon.onResume", + "InCallPresenter is ready for tear down, not sending updates"); + } else { + updateTaskDescription(); + InCallPresenter.getInstance().onUiShowing(true); + } + + // If there is a pending request to show or hide the dialpad, handle that now. + if (showDialpadRequest != DIALPAD_REQUEST_NONE) { + if (showDialpadRequest == DIALPAD_REQUEST_SHOW) { + // Exit fullscreen so that the user has access to the dialpad hide/show button and + // can hide the dialpad. Important when showing the dialpad from within dialer. + InCallPresenter.getInstance().setFullScreen(false, true /* force */); + + inCallActivity.showDialpadFragment(true /* show */, animateDialpadOnShow /* animate */); + animateDialpadOnShow = false; + + DialpadFragment dialpadFragment = getDialpadFragment(); + if (dialpadFragment != null) { + dialpadFragment.setDtmfText(dtmfTextToPreopulate); + dtmfTextToPreopulate = null; + } + } else { + LogUtil.i("InCallActivityCommon.onResume", "force hide dialpad"); + if (getDialpadFragment() != null) { + inCallActivity.showDialpadFragment(false /* show */, false /* animate */); + } + } + showDialpadRequest = DIALPAD_REQUEST_NONE; + } + + if (showPostCharWaitDialogOnResume) { + showPostCharWaitDialog(showPostCharWaitDialogCallId, showPostCharWaitDialogChars); + } + + CallList.getInstance() + .onInCallUiShown( + inCallActivity.getIntent().getBooleanExtra(INTENT_EXTRA_FOR_FULL_SCREEN, false)); + } + + // onPause is guaranteed to be called when the InCallActivity goes + // in the background. + public void onPause() { + DialpadFragment dialpadFragment = getDialpadFragment(); + if (dialpadFragment != null) { + dialpadFragment.onDialerKeyUp(null); + } + + InCallPresenter.getInstance().onUiShowing(false); + if (inCallActivity.isFinishing()) { + InCallPresenter.getInstance().unsetActivity(inCallActivity); + } + } + + public void onStop() { + enableInCallOrientationEventListener(false); + InCallPresenter.getInstance().updateIsChangingConfigurations(); + InCallPresenter.getInstance().onActivityStopped(); + } + + public void onDestroy() { + InCallPresenter.getInstance().unsetActivity(inCallActivity); + InCallPresenter.getInstance().updateIsChangingConfigurations(); + } + + public void onNewIntent(Intent intent) { + LogUtil.i("InCallActivityCommon.onNewIntent", ""); + + // We're being re-launched with a new Intent. Since it's possible for a + // single InCallActivity instance to persist indefinitely (even if we + // finish() ourselves), this sequence can potentially happen any time + // the InCallActivity needs to be displayed. + + // Stash away the new intent so that we can get it in the future + // by calling getIntent(). (Otherwise getIntent() will return the + // original Intent from when we first got created!) + inCallActivity.setIntent(intent); + + // Activities are always paused before receiving a new intent, so + // we can count on our onResume() method being called next. + + // Just like in onCreate(), handle the intent. + internalResolveIntent(intent); + } + + public boolean onBackPressed(boolean isInCallScreenVisible) { + LogUtil.i("InCallActivityCommon.onBackPressed", ""); + + // BACK is also used to exit out of any "special modes" of the + // in-call UI: + if (!inCallActivity.isVisible()) { + return true; + } + + if (!isInCallScreenVisible) { + return true; + } + + DialpadFragment dialpadFragment = getDialpadFragment(); + if (dialpadFragment != null && dialpadFragment.isVisible()) { + inCallActivity.showDialpadFragment(false /* show */, true /* animate */); + return true; + } + + // Always disable the Back key while an incoming call is ringing + DialerCall call = CallList.getInstance().getIncomingCall(); + if (call != null) { + LogUtil.i("InCallActivityCommon.onBackPressed", "consume Back press for an incoming call"); + return true; + } + + // Nothing special to do. Fall back to the default behavior. + return false; + } + + public boolean onKeyUp(int keyCode, KeyEvent event) { + DialpadFragment dialpadFragment = getDialpadFragment(); + // push input to the dialer. + if (dialpadFragment != null + && (dialpadFragment.isVisible()) + && (dialpadFragment.onDialerKeyUp(event))) { + return true; + } else if (keyCode == KeyEvent.KEYCODE_CALL) { + // Always consume CALL to be sure the PhoneWindow won't do anything with it + return true; + } + return false; + } + + public boolean onKeyDown(int keyCode, KeyEvent event) { + switch (keyCode) { + case KeyEvent.KEYCODE_CALL: + boolean handled = InCallPresenter.getInstance().handleCallKey(); + if (!handled) { + LogUtil.e( + "InCallActivityCommon.onKeyDown", + "InCallPresenter should always handle KEYCODE_CALL in onKeyDown"); + } + // Always consume CALL to be sure the PhoneWindow won't do anything with it + return true; + + // Note there's no KeyEvent.KEYCODE_ENDCALL case here. + // The standard system-wide handling of the ENDCALL key + // (see PhoneWindowManager's handling of KEYCODE_ENDCALL) + // already implements exactly what the UI spec wants, + // namely (1) "hang up" if there's a current active call, + // or (2) "don't answer" if there's a current ringing call. + + case KeyEvent.KEYCODE_CAMERA: + // Disable the CAMERA button while in-call since it's too + // easy to press accidentally. + return true; + + case KeyEvent.KEYCODE_VOLUME_UP: + case KeyEvent.KEYCODE_VOLUME_DOWN: + case KeyEvent.KEYCODE_VOLUME_MUTE: + // Ringer silencing handled by PhoneWindowManager. + break; + + case KeyEvent.KEYCODE_MUTE: + TelecomAdapter.getInstance() + .mute(!AudioModeProvider.getInstance().getAudioState().isMuted()); + return true; + + // Various testing/debugging features, enabled ONLY when VERBOSE == true. + case KeyEvent.KEYCODE_SLASH: + if (LogUtil.isVerboseEnabled()) { + LogUtil.v( + "InCallActivityCommon.onKeyDown", + "----------- InCallActivity View dump --------------"); + // Dump starting from the top-level view of the entire activity: + Window w = inCallActivity.getWindow(); + View decorView = w.getDecorView(); + LogUtil.v("InCallActivityCommon.onKeyDown", "View dump:" + decorView); + return true; + } + break; + case KeyEvent.KEYCODE_EQUALS: + break; + } + + return event.getRepeatCount() == 0 && handleDialerKeyDown(keyCode, event); + } + + private boolean handleDialerKeyDown(int keyCode, KeyEvent event) { + LogUtil.v("InCallActivityCommon.handleDialerKeyDown", "keyCode %d, event: %s", keyCode, event); + + // As soon as the user starts typing valid dialable keys on the + // keyboard (presumably to type DTMF tones) we start passing the + // key events to the DTMFDialer's onDialerKeyDown. + DialpadFragment dialpadFragment = getDialpadFragment(); + if (dialpadFragment != null && dialpadFragment.isVisible()) { + return dialpadFragment.onDialerKeyDown(event); + } + + return false; + } + + public void dismissKeyguard(boolean dismiss) { + if (dismissKeyguard == dismiss) { + return; + } + dismissKeyguard = dismiss; + if (dismiss) { + inCallActivity.getWindow().addFlags(WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD); + } else { + inCallActivity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD); + } + } + + public void showPostCharWaitDialog(String callId, String chars) { + if (inCallActivity.isVisible()) { + PostCharDialogFragment fragment = new PostCharDialogFragment(callId, chars); + fragment.show(inCallActivity.getSupportFragmentManager(), "postCharWait"); + + showPostCharWaitDialogOnResume = false; + showPostCharWaitDialogCallId = null; + showPostCharWaitDialogChars = null; + } else { + showPostCharWaitDialogOnResume = true; + showPostCharWaitDialogCallId = callId; + showPostCharWaitDialogChars = chars; + } + } + + public void maybeShowErrorDialogOnDisconnect(DisconnectCause cause) { + LogUtil.i( + "InCallActivityCommon.maybeShowErrorDialogOnDisconnect", "disconnect cause: %s", cause); + + if (!inCallActivity.isFinishing()) { + if (EnableWifiCallingPrompt.shouldShowPrompt(cause)) { + Pair<Dialog, CharSequence> pair = + EnableWifiCallingPrompt.createDialog(inCallActivity, cause); + showErrorDialog(pair.first, pair.second); + } else if (shouldShowDisconnectErrorDialog(cause)) { + Pair<Dialog, CharSequence> pair = getDisconnectErrorDialog(inCallActivity, cause); + showErrorDialog(pair.first, pair.second); + } + } + } + + /** + * When relaunching from the dialer app, {@code showDialpad} indicates whether the dialpad should + * be shown on launch. + * + * @param showDialpad {@code true} to indicate the dialpad should be shown on launch, and {@code + * false} to indicate no change should be made to the dialpad visibility. + */ + private void relaunchedFromDialer(boolean showDialpad) { + showDialpadRequest = showDialpad ? DIALPAD_REQUEST_SHOW : DIALPAD_REQUEST_NONE; + animateDialpadOnShow = true; + + if (showDialpadRequest == DIALPAD_REQUEST_SHOW) { + // If there's only one line in use, AND it's on hold, then we're sure the user + // wants to use the dialpad toward the exact line, so un-hold the holding line. + DialerCall call = CallList.getInstance().getActiveOrBackgroundCall(); + if (call != null && call.getState() == State.ONHOLD) { + call.unhold(); + } + } + } + + public void dismissPendingDialogs() { + if (dialog != null) { + dialog.dismiss(); + dialog = null; + } + } + + private static boolean shouldShowDisconnectErrorDialog(@NonNull DisconnectCause cause) { + return !TextUtils.isEmpty(cause.getDescription()) + && (cause.getCode() == DisconnectCause.ERROR + || cause.getCode() == DisconnectCause.RESTRICTED); + } + + private static Pair<Dialog, CharSequence> getDisconnectErrorDialog( + @NonNull Context context, @NonNull DisconnectCause cause) { + CharSequence message = cause.getDescription(); + Dialog dialog = + new AlertDialog.Builder(context) + .setMessage(message) + .setPositiveButton(android.R.string.ok, null) + .create(); + return new Pair<>(dialog, message); + } + + private void showErrorDialog(Dialog dialog, CharSequence message) { + LogUtil.i("InCallActivityCommon.showErrorDialog", "message: %s", message); + inCallActivity.dismissPendingDialogs(); + + // Show toast if apps is in background when dialog won't be visible. + if (!inCallActivity.isVisible()) { + Toast.makeText(inCallActivity.getApplicationContext(), message, Toast.LENGTH_LONG).show(); + return; + } + + this.dialog = dialog; + dialog.setOnDismissListener( + new OnDismissListener() { + @Override + public void onDismiss(DialogInterface dialog) { + LogUtil.i("InCallActivityCommon.showErrorDialog", "dialog dismissed"); + onDialogDismissed(); + } + }); + dialog.getWindow().addFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND); + dialog.show(); + } + + private void onDialogDismissed() { + dialog = null; + CallList.getInstance().onErrorDialogDismissed(); + InCallPresenter.getInstance().onDismissDialog(); + } + + public void enableInCallOrientationEventListener(boolean enable) { + if (enable) { + inCallOrientationEventListener.enable(true); + } else { + inCallOrientationEventListener.disable(); + } + } + + public void setExcludeFromRecents(boolean exclude) { + List<AppTask> tasks = inCallActivity.getSystemService(ActivityManager.class).getAppTasks(); + int taskId = inCallActivity.getTaskId(); + for (int i = 0; i < tasks.size(); i++) { + ActivityManager.AppTask task = tasks.get(i); + try { + if (task.getTaskInfo().id == taskId) { + task.setExcludeFromRecents(exclude); + } + } catch (RuntimeException e) { + LogUtil.e( + "InCallActivityCommon.setExcludeFromRecents", + "RuntimeException when excluding task from recents.", + e); + } + } + } + + public void showWifiToLteHandoverToast(DialerCall call) { + if (call.hasShownWiFiToLteHandoverToast()) { + return; + } + Toast.makeText( + inCallActivity, R.string.video_call_wifi_to_lte_handover_toast, Toast.LENGTH_LONG) + .show(); + call.setHasShownWiFiToLteHandoverToast(); + } + + public void showWifiFailedDialog(final DialerCall call) { + if (call.showWifiHandoverAlertAsToast()) { + LogUtil.i("InCallActivityCommon.showWifiFailedDialog", "as toast"); + Toast.makeText( + inCallActivity, R.string.video_call_lte_to_wifi_failed_message, Toast.LENGTH_SHORT) + .show(); + return; + } + + dismissPendingDialogs(); + + AlertDialog.Builder builder = + new AlertDialog.Builder(inCallActivity) + .setTitle(R.string.video_call_lte_to_wifi_failed_title); + + // This allows us to use the theme of the dialog instead of the activity + View dialogCheckBoxView = + View.inflate(builder.getContext(), R.layout.video_call_lte_to_wifi_failed, null); + final CheckBox wifiHandoverFailureCheckbox = + (CheckBox) dialogCheckBoxView.findViewById(R.id.video_call_lte_to_wifi_failed_checkbox); + wifiHandoverFailureCheckbox.setChecked(false); + + dialog = + builder + .setView(dialogCheckBoxView) + .setMessage(R.string.video_call_lte_to_wifi_failed_message) + .setOnCancelListener( + new OnCancelListener() { + @Override + public void onCancel(DialogInterface dialog) { + onDialogDismissed(); + } + }) + .setPositiveButton( + android.R.string.ok, + new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int id) { + call.setDoNotShowDialogForHandoffToWifiFailure( + wifiHandoverFailureCheckbox.isChecked()); + dialog.cancel(); + onDialogDismissed(); + } + }) + .create(); + + LogUtil.i("InCallActivityCommon.showWifiFailedDialog", "as dialog"); + dialog.show(); + } + + public boolean showDialpadFragment(boolean show, boolean animate) { + // If the dialpad is already visible, don't animate in. If it's gone, don't animate out. + boolean isDialpadVisible = isDialpadVisible(); + LogUtil.i( + "InCallActivityCommon.showDialpadFragment", + "show: %b, animate: %b, " + "isDialpadVisible: %b", + show, + animate, + isDialpadVisible); + if (show == isDialpadVisible) { + return false; + } + + FragmentManager dialpadFragmentManager = inCallActivity.getDialpadFragmentManager(); + if (dialpadFragmentManager == null) { + LogUtil.i( + "InCallActivityCommon.showDialpadFragment", "unable to show or hide dialpad fragment"); + return false; + } + + // We don't do a FragmentTransaction on the hide case because it will be dealt with when + // the listener is fired after an animation finishes. + if (!animate) { + if (show) { + performShowDialpadFragment(dialpadFragmentManager); + } else { + performHideDialpadFragment(); + } + } else { + if (show) { + performShowDialpadFragment(dialpadFragmentManager); + getDialpadFragment().animateShowDialpad(); + } + getDialpadFragment() + .getView() + .startAnimation(show ? dialpadSlideInAnimation : dialpadSlideOutAnimation); + } + + ProximitySensor sensor = InCallPresenter.getInstance().getProximitySensor(); + if (sensor != null) { + sensor.onDialpadVisible(show); + } + showDialpadRequest = DIALPAD_REQUEST_NONE; + return true; + } + + private void performShowDialpadFragment(@NonNull FragmentManager dialpadFragmentManager) { + FragmentTransaction transaction = dialpadFragmentManager.beginTransaction(); + DialpadFragment dialpadFragment = getDialpadFragment(); + if (dialpadFragment == null) { + transaction.add( + inCallActivity.getDialpadContainerId(), new DialpadFragment(), TAG_DIALPAD_FRAGMENT); + } else { + transaction.show(dialpadFragment); + } + + transaction.commitAllowingStateLoss(); + dialpadFragmentManager.executePendingTransactions(); + + Logger.get(inCallActivity).logScreenView(ScreenEvent.Type.INCALL_DIALPAD, inCallActivity); + } + + private void performHideDialpadFragment() { + FragmentManager fragmentManager = inCallActivity.getDialpadFragmentManager(); + if (fragmentManager == null) { + LogUtil.e( + "InCallActivityCommon.performHideDialpadFragment", "child fragment manager is null"); + return; + } + + Fragment fragment = fragmentManager.findFragmentByTag(TAG_DIALPAD_FRAGMENT); + if (fragment != null) { + FragmentTransaction transaction = fragmentManager.beginTransaction(); + transaction.hide(fragment); + transaction.commitAllowingStateLoss(); + fragmentManager.executePendingTransactions(); + } + } + + public boolean isDialpadVisible() { + DialpadFragment dialpadFragment = getDialpadFragment(); + return dialpadFragment != null && dialpadFragment.isVisible(); + } + + /** Returns the {@link DialpadFragment} that's shown by this activity, or {@code null} */ + @Nullable + private DialpadFragment getDialpadFragment() { + FragmentManager fragmentManager = inCallActivity.getDialpadFragmentManager(); + if (fragmentManager == null) { + return null; + } + return (DialpadFragment) fragmentManager.findFragmentByTag(TAG_DIALPAD_FRAGMENT); + } + + public void updateTaskDescription() { + Resources resources = inCallActivity.getResources(); + int color; + if (resources.getBoolean(R.bool.is_layout_landscape)) { + color = + ResourcesCompat.getColor( + resources, R.color.statusbar_background_color, inCallActivity.getTheme()); + } else { + color = InCallPresenter.getInstance().getThemeColorManager().getSecondaryColor(); + } + + TaskDescription td = + new TaskDescription(resources.getString(R.string.notification_ongoing_call), null, color); + inCallActivity.setTaskDescription(td); + } + + public boolean hasPendingDialogs() { + return dialog != null; + } + + private void internalResolveIntent(Intent intent) { + if (!intent.getAction().equals(Intent.ACTION_MAIN)) { + return; + } + + if (intent.hasExtra(INTENT_EXTRA_SHOW_DIALPAD)) { + // SHOW_DIALPAD_EXTRA can be used here to specify whether the DTMF + // dialpad should be initially visible. If the extra isn't + // present at all, we just leave the dialpad in its previous state. + boolean showDialpad = intent.getBooleanExtra(INTENT_EXTRA_SHOW_DIALPAD, false); + LogUtil.i("InCallActivityCommon.internalResolveIntent", "SHOW_DIALPAD_EXTRA: " + showDialpad); + + relaunchedFromDialer(showDialpad); + } + + DialerCall outgoingCall = CallList.getInstance().getOutgoingCall(); + if (outgoingCall == null) { + outgoingCall = CallList.getInstance().getPendingOutgoingCall(); + } + + boolean isNewOutgoingCall = false; + if (intent.getBooleanExtra(INTENT_EXTRA_NEW_OUTGOING_CALL, false)) { + isNewOutgoingCall = true; + intent.removeExtra(INTENT_EXTRA_NEW_OUTGOING_CALL); + + // InCallActivity is responsible for disconnecting a new outgoing call if there + // is no way of making it (i.e. no valid call capable accounts). + // If the version is not MSIM compatible, then ignore this code. + if (CompatUtils.isMSIMCompatible() + && InCallPresenter.isCallWithNoValidAccounts(outgoingCall)) { + LogUtil.i( + "InCallActivityCommon.internalResolveIntent", + "call with no valid accounts, disconnecting"); + outgoingCall.disconnect(); + } + + dismissKeyguard(true); + } + + boolean didShowAccountSelectionDialog = maybeShowAccountSelectionDialog(); + inCallActivity.onResolveIntent(outgoingCall, isNewOutgoingCall, didShowAccountSelectionDialog); + } + + private boolean maybeShowAccountSelectionDialog() { + DialerCall call = CallList.getInstance().getWaitingForAccountCall(); + if (call == null) { + return false; + } + + Bundle extras = call.getIntentExtras(); + List<PhoneAccountHandle> phoneAccountHandles; + if (extras != null) { + phoneAccountHandles = + extras.getParcelableArrayList(android.telecom.Call.AVAILABLE_PHONE_ACCOUNTS); + } else { + phoneAccountHandles = new ArrayList<>(); + } + + DialogFragment dialogFragment = + SelectPhoneAccountDialogFragment.newInstance( + R.string.select_phone_account_for_calls, + true, + phoneAccountHandles, + selectAccountListener, + call.getId()); + dialogFragment.show(inCallActivity.getFragmentManager(), TAG_SELECT_ACCOUNT_FRAGMENT); + return true; + } +} diff --git a/java/com/android/incallui/InCallCameraManager.java b/java/com/android/incallui/InCallCameraManager.java new file mode 100644 index 000000000..fdb422643 --- /dev/null +++ b/java/com/android/incallui/InCallCameraManager.java @@ -0,0 +1,173 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.android.incallui; + +import android.content.Context; +import android.hardware.camera2.CameraAccessException; +import android.hardware.camera2.CameraCharacteristics; +import android.hardware.camera2.CameraManager; +import java.util.Collections; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +/** Used to track which camera is used for outgoing video. */ +public class InCallCameraManager { + + private final Set<Listener> mCameraSelectionListeners = + Collections.newSetFromMap(new ConcurrentHashMap<Listener, Boolean>(8, 0.9f, 1)); + /** The camera ID for the front facing camera. */ + private String mFrontFacingCameraId; + /** The camera ID for the rear facing camera. */ + private String mRearFacingCameraId; + /** The currently active camera. */ + private boolean mUseFrontFacingCamera; + /** + * Indicates whether the list of cameras has been initialized yet. Initialization is delayed until + * a video call is present. + */ + private boolean mIsInitialized = false; + /** The context. */ + private Context mContext; + + /** + * Initializes the InCall CameraManager. + * + * @param context The current context. + */ + public InCallCameraManager(Context context) { + mUseFrontFacingCamera = true; + mContext = context; + } + + /** + * Sets whether the front facing camera should be used or not. + * + * @param useFrontFacingCamera {@code True} if the front facing camera is to be used. + */ + public void setUseFrontFacingCamera(boolean useFrontFacingCamera) { + mUseFrontFacingCamera = useFrontFacingCamera; + for (Listener listener : mCameraSelectionListeners) { + listener.onActiveCameraSelectionChanged(mUseFrontFacingCamera); + } + } + + /** + * Determines whether the front facing camera is currently in use. + * + * @return {@code True} if the front facing camera is in use. + */ + public boolean isUsingFrontFacingCamera() { + return mUseFrontFacingCamera; + } + + /** + * Determines the active camera ID. + * + * @return The active camera ID. + */ + public String getActiveCameraId() { + maybeInitializeCameraList(mContext); + + if (mUseFrontFacingCamera) { + return mFrontFacingCameraId; + } else { + return mRearFacingCameraId; + } + } + + /** Calls when camera permission is granted by user. */ + public void onCameraPermissionGranted() { + for (Listener listener : mCameraSelectionListeners) { + listener.onCameraPermissionGranted(); + } + } + + /** + * Get the list of cameras available for use. + * + * @param context The context. + */ + private void maybeInitializeCameraList(Context context) { + if (mIsInitialized || context == null) { + return; + } + + Log.v(this, "initializeCameraList"); + + CameraManager cameraManager = null; + try { + cameraManager = (CameraManager) context.getSystemService(Context.CAMERA_SERVICE); + } catch (Exception e) { + Log.e(this, "Could not get camera service."); + return; + } + + if (cameraManager == null) { + return; + } + + String[] cameraIds = {}; + try { + cameraIds = cameraManager.getCameraIdList(); + } catch (CameraAccessException e) { + Log.d(this, "Could not access camera: " + e); + // Camera disabled by device policy. + return; + } + + for (int i = 0; i < cameraIds.length; i++) { + CameraCharacteristics c = null; + try { + c = cameraManager.getCameraCharacteristics(cameraIds[i]); + } catch (IllegalArgumentException e) { + // Device Id is unknown. + } catch (CameraAccessException e) { + // Camera disabled by device policy. + } + if (c != null) { + int facingCharacteristic = c.get(CameraCharacteristics.LENS_FACING); + if (facingCharacteristic == CameraCharacteristics.LENS_FACING_FRONT) { + mFrontFacingCameraId = cameraIds[i]; + } else if (facingCharacteristic == CameraCharacteristics.LENS_FACING_BACK) { + mRearFacingCameraId = cameraIds[i]; + } + } + } + + mIsInitialized = true; + Log.v(this, "initializeCameraList : done"); + } + + public void addCameraSelectionListener(Listener listener) { + if (listener != null) { + mCameraSelectionListeners.add(listener); + } + } + + public void removeCameraSelectionListener(Listener listener) { + if (listener != null) { + mCameraSelectionListeners.remove(listener); + } + } + + public interface Listener { + + void onActiveCameraSelectionChanged(boolean isUsingFrontFacingCamera); + + void onCameraPermissionGranted(); + } +} diff --git a/java/com/android/incallui/InCallOrientationEventListener.java b/java/com/android/incallui/InCallOrientationEventListener.java new file mode 100644 index 000000000..e6b0bc027 --- /dev/null +++ b/java/com/android/incallui/InCallOrientationEventListener.java @@ -0,0 +1,194 @@ +/* + * 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; + +import android.content.Context; +import android.content.pm.ActivityInfo; +import android.support.annotation.IntDef; +import android.view.OrientationEventListener; +import com.android.dialer.common.LogUtil; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** + * This class listens to Orientation events and overrides onOrientationChanged which gets invoked + * when an orientation change occurs. When that happens, we notify InCallUI registrants of the + * change. + */ +public class InCallOrientationEventListener extends OrientationEventListener { + + public static final int SCREEN_ORIENTATION_0 = 0; + public static final int SCREEN_ORIENTATION_90 = 90; + public static final int SCREEN_ORIENTATION_180 = 180; + public static final int SCREEN_ORIENTATION_270 = 270; + public static final int SCREEN_ORIENTATION_360 = 360; + + /** Screen orientation angles one of 0, 90, 180, 270, 360 in degrees. */ + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + SCREEN_ORIENTATION_0, + SCREEN_ORIENTATION_90, + SCREEN_ORIENTATION_180, + SCREEN_ORIENTATION_270, + SCREEN_ORIENTATION_360, + SCREEN_ORIENTATION_UNKNOWN + }) + public @interface ScreenOrientation {} + + // We use SCREEN_ORIENTATION_USER so that reverse-portrait is not allowed. + public static final int ACTIVITY_PREFERENCE_ALLOW_ROTATION = ActivityInfo.SCREEN_ORIENTATION_USER; + + public static final int ACTIVITY_PREFERENCE_DISALLOW_ROTATION = + ActivityInfo.SCREEN_ORIENTATION_NOSENSOR; + + /** + * This is to identify dead zones where we won't notify others of orientation changed. Say for e.g + * our threshold is x degrees. We will only notify UI when our current rotation is within x + * degrees right or left of the screen orientation angles. If it's not within those ranges, we + * return SCREEN_ORIENTATION_UNKNOWN and ignore it. + */ + public static final int SCREEN_ORIENTATION_UNKNOWN = -1; + + // Rotation threshold is 10 degrees. So if the rotation angle is within 10 degrees of any of + // the above angles, we will notify orientation changed. + private static final int ROTATION_THRESHOLD = 10; + + /** Cache the current rotation of the device. */ + @ScreenOrientation private static int sCurrentOrientation = SCREEN_ORIENTATION_0; + + private boolean mEnabled = false; + + public InCallOrientationEventListener(Context context) { + super(context); + } + + private static boolean isWithinRange(int value, int begin, int end) { + return value >= begin && value < end; + } + + private static boolean isWithinThreshold(int value, int center, int threshold) { + return isWithinRange(value, center - threshold, center + threshold); + } + + private static boolean isInLeftRange(int value, int center, int threshold) { + return isWithinRange(value, center - threshold, center); + } + + private static boolean isInRightRange(int value, int center, int threshold) { + return isWithinRange(value, center, center + threshold); + } + + @ScreenOrientation + public static int getCurrentOrientation() { + return sCurrentOrientation; + } + + /** + * Handles changes in device orientation. Notifies InCallPresenter of orientation changes. + * + * <p>Note that this API receives sensor rotation in degrees as a param and we convert that to one + * of our screen orientation constants - (one of: {@link #SCREEN_ORIENTATION_0}, {@link + * #SCREEN_ORIENTATION_90}, {@link #SCREEN_ORIENTATION_180}, {@link #SCREEN_ORIENTATION_270}). + * + * @param rotation The new device sensor rotation in degrees + */ + @Override + public void onOrientationChanged(int rotation) { + if (rotation == OrientationEventListener.ORIENTATION_UNKNOWN) { + return; + } + + final int orientation = toScreenOrientation(rotation); + + if (orientation != SCREEN_ORIENTATION_UNKNOWN && sCurrentOrientation != orientation) { + LogUtil.i( + "InCallOrientationEventListener.onOrientationChanged", + "orientation: %d -> %d", + sCurrentOrientation, + orientation); + sCurrentOrientation = orientation; + InCallPresenter.getInstance().onDeviceOrientationChange(sCurrentOrientation); + } + } + + /** + * Enables the OrientationEventListener and notifies listeners of current orientation if notify + * flag is true + * + * @param notify true or false. Notify device orientation changed if true. + */ + public void enable(boolean notify) { + if (mEnabled) { + Log.v(this, "enable: Orientation listener is already enabled. Ignoring..."); + return; + } + + super.enable(); + mEnabled = true; + if (notify) { + InCallPresenter.getInstance().onDeviceOrientationChange(sCurrentOrientation); + } + } + + /** Enables the OrientationEventListener with notify flag defaulting to false. */ + @Override + public void enable() { + enable(false); + } + + /** Disables the OrientationEventListener. */ + @Override + public void disable() { + if (!mEnabled) { + Log.v(this, "enable: Orientation listener is already disabled. Ignoring..."); + return; + } + + mEnabled = false; + super.disable(); + } + + /** Returns true the OrientationEventListener is enabled, false otherwise. */ + public boolean isEnabled() { + return mEnabled; + } + + /** + * Converts sensor rotation in degrees to screen orientation constants. + * + * @param rotation sensor rotation angle in degrees + * @return Screen orientation angle in degrees (0, 90, 180, 270). Returns -1 for degrees not + * within threshold to identify zones where orientation change should not be trigerred. + */ + @ScreenOrientation + private int toScreenOrientation(int rotation) { + // Sensor orientation 90 is equivalent to screen orientation 270 and vice versa. This + // function returns the screen orientation. So we convert sensor rotation 90 to 270 and + // vice versa here. + if (isInLeftRange(rotation, SCREEN_ORIENTATION_360, ROTATION_THRESHOLD) + || isInRightRange(rotation, SCREEN_ORIENTATION_0, ROTATION_THRESHOLD)) { + return SCREEN_ORIENTATION_0; + } else if (isWithinThreshold(rotation, SCREEN_ORIENTATION_90, ROTATION_THRESHOLD)) { + return SCREEN_ORIENTATION_270; + } else if (isWithinThreshold(rotation, SCREEN_ORIENTATION_180, ROTATION_THRESHOLD)) { + return SCREEN_ORIENTATION_180; + } else if (isWithinThreshold(rotation, SCREEN_ORIENTATION_270, ROTATION_THRESHOLD)) { + return SCREEN_ORIENTATION_90; + } + return SCREEN_ORIENTATION_UNKNOWN; + } +} diff --git a/java/com/android/incallui/InCallPresenter.java b/java/com/android/incallui/InCallPresenter.java new file mode 100644 index 000000000..97105fb78 --- /dev/null +++ b/java/com/android/incallui/InCallPresenter.java @@ -0,0 +1,1679 @@ +/* + * Copyright (C) 2013 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; + +import android.content.Context; +import android.content.Intent; +import android.graphics.Point; +import android.os.Bundle; +import android.os.Handler; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.annotation.VisibleForTesting; +import android.telecom.Call.Details; +import android.telecom.DisconnectCause; +import android.telecom.PhoneAccount; +import android.telecom.PhoneAccountHandle; +import android.telecom.TelecomManager; +import android.telecom.VideoProfile; +import android.telephony.PhoneStateListener; +import android.telephony.TelephonyManager; +import android.view.Window; +import android.view.WindowManager; +import com.android.contacts.common.GeoUtil; +import com.android.contacts.common.compat.CallCompat; +import com.android.dialer.blocking.FilteredNumberAsyncQueryHandler; +import com.android.dialer.blocking.FilteredNumberAsyncQueryHandler.OnCheckBlockedListener; +import com.android.dialer.blocking.FilteredNumbersUtil; +import com.android.dialer.common.LogUtil; +import com.android.dialer.logging.Logger; +import com.android.dialer.logging.nano.InteractionEvent; +import com.android.dialer.telecom.TelecomUtil; +import com.android.dialer.util.TouchPointManager; +import com.android.incallui.InCallOrientationEventListener.ScreenOrientation; +import com.android.incallui.answerproximitysensor.PseudoScreenState; +import com.android.incallui.call.CallList; +import com.android.incallui.call.DialerCall; +import com.android.incallui.call.DialerCall.SessionModificationState; +import com.android.incallui.call.ExternalCallList; +import com.android.incallui.call.InCallVideoCallCallbackNotifier; +import com.android.incallui.call.TelecomAdapter; +import com.android.incallui.call.VideoUtils; +import com.android.incallui.latencyreport.LatencyReport; +import com.android.incallui.legacyblocking.BlockedNumberContentObserver; +import com.android.incallui.spam.SpamCallListListener; +import com.android.incallui.util.TelecomCallUtil; +import com.android.incallui.videosurface.bindings.VideoSurfaceBindings; +import com.android.incallui.videosurface.protocol.VideoSurfaceTexture; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Takes updates from the CallList and notifies the InCallActivity (UI) of the changes. Responsible + * for starting the activity for a new call and finishing the activity when all calls are + * disconnected. Creates and manages the in-call state and provides a listener pattern for the + * presenters that want to listen in on the in-call state changes. TODO: This class has become more + * of a state machine at this point. Consider renaming. + */ +public class InCallPresenter + implements CallList.Listener, InCallVideoCallCallbackNotifier.SessionModificationListener { + + private static final String EXTRA_FIRST_TIME_SHOWN = + "com.android.incallui.intent.extra.FIRST_TIME_SHOWN"; + + private static final long BLOCK_QUERY_TIMEOUT_MS = 1000; + + private static final Bundle EMPTY_EXTRAS = new Bundle(); + + private static InCallPresenter sInCallPresenter; + + /** + * ConcurrentHashMap constructor params: 8 is initial table size, 0.9f is load factor before + * resizing, 1 means we only expect a single thread to access the map so make only a single shard + */ + private final Set<InCallStateListener> mListeners = + Collections.newSetFromMap(new ConcurrentHashMap<InCallStateListener, Boolean>(8, 0.9f, 1)); + + private final List<IncomingCallListener> mIncomingCallListeners = new CopyOnWriteArrayList<>(); + private final Set<InCallDetailsListener> mDetailsListeners = + Collections.newSetFromMap(new ConcurrentHashMap<InCallDetailsListener, Boolean>(8, 0.9f, 1)); + private final Set<CanAddCallListener> mCanAddCallListeners = + Collections.newSetFromMap(new ConcurrentHashMap<CanAddCallListener, Boolean>(8, 0.9f, 1)); + private final Set<InCallUiListener> mInCallUiListeners = + Collections.newSetFromMap(new ConcurrentHashMap<InCallUiListener, Boolean>(8, 0.9f, 1)); + private final Set<InCallOrientationListener> mOrientationListeners = + Collections.newSetFromMap( + new ConcurrentHashMap<InCallOrientationListener, Boolean>(8, 0.9f, 1)); + private final Set<InCallEventListener> mInCallEventListeners = + Collections.newSetFromMap(new ConcurrentHashMap<InCallEventListener, Boolean>(8, 0.9f, 1)); + + private StatusBarNotifier mStatusBarNotifier; + private ExternalCallNotifier mExternalCallNotifier; + private ContactInfoCache mContactInfoCache; + private Context mContext; + private final OnCheckBlockedListener mOnCheckBlockedListener = + new OnCheckBlockedListener() { + @Override + public void onCheckComplete(final Integer id) { + if (id != null && id != FilteredNumberAsyncQueryHandler.INVALID_ID) { + // Silence the ringer now to prevent ringing and vibration before the call is + // terminated when Telecom attempts to add it. + TelecomUtil.silenceRinger(mContext); + } + } + }; + private CallList mCallList; + private ExternalCallList mExternalCallList; + private InCallActivity mInCallActivity; + private ManageConferenceActivity mManageConferenceActivity; + private final android.telecom.Call.Callback mCallCallback = + new android.telecom.Call.Callback() { + @Override + public void onPostDialWait( + android.telecom.Call telecomCall, String remainingPostDialSequence) { + final DialerCall call = mCallList.getDialerCallFromTelecomCall(telecomCall); + if (call == null) { + Log.w(this, "DialerCall not found in call list: " + telecomCall); + return; + } + onPostDialCharWait(call.getId(), remainingPostDialSequence); + } + + @Override + public void onDetailsChanged( + android.telecom.Call telecomCall, android.telecom.Call.Details details) { + final DialerCall call = mCallList.getDialerCallFromTelecomCall(telecomCall); + if (call == null) { + Log.w(this, "DialerCall not found in call list: " + telecomCall); + return; + } + + if (details.hasProperty(Details.PROPERTY_IS_EXTERNAL_CALL) + && !mExternalCallList.isCallTracked(telecomCall)) { + + // A regular call became an external call so swap call lists. + Log.i(this, "Call became external: " + telecomCall); + mCallList.onInternalCallMadeExternal(mContext, telecomCall); + mExternalCallList.onCallAdded(telecomCall); + return; + } + + for (InCallDetailsListener listener : mDetailsListeners) { + listener.onDetailsChanged(call, details); + } + } + + @Override + public void onConferenceableCallsChanged( + android.telecom.Call telecomCall, List<android.telecom.Call> conferenceableCalls) { + Log.i(this, "onConferenceableCallsChanged: " + telecomCall); + onDetailsChanged(telecomCall, telecomCall.getDetails()); + } + }; + private InCallState mInCallState = InCallState.NO_CALLS; + private ProximitySensor mProximitySensor; + private final PseudoScreenState mPseudoScreenState = new PseudoScreenState(); + private boolean mServiceConnected; + private boolean mAccountSelectionCancelled; + private InCallCameraManager mInCallCameraManager; + private FilteredNumberAsyncQueryHandler mFilteredQueryHandler; + private CallList.Listener mSpamCallListListener; + /** Whether or not we are currently bound and waiting for Telecom to send us a new call. */ + private boolean mBoundAndWaitingForOutgoingCall; + /** Determines if the InCall UI is in fullscreen mode or not. */ + private boolean mIsFullScreen = false; + + private PhoneStateListener mPhoneStateListener = + new PhoneStateListener() { + @Override + public void onCallStateChanged(int state, String incomingNumber) { + if (state == TelephonyManager.CALL_STATE_RINGING) { + if (FilteredNumbersUtil.hasRecentEmergencyCall(mContext)) { + return; + } + // Check if the number is blocked, to silence the ringer. + String countryIso = GeoUtil.getCurrentCountryIso(mContext); + mFilteredQueryHandler.isBlockedNumber( + mOnCheckBlockedListener, incomingNumber, countryIso); + } + } + }; + /** + * Is true when the activity has been previously started. Some code needs to know not just if the + * activity is currently up, but if it had been previously shown in foreground for this in-call + * session (e.g., StatusBarNotifier). This gets reset when the session ends in the tear-down + * method. + */ + private boolean mIsActivityPreviouslyStarted = false; + + /** Whether or not InCallService is bound to Telecom. */ + private boolean mServiceBound = false; + + /** + * When configuration changes Android kills the current activity and starts a new one. The flag is + * used to check if full clean up is necessary (activity is stopped and new activity won't be + * started), or if a new activity will be started right after the current one is destroyed, and + * therefore no need in release all resources. + */ + private boolean mIsChangingConfigurations = false; + + private boolean mAwaitingCallListUpdate = false; + + private ExternalCallList.ExternalCallListener mExternalCallListener = + new ExternalCallList.ExternalCallListener() { + + @Override + public void onExternalCallPulled(android.telecom.Call call) { + // Note: keep this code in sync with InCallPresenter#onCallAdded + LatencyReport latencyReport = new LatencyReport(call); + latencyReport.onCallBlockingDone(); + // Note: External calls do not require spam checking. + mCallList.onCallAdded(mContext, call, latencyReport); + call.registerCallback(mCallCallback); + } + + @Override + public void onExternalCallAdded(android.telecom.Call call) { + // No-op + } + + @Override + public void onExternalCallRemoved(android.telecom.Call call) { + // No-op + } + + @Override + public void onExternalCallUpdated(android.telecom.Call call) { + // No-op + } + }; + + private ThemeColorManager mThemeColorManager; + private VideoSurfaceTexture mLocalVideoSurfaceTexture; + private VideoSurfaceTexture mRemoteVideoSurfaceTexture; + + /** Inaccessible constructor. Must use getInstance() to get this singleton. */ + @VisibleForTesting + InCallPresenter() {} + + public static synchronized InCallPresenter getInstance() { + if (sInCallPresenter == null) { + sInCallPresenter = new InCallPresenter(); + } + return sInCallPresenter; + } + + /** + * Determines whether or not a call has no valid phone accounts that can be used to make the call + * with. Emergency calls do not require a phone account. + * + * @param call to check accounts for. + * @return {@code true} if the call has no call capable phone accounts set, {@code false} if the + * call contains a phone account that could be used to initiate it with, or is an emergency + * call. + */ + public static boolean isCallWithNoValidAccounts(DialerCall call) { + if (call != null && !call.isEmergencyCall()) { + Bundle extras = call.getIntentExtras(); + + if (extras == null) { + extras = EMPTY_EXTRAS; + } + + final List<PhoneAccountHandle> phoneAccountHandles = + extras.getParcelableArrayList(android.telecom.Call.AVAILABLE_PHONE_ACCOUNTS); + + if ((call.getAccountHandle() == null + && (phoneAccountHandles == null || phoneAccountHandles.isEmpty()))) { + Log.i(InCallPresenter.getInstance(), "No valid accounts for call " + call); + return true; + } + } + return false; + } + + public InCallState getInCallState() { + return mInCallState; + } + + public CallList getCallList() { + return mCallList; + } + + public void setUp( + @NonNull Context context, + CallList callList, + ExternalCallList externalCallList, + StatusBarNotifier statusBarNotifier, + ExternalCallNotifier externalCallNotifier, + ContactInfoCache contactInfoCache, + ProximitySensor proximitySensor) { + if (mServiceConnected) { + Log.i(this, "New service connection replacing existing one."); + if (context != mContext || callList != mCallList) { + throw new IllegalStateException(); + } + return; + } + + Objects.requireNonNull(context); + mContext = context; + + mContactInfoCache = contactInfoCache; + + mStatusBarNotifier = statusBarNotifier; + mExternalCallNotifier = externalCallNotifier; + addListener(mStatusBarNotifier); + + mProximitySensor = proximitySensor; + addListener(mProximitySensor); + + mThemeColorManager = + new ThemeColorManager(new InCallUIMaterialColorMapUtils(mContext.getResources())); + + mCallList = callList; + mExternalCallList = externalCallList; + externalCallList.addExternalCallListener(mExternalCallNotifier); + externalCallList.addExternalCallListener(mExternalCallListener); + + // This only gets called by the service so this is okay. + mServiceConnected = true; + + // The final thing we do in this set up is add ourselves as a listener to CallList. This + // will kick off an update and the whole process can start. + mCallList.addListener(this); + + // Create spam call list listener and add it to the list of listeners + mSpamCallListListener = new SpamCallListListener(context); + mCallList.addListener(mSpamCallListListener); + + VideoPauseController.getInstance().setUp(this); + InCallVideoCallCallbackNotifier.getInstance().addSessionModificationListener(this); + + mFilteredQueryHandler = new FilteredNumberAsyncQueryHandler(context); + mContext + .getSystemService(TelephonyManager.class) + .listen(mPhoneStateListener, PhoneStateListener.LISTEN_CALL_STATE); + + Log.d(this, "Finished InCallPresenter.setUp"); + } + + /** + * Called when the telephony service has disconnected from us. This will happen when there are no + * more active calls. However, we may still want to continue showing the UI for certain cases like + * showing "Call Ended". What we really want is to wait for the activity and the service to both + * disconnect before we tear things down. This method sets a serviceConnected boolean and calls a + * secondary method that performs the aforementioned logic. + */ + public void tearDown() { + Log.d(this, "tearDown"); + mCallList.clearOnDisconnect(); + + mServiceConnected = false; + + mContext + .getSystemService(TelephonyManager.class) + .listen(mPhoneStateListener, PhoneStateListener.LISTEN_NONE); + + attemptCleanup(); + VideoPauseController.getInstance().tearDown(); + InCallVideoCallCallbackNotifier.getInstance().removeSessionModificationListener(this); + } + + private void attemptFinishActivity() { + final boolean doFinish = (mInCallActivity != null && isActivityStarted()); + Log.i(this, "Hide in call UI: " + doFinish); + if (doFinish) { + mInCallActivity.setExcludeFromRecents(true); + mInCallActivity.finish(); + + if (mAccountSelectionCancelled) { + // This finish is a result of account selection cancellation + // do not include activity ending transition + mInCallActivity.overridePendingTransition(0, 0); + } + } + } + + /** + * Called when the UI ends. Attempts to tear down everything if necessary. See {@link #tearDown()} + * for more insight on the tear-down process. + */ + public void unsetActivity(InCallActivity inCallActivity) { + if (inCallActivity == null) { + throw new IllegalArgumentException("unregisterActivity cannot be called with null"); + } + if (mInCallActivity == null) { + Log.i(this, "No InCallActivity currently set, no need to unset."); + return; + } + if (mInCallActivity != inCallActivity) { + Log.w( + this, + "Second instance of InCallActivity is trying to unregister when another" + + " instance is active. Ignoring."); + return; + } + updateActivity(null); + } + + /** + * Updates the current instance of {@link InCallActivity} with the provided one. If a {@code null} + * activity is provided, it means that the activity was finished and we should attempt to cleanup. + */ + private void updateActivity(InCallActivity inCallActivity) { + boolean updateListeners = false; + boolean doAttemptCleanup = false; + + if (inCallActivity != null) { + if (mInCallActivity == null) { + updateListeners = true; + Log.i(this, "UI Initialized"); + } else { + // since setActivity is called onStart(), it can be called multiple times. + // This is fine and ignorable, but we do not want to update the world every time + // this happens (like going to/from background) so we do not set updateListeners. + } + + mInCallActivity = inCallActivity; + mInCallActivity.setExcludeFromRecents(false); + + // By the time the UI finally comes up, the call may already be disconnected. + // If that's the case, we may need to show an error dialog. + if (mCallList != null && mCallList.getDisconnectedCall() != null) { + maybeShowErrorDialogOnDisconnect(mCallList.getDisconnectedCall()); + } + + // When the UI comes up, we need to first check the in-call state. + // If we are showing NO_CALLS, that means that a call probably connected and + // then immediately disconnected before the UI was able to come up. + // If we dont have any calls, start tearing down the UI instead. + // NOTE: This code relies on {@link #mInCallActivity} being set so we run it after + // it has been set. + if (mInCallState == InCallState.NO_CALLS) { + Log.i(this, "UI Initialized, but no calls left. shut down."); + attemptFinishActivity(); + return; + } + } else { + Log.i(this, "UI Destroyed"); + updateListeners = true; + mInCallActivity = null; + + // We attempt cleanup for the destroy case but only after we recalculate the state + // to see if we need to come back up or stay shut down. This is why we do the + // cleanup after the call to onCallListChange() instead of directly here. + doAttemptCleanup = true; + } + + // Messages can come from the telephony layer while the activity is coming up + // and while the activity is going down. So in both cases we need to recalculate what + // state we should be in after they complete. + // Examples: (1) A new incoming call could come in and then get disconnected before + // the activity is created. + // (2) All calls could disconnect and then get a new incoming call before the + // activity is destroyed. + // + // b/1122139 - We previously had a check for mServiceConnected here as well, but there are + // cases where we need to recalculate the current state even if the service in not + // connected. In particular the case where startOrFinish() is called while the app is + // already finish()ing. In that case, we skip updating the state with the knowledge that + // we will check again once the activity has finished. That means we have to recalculate the + // state here even if the service is disconnected since we may not have finished a state + // transition while finish()ing. + if (updateListeners) { + onCallListChange(mCallList); + } + + if (doAttemptCleanup) { + attemptCleanup(); + } + } + + public void setManageConferenceActivity( + @Nullable ManageConferenceActivity manageConferenceActivity) { + mManageConferenceActivity = manageConferenceActivity; + } + + public void onBringToForeground(boolean showDialpad) { + Log.i(this, "Bringing UI to foreground."); + bringToForeground(showDialpad); + } + + public void onCallAdded(final android.telecom.Call call) { + LatencyReport latencyReport = new LatencyReport(call); + if (shouldAttemptBlocking(call)) { + maybeBlockCall(call, latencyReport); + } else { + if (call.getDetails().hasProperty(CallCompat.Details.PROPERTY_IS_EXTERNAL_CALL)) { + mExternalCallList.onCallAdded(call); + } else { + latencyReport.onCallBlockingDone(); + mCallList.onCallAdded(mContext, call, latencyReport); + } + } + + // Since a call has been added we are no longer waiting for Telecom to send us a call. + setBoundAndWaitingForOutgoingCall(false, null); + call.registerCallback(mCallCallback); + } + + private boolean shouldAttemptBlocking(android.telecom.Call call) { + if (call.getState() != android.telecom.Call.STATE_RINGING) { + return false; + } + if (TelecomCallUtil.isEmergencyCall(call)) { + Log.i(this, "Not attempting to block incoming emergency call"); + return false; + } + if (FilteredNumbersUtil.hasRecentEmergencyCall(mContext)) { + Log.i(this, "Not attempting to block incoming call due to recent emergency call"); + return false; + } + if (call.getDetails().hasProperty(CallCompat.Details.PROPERTY_IS_EXTERNAL_CALL)) { + return false; + } + return true; + } + + /** + * Checks whether a call should be blocked, and blocks it if so. Otherwise, it adds the call to + * the CallList so it can proceed as normal. There is a timeout, so if the function for checking + * whether a function is blocked does not return in a reasonable time, we proceed with adding the + * call anyways. + */ + private void maybeBlockCall(final android.telecom.Call call, final LatencyReport latencyReport) { + final String countryIso = GeoUtil.getCurrentCountryIso(mContext); + final String number = TelecomCallUtil.getNumber(call); + final long timeAdded = System.currentTimeMillis(); + + // Though AtomicBoolean's can be scary, don't fear, as in this case it is only used on the + // main UI thread. It is needed so we can change its value within different scopes, since + // that cannot be done with a final boolean. + final AtomicBoolean hasTimedOut = new AtomicBoolean(false); + + final Handler handler = new Handler(); + + // Proceed if the query is slow; the call may still be blocked after the query returns. + final Runnable runnable = + new Runnable() { + @Override + public void run() { + hasTimedOut.set(true); + latencyReport.onCallBlockingDone(); + mCallList.onCallAdded(mContext, call, latencyReport); + } + }; + handler.postDelayed(runnable, BLOCK_QUERY_TIMEOUT_MS); + + OnCheckBlockedListener onCheckBlockedListener = + new OnCheckBlockedListener() { + @Override + public void onCheckComplete(final Integer id) { + if (isReadyForTearDown()) { + Log.i(this, "InCallPresenter is torn down, not adding call"); + return; + } + if (!hasTimedOut.get()) { + handler.removeCallbacks(runnable); + } + if (id == null) { + if (!hasTimedOut.get()) { + latencyReport.onCallBlockingDone(); + mCallList.onCallAdded(mContext, call, latencyReport); + } + } else if (id == FilteredNumberAsyncQueryHandler.INVALID_ID) { + Log.d(this, "checkForBlockedCall: invalid number, skipping block checking"); + if (!hasTimedOut.get()) { + handler.removeCallbacks(runnable); + + latencyReport.onCallBlockingDone(); + mCallList.onCallAdded(mContext, call, latencyReport); + } + } else { + Log.i(this, "Rejecting incoming call from blocked number"); + call.reject(false, null); + Logger.get(mContext).logInteraction(InteractionEvent.Type.CALL_BLOCKED); + + /* + * If mContext is null, then the InCallPresenter was torn down before the + * block check had a chance to complete. The context is no longer valid, so + * don't attempt to remove the call log entry. + */ + if (mContext == null) { + return; + } + // Register observer to update the call log. + // BlockedNumberContentObserver will unregister after successful log or timeout. + BlockedNumberContentObserver contentObserver = + new BlockedNumberContentObserver(mContext, new Handler(), number, timeAdded); + contentObserver.register(); + } + } + }; + + mFilteredQueryHandler.isBlockedNumber(onCheckBlockedListener, number, countryIso); + } + + public void onCallRemoved(android.telecom.Call call) { + if (call.getDetails().hasProperty(CallCompat.Details.PROPERTY_IS_EXTERNAL_CALL)) { + mExternalCallList.onCallRemoved(call); + } else { + mCallList.onCallRemoved(mContext, call); + call.unregisterCallback(mCallCallback); + } + } + + public void onCanAddCallChanged(boolean canAddCall) { + for (CanAddCallListener listener : mCanAddCallListeners) { + listener.onCanAddCallChanged(canAddCall); + } + } + + @Override + public void onWiFiToLteHandover(DialerCall call) { + if (mInCallActivity != null) { + mInCallActivity.onWiFiToLteHandover(call); + } + } + + @Override + public void onHandoverToWifiFailed(DialerCall call) { + if (mInCallActivity != null) { + mInCallActivity.onHandoverToWifiFailed(call); + } + } + + /** + * Called when there is a change to the call list. Sets the In-Call state for the entire in-call + * app based on the information it gets from CallList. Dispatches the in-call state to all + * listeners. Can trigger the creation or destruction of the UI based on the states that is + * calculates. + */ + @Override + public void onCallListChange(CallList callList) { + if (mInCallActivity != null && mInCallActivity.isInCallScreenAnimating()) { + mAwaitingCallListUpdate = true; + return; + } + if (callList == null) { + return; + } + + mAwaitingCallListUpdate = false; + + InCallState newState = getPotentialStateFromCallList(callList); + InCallState oldState = mInCallState; + Log.d(this, "onCallListChange oldState= " + oldState + " newState=" + newState); + newState = startOrFinishUi(newState); + Log.d(this, "onCallListChange newState changed to " + newState); + + // Set the new state before announcing it to the world + Log.i(this, "Phone switching state: " + oldState + " -> " + newState); + mInCallState = newState; + + // notify listeners of new state + for (InCallStateListener listener : mListeners) { + Log.d(this, "Notify " + listener + " of state " + mInCallState.toString()); + listener.onStateChange(oldState, mInCallState, callList); + } + + if (isActivityStarted()) { + final boolean hasCall = + callList.getActiveOrBackgroundCall() != null || callList.getOutgoingCall() != null; + mInCallActivity.dismissKeyguard(hasCall); + } + } + + /** Called when there is a new incoming call. */ + @Override + public void onIncomingCall(DialerCall call) { + InCallState newState = startOrFinishUi(InCallState.INCOMING); + InCallState oldState = mInCallState; + + Log.i(this, "Phone switching state: " + oldState + " -> " + newState); + mInCallState = newState; + + for (IncomingCallListener listener : mIncomingCallListeners) { + listener.onIncomingCall(oldState, mInCallState, call); + } + + if (mInCallActivity != null) { + // Re-evaluate which fragment is being shown. + mInCallActivity.onPrimaryCallStateChanged(); + } + } + + @Override + public void onUpgradeToVideo(DialerCall call) { + if (call.getSessionModificationState() + == DialerCall.SESSION_MODIFICATION_STATE_RECEIVED_UPGRADE_TO_VIDEO_REQUEST + && mInCallState == InCallPresenter.InCallState.INCOMING) { + LogUtil.i( + "InCallPresenter.onUpgradeToVideo", + "rejecting upgrade request due to existing incoming call"); + call.declineUpgradeRequest(); + } + + if (mInCallActivity != null) { + // Re-evaluate which fragment is being shown. + mInCallActivity.onPrimaryCallStateChanged(); + } + } + + @Override + public void onSessionModificationStateChange(@SessionModificationState int newState) { + LogUtil.i("InCallPresenter.onSessionModificationStateChange", "state: %d", newState); + if (mProximitySensor == null) { + LogUtil.i("InCallPresenter.onSessionModificationStateChange", "proximitySensor is null"); + return; + } + mProximitySensor.setIsAttemptingVideoCall( + VideoUtils.hasSentVideoUpgradeRequest(newState) + || VideoUtils.hasReceivedVideoUpgradeRequest(newState)); + if (mInCallActivity != null) { + // Re-evaluate which fragment is being shown. + mInCallActivity.onPrimaryCallStateChanged(); + } + } + + /** + * Called when a call becomes disconnected. Called everytime an existing call changes from being + * connected (incoming/outgoing/active) to disconnected. + */ + @Override + public void onDisconnect(DialerCall call) { + maybeShowErrorDialogOnDisconnect(call); + + // We need to do the run the same code as onCallListChange. + onCallListChange(mCallList); + + if (isActivityStarted()) { + mInCallActivity.dismissKeyguard(false); + } + + if (call.isEmergencyCall()) { + FilteredNumbersUtil.recordLastEmergencyCallTime(mContext); + } + } + + @Override + public void onUpgradeToVideoRequest(DialerCall call, int videoState) { + LogUtil.d( + "InCallPresenter.onUpgradeToVideoRequest", + "call = " + call + " video state = " + videoState); + + if (call == null) { + return; + } + + call.setRequestedVideoState(videoState); + } + + /** Given the call list, return the state in which the in-call screen should be. */ + public InCallState getPotentialStateFromCallList(CallList callList) { + + InCallState newState = InCallState.NO_CALLS; + + if (callList == null) { + return newState; + } + if (callList.getIncomingCall() != null) { + newState = InCallState.INCOMING; + } else if (callList.getWaitingForAccountCall() != null) { + newState = InCallState.WAITING_FOR_ACCOUNT; + } else if (callList.getPendingOutgoingCall() != null) { + newState = InCallState.PENDING_OUTGOING; + } else if (callList.getOutgoingCall() != null) { + newState = InCallState.OUTGOING; + } else if (callList.getActiveCall() != null + || callList.getBackgroundCall() != null + || callList.getDisconnectedCall() != null + || callList.getDisconnectingCall() != null) { + newState = InCallState.INCALL; + } + + if (newState == InCallState.NO_CALLS) { + if (mBoundAndWaitingForOutgoingCall) { + return InCallState.OUTGOING; + } + } + + return newState; + } + + public boolean isBoundAndWaitingForOutgoingCall() { + return mBoundAndWaitingForOutgoingCall; + } + + public void setBoundAndWaitingForOutgoingCall(boolean isBound, PhoneAccountHandle handle) { + Log.i(this, "setBoundAndWaitingForOutgoingCall: " + isBound); + mBoundAndWaitingForOutgoingCall = isBound; + mThemeColorManager.setPendingPhoneAccountHandle(handle); + if (isBound && mInCallState == InCallState.NO_CALLS) { + mInCallState = InCallState.OUTGOING; + } + } + + public void onShrinkAnimationComplete() { + if (mAwaitingCallListUpdate) { + onCallListChange(mCallList); + } + } + + public void addIncomingCallListener(IncomingCallListener listener) { + Objects.requireNonNull(listener); + mIncomingCallListeners.add(listener); + } + + public void removeIncomingCallListener(IncomingCallListener listener) { + if (listener != null) { + mIncomingCallListeners.remove(listener); + } + } + + public void addListener(InCallStateListener listener) { + Objects.requireNonNull(listener); + mListeners.add(listener); + } + + public void removeListener(InCallStateListener listener) { + if (listener != null) { + mListeners.remove(listener); + } + } + + public void addDetailsListener(InCallDetailsListener listener) { + Objects.requireNonNull(listener); + mDetailsListeners.add(listener); + } + + public void removeDetailsListener(InCallDetailsListener listener) { + if (listener != null) { + mDetailsListeners.remove(listener); + } + } + + public void addCanAddCallListener(CanAddCallListener listener) { + Objects.requireNonNull(listener); + mCanAddCallListeners.add(listener); + } + + public void removeCanAddCallListener(CanAddCallListener listener) { + if (listener != null) { + mCanAddCallListeners.remove(listener); + } + } + + public void addOrientationListener(InCallOrientationListener listener) { + Objects.requireNonNull(listener); + mOrientationListeners.add(listener); + } + + public void removeOrientationListener(InCallOrientationListener listener) { + if (listener != null) { + mOrientationListeners.remove(listener); + } + } + + public void addInCallEventListener(InCallEventListener listener) { + Objects.requireNonNull(listener); + mInCallEventListeners.add(listener); + } + + public void removeInCallEventListener(InCallEventListener listener) { + if (listener != null) { + mInCallEventListeners.remove(listener); + } + } + + public ProximitySensor getProximitySensor() { + return mProximitySensor; + } + + public PseudoScreenState getPseudoScreenState() { + return mPseudoScreenState; + } + + /** Returns true if the incall app is the foreground application. */ + public boolean isShowingInCallUi() { + if (!isActivityStarted()) { + return false; + } + if (mManageConferenceActivity != null && mManageConferenceActivity.isVisible()) { + return true; + } + return mInCallActivity.isVisible(); + } + + /** + * Returns true if the activity has been created and is running. Returns true as long as activity + * is not destroyed or finishing. This ensures that we return true even if the activity is paused + * (not in foreground). + */ + public boolean isActivityStarted() { + return (mInCallActivity != null + && !mInCallActivity.isDestroyed() + && !mInCallActivity.isFinishing()); + } + + /** + * Determines if the In-Call app is currently changing configuration. + * + * @return {@code true} if the In-Call app is changing configuration. + */ + public boolean isChangingConfigurations() { + return mIsChangingConfigurations; + } + + /** + * Tracks whether the In-Call app is currently in the process of changing configuration (i.e. + * screen orientation). + */ + /*package*/ + void updateIsChangingConfigurations() { + mIsChangingConfigurations = false; + if (mInCallActivity != null) { + mIsChangingConfigurations = mInCallActivity.isChangingConfigurations(); + } + Log.v(this, "updateIsChangingConfigurations = " + mIsChangingConfigurations); + } + + /** Called when the activity goes in/out of the foreground. */ + public void onUiShowing(boolean showing) { + // We need to update the notification bar when we leave the UI because that + // could trigger it to show again. + if (mStatusBarNotifier != null) { + mStatusBarNotifier.updateNotification(mCallList); + } + + if (mProximitySensor != null) { + mProximitySensor.onInCallShowing(showing); + } + + Intent broadcastIntent = Bindings.get(mContext).getUiReadyBroadcastIntent(mContext); + if (broadcastIntent != null) { + broadcastIntent.putExtra(EXTRA_FIRST_TIME_SHOWN, !mIsActivityPreviouslyStarted); + + if (showing) { + Log.d(this, "Sending sticky broadcast: ", broadcastIntent); + mContext.sendStickyBroadcast(broadcastIntent); + } else { + Log.d(this, "Removing sticky broadcast: ", broadcastIntent); + mContext.removeStickyBroadcast(broadcastIntent); + } + } + + if (showing) { + mIsActivityPreviouslyStarted = true; + } else { + updateIsChangingConfigurations(); + } + + for (InCallUiListener listener : mInCallUiListeners) { + listener.onUiShowing(showing); + } + + if (mInCallActivity != null) { + // Re-evaluate which fragment is being shown. + mInCallActivity.onPrimaryCallStateChanged(); + } + } + + public void addInCallUiListener(InCallUiListener listener) { + mInCallUiListeners.add(listener); + } + + public boolean removeInCallUiListener(InCallUiListener listener) { + return mInCallUiListeners.remove(listener); + } + + /*package*/ + void onActivityStarted() { + Log.d(this, "onActivityStarted"); + notifyVideoPauseController(true); + mStatusBarNotifier.updateNotification(mCallList); + } + + /*package*/ + void onActivityStopped() { + Log.d(this, "onActivityStopped"); + notifyVideoPauseController(false); + } + + private void notifyVideoPauseController(boolean showing) { + Log.d( + this, "notifyVideoPauseController: mIsChangingConfigurations=" + mIsChangingConfigurations); + if (!mIsChangingConfigurations) { + VideoPauseController.getInstance().onUiShowing(showing); + } + } + + /** Brings the app into the foreground if possible. */ + public void bringToForeground(boolean showDialpad) { + // Before we bring the incall UI to the foreground, we check to see if: + // 1. It is not currently in the foreground + // 2. We are in a state where we want to show the incall ui (i.e. there are calls to + // be displayed) + // If the activity hadn't actually been started previously, yet there are still calls + // present (e.g. a call was accepted by a bluetooth or wired headset), we want to + // bring it up the UI regardless. + if (!isShowingInCallUi() && mInCallState != InCallState.NO_CALLS) { + showInCall(showDialpad, false /* newOutgoingCall */, false /* isVideoCall */); + } + } + + public void onPostDialCharWait(String callId, String chars) { + if (isActivityStarted()) { + mInCallActivity.showPostCharWaitDialog(callId, chars); + } + } + + /** + * Handles the green CALL key while in-call. + * + * @return true if we consumed the event. + */ + public boolean handleCallKey() { + LogUtil.v("InCallPresenter.handleCallKey", null); + + // The green CALL button means either "Answer", "Unhold", or + // "Swap calls", or can be a no-op, depending on the current state + // of the Phone. + + /** INCOMING CALL */ + final CallList calls = mCallList; + final DialerCall incomingCall = calls.getIncomingCall(); + LogUtil.v("InCallPresenter.handleCallKey", "incomingCall: " + incomingCall); + + // (1) Attempt to answer a call + if (incomingCall != null) { + incomingCall.answer(VideoProfile.STATE_AUDIO_ONLY); + return true; + } + + /** STATE_ACTIVE CALL */ + final DialerCall activeCall = calls.getActiveCall(); + if (activeCall != null) { + // TODO: This logic is repeated from CallButtonPresenter.java. We should + // consolidate this logic. + final boolean canMerge = + activeCall.can(android.telecom.Call.Details.CAPABILITY_MERGE_CONFERENCE); + final boolean canSwap = + activeCall.can(android.telecom.Call.Details.CAPABILITY_SWAP_CONFERENCE); + + Log.v( + this, "activeCall: " + activeCall + ", canMerge: " + canMerge + ", canSwap: " + canSwap); + + // (2) Attempt actions on conference calls + if (canMerge) { + TelecomAdapter.getInstance().merge(activeCall.getId()); + return true; + } else if (canSwap) { + TelecomAdapter.getInstance().swap(activeCall.getId()); + return true; + } + } + + /** BACKGROUND CALL */ + final DialerCall heldCall = calls.getBackgroundCall(); + if (heldCall != null) { + // We have a hold call so presumeable it will always support HOLD...but + // there is no harm in double checking. + final boolean canHold = heldCall.can(android.telecom.Call.Details.CAPABILITY_HOLD); + + Log.v(this, "heldCall: " + heldCall + ", canHold: " + canHold); + + // (4) unhold call + if (heldCall.getState() == DialerCall.State.ONHOLD && canHold) { + heldCall.unhold(); + return true; + } + } + + // Always consume hard keys + return true; + } + + /** + * A dialog could have prevented in-call screen from being previously finished. This function + * checks to see if there should be any UI left and if not attempts to tear down the UI. + */ + public void onDismissDialog() { + Log.i(this, "Dialog dismissed"); + if (mInCallState == InCallState.NO_CALLS) { + attemptFinishActivity(); + attemptCleanup(); + } + } + + /** Clears the previous fullscreen state. */ + public void clearFullscreen() { + mIsFullScreen = false; + } + + /** + * Changes the fullscreen mode of the in-call UI. + * + * @param isFullScreen {@code true} if in-call should be in fullscreen mode, {@code false} + * otherwise. + */ + public void setFullScreen(boolean isFullScreen) { + setFullScreen(isFullScreen, false /* force */); + } + + /** + * Changes the fullscreen mode of the in-call UI. + * + * @param isFullScreen {@code true} if in-call should be in fullscreen mode, {@code false} + * otherwise. + * @param force {@code true} if fullscreen mode should be set regardless of its current state. + */ + public void setFullScreen(boolean isFullScreen, boolean force) { + Log.i(this, "setFullScreen = " + isFullScreen); + + // As a safeguard, ensure we cannot enter fullscreen if the dialpad is shown. + if (isDialpadVisible()) { + isFullScreen = false; + Log.v(this, "setFullScreen overridden as dialpad is shown = " + isFullScreen); + } + + if (mIsFullScreen == isFullScreen && !force) { + Log.v(this, "setFullScreen ignored as already in that state."); + return; + } + mIsFullScreen = isFullScreen; + notifyFullscreenModeChange(mIsFullScreen); + } + + /** + * @return {@code true} if the in-call ui is currently in fullscreen mode, {@code false} + * otherwise. + */ + public boolean isFullscreen() { + return mIsFullScreen; + } + + /** + * Called by the {@link VideoCallPresenter} to inform of a change in full screen video status. + * + * @param isFullscreenMode {@code True} if entering full screen mode. + */ + public void notifyFullscreenModeChange(boolean isFullscreenMode) { + for (InCallEventListener listener : mInCallEventListeners) { + listener.onFullscreenModeChanged(isFullscreenMode); + } + } + + /** + * For some disconnected causes, we show a dialog. This calls into the activity to show the dialog + * if appropriate for the call. + */ + private void maybeShowErrorDialogOnDisconnect(DialerCall call) { + // For newly disconnected calls, we may want to show a dialog on specific error conditions + if (isActivityStarted() && call.getState() == DialerCall.State.DISCONNECTED) { + if (call.getAccountHandle() == null && !call.isConferenceCall()) { + setDisconnectCauseForMissingAccounts(call); + } + mInCallActivity.maybeShowErrorDialogOnDisconnect(call.getDisconnectCause()); + } + } + + /** + * When the state of in-call changes, this is the first method to get called. It determines if the + * UI needs to be started or finished depending on the new state and does it. + */ + private InCallState startOrFinishUi(InCallState newState) { + Log.d(this, "startOrFinishUi: " + mInCallState + " -> " + newState); + + // TODO: Consider a proper state machine implementation + + // If the state isn't changing we have already done any starting/stopping of activities in + // a previous pass...so lets cut out early + if (newState == mInCallState) { + return newState; + } + + // A new Incoming call means that the user needs to be notified of the the call (since + // it wasn't them who initiated it). We do this through full screen notifications and + // happens indirectly through {@link StatusBarNotifier}. + // + // The process for incoming calls is as follows: + // + // 1) CallList - Announces existence of new INCOMING call + // 2) InCallPresenter - Gets announcement and calculates that the new InCallState + // - should be set to INCOMING. + // 3) InCallPresenter - This method is called to see if we need to start or finish + // the app given the new state. + // 4) StatusBarNotifier - Listens to InCallState changes. InCallPresenter calls + // StatusBarNotifier explicitly to issue a FullScreen Notification + // that will either start the InCallActivity or show the user a + // top-level notification dialog if the user is in an immersive app. + // That notification can also start the InCallActivity. + // 5) InCallActivity - Main activity starts up and at the end of its onCreate will + // call InCallPresenter::setActivity() to let the presenter + // know that start-up is complete. + // + // [ AND NOW YOU'RE IN THE CALL. voila! ] + // + // Our app is started using a fullScreen notification. We need to do this whenever + // we get an incoming call. Depending on the current context of the device, either a + // incoming call HUN or the actual InCallActivity will be shown. + final boolean startIncomingCallSequence = (InCallState.INCOMING == newState); + + // A dialog to show on top of the InCallUI to select a PhoneAccount + final boolean showAccountPicker = (InCallState.WAITING_FOR_ACCOUNT == newState); + + // A new outgoing call indicates that the user just now dialed a number and when that + // happens we need to display the screen immediately or show an account picker dialog if + // no default is set. However, if the main InCallUI is already visible, we do not want to + // re-initiate the start-up animation, so we do not need to do anything here. + // + // It is also possible to go into an intermediate state where the call has been initiated + // but Telecom has not yet returned with the details of the call (handle, gateway, etc.). + // This pending outgoing state can also launch the call screen. + // + // This is different from the incoming call sequence because we do not need to shock the + // user with a top-level notification. Just show the call UI normally. + boolean callCardFragmentVisible = + mInCallActivity != null && mInCallActivity.getCallCardFragmentVisible(); + final boolean mainUiNotVisible = !isShowingInCallUi() || !callCardFragmentVisible; + boolean showCallUi = InCallState.OUTGOING == newState && mainUiNotVisible; + + // Direct transition from PENDING_OUTGOING -> INCALL means that there was an error in the + // outgoing call process, so the UI should be brought up to show an error dialog. + showCallUi |= + (InCallState.PENDING_OUTGOING == mInCallState + && InCallState.INCALL == newState + && !isShowingInCallUi()); + + // Another exception - InCallActivity is in charge of disconnecting a call with no + // valid accounts set. Bring the UI up if this is true for the current pending outgoing + // call so that: + // 1) The call can be disconnected correctly + // 2) The UI comes up and correctly displays the error dialog. + // TODO: Remove these special case conditions by making InCallPresenter a true state + // machine. Telecom should also be the component responsible for disconnecting a call + // with no valid accounts. + showCallUi |= + InCallState.PENDING_OUTGOING == newState + && mainUiNotVisible + && isCallWithNoValidAccounts(mCallList.getPendingOutgoingCall()); + + // The only time that we have an instance of mInCallActivity and it isn't started is + // when it is being destroyed. In that case, lets avoid bringing up another instance of + // the activity. When it is finally destroyed, we double check if we should bring it back + // up so we aren't going to lose anything by avoiding a second startup here. + boolean activityIsFinishing = mInCallActivity != null && !isActivityStarted(); + if (activityIsFinishing) { + Log.i(this, "Undo the state change: " + newState + " -> " + mInCallState); + return mInCallState; + } + + // We're about the bring up the in-call UI for outgoing and incoming call. If we still have + // dialogs up, we need to clear them out before showing in-call screen. This is necessary + // to fix the bug that dialog will show up when data reaches limit even after makeing new + // outgoing call after user ignore it by pressing home button. + if ((newState == InCallState.INCOMING || newState == InCallState.PENDING_OUTGOING) + && !showCallUi + && isActivityStarted()) { + mInCallActivity.dismissPendingDialogs(); + } + + if (showCallUi || showAccountPicker) { + Log.i(this, "Start in call UI"); + showInCall(false /* showDialpad */, !showAccountPicker /* newOutgoingCall */, false); + } else if (startIncomingCallSequence) { + Log.i(this, "Start Full Screen in call UI"); + + if (!startUi()) { + // startUI refused to start the UI. This indicates that it needed to restart the + // activity. When it finally restarts, it will call us back, so we do not actually + // change the state yet (we return mInCallState instead of newState). + return mInCallState; + } + } else if (newState == InCallState.NO_CALLS) { + // The new state is the no calls state. Tear everything down. + attemptFinishActivity(); + attemptCleanup(); + } + + return newState; + } + + /** + * Sets the DisconnectCause for a call that was disconnected because it was missing a PhoneAccount + * or PhoneAccounts to select from. + */ + private void setDisconnectCauseForMissingAccounts(DialerCall call) { + + Bundle extras = call.getIntentExtras(); + // Initialize the extras bundle to avoid NPE + if (extras == null) { + extras = new Bundle(); + } + + final List<PhoneAccountHandle> phoneAccountHandles = + extras.getParcelableArrayList(android.telecom.Call.AVAILABLE_PHONE_ACCOUNTS); + + if (phoneAccountHandles == null || phoneAccountHandles.isEmpty()) { + String scheme = call.getHandle().getScheme(); + final String errorMsg = + PhoneAccount.SCHEME_TEL.equals(scheme) + ? mContext.getString(R.string.callFailed_simError) + : mContext.getString(R.string.incall_error_supp_service_unknown); + DisconnectCause disconnectCause = + new DisconnectCause(DisconnectCause.ERROR, null, errorMsg, errorMsg); + call.setDisconnectCause(disconnectCause); + } + } + + private boolean startUi() { + boolean isCallWaiting = + mCallList.getActiveCall() != null && mCallList.getIncomingCall() != null; + + if (isCallWaiting) { + showInCall(false, false, false /* isVideoCall */); + } else { + mStatusBarNotifier.updateNotification(mCallList); + } + return true; + } + + /** + * @return {@code true} if the InCallPresenter is ready to be torn down, {@code false} otherwise. + * Calling classes should use this as an indication whether to interact with the + * InCallPresenter or not. + */ + public boolean isReadyForTearDown() { + return mInCallActivity == null && !mServiceConnected && mInCallState == InCallState.NO_CALLS; + } + + /** + * Checks to see if both the UI is gone and the service is disconnected. If so, tear it all down. + */ + private void attemptCleanup() { + if (isReadyForTearDown()) { + Log.i(this, "Cleaning up"); + + cleanupSurfaces(); + + mIsActivityPreviouslyStarted = false; + mIsChangingConfigurations = false; + + // blow away stale contact info so that we get fresh data on + // the next set of calls + if (mContactInfoCache != null) { + mContactInfoCache.clearCache(); + } + mContactInfoCache = null; + + if (mProximitySensor != null) { + removeListener(mProximitySensor); + mProximitySensor.tearDown(); + } + mProximitySensor = null; + + if (mStatusBarNotifier != null) { + removeListener(mStatusBarNotifier); + } + if (mExternalCallNotifier != null && mExternalCallList != null) { + mExternalCallList.removeExternalCallListener(mExternalCallNotifier); + } + mStatusBarNotifier = null; + + if (mCallList != null) { + mCallList.removeListener(this); + mCallList.removeListener(mSpamCallListListener); + } + mCallList = null; + + mContext = null; + mInCallActivity = null; + mManageConferenceActivity = null; + + mListeners.clear(); + mIncomingCallListeners.clear(); + mDetailsListeners.clear(); + mCanAddCallListeners.clear(); + mOrientationListeners.clear(); + mInCallEventListeners.clear(); + mInCallUiListeners.clear(); + + Log.d(this, "Finished InCallPresenter.CleanUp"); + } + } + + public void showInCall(boolean showDialpad, boolean newOutgoingCall, boolean isVideoCall) { + Log.i(this, "Showing InCallActivity"); + mContext.startActivity( + InCallActivity.getIntent( + mContext, showDialpad, newOutgoingCall, isVideoCall, false /* forFullScreen */)); + } + + public void onServiceBind() { + mServiceBound = true; + } + + public void onServiceUnbind() { + InCallPresenter.getInstance().setBoundAndWaitingForOutgoingCall(false, null); + mServiceBound = false; + } + + public boolean isServiceBound() { + return mServiceBound; + } + + public void maybeStartRevealAnimation(Intent intent) { + if (intent == null || mInCallActivity != null) { + return; + } + final Bundle extras = intent.getBundleExtra(TelecomManager.EXTRA_OUTGOING_CALL_EXTRAS); + if (extras == null) { + // Incoming call, just show the in-call UI directly. + return; + } + + if (extras.containsKey(android.telecom.Call.AVAILABLE_PHONE_ACCOUNTS)) { + // Account selection dialog will show up so don't show the animation. + return; + } + + final PhoneAccountHandle accountHandle = + intent.getParcelableExtra(TelecomManager.EXTRA_PHONE_ACCOUNT_HANDLE); + final Point touchPoint = extras.getParcelable(TouchPointManager.TOUCH_POINT); + int videoState = + extras.getInt( + TelecomManager.EXTRA_START_CALL_WITH_VIDEO_STATE, VideoProfile.STATE_AUDIO_ONLY); + + InCallPresenter.getInstance().setBoundAndWaitingForOutgoingCall(true, accountHandle); + + final Intent activityIntent = + InCallActivity.getIntent( + mContext, false, true, VideoUtils.isVideoCall(videoState), false /* forFullScreen */); + activityIntent.putExtra(TouchPointManager.TOUCH_POINT, touchPoint); + mContext.startActivity(activityIntent); + } + + /** + * Retrieves the current in-call camera manager instance, creating if necessary. + * + * @return The {@link InCallCameraManager}. + */ + public InCallCameraManager getInCallCameraManager() { + synchronized (this) { + if (mInCallCameraManager == null) { + mInCallCameraManager = new InCallCameraManager(mContext); + } + + return mInCallCameraManager; + } + } + + /** + * Notifies listeners of changes in orientation and notify calls of rotation angle change. + * + * @param orientation The screen orientation of the device (one of: {@link + * InCallOrientationEventListener#SCREEN_ORIENTATION_0}, {@link + * InCallOrientationEventListener#SCREEN_ORIENTATION_90}, {@link + * InCallOrientationEventListener#SCREEN_ORIENTATION_180}, {@link + * InCallOrientationEventListener#SCREEN_ORIENTATION_270}). + */ + public void onDeviceOrientationChange(@ScreenOrientation int orientation) { + Log.d(this, "onDeviceOrientationChange: orientation= " + orientation); + + if (mCallList != null) { + mCallList.notifyCallsOfDeviceRotation(orientation); + } else { + Log.w(this, "onDeviceOrientationChange: CallList is null."); + } + + // Notify listeners of device orientation changed. + for (InCallOrientationListener listener : mOrientationListeners) { + listener.onDeviceOrientationChanged(orientation); + } + } + + /** + * Configures the in-call UI activity so it can change orientations or not. Enables the + * orientation event listener if allowOrientationChange is true, disables it if false. + * + * @param allowOrientationChange {@code true} if the in-call UI can change between portrait and + * landscape. {@code false} if the in-call UI should be locked in portrait. + */ + public void setInCallAllowsOrientationChange(boolean allowOrientationChange) { + if (mInCallActivity == null) { + Log.e(this, "InCallActivity is null. Can't set requested orientation."); + return; + } + mInCallActivity.setAllowOrientationChange(allowOrientationChange); + } + + public void enableScreenTimeout(boolean enable) { + Log.v(this, "enableScreenTimeout: value=" + enable); + if (mInCallActivity == null) { + Log.e(this, "enableScreenTimeout: InCallActivity is null."); + return; + } + + final Window window = mInCallActivity.getWindow(); + if (enable) { + window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + } else { + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + } + } + + /** + * Hides or shows the conference manager fragment. + * + * @param show {@code true} if the conference manager should be shown, {@code false} if it should + * be hidden. + */ + public void showConferenceCallManager(boolean show) { + if (mInCallActivity != null) { + mInCallActivity.showConferenceFragment(show); + } + if (!show && mManageConferenceActivity != null) { + mManageConferenceActivity.finish(); + } + } + + /** + * Determines if the dialpad is visible. + * + * @return {@code true} if the dialpad is visible, {@code false} otherwise. + */ + public boolean isDialpadVisible() { + if (mInCallActivity == null) { + return false; + } + return mInCallActivity.isDialpadVisible(); + } + + public ThemeColorManager getThemeColorManager() { + return mThemeColorManager; + } + + /** Called when the foreground call changes. */ + public void onForegroundCallChanged(DialerCall newForegroundCall) { + mThemeColorManager.onForegroundCallChanged(mContext, newForegroundCall); + if (mInCallActivity != null) { + mInCallActivity.onForegroundCallChanged(newForegroundCall); + } + } + + public InCallActivity getActivity() { + return mInCallActivity; + } + + /** Called when the UI begins, and starts the callstate callbacks if necessary. */ + public void setActivity(InCallActivity inCallActivity) { + if (inCallActivity == null) { + throw new IllegalArgumentException("registerActivity cannot be called with null"); + } + if (mInCallActivity != null && mInCallActivity != inCallActivity) { + Log.w(this, "Setting a second activity before destroying the first."); + } + updateActivity(inCallActivity); + } + + ExternalCallNotifier getExternalCallNotifier() { + return mExternalCallNotifier; + } + + VideoSurfaceTexture getLocalVideoSurfaceTexture() { + if (mLocalVideoSurfaceTexture == null) { + mLocalVideoSurfaceTexture = VideoSurfaceBindings.createLocalVideoSurfaceTexture(); + } + return mLocalVideoSurfaceTexture; + } + + VideoSurfaceTexture getRemoteVideoSurfaceTexture() { + if (mRemoteVideoSurfaceTexture == null) { + mRemoteVideoSurfaceTexture = VideoSurfaceBindings.createRemoteVideoSurfaceTexture(); + } + return mRemoteVideoSurfaceTexture; + } + + void cleanupSurfaces() { + if (mRemoteVideoSurfaceTexture != null) { + mRemoteVideoSurfaceTexture.setDoneWithSurface(); + mRemoteVideoSurfaceTexture = null; + } + if (mLocalVideoSurfaceTexture != null) { + mLocalVideoSurfaceTexture.setDoneWithSurface(); + mLocalVideoSurfaceTexture = null; + } + } + + /** All the main states of InCallActivity. */ + public enum InCallState { + // InCall Screen is off and there are no calls + NO_CALLS, + + // Incoming-call screen is up + INCOMING, + + // In-call experience is showing + INCALL, + + // Waiting for user input before placing outgoing call + WAITING_FOR_ACCOUNT, + + // UI is starting up but no call has been initiated yet. + // The UI is waiting for Telecom to respond. + PENDING_OUTGOING, + + // User is dialing out + OUTGOING; + + public boolean isIncoming() { + return (this == INCOMING); + } + + public boolean isConnectingOrConnected() { + return (this == INCOMING || this == OUTGOING || this == INCALL); + } + } + + /** Interface implemented by classes that need to know about the InCall State. */ + public interface InCallStateListener { + + // TODO: Enhance state to contain the call objects instead of passing CallList + void onStateChange(InCallState oldState, InCallState newState, CallList callList); + } + + public interface IncomingCallListener { + + void onIncomingCall(InCallState oldState, InCallState newState, DialerCall call); + } + + public interface CanAddCallListener { + + void onCanAddCallChanged(boolean canAddCall); + } + + public interface InCallDetailsListener { + + void onDetailsChanged(DialerCall call, android.telecom.Call.Details details); + } + + public interface InCallOrientationListener { + + void onDeviceOrientationChanged(@ScreenOrientation int orientation); + } + + /** + * Interface implemented by classes that need to know about events which occur within the In-Call + * UI. Used as a means of communicating between fragments that make up the UI. + */ + public interface InCallEventListener { + + void onFullscreenModeChanged(boolean isFullscreenMode); + } + + public interface InCallUiListener { + + void onUiShowing(boolean showing); + } +} diff --git a/java/com/android/incallui/InCallServiceImpl.java b/java/com/android/incallui/InCallServiceImpl.java new file mode 100644 index 000000000..33e8393ae --- /dev/null +++ b/java/com/android/incallui/InCallServiceImpl.java @@ -0,0 +1,99 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.android.incallui; + +import android.content.Context; +import android.content.Intent; +import android.os.IBinder; +import android.telecom.Call; +import android.telecom.CallAudioState; +import android.telecom.InCallService; +import com.android.incallui.call.CallList; +import com.android.incallui.call.ExternalCallList; +import com.android.incallui.call.TelecomAdapter; + +/** + * Used to receive updates about calls from the Telecom component. This service is bound to Telecom + * while there exist calls which potentially require UI. This includes ringing (incoming), dialing + * (outgoing), and active calls. When the last call is disconnected, Telecom will unbind to the + * service triggering InCallActivity (via CallList) to finish soon after. + */ +public class InCallServiceImpl extends InCallService { + + @Override + public void onCallAudioStateChanged(CallAudioState audioState) { + AudioModeProvider.getInstance().onAudioStateChanged(audioState); + } + + @Override + public void onBringToForeground(boolean showDialpad) { + InCallPresenter.getInstance().onBringToForeground(showDialpad); + } + + @Override + public void onCallAdded(Call call) { + InCallPresenter.getInstance().onCallAdded(call); + } + + @Override + public void onCallRemoved(Call call) { + InCallPresenter.getInstance().onCallRemoved(call); + } + + @Override + public void onCanAddCallChanged(boolean canAddCall) { + InCallPresenter.getInstance().onCanAddCallChanged(canAddCall); + } + + @Override + public IBinder onBind(Intent intent) { + final Context context = getApplicationContext(); + final ContactInfoCache contactInfoCache = ContactInfoCache.getInstance(context); + InCallPresenter.getInstance() + .setUp( + getApplicationContext(), + CallList.getInstance(), + new ExternalCallList(), + new StatusBarNotifier(context, contactInfoCache), + new ExternalCallNotifier(context, contactInfoCache), + contactInfoCache, + new ProximitySensor( + context, AudioModeProvider.getInstance(), new AccelerometerListener(context))); + InCallPresenter.getInstance().onServiceBind(); + InCallPresenter.getInstance().maybeStartRevealAnimation(intent); + TelecomAdapter.getInstance().setInCallService(this); + + return super.onBind(intent); + } + + @Override + public boolean onUnbind(Intent intent) { + super.onUnbind(intent); + + InCallPresenter.getInstance().onServiceUnbind(); + tearDown(); + + return false; + } + + private void tearDown() { + Log.v(this, "tearDown"); + // Tear down the InCall system + TelecomAdapter.getInstance().clearInCallService(); + InCallPresenter.getInstance().tearDown(); + } +} diff --git a/java/com/android/incallui/InCallUIMaterialColorMapUtils.java b/java/com/android/incallui/InCallUIMaterialColorMapUtils.java new file mode 100644 index 000000000..7b06a5e39 --- /dev/null +++ b/java/com/android/incallui/InCallUIMaterialColorMapUtils.java @@ -0,0 +1,67 @@ +/* + * 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; + +import android.content.res.Resources; +import android.content.res.TypedArray; +import android.telecom.PhoneAccount; +import com.android.contacts.common.util.MaterialColorMapUtils; + +public class InCallUIMaterialColorMapUtils extends MaterialColorMapUtils { + + private final TypedArray mPrimaryColors; + private final TypedArray mSecondaryColors; + private final Resources mResources; + + public InCallUIMaterialColorMapUtils(Resources resources) { + super(resources); + mPrimaryColors = resources.obtainTypedArray(R.array.background_colors); + mSecondaryColors = resources.obtainTypedArray(R.array.background_colors_dark); + mResources = resources; + } + + /** + * {@link Resources#getColor(int) used for compatibility + */ + @SuppressWarnings("deprecation") + public static MaterialPalette getDefaultPrimaryAndSecondaryColors(Resources resources) { + final int primaryColor = resources.getColor(R.color.dialer_theme_color); + final int secondaryColor = resources.getColor(R.color.dialer_theme_color_dark); + return new MaterialPalette(primaryColor, secondaryColor); + } + + /** + * Currently the InCallUI color will only vary by SIM color which is a list of colors defined in + * the background_colors array, so first search the list for the matching color and fall back to + * the closest matching color if an exact match does not exist. + */ + @Override + public MaterialPalette calculatePrimaryAndSecondaryColor(int color) { + if (color == PhoneAccount.NO_HIGHLIGHT_COLOR) { + return getDefaultPrimaryAndSecondaryColors(mResources); + } + + for (int i = 0; i < mPrimaryColors.length(); i++) { + if (mPrimaryColors.getColor(i, 0) == color) { + return new MaterialPalette(mPrimaryColors.getColor(i, 0), mSecondaryColors.getColor(i, 0)); + } + } + + // The color isn't in the list, so use the superclass to find an approximate color. + return super.calculatePrimaryAndSecondaryColor(color); + } +} diff --git a/java/com/android/incallui/Log.java b/java/com/android/incallui/Log.java new file mode 100644 index 000000000..c63eccbd4 --- /dev/null +++ b/java/com/android/incallui/Log.java @@ -0,0 +1,145 @@ +/* + * Copyright (C) 2013 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; + +import android.net.Uri; +import android.telecom.PhoneAccount; +import android.telephony.PhoneNumberUtils; +import com.android.dialer.common.LogUtil; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +/** Manages logging for the entire class. */ +public class Log { + + public static void d(String tag, String msg) { + LogUtil.d(tag, msg); + } + + public static void d(Object obj, String msg) { + LogUtil.d(getPrefix(obj), msg); + } + + public static void d(Object obj, String str1, Object str2) { + LogUtil.d(getPrefix(obj), str1 + str2); + } + + public static void v(Object obj, String msg) { + LogUtil.v(getPrefix(obj), msg); + } + + public static void v(Object obj, String str1, Object str2) { + LogUtil.v(getPrefix(obj), str1 + str2); + } + + public static void e(String tag, String msg, Exception e) { + LogUtil.e(tag, msg, e); + } + + public static void e(String tag, String msg) { + LogUtil.e(tag, msg); + } + + public static void e(Object obj, String msg, Exception e) { + LogUtil.e(getPrefix(obj), msg, e); + } + + public static void e(Object obj, String msg) { + LogUtil.e(getPrefix(obj), msg); + } + + public static void i(String tag, String msg) { + LogUtil.i(tag, msg); + } + + public static void i(Object obj, String msg) { + LogUtil.i(getPrefix(obj), msg); + } + + public static void w(Object obj, String msg) { + LogUtil.w(getPrefix(obj), msg); + } + + public static String piiHandle(Object pii) { + if (pii == null || LogUtil.isVerboseEnabled()) { + return String.valueOf(pii); + } + + if (pii instanceof Uri) { + Uri uri = (Uri) pii; + + // All Uri's which are not "tel" go through normal pii() method. + if (!PhoneAccount.SCHEME_TEL.equals(uri.getScheme())) { + return pii(pii); + } else { + pii = uri.getSchemeSpecificPart(); + } + } + + String originalString = String.valueOf(pii); + StringBuilder stringBuilder = new StringBuilder(originalString.length()); + for (char c : originalString.toCharArray()) { + if (PhoneNumberUtils.isDialable(c)) { + stringBuilder.append('*'); + } else { + stringBuilder.append(c); + } + } + return stringBuilder.toString(); + } + + /** + * Redact personally identifiable information for production users. If we are running in verbose + * mode, return the original string, otherwise return a SHA-1 hash of the input string. + */ + public static String pii(Object pii) { + if (pii == null || LogUtil.isVerboseEnabled()) { + return String.valueOf(pii); + } + return "[" + secureHash(String.valueOf(pii).getBytes()) + "]"; + } + + private static String secureHash(byte[] input) { + MessageDigest messageDigest; + try { + messageDigest = MessageDigest.getInstance("SHA-1"); + } catch (NoSuchAlgorithmException e) { + return null; + } + messageDigest.update(input); + byte[] result = messageDigest.digest(); + return encodeHex(result); + } + + private static String encodeHex(byte[] bytes) { + StringBuffer hex = new StringBuffer(bytes.length * 2); + + for (int i = 0; i < bytes.length; i++) { + int byteIntValue = bytes[i] & 0xff; + if (byteIntValue < 0x10) { + hex.append("0"); + } + hex.append(Integer.toString(byteIntValue, 16)); + } + + return hex.toString(); + } + + private static String getPrefix(Object obj) { + return (obj == null ? "" : (obj.getClass().getSimpleName())); + } +} diff --git a/java/com/android/incallui/ManageConferenceActivity.java b/java/com/android/incallui/ManageConferenceActivity.java new file mode 100644 index 000000000..6584e4f67 --- /dev/null +++ b/java/com/android/incallui/ManageConferenceActivity.java @@ -0,0 +1,86 @@ +/* + * 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; + +import android.os.Bundle; +import android.support.v4.app.Fragment; +import android.support.v7.app.AppCompatActivity; +import android.view.MenuItem; + +/** Shows the {@link ConferenceManagerFragment} */ +public class ManageConferenceActivity extends AppCompatActivity { + + private boolean isVisible; + + public boolean isVisible() { + return isVisible; + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + InCallPresenter.getInstance().setManageConferenceActivity(this); + + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + + setContentView(R.layout.activity_manage_conference); + Fragment fragment = getSupportFragmentManager().findFragmentById(R.id.manageConferencePanel); + if (fragment == null) { + fragment = new ConferenceManagerFragment(); + getSupportFragmentManager() + .beginTransaction() + .replace(R.id.manageConferencePanel, fragment) + .commit(); + } + } + + @Override + protected void onDestroy() { + super.onDestroy(); + if (isFinishing()) { + InCallPresenter.getInstance().setManageConferenceActivity(null); + } + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + final int itemId = item.getItemId(); + if (itemId == android.R.id.home) { + onBackPressed(); + return true; + } + return super.onOptionsItemSelected(item); + } + + @Override + public void onBackPressed() { + InCallPresenter.getInstance().bringToForeground(false); + finish(); + } + + @Override + protected void onStart() { + super.onStart(); + isVisible = true; + } + + @Override + protected void onStop() { + super.onStop(); + isVisible = false; + } +} diff --git a/java/com/android/incallui/NotificationBroadcastReceiver.java b/java/com/android/incallui/NotificationBroadcastReceiver.java new file mode 100644 index 000000000..5c5d255cc --- /dev/null +++ b/java/com/android/incallui/NotificationBroadcastReceiver.java @@ -0,0 +1,165 @@ +/* + * 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; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.os.Build.VERSION_CODES; +import android.support.annotation.RequiresApi; +import android.telecom.VideoProfile; +import com.android.dialer.common.LogUtil; +import com.android.dialer.logging.Logger; +import com.android.dialer.logging.nano.DialerImpression; +import com.android.incallui.call.CallList; +import com.android.incallui.call.DialerCall; +import com.android.incallui.call.VideoUtils; + +/** + * Accepts broadcast Intents which will be prepared by {@link StatusBarNotifier} and thus sent from + * the notification manager. This should be visible from outside, but shouldn't be exported. + */ +public class NotificationBroadcastReceiver extends BroadcastReceiver { + + /** + * Intent Action used for hanging up the current call from Notification bar. This will choose + * first ringing call, first active call, or first background call (typically in STATE_HOLDING + * state). + */ + public static final String ACTION_DECLINE_INCOMING_CALL = + "com.android.incallui.ACTION_DECLINE_INCOMING_CALL"; + + public static final String ACTION_HANG_UP_ONGOING_CALL = + "com.android.incallui.ACTION_HANG_UP_ONGOING_CALL"; + public static final String ACTION_ANSWER_VIDEO_INCOMING_CALL = + "com.android.incallui.ACTION_ANSWER_VIDEO_INCOMING_CALL"; + public static final String ACTION_ANSWER_VOICE_INCOMING_CALL = + "com.android.incallui.ACTION_ANSWER_VOICE_INCOMING_CALL"; + public static final String ACTION_ACCEPT_VIDEO_UPGRADE_REQUEST = + "com.android.incallui.ACTION_ACCEPT_VIDEO_UPGRADE_REQUEST"; + public static final String ACTION_DECLINE_VIDEO_UPGRADE_REQUEST = + "com.android.incallui.ACTION_DECLINE_VIDEO_UPGRADE_REQUEST"; + + @RequiresApi(VERSION_CODES.N_MR1) + public static final String ACTION_PULL_EXTERNAL_CALL = + "com.android.incallui.ACTION_PULL_EXTERNAL_CALL"; + + public static final String EXTRA_NOTIFICATION_ID = + "com.android.incallui.extra.EXTRA_NOTIFICATION_ID"; + + @Override + public void onReceive(Context context, Intent intent) { + final String action = intent.getAction(); + LogUtil.i("NotificationBroadcastReceiver.onReceive", "Broadcast from Notification: " + action); + + // TODO: Commands of this nature should exist in the CallList. + if (action.equals(ACTION_ANSWER_VIDEO_INCOMING_CALL)) { + answerIncomingCall(context, VideoProfile.STATE_BIDIRECTIONAL); + } else if (action.equals(ACTION_ANSWER_VOICE_INCOMING_CALL)) { + answerIncomingCall(context, VideoProfile.STATE_AUDIO_ONLY); + } else if (action.equals(ACTION_DECLINE_INCOMING_CALL)) { + Logger.get(context) + .logImpression(DialerImpression.Type.REJECT_INCOMING_CALL_FROM_NOTIFICATION); + declineIncomingCall(context); + } else if (action.equals(ACTION_HANG_UP_ONGOING_CALL)) { + hangUpOngoingCall(context); + } else if (action.equals(ACTION_ACCEPT_VIDEO_UPGRADE_REQUEST)) { + acceptUpgradeRequest(context); + } else if (action.equals(ACTION_DECLINE_VIDEO_UPGRADE_REQUEST)) { + declineUpgradeRequest(context); + } else if (action.equals(ACTION_PULL_EXTERNAL_CALL)) { + context.sendBroadcast(new Intent(Intent.ACTION_CLOSE_SYSTEM_DIALOGS)); + int notificationId = intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1); + InCallPresenter.getInstance().getExternalCallNotifier().pullExternalCall(notificationId); + } + } + + private void acceptUpgradeRequest(Context context) { + CallList callList = InCallPresenter.getInstance().getCallList(); + if (callList == null) { + StatusBarNotifier.clearAllCallNotifications(context); + LogUtil.e("NotificationBroadcastReceiver.acceptUpgradeRequest", "call list is empty"); + } else { + DialerCall call = callList.getVideoUpgradeRequestCall(); + if (call != null) { + call.acceptUpgradeRequest(call.getRequestedVideoState()); + } + } + } + + private void declineUpgradeRequest(Context context) { + CallList callList = InCallPresenter.getInstance().getCallList(); + if (callList == null) { + StatusBarNotifier.clearAllCallNotifications(context); + LogUtil.e("NotificationBroadcastReceiver.declineUpgradeRequest", "call list is empty"); + } else { + DialerCall call = callList.getVideoUpgradeRequestCall(); + if (call != null) { + call.declineUpgradeRequest(); + } + } + } + + private void hangUpOngoingCall(Context context) { + CallList callList = InCallPresenter.getInstance().getCallList(); + if (callList == null) { + StatusBarNotifier.clearAllCallNotifications(context); + LogUtil.e("NotificationBroadcastReceiver.hangUpOngoingCall", "call list is empty"); + } else { + DialerCall call = callList.getOutgoingCall(); + if (call == null) { + call = callList.getActiveOrBackgroundCall(); + } + LogUtil.i( + "NotificationBroadcastReceiver.hangUpOngoingCall", "disconnecting call, call: " + call); + if (call != null) { + call.disconnect(); + } + } + } + + private void answerIncomingCall(Context context, int videoState) { + CallList callList = InCallPresenter.getInstance().getCallList(); + if (callList == null) { + StatusBarNotifier.clearAllCallNotifications(context); + LogUtil.e("NotificationBroadcastReceiver.answerIncomingCall", "call list is empty"); + } else { + DialerCall call = callList.getIncomingCall(); + if (call != null) { + call.answer(videoState); + InCallPresenter.getInstance() + .showInCall( + false /* showDialpad */, + false /* newOutgoingCall */, + VideoUtils.isVideoCall(videoState)); + } + } + } + + private void declineIncomingCall(Context context) { + CallList callList = InCallPresenter.getInstance().getCallList(); + if (callList == null) { + StatusBarNotifier.clearAllCallNotifications(context); + LogUtil.e("NotificationBroadcastReceiver.declineIncomingCall", "call list is empty"); + } else { + DialerCall call = callList.getIncomingCall(); + if (call != null) { + call.reject(false /* rejectWithMessage */, null); + } + } + } +} diff --git a/java/com/android/incallui/PostCharDialogFragment.java b/java/com/android/incallui/PostCharDialogFragment.java new file mode 100644 index 000000000..a852f7683 --- /dev/null +++ b/java/com/android/incallui/PostCharDialogFragment.java @@ -0,0 +1,96 @@ +/* + * Copyright (C) 2013 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; + +import android.app.AlertDialog; +import android.app.Dialog; +import android.content.DialogInterface; +import android.os.Bundle; +import android.support.v4.app.DialogFragment; +import com.android.incallui.call.TelecomAdapter; + +/** + * Pop up an alert dialog with OK and Cancel buttons to allow user to Accept or Reject the WAIT + * inserted as part of the Dial string. + */ +public class PostCharDialogFragment extends DialogFragment { + + private static final String STATE_CALL_ID = "CALL_ID"; + private static final String STATE_POST_CHARS = "POST_CHARS"; + + private String mCallId; + private String mPostDialStr; + + public PostCharDialogFragment() {} + + public PostCharDialogFragment(String callId, String postDialStr) { + mCallId = callId; + mPostDialStr = postDialStr; + } + + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + super.onCreateDialog(savedInstanceState); + + if (mPostDialStr == null && savedInstanceState != null) { + mCallId = savedInstanceState.getString(STATE_CALL_ID); + mPostDialStr = savedInstanceState.getString(STATE_POST_CHARS); + } + + final StringBuilder buf = new StringBuilder(); + buf.append(getResources().getText(R.string.wait_prompt_str)); + buf.append(mPostDialStr); + + final AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()); + builder.setMessage(buf.toString()); + + builder.setPositiveButton( + R.string.pause_prompt_yes, + new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int whichButton) { + TelecomAdapter.getInstance().postDialContinue(mCallId, true); + } + }); + builder.setNegativeButton( + R.string.pause_prompt_no, + new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int whichButton) { + dialog.cancel(); + } + }); + + final AlertDialog dialog = builder.create(); + return dialog; + } + + @Override + public void onCancel(DialogInterface dialog) { + super.onCancel(dialog); + + TelecomAdapter.getInstance().postDialContinue(mCallId, false); + } + + @Override + public void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + + outState.putString(STATE_CALL_ID, mCallId); + outState.putString(STATE_POST_CHARS, mPostDialStr); + } +} diff --git a/java/com/android/incallui/ProximitySensor.java b/java/com/android/incallui/ProximitySensor.java new file mode 100644 index 000000000..91220627c --- /dev/null +++ b/java/com/android/incallui/ProximitySensor.java @@ -0,0 +1,292 @@ +/* + * Copyright (C) 2013 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; + +import android.content.Context; +import android.hardware.display.DisplayManager; +import android.hardware.display.DisplayManager.DisplayListener; +import android.os.PowerManager; +import android.support.annotation.NonNull; +import android.telecom.CallAudioState; +import android.view.Display; +import com.android.dialer.common.LogUtil; +import com.android.incallui.AudioModeProvider.AudioModeListener; +import com.android.incallui.InCallPresenter.InCallState; +import com.android.incallui.InCallPresenter.InCallStateListener; +import com.android.incallui.call.CallList; +import com.android.incallui.call.VideoUtils; + +/** + * Class manages the proximity sensor for the in-call UI. We enable the proximity sensor while the + * user in a phone call. The Proximity sensor turns off the touchscreen and display when the user is + * close to the screen to prevent user's cheek from causing touch events. The class requires special + * knowledge of the activity and device state to know when the proximity sensor should be enabled + * and disabled. Most of that state is fed into this class through public methods. + */ +public class ProximitySensor + implements AccelerometerListener.OrientationListener, InCallStateListener, AudioModeListener { + + private static final String TAG = ProximitySensor.class.getSimpleName(); + + private final PowerManager mPowerManager; + private final PowerManager.WakeLock mProximityWakeLock; + private final AudioModeProvider mAudioModeProvider; + private final AccelerometerListener mAccelerometerListener; + private final ProximityDisplayListener mDisplayListener; + private int mOrientation = AccelerometerListener.ORIENTATION_UNKNOWN; + private boolean mUiShowing = false; + private boolean mIsPhoneOffhook = false; + private boolean mDialpadVisible; + private boolean mIsAttemptingVideoCall; + private boolean mIsVideoCall; + + public ProximitySensor( + @NonNull Context context, + @NonNull AudioModeProvider audioModeProvider, + @NonNull AccelerometerListener accelerometerListener) { + mPowerManager = (PowerManager) context.getSystemService(Context.POWER_SERVICE); + if (mPowerManager.isWakeLockLevelSupported(PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK)) { + mProximityWakeLock = + mPowerManager.newWakeLock(PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK, TAG); + } else { + LogUtil.i("ProximitySensor.constructor", "Device does not support proximity wake lock."); + mProximityWakeLock = null; + } + mAccelerometerListener = accelerometerListener; + mAccelerometerListener.setListener(this); + + mDisplayListener = + new ProximityDisplayListener( + (DisplayManager) context.getSystemService(Context.DISPLAY_SERVICE)); + mDisplayListener.register(); + + mAudioModeProvider = audioModeProvider; + mAudioModeProvider.addListener(this); + } + + public void tearDown() { + mAudioModeProvider.removeListener(this); + + mAccelerometerListener.enable(false); + mDisplayListener.unregister(); + + turnOffProximitySensor(true); + } + + /** Called to identify when the device is laid down flat. */ + @Override + public void orientationChanged(int orientation) { + mOrientation = orientation; + updateProximitySensorMode(); + } + + /** Called to keep track of the overall UI state. */ + @Override + public void onStateChange(InCallState oldState, InCallState newState, CallList callList) { + // We ignore incoming state because we do not want to enable proximity + // sensor during incoming call screen. We check hasLiveCall() because a disconnected call + // can also put the in-call screen in the INCALL state. + boolean hasOngoingCall = InCallState.INCALL == newState && callList.hasLiveCall(); + boolean isOffhook = (InCallState.OUTGOING == newState) || hasOngoingCall; + + boolean isVideoCall = VideoUtils.isVideoCall(callList.getActiveCall()); + + if (isOffhook != mIsPhoneOffhook || mIsVideoCall != isVideoCall) { + mIsPhoneOffhook = isOffhook; + mIsVideoCall = isVideoCall; + + mOrientation = AccelerometerListener.ORIENTATION_UNKNOWN; + mAccelerometerListener.enable(mIsPhoneOffhook); + + updateProximitySensorMode(); + } + } + + @Override + public void onAudioStateChanged(CallAudioState audioState) { + updateProximitySensorMode(); + } + + public void onDialpadVisible(boolean visible) { + mDialpadVisible = visible; + updateProximitySensorMode(); + } + + public void setIsAttemptingVideoCall(boolean isAttemptingVideoCall) { + LogUtil.i( + "ProximitySensor.setIsAttemptingVideoCall", + "isAttemptingVideoCall: %b", + isAttemptingVideoCall); + mIsAttemptingVideoCall = isAttemptingVideoCall; + updateProximitySensorMode(); + } + /** Used to save when the UI goes in and out of the foreground. */ + public void onInCallShowing(boolean showing) { + if (showing) { + mUiShowing = true; + + // We only consider the UI not showing for instances where another app took the foreground. + // If we stopped showing because the screen is off, we still consider that showing. + } else if (mPowerManager.isScreenOn()) { + mUiShowing = false; + } + updateProximitySensorMode(); + } + + void onDisplayStateChanged(boolean isDisplayOn) { + LogUtil.i("ProximitySensor.onDisplayStateChanged", "isDisplayOn: %b", isDisplayOn); + mAccelerometerListener.enable(isDisplayOn); + } + + /** + * TODO: There is no way to determine if a screen is off due to proximity or if it is legitimately + * off, but if ever we can do that in the future, it would be useful here. Until then, this + * function will simply return true of the screen is off. TODO: Investigate whether this can be + * replaced with the ProximityDisplayListener. + */ + public boolean isScreenReallyOff() { + return !mPowerManager.isScreenOn(); + } + + private void turnOnProximitySensor() { + if (mProximityWakeLock != null) { + if (!mProximityWakeLock.isHeld()) { + LogUtil.i("ProximitySensor.turnOnProximitySensor", "acquiring wake lock"); + mProximityWakeLock.acquire(); + } else { + LogUtil.i("ProximitySensor.turnOnProximitySensor", "wake lock already acquired"); + } + } + } + + private void turnOffProximitySensor(boolean screenOnImmediately) { + if (mProximityWakeLock != null) { + if (mProximityWakeLock.isHeld()) { + LogUtil.i("ProximitySensor.turnOffProximitySensor", "releasing wake lock"); + int flags = (screenOnImmediately ? 0 : PowerManager.RELEASE_FLAG_WAIT_FOR_NO_PROXIMITY); + mProximityWakeLock.release(flags); + } else { + LogUtil.i("ProximitySensor.turnOffProximitySensor", "wake lock already released"); + } + } + } + + /** + * Updates the wake lock used to control proximity sensor behavior, based on the current state of + * the phone. + * + * <p>On devices that have a proximity sensor, to avoid false touches during a call, we hold a + * PROXIMITY_SCREEN_OFF_WAKE_LOCK wake lock whenever the phone is off hook. (When held, that wake + * lock causes the screen to turn off automatically when the sensor detects an object close to the + * screen.) + * + * <p>This method is a no-op for devices that don't have a proximity sensor. + * + * <p>Proximity wake lock will be released if any of the following conditions are true: the audio + * is routed through bluetooth, a wired headset, or the speaker; the user requested, received a + * request for, or is in a video call; or the phone is horizontal while in a call. + */ + private synchronized void updateProximitySensorMode() { + final int audioRoute = mAudioModeProvider.getAudioState().getRoute(); + + boolean screenOnImmediately = + (CallAudioState.ROUTE_WIRED_HEADSET == audioRoute + || CallAudioState.ROUTE_SPEAKER == audioRoute + || CallAudioState.ROUTE_BLUETOOTH == audioRoute + || mIsAttemptingVideoCall + || mIsVideoCall); + + // We do not keep the screen off when the user is outside in-call screen and we are + // horizontal, but we do not force it on when we become horizontal until the + // proximity sensor goes negative. + final boolean horizontal = (mOrientation == AccelerometerListener.ORIENTATION_HORIZONTAL); + screenOnImmediately |= !mUiShowing && horizontal; + + // We do not keep the screen off when dialpad is visible, we are horizontal, and + // the in-call screen is being shown. + // At that moment we're pretty sure users want to use it, instead of letting the + // proximity sensor turn off the screen by their hands. + screenOnImmediately |= mDialpadVisible && horizontal; + + LogUtil.i( + "ProximitySensor.updateProximitySensorMode", + "screenOnImmediately: %b, dialPadVisible: %b, " + + "offHook: %b, horizontal: %b, uiShowing: %b, audioRoute: %s", + screenOnImmediately, + mDialpadVisible, + mIsPhoneOffhook, + mOrientation == AccelerometerListener.ORIENTATION_HORIZONTAL, + mUiShowing, + CallAudioState.audioRouteToString(audioRoute)); + + if (mIsPhoneOffhook && !screenOnImmediately) { + LogUtil.v("ProximitySensor.updateProximitySensorMode", "turning on proximity sensor"); + // Phone is in use! Arrange for the screen to turn off + // automatically when the sensor detects a close object. + turnOnProximitySensor(); + } else { + LogUtil.v("ProximitySensor.updateProximitySensorMode", "turning off proximity sensor"); + // Phone is either idle, or ringing. We don't want any special proximity sensor + // behavior in either case. + turnOffProximitySensor(screenOnImmediately); + } + } + + /** + * Implementation of a {@link DisplayListener} that maintains a binary state: Screen on vs screen + * off. Used by the proximity sensor manager to decide whether or not it needs to listen to + * accelerometer events. + */ + public class ProximityDisplayListener implements DisplayListener { + + private DisplayManager mDisplayManager; + private boolean mIsDisplayOn = true; + + ProximityDisplayListener(DisplayManager displayManager) { + mDisplayManager = displayManager; + } + + void register() { + mDisplayManager.registerDisplayListener(this, null); + } + + void unregister() { + mDisplayManager.unregisterDisplayListener(this); + } + + @Override + public void onDisplayRemoved(int displayId) {} + + @Override + public void onDisplayChanged(int displayId) { + if (displayId == Display.DEFAULT_DISPLAY) { + final Display display = mDisplayManager.getDisplay(displayId); + + final boolean isDisplayOn = display.getState() != Display.STATE_OFF; + // For call purposes, we assume that as long as the screen is not truly off, it is + // considered on, even if it is in an unknown or low power idle state. + if (isDisplayOn != mIsDisplayOn) { + mIsDisplayOn = isDisplayOn; + onDisplayStateChanged(mIsDisplayOn); + } + } + } + + @Override + public void onDisplayAdded(int displayId) {} + } +} diff --git a/java/com/android/incallui/StatusBarNotifier.java b/java/com/android/incallui/StatusBarNotifier.java new file mode 100644 index 000000000..c7226753f --- /dev/null +++ b/java/com/android/incallui/StatusBarNotifier.java @@ -0,0 +1,842 @@ +/* + * Copyright (C) 2013 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; + +import static com.android.contacts.common.compat.CallCompat.Details.PROPERTY_ENTERPRISE_CALL; +import static com.android.incallui.NotificationBroadcastReceiver.ACTION_ACCEPT_VIDEO_UPGRADE_REQUEST; +import static com.android.incallui.NotificationBroadcastReceiver.ACTION_ANSWER_VIDEO_INCOMING_CALL; +import static com.android.incallui.NotificationBroadcastReceiver.ACTION_ANSWER_VOICE_INCOMING_CALL; +import static com.android.incallui.NotificationBroadcastReceiver.ACTION_DECLINE_INCOMING_CALL; +import static com.android.incallui.NotificationBroadcastReceiver.ACTION_DECLINE_VIDEO_UPGRADE_REQUEST; +import static com.android.incallui.NotificationBroadcastReceiver.ACTION_HANG_UP_ONGOING_CALL; + +import android.app.ActivityManager; +import android.app.Notification; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.media.AudioAttributes; +import android.net.Uri; +import android.os.Build.VERSION; +import android.os.Build.VERSION_CODES; +import android.support.annotation.ColorRes; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.annotation.StringRes; +import android.support.annotation.VisibleForTesting; +import android.telecom.Call.Details; +import android.telecom.PhoneAccount; +import android.telecom.TelecomManager; +import android.text.BidiFormatter; +import android.text.Spannable; +import android.text.SpannableString; +import android.text.TextDirectionHeuristics; +import android.text.TextUtils; +import android.text.style.ForegroundColorSpan; +import com.android.contacts.common.ContactsUtils; +import com.android.contacts.common.ContactsUtils.UserType; +import com.android.contacts.common.preference.ContactsPreferences; +import com.android.contacts.common.util.BitmapUtil; +import com.android.contacts.common.util.ContactDisplayUtils; +import com.android.dialer.common.LogUtil; +import com.android.dialer.util.DrawableConverter; +import com.android.incallui.ContactInfoCache.ContactCacheEntry; +import com.android.incallui.ContactInfoCache.ContactInfoCacheCallback; +import com.android.incallui.InCallPresenter.InCallState; +import com.android.incallui.async.PausableExecutorImpl; +import com.android.incallui.call.CallList; +import com.android.incallui.call.DialerCall; +import com.android.incallui.call.DialerCall.SessionModificationState; +import com.android.incallui.call.DialerCallListener; +import com.android.incallui.ringtone.DialerRingtoneManager; +import com.android.incallui.ringtone.InCallTonePlayer; +import com.android.incallui.ringtone.ToneGeneratorFactory; +import java.util.Objects; + +/** This class adds Notifications to the status bar for the in-call experience. */ +public class StatusBarNotifier implements InCallPresenter.InCallStateListener { + + // Notification types + // Indicates that no notification is currently showing. + private static final int NOTIFICATION_NONE = 0; + // Notification for an active call. This is non-interruptive, but cannot be dismissed. + private static final int NOTIFICATION_IN_CALL = 1; + // Notification for incoming calls. This is interruptive and will show up as a HUN. + private static final int NOTIFICATION_INCOMING_CALL = 2; + + private static final int PENDING_INTENT_REQUEST_CODE_NON_FULL_SCREEN = 0; + private static final int PENDING_INTENT_REQUEST_CODE_FULL_SCREEN = 1; + + private static final long[] VIBRATE_PATTERN = new long[] {0, 1000, 1000}; + + private final Context mContext; + private final ContactInfoCache mContactInfoCache; + private final NotificationManager mNotificationManager; + private final DialerRingtoneManager mDialerRingtoneManager; + @Nullable private ContactsPreferences mContactsPreferences; + private int mCurrentNotification = NOTIFICATION_NONE; + private int mCallState = DialerCall.State.INVALID; + private int mSavedIcon = 0; + private String mSavedContent = null; + private Bitmap mSavedLargeIcon; + private String mSavedContentTitle; + private Uri mRingtone; + private StatusBarCallListener mStatusBarCallListener; + + public StatusBarNotifier(@NonNull Context context, @NonNull ContactInfoCache contactInfoCache) { + Objects.requireNonNull(context); + mContext = context; + mContactsPreferences = ContactsPreferencesFactory.newContactsPreferences(mContext); + mContactInfoCache = contactInfoCache; + mNotificationManager = context.getSystemService(NotificationManager.class); + mDialerRingtoneManager = + new DialerRingtoneManager( + new InCallTonePlayer(new ToneGeneratorFactory(), new PausableExecutorImpl()), + CallList.getInstance()); + mCurrentNotification = NOTIFICATION_NONE; + } + + /** + * Should only be called from a irrecoverable state where it is necessary to dismiss all + * notifications. + */ + static void clearAllCallNotifications(Context backupContext) { + Log.i( + StatusBarNotifier.class.getSimpleName(), + "Something terrible happened. Clear all InCall notifications"); + + NotificationManager notificationManager = + backupContext.getSystemService(NotificationManager.class); + notificationManager.cancel(NOTIFICATION_IN_CALL); + notificationManager.cancel(NOTIFICATION_INCOMING_CALL); + } + + private static int getWorkStringFromPersonalString(int resId) { + if (resId == R.string.notification_ongoing_call) { + return R.string.notification_ongoing_work_call; + } else if (resId == R.string.notification_ongoing_call_wifi) { + return R.string.notification_ongoing_work_call_wifi; + } else if (resId == R.string.notification_incoming_call_wifi) { + return R.string.notification_incoming_work_call_wifi; + } else if (resId == R.string.notification_incoming_call) { + return R.string.notification_incoming_work_call; + } else { + return resId; + } + } + + /** + * Returns PendingIntent for answering a phone call. This will typically be used from Notification + * context. + */ + private static PendingIntent createNotificationPendingIntent(Context context, String action) { + final Intent intent = new Intent(action, null, context, NotificationBroadcastReceiver.class); + return PendingIntent.getBroadcast(context, 0, intent, 0); + } + + /** Creates notifications according to the state we receive from {@link InCallPresenter}. */ + @Override + public void onStateChange(InCallState oldState, InCallState newState, CallList callList) { + Log.d(this, "onStateChange"); + updateNotification(callList); + } + + /** + * Updates the phone app's status bar notification *and* launches the incoming call UI in response + * to a new incoming call. + * + * <p>If an incoming call is ringing (or call-waiting), the notification will also include a + * "fullScreenIntent" that will cause the InCallScreen to be launched, unless the current + * foreground activity is marked as "immersive". + * + * <p>(This is the mechanism that actually brings up the incoming call UI when we receive a "new + * ringing connection" event from the telephony layer.) + * + * <p>Also note that this method is safe to call even if the phone isn't actually ringing (or, + * more likely, if an incoming call *was* ringing briefly but then disconnected). In that case, + * we'll simply update or cancel the in-call notification based on the current phone state. + * + * @see #updateInCallNotification(CallList) + */ + public void updateNotification(CallList callList) { + updateInCallNotification(callList); + } + + /** + * Take down the in-call notification. + * + * @see #updateInCallNotification(CallList) + */ + private void cancelNotification() { + if (mStatusBarCallListener != null) { + setStatusBarCallListener(null); + } + if (mCurrentNotification != NOTIFICATION_NONE) { + Log.d(this, "cancelInCall()..."); + mNotificationManager.cancel(mCurrentNotification); + } + mCurrentNotification = NOTIFICATION_NONE; + } + + /** + * Helper method for updateInCallNotification() and updateNotification(): Update the phone app's + * status bar notification based on the current telephony state, or cancels the notification if + * the phone is totally idle. + */ + private void updateInCallNotification(CallList callList) { + Log.d(this, "updateInCallNotification..."); + + final DialerCall call = getCallToShow(callList); + + if (call != null) { + showNotification(callList, call); + } else { + cancelNotification(); + } + } + + private void showNotification(final CallList callList, final DialerCall call) { + final boolean isIncoming = + (call.getState() == DialerCall.State.INCOMING + || call.getState() == DialerCall.State.CALL_WAITING); + setStatusBarCallListener(new StatusBarCallListener(call)); + + // we make a call to the contact info cache to query for supplemental data to what the + // call provides. This includes the contact name and photo. + // This callback will always get called immediately and synchronously with whatever data + // it has available, and may make a subsequent call later (same thread) if it had to + // call into the contacts provider for more data. + mContactInfoCache.findInfo( + call, + isIncoming, + new ContactInfoCacheCallback() { + @Override + public void onContactInfoComplete(String callId, ContactCacheEntry entry) { + DialerCall call = callList.getCallById(callId); + if (call != null) { + call.getLogState().contactLookupResult = entry.contactLookupResult; + buildAndSendNotification(callList, call, entry); + } + } + + @Override + public void onImageLoadComplete(String callId, ContactCacheEntry entry) { + DialerCall call = callList.getCallById(callId); + if (call != null) { + buildAndSendNotification(callList, call, entry); + } + } + }); + } + + /** Sets up the main Ui for the notification */ + private void buildAndSendNotification( + CallList callList, DialerCall originalCall, ContactCacheEntry contactInfo) { + // This can get called to update an existing notification after contact information has come + // back. However, it can happen much later. Before we continue, we need to make sure that + // the call being passed in is still the one we want to show in the notification. + final DialerCall call = getCallToShow(callList); + if (call == null || !call.getId().equals(originalCall.getId())) { + return; + } + + final int callState = call.getState(); + + // Check if data has changed; if nothing is different, don't issue another notification. + final int iconResId = getIconToDisplay(call); + Bitmap largeIcon = getLargeIconToDisplay(contactInfo, call); + final String content = getContentString(call, contactInfo.userType); + final String contentTitle = getContentTitle(contactInfo, call); + + final boolean isVideoUpgradeRequest = + call.getSessionModificationState() + == DialerCall.SESSION_MODIFICATION_STATE_RECEIVED_UPGRADE_TO_VIDEO_REQUEST; + final int notificationType; + if (callState == DialerCall.State.INCOMING + || callState == DialerCall.State.CALL_WAITING + || isVideoUpgradeRequest) { + notificationType = NOTIFICATION_INCOMING_CALL; + } else { + notificationType = NOTIFICATION_IN_CALL; + } + + if (!checkForChangeAndSaveData( + iconResId, + content, + largeIcon, + contentTitle, + callState, + notificationType, + contactInfo.contactRingtoneUri)) { + return; + } + + if (largeIcon != null) { + largeIcon = getRoundedIcon(largeIcon); + } + + // This builder is used for the notification shown when the device is locked and the user + // has set their notification settings to 'hide sensitive content' + // {@see Notification.Builder#setPublicVersion}. + Notification.Builder publicBuilder = new Notification.Builder(mContext); + publicBuilder + .setSmallIcon(iconResId) + .setColor(mContext.getResources().getColor(R.color.dialer_theme_color)) + // Hide work call state for the lock screen notification + .setContentTitle(getContentString(call, ContactsUtils.USER_TYPE_CURRENT)); + setNotificationWhen(call, callState, publicBuilder); + + // Builder for the notification shown when the device is unlocked or the user has set their + // notification settings to 'show all notification content'. + final Notification.Builder builder = getNotificationBuilder(); + builder.setPublicVersion(publicBuilder.build()); + + // Set up the main intent to send the user to the in-call screen + builder.setContentIntent( + createLaunchPendingIntent(false /* isFullScreen */, call.isVideoCall())); + + // Set the intent as a full screen intent as well if a call is incoming + if (notificationType == NOTIFICATION_INCOMING_CALL) { + if (!InCallPresenter.getInstance().isActivityStarted()) { + configureFullScreenIntent( + builder, + createLaunchPendingIntent(true /* isFullScreen */, call.isVideoCall()), + callList, + call); + } else { + // If the incall screen is already up, we don't want to show HUN but regular notification + // should still be shown. In order to do that the previous one with full screen intent + // needs to be cancelled. + LogUtil.d( + "StatusBarNotifier.buildAndSendNotification", + "cancel previous incoming call notification"); + mNotificationManager.cancel(NOTIFICATION_INCOMING_CALL); + } + // Set the notification category for incoming calls + builder.setCategory(Notification.CATEGORY_CALL); + } + + // Set the content + builder.setContentText(content); + builder.setSmallIcon(iconResId); + builder.setContentTitle(contentTitle); + builder.setLargeIcon(largeIcon); + builder.setColor(mContext.getResources().getColor(R.color.dialer_theme_color)); + + if (isVideoUpgradeRequest) { + builder.setUsesChronometer(false); + addDismissUpgradeRequestAction(builder); + addAcceptUpgradeRequestAction(builder); + } else { + createIncomingCallNotification(call, callState, builder); + } + + addPersonReference(builder, contactInfo, call); + + // Fire off the notification + Notification notification = builder.build(); + + if (mDialerRingtoneManager.shouldPlayRingtone(callState, contactInfo.contactRingtoneUri)) { + notification.flags |= Notification.FLAG_INSISTENT; + notification.sound = contactInfo.contactRingtoneUri; + AudioAttributes.Builder audioAttributes = new AudioAttributes.Builder(); + audioAttributes.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC); + audioAttributes.setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE); + notification.audioAttributes = audioAttributes.build(); + if (mDialerRingtoneManager.shouldVibrate(mContext.getContentResolver())) { + notification.vibrate = VIBRATE_PATTERN; + } + } + if (mDialerRingtoneManager.shouldPlayCallWaitingTone(callState)) { + Log.v(this, "Playing call waiting tone"); + mDialerRingtoneManager.playCallWaitingTone(); + } + if (mCurrentNotification != notificationType && mCurrentNotification != NOTIFICATION_NONE) { + Log.i(this, "Previous notification already showing - cancelling " + mCurrentNotification); + mNotificationManager.cancel(mCurrentNotification); + } + + Log.i(this, "Displaying notification for " + notificationType); + try { + mNotificationManager.notify(notificationType, notification); + } catch (RuntimeException e) { + // TODO(b/34744003): Move the memory stats into silent feedback PSD. + ActivityManager activityManager = mContext.getSystemService(ActivityManager.class); + ActivityManager.MemoryInfo memoryInfo = new ActivityManager.MemoryInfo(); + activityManager.getMemoryInfo(memoryInfo); + throw new RuntimeException( + String.format( + "Error displaying notification with photo type: %d (low memory? %b, availMem: %d)", + contactInfo.photoType, memoryInfo.lowMemory, memoryInfo.availMem), + e); + } + call.getLatencyReport().onNotificationShown(); + mCurrentNotification = notificationType; + } + + private void createIncomingCallNotification( + DialerCall call, int state, Notification.Builder builder) { + setNotificationWhen(call, state, builder); + + // Add hang up option for any active calls (active | onhold), outgoing calls (dialing). + if (state == DialerCall.State.ACTIVE + || state == DialerCall.State.ONHOLD + || DialerCall.State.isDialing(state)) { + addHangupAction(builder); + } else if (state == DialerCall.State.INCOMING || state == DialerCall.State.CALL_WAITING) { + addDismissAction(builder); + if (call.isVideoCall()) { + addVideoCallAction(builder); + } else { + addAnswerAction(builder); + } + } + } + + /** + * Sets the notification's when section as needed. For active calls, this is explicitly set as the + * duration of the call. For all other states, the notification will automatically show the time + * at which the notification was created. + */ + private void setNotificationWhen(DialerCall call, int state, Notification.Builder builder) { + if (state == DialerCall.State.ACTIVE) { + builder.setUsesChronometer(true); + builder.setWhen(call.getConnectTimeMillis()); + } else { + builder.setUsesChronometer(false); + } + } + + /** + * Checks the new notification data and compares it against any notification that we are already + * displaying. If the data is exactly the same, we return false so that we do not issue a new + * notification for the exact same data. + */ + private boolean checkForChangeAndSaveData( + int icon, + String content, + Bitmap largeIcon, + String contentTitle, + int state, + int notificationType, + Uri ringtone) { + + // The two are different: + // if new title is not null, it should be different from saved version OR + // if new title is null, the saved version should not be null + final boolean contentTitleChanged = + (contentTitle != null && !contentTitle.equals(mSavedContentTitle)) + || (contentTitle == null && mSavedContentTitle != null); + + // any change means we are definitely updating + boolean retval = + (mSavedIcon != icon) + || !Objects.equals(mSavedContent, content) + || (mCallState != state) + || (mSavedLargeIcon != largeIcon) + || contentTitleChanged + || !Objects.equals(mRingtone, ringtone); + + // If we aren't showing a notification right now or the notification type is changing, + // definitely do an update. + if (mCurrentNotification != notificationType) { + if (mCurrentNotification == NOTIFICATION_NONE) { + Log.d(this, "Showing notification for first time."); + } + retval = true; + } + + mSavedIcon = icon; + mSavedContent = content; + mCallState = state; + mSavedLargeIcon = largeIcon; + mSavedContentTitle = contentTitle; + mRingtone = ringtone; + + if (retval) { + Log.d(this, "Data changed. Showing notification"); + } + + return retval; + } + + /** Returns the main string to use in the notification. */ + @VisibleForTesting + @Nullable + String getContentTitle(ContactCacheEntry contactInfo, DialerCall call) { + if (call.isConferenceCall() && !call.hasProperty(Details.PROPERTY_GENERIC_CONFERENCE)) { + return mContext.getResources().getString(R.string.conference_call_name); + } + + String preferredName = + ContactDisplayUtils.getPreferredDisplayName( + contactInfo.namePrimary, contactInfo.nameAlternative, mContactsPreferences); + if (TextUtils.isEmpty(preferredName)) { + return TextUtils.isEmpty(contactInfo.number) + ? null + : BidiFormatter.getInstance() + .unicodeWrap(contactInfo.number, TextDirectionHeuristics.LTR); + } + return preferredName; + } + + private void addPersonReference( + Notification.Builder builder, ContactCacheEntry contactInfo, DialerCall call) { + // Query {@link Contacts#CONTENT_LOOKUP_URI} directly with work lookup key is not allowed. + // So, do not pass {@link Contacts#CONTENT_LOOKUP_URI} to NotificationManager to avoid + // NotificationManager using it. + if (contactInfo.lookupUri != null && contactInfo.userType != ContactsUtils.USER_TYPE_WORK) { + builder.addPerson(contactInfo.lookupUri.toString()); + } else if (!TextUtils.isEmpty(call.getNumber())) { + builder.addPerson(Uri.fromParts(PhoneAccount.SCHEME_TEL, call.getNumber(), null).toString()); + } + } + + /** Gets a large icon from the contact info object to display in the notification. */ + private Bitmap getLargeIconToDisplay(ContactCacheEntry contactInfo, DialerCall call) { + Bitmap largeIcon = null; + if (call.isConferenceCall() && !call.hasProperty(Details.PROPERTY_GENERIC_CONFERENCE)) { + largeIcon = BitmapFactory.decodeResource(mContext.getResources(), R.drawable.img_conference); + } + if (contactInfo.photo != null && (contactInfo.photo instanceof BitmapDrawable)) { + largeIcon = ((BitmapDrawable) contactInfo.photo).getBitmap(); + } + if (call.isSpam()) { + Drawable drawable = mContext.getResources().getDrawable(R.drawable.blocked_contact); + largeIcon = DrawableConverter.drawableToBitmap(drawable); + } + return largeIcon; + } + + private Bitmap getRoundedIcon(Bitmap bitmap) { + if (bitmap == null) { + return null; + } + final int height = + (int) mContext.getResources().getDimension(android.R.dimen.notification_large_icon_height); + final int width = + (int) mContext.getResources().getDimension(android.R.dimen.notification_large_icon_width); + return BitmapUtil.getRoundedBitmap(bitmap, width, height); + } + + /** + * Returns the appropriate icon res Id to display based on the call for which we want to display + * information. + */ + private int getIconToDisplay(DialerCall call) { + // Even if both lines are in use, we only show a single item in + // the expanded Notifications UI. It's labeled "Ongoing call" + // (or "On hold" if there's only one call, and it's on hold.) + // Also, we don't have room to display caller-id info from two + // different calls. So if both lines are in use, display info + // from the foreground call. And if there's a ringing call, + // display that regardless of the state of the other calls. + if (call.getState() == DialerCall.State.ONHOLD) { + return R.drawable.ic_phone_paused_white_24dp; + } else if (call.getSessionModificationState() + == DialerCall.SESSION_MODIFICATION_STATE_RECEIVED_UPGRADE_TO_VIDEO_REQUEST) { + return R.drawable.ic_videocam; + } + return R.anim.on_going_call; + } + + /** Returns the message to use with the notification. */ + private String getContentString(DialerCall call, @UserType long userType) { + boolean isIncomingOrWaiting = + call.getState() == DialerCall.State.INCOMING + || call.getState() == DialerCall.State.CALL_WAITING; + + if (isIncomingOrWaiting + && call.getNumberPresentation() == TelecomManager.PRESENTATION_ALLOWED) { + + if (!TextUtils.isEmpty(call.getChildNumber())) { + return mContext.getString(R.string.child_number, call.getChildNumber()); + } else if (!TextUtils.isEmpty(call.getCallSubject()) && call.isCallSubjectSupported()) { + return call.getCallSubject(); + } + } + + int resId = R.string.notification_ongoing_call; + if (call.hasProperty(Details.PROPERTY_WIFI)) { + resId = R.string.notification_ongoing_call_wifi; + } + + if (isIncomingOrWaiting) { + if (call.hasProperty(Details.PROPERTY_WIFI)) { + resId = R.string.notification_incoming_call_wifi; + } else { + if (call.isSpam()) { + resId = R.string.notification_incoming_spam_call; + } else { + resId = R.string.notification_incoming_call; + } + } + } else if (call.getState() == DialerCall.State.ONHOLD) { + resId = R.string.notification_on_hold; + } else if (DialerCall.State.isDialing(call.getState())) { + resId = R.string.notification_dialing; + } else if (call.getSessionModificationState() + == DialerCall.SESSION_MODIFICATION_STATE_RECEIVED_UPGRADE_TO_VIDEO_REQUEST) { + resId = R.string.notification_requesting_video_call; + } + + // Is the call placed through work connection service. + boolean isWorkCall = call.hasProperty(PROPERTY_ENTERPRISE_CALL); + if (userType == ContactsUtils.USER_TYPE_WORK || isWorkCall) { + resId = getWorkStringFromPersonalString(resId); + } + + return mContext.getString(resId); + } + + /** Gets the most relevant call to display in the notification. */ + private DialerCall getCallToShow(CallList callList) { + if (callList == null) { + return null; + } + DialerCall call = callList.getIncomingCall(); + if (call == null) { + call = callList.getOutgoingCall(); + } + if (call == null) { + call = callList.getVideoUpgradeRequestCall(); + } + if (call == null) { + call = callList.getActiveOrBackgroundCall(); + } + return call; + } + + private Spannable getActionText(@StringRes int stringRes, @ColorRes int colorRes) { + Spannable spannable = new SpannableString(mContext.getText(stringRes)); + if (VERSION.SDK_INT >= VERSION_CODES.N_MR1) { + // This will only work for cases where the Notification.Builder has a fullscreen intent set + // Notification.Builder that does not have a full screen intent will take the color of the + // app and the following leads to a no-op. + spannable.setSpan( + new ForegroundColorSpan(mContext.getColor(colorRes)), 0, spannable.length(), 0); + } + return spannable; + } + + private void addAnswerAction(Notification.Builder builder) { + Log.d(this, "Will show \"answer\" action in the incoming call Notification"); + PendingIntent answerVoicePendingIntent = + createNotificationPendingIntent(mContext, ACTION_ANSWER_VOICE_INCOMING_CALL); + builder.addAction( + R.anim.on_going_call, + getActionText(R.string.notification_action_answer, R.color.notification_action_accept), + answerVoicePendingIntent); + } + + private void addDismissAction(Notification.Builder builder) { + Log.d(this, "Will show \"decline\" action in the incoming call Notification"); + PendingIntent declinePendingIntent = + createNotificationPendingIntent(mContext, ACTION_DECLINE_INCOMING_CALL); + builder.addAction( + R.drawable.ic_close_dk, + getActionText(R.string.notification_action_dismiss, R.color.notification_action_dismiss), + declinePendingIntent); + } + + private void addHangupAction(Notification.Builder builder) { + Log.d(this, "Will show \"hang-up\" action in the ongoing active call Notification"); + PendingIntent hangupPendingIntent = + createNotificationPendingIntent(mContext, ACTION_HANG_UP_ONGOING_CALL); + builder.addAction( + R.drawable.ic_call_end_white_24dp, + getActionText(R.string.notification_action_end_call, R.color.notification_action_end_call), + hangupPendingIntent); + } + + private void addVideoCallAction(Notification.Builder builder) { + Log.i(this, "Will show \"video\" action in the incoming call Notification"); + PendingIntent answerVideoPendingIntent = + createNotificationPendingIntent(mContext, ACTION_ANSWER_VIDEO_INCOMING_CALL); + builder.addAction( + R.drawable.ic_videocam, + getActionText( + R.string.notification_action_answer_video, R.color.notification_action_answer_video), + answerVideoPendingIntent); + } + + private void addAcceptUpgradeRequestAction(Notification.Builder builder) { + Log.i(this, "Will show \"accept upgrade\" action in the incoming call Notification"); + PendingIntent acceptVideoPendingIntent = + createNotificationPendingIntent(mContext, ACTION_ACCEPT_VIDEO_UPGRADE_REQUEST); + builder.addAction( + R.drawable.ic_videocam, + getActionText(R.string.notification_action_accept, R.color.notification_action_accept), + acceptVideoPendingIntent); + } + + private void addDismissUpgradeRequestAction(Notification.Builder builder) { + Log.i(this, "Will show \"dismiss upgrade\" action in the incoming call Notification"); + PendingIntent declineVideoPendingIntent = + createNotificationPendingIntent(mContext, ACTION_DECLINE_VIDEO_UPGRADE_REQUEST); + builder.addAction( + R.drawable.ic_videocam, + getActionText(R.string.notification_action_dismiss, R.color.notification_action_dismiss), + declineVideoPendingIntent); + } + + /** Adds fullscreen intent to the builder. */ + private void configureFullScreenIntent( + Notification.Builder builder, PendingIntent intent, CallList callList, DialerCall call) { + // Ok, we actually want to launch the incoming call + // UI at this point (in addition to simply posting a notification + // to the status bar). Setting fullScreenIntent will cause + // the InCallScreen to be launched immediately *unless* the + // current foreground activity is marked as "immersive". + Log.d(this, "- Setting fullScreenIntent: " + intent); + builder.setFullScreenIntent(intent, true); + + // Ugly hack alert: + // + // The NotificationManager has the (undocumented) behavior + // that it will *ignore* the fullScreenIntent field if you + // post a new Notification that matches the ID of one that's + // already active. Unfortunately this is exactly what happens + // when you get an incoming call-waiting call: the + // "ongoing call" notification is already visible, so the + // InCallScreen won't get launched in this case! + // (The result: if you bail out of the in-call UI while on a + // call and then get a call-waiting call, the incoming call UI + // won't come up automatically.) + // + // The workaround is to just notice this exact case (this is a + // call-waiting call *and* the InCallScreen is not in the + // foreground) and manually cancel the in-call notification + // before (re)posting it. + // + // TODO: there should be a cleaner way of avoiding this + // problem (see discussion in bug 3184149.) + + // If a call is onhold during an incoming call, the call actually comes in as + // INCOMING. For that case *and* traditional call-waiting, we want to + // cancel the notification. + boolean isCallWaiting = + (call.getState() == DialerCall.State.CALL_WAITING + || (call.getState() == DialerCall.State.INCOMING + && callList.getBackgroundCall() != null)); + + if (isCallWaiting) { + Log.i(this, "updateInCallNotification: call-waiting! force relaunch..."); + // Cancel the IN_CALL_NOTIFICATION immediately before + // (re)posting it; this seems to force the + // NotificationManager to launch the fullScreenIntent. + mNotificationManager.cancel(NOTIFICATION_IN_CALL); + } + } + + private Notification.Builder getNotificationBuilder() { + final Notification.Builder builder = new Notification.Builder(mContext); + builder.setOngoing(true); + + // Make the notification prioritized over the other normal notifications. + builder.setPriority(Notification.PRIORITY_HIGH); + + return builder; + } + + private PendingIntent createLaunchPendingIntent(boolean isFullScreen, boolean isVideoCall) { + Intent intent = + InCallActivity.getIntent( + mContext, + false /* showDialpad */, + false /* newOutgoingCall */, + isVideoCall, + isFullScreen); + + int requestCode = PENDING_INTENT_REQUEST_CODE_NON_FULL_SCREEN; + if (isFullScreen) { + // Use a unique request code so that the pending intent isn't clobbered by the + // non-full screen pending intent. + requestCode = PENDING_INTENT_REQUEST_CODE_FULL_SCREEN; + } + + // PendingIntent that can be used to launch the InCallActivity. The + // system fires off this intent if the user pulls down the windowshade + // and clicks the notification's expanded view. It's also used to + // launch the InCallActivity immediately when when there's an incoming + // call (see the "fullScreenIntent" field below). + return PendingIntent.getActivity(mContext, requestCode, intent, 0); + } + + private void setStatusBarCallListener(StatusBarCallListener listener) { + if (mStatusBarCallListener != null) { + mStatusBarCallListener.cleanup(); + } + mStatusBarCallListener = listener; + } + + private class StatusBarCallListener implements DialerCallListener { + + private DialerCall mDialerCall; + + StatusBarCallListener(DialerCall dialerCall) { + mDialerCall = dialerCall; + mDialerCall.addListener(this); + } + + void cleanup() { + mDialerCall.removeListener(this); + } + + @Override + public void onDialerCallDisconnect() {} + + @Override + public void onDialerCallUpdate() { + if (CallList.getInstance().getIncomingCall() == null) { + mDialerRingtoneManager.stopCallWaitingTone(); + } + } + + @Override + public void onDialerCallChildNumberChange() {} + + @Override + public void onDialerCallLastForwardedNumberChange() {} + + @Override + public void onDialerCallUpgradeToVideo() {} + + @Override + public void onWiFiToLteHandover() {} + + @Override + public void onHandoverToWifiFailure() {} + + /** + * Responds to changes in the session modification state for the call by dismissing the status + * bar notification as required. + */ + @Override + public void onDialerCallSessionModificationStateChange(@SessionModificationState int state) { + if (state == DialerCall.SESSION_MODIFICATION_STATE_NO_REQUEST) { + cleanup(); + updateNotification(CallList.getInstance()); + } + } + } +} diff --git a/java/com/android/incallui/ThemeColorManager.java b/java/com/android/incallui/ThemeColorManager.java new file mode 100644 index 000000000..a88ae33cd --- /dev/null +++ b/java/com/android/incallui/ThemeColorManager.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; + +import android.content.Context; +import android.graphics.Color; +import android.support.annotation.ColorInt; +import android.support.annotation.Nullable; +import android.support.v4.graphics.ColorUtils; +import android.telecom.PhoneAccount; +import android.telecom.PhoneAccountHandle; +import android.telecom.TelecomManager; +import com.android.contacts.common.util.MaterialColorMapUtils; +import com.android.contacts.common.util.MaterialColorMapUtils.MaterialPalette; +import com.android.incallui.call.DialerCall; + +/** + * Calculates the background color for the in call window. The background color is based on the SIM + * and spam status. + */ +public class ThemeColorManager { + private final MaterialColorMapUtils colorMap; + @ColorInt private int primaryColor; + @ColorInt private int secondaryColor; + @ColorInt private int backgroundColorTop; + @ColorInt private int backgroundColorMiddle; + @ColorInt private int backgroundColorBottom; + @ColorInt private int backgroundColorSolid; + + /** + * If there is no actual call currently in the call list, this will be used as a fallback to + * determine the theme color for InCallUI. + */ + @Nullable private PhoneAccountHandle pendingPhoneAccountHandle; + + public ThemeColorManager(MaterialColorMapUtils colorMap) { + this.colorMap = colorMap; + } + + public void setPendingPhoneAccountHandle(@Nullable PhoneAccountHandle pendingPhoneAccountHandle) { + this.pendingPhoneAccountHandle = pendingPhoneAccountHandle; + } + + public void onForegroundCallChanged(Context context, @Nullable DialerCall newForegroundCall) { + if (newForegroundCall == null) { + updateThemeColors(context, pendingPhoneAccountHandle, false); + } else { + updateThemeColors(context, newForegroundCall.getAccountHandle(), newForegroundCall.isSpam()); + } + } + + private void updateThemeColors( + Context context, @Nullable PhoneAccountHandle handle, boolean isSpam) { + MaterialPalette palette; + if (isSpam) { + palette = + colorMap.calculatePrimaryAndSecondaryColor(R.color.incall_call_spam_background_color); + backgroundColorTop = context.getColor(R.color.incall_background_gradient_spam_top); + backgroundColorMiddle = context.getColor(R.color.incall_background_gradient_spam_middle); + backgroundColorBottom = context.getColor(R.color.incall_background_gradient_spam_bottom); + backgroundColorSolid = context.getColor(R.color.incall_background_multiwindow_spam); + } else { + @ColorInt int highlightColor = getHighlightColor(context, handle); + palette = colorMap.calculatePrimaryAndSecondaryColor(highlightColor); + backgroundColorTop = context.getColor(R.color.incall_background_gradient_top); + backgroundColorMiddle = context.getColor(R.color.incall_background_gradient_middle); + backgroundColorBottom = context.getColor(R.color.incall_background_gradient_bottom); + backgroundColorSolid = context.getColor(R.color.incall_background_multiwindow); + if (highlightColor != PhoneAccount.NO_HIGHLIGHT_COLOR) { + // The default background gradient has a subtle alpha. We grab that alpha and apply it to + // the phone account color. + backgroundColorTop = applyAlpha(palette.mPrimaryColor, backgroundColorTop); + backgroundColorMiddle = applyAlpha(palette.mPrimaryColor, backgroundColorMiddle); + backgroundColorBottom = applyAlpha(palette.mPrimaryColor, backgroundColorBottom); + backgroundColorSolid = applyAlpha(palette.mPrimaryColor, backgroundColorSolid); + } + } + + primaryColor = palette.mPrimaryColor; + secondaryColor = palette.mSecondaryColor; + } + + @ColorInt + private static int getHighlightColor(Context context, @Nullable PhoneAccountHandle handle) { + if (handle != null) { + PhoneAccount account = context.getSystemService(TelecomManager.class).getPhoneAccount(handle); + if (account != null) { + return account.getHighlightColor(); + } + } + return PhoneAccount.NO_HIGHLIGHT_COLOR; + } + + @ColorInt + public int getPrimaryColor() { + return primaryColor; + } + + @ColorInt + public int getSecondaryColor() { + return secondaryColor; + } + + @ColorInt + public int getBackgroundColorTop() { + return backgroundColorTop; + } + + @ColorInt + public int getBackgroundColorMiddle() { + return backgroundColorMiddle; + } + + @ColorInt + public int getBackgroundColorBottom() { + return backgroundColorBottom; + } + + @ColorInt + public int getBackgroundColorSolid() { + return backgroundColorSolid; + } + + @ColorInt + private static int applyAlpha(@ColorInt int color, @ColorInt int sourceColorWithAlpha) { + return ColorUtils.setAlphaComponent(color, Color.alpha(sourceColorWithAlpha)); + } +} diff --git a/java/com/android/incallui/TransactionSafeFragmentActivity.java b/java/com/android/incallui/TransactionSafeFragmentActivity.java new file mode 100644 index 000000000..a6b078cb4 --- /dev/null +++ b/java/com/android/incallui/TransactionSafeFragmentActivity.java @@ -0,0 +1,64 @@ +/* + * Copyright (C) 2011 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; + +import android.os.Bundle; +import android.support.v4.app.FragmentActivity; + +/** + * A common superclass that keeps track of whether an {@link Activity} has saved its state yet or + * not. + */ +public abstract class TransactionSafeFragmentActivity extends FragmentActivity { + + private boolean mIsSafeToCommitTransactions; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + mIsSafeToCommitTransactions = true; + } + + @Override + protected void onStart() { + super.onStart(); + mIsSafeToCommitTransactions = true; + } + + @Override + protected void onResume() { + super.onResume(); + mIsSafeToCommitTransactions = true; + } + + @Override + protected void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + mIsSafeToCommitTransactions = false; + } + + /** + * Returns true if it is safe to commit {@link FragmentTransaction}s at this time, based on + * whether {@link Activity#onSaveInstanceState} has been called or not. + * + * <p>Make sure that the current activity calls into {@link super.onSaveInstanceState(Bundle + * outState)} (if that method is overridden), so the flag is properly set. + */ + public boolean isSafeToCommitTransactions() { + return mIsSafeToCommitTransactions; + } +} diff --git a/java/com/android/incallui/VideoCallPresenter.java b/java/com/android/incallui/VideoCallPresenter.java new file mode 100644 index 000000000..971b6957a --- /dev/null +++ b/java/com/android/incallui/VideoCallPresenter.java @@ -0,0 +1,1289 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.android.incallui; + +import android.app.Activity; +import android.content.Context; +import android.graphics.Point; +import android.os.Handler; +import android.support.annotation.Nullable; +import android.telecom.Connection; +import android.telecom.InCallService.VideoCall; +import android.telecom.VideoProfile; +import android.telecom.VideoProfile.CameraCapabilities; +import android.view.Surface; +import com.android.dialer.common.Assert; +import com.android.dialer.common.ConfigProviderBindings; +import com.android.dialer.common.LogUtil; +import com.android.dialer.compat.CompatUtils; +import com.android.incallui.InCallPresenter.InCallDetailsListener; +import com.android.incallui.InCallPresenter.InCallOrientationListener; +import com.android.incallui.InCallPresenter.InCallStateListener; +import com.android.incallui.InCallPresenter.IncomingCallListener; +import com.android.incallui.call.CallList; +import com.android.incallui.call.DialerCall; +import com.android.incallui.call.DialerCall.SessionModificationState; +import com.android.incallui.call.DialerCall.State; +import com.android.incallui.call.InCallVideoCallCallbackNotifier; +import com.android.incallui.call.InCallVideoCallCallbackNotifier.SurfaceChangeListener; +import com.android.incallui.call.InCallVideoCallCallbackNotifier.VideoEventListener; +import com.android.incallui.call.VideoUtils; +import com.android.incallui.util.AccessibilityUtil; +import com.android.incallui.video.protocol.VideoCallScreen; +import com.android.incallui.video.protocol.VideoCallScreenDelegate; +import com.android.incallui.videosurface.protocol.VideoSurfaceDelegate; +import com.android.incallui.videosurface.protocol.VideoSurfaceTexture; +import java.util.Objects; + +/** + * Logic related to the {@link VideoCallScreen} and for managing changes to the video calling + * surfaces based on other user interface events and incoming events from the {@class + * VideoCallListener}. + * + * <p>When a call's video state changes to bi-directional video, the {@link + * com.android.incallui.VideoCallPresenter} performs the following negotiation with the telephony + * layer: + * + * <ul> + * <li>{@code VideoCallPresenter} creates and informs telephony of the display surface. + * <li>{@code VideoCallPresenter} creates the preview surface. + * <li>{@code VideoCallPresenter} informs telephony of the currently selected camera. + * <li>Telephony layer sends {@link CameraCapabilities}, including the dimensions of the video for + * the current camera. + * <li>{@code VideoCallPresenter} adjusts size of the preview surface to match the aspect ratio of + * the camera. + * <li>{@code VideoCallPresenter} informs telephony of the new preview surface. + * </ul> + * + * <p>When downgrading to an audio-only video state, the {@code VideoCallPresenter} nulls both + * surfaces. + */ +public class VideoCallPresenter + implements IncomingCallListener, + InCallOrientationListener, + InCallStateListener, + InCallDetailsListener, + SurfaceChangeListener, + VideoEventListener, + InCallPresenter.InCallEventListener, + VideoCallScreenDelegate { + + private static boolean mIsVideoMode = false; + + private final Handler mHandler = new Handler(); + private VideoCallScreen mVideoCallScreen; + + /** The current context. */ + private Context mContext; + + @Override + public boolean shouldShowCameraPermissionDialog() { + if (mPrimaryCall == null) { + LogUtil.i("VideoCallPresenter.shouldShowCameraPermissionDialog", "null call"); + return false; + } + if (mPrimaryCall.didShowCameraPermission()) { + LogUtil.i( + "VideoCallPresenter.shouldShowCameraPermissionDialog", "already shown for this call"); + return false; + } + if (!ConfigProviderBindings.get(mContext) + .getBoolean("camera_permission_dialog_allowed", true)) { + LogUtil.i("VideoCallPresenter.shouldShowCameraPermissionDialog", "disabled by config"); + return false; + } + return !VideoUtils.hasCameraPermission(mContext) || !VideoUtils.isCameraAllowedByUser(mContext); + } + + @Override + public void onCameraPermissionDialogShown() { + if (mPrimaryCall != null) { + mPrimaryCall.setDidShowCameraPermission(true); + } + } + + /** The call the video surfaces are currently related to */ + private DialerCall mPrimaryCall; + /** + * The {@link VideoCall} used to inform the video telephony layer of changes to the video + * surfaces. + */ + private VideoCall mVideoCall; + /** Determines if the current UI state represents a video call. */ + private int mCurrentVideoState; + /** DialerCall's current state */ + private int mCurrentCallState = DialerCall.State.INVALID; + /** Determines the device orientation (portrait/lanscape). */ + private int mDeviceOrientation = InCallOrientationEventListener.SCREEN_ORIENTATION_UNKNOWN; + /** Tracks the state of the preview surface negotiation with the telephony layer. */ + private int mPreviewSurfaceState = PreviewSurfaceState.NONE; + /** + * Determines whether video calls should automatically enter full screen mode after {@link + * #mAutoFullscreenTimeoutMillis} milliseconds. + */ + private boolean mIsAutoFullscreenEnabled = false; + /** + * Determines the number of milliseconds after which a video call will automatically enter + * fullscreen mode. Requires {@link #mIsAutoFullscreenEnabled} to be {@code true}. + */ + private int mAutoFullscreenTimeoutMillis = 0; + /** + * Determines if the countdown is currently running to automatically enter full screen video mode. + */ + private boolean mAutoFullScreenPending = false; + /** Whether if the call is remotely held. */ + private boolean mIsRemotelyHeld = false; + /** + * Runnable which is posted to schedule automatically entering fullscreen mode. Will not auto + * enter fullscreen mode if the dialpad is visible (doing so would make it impossible to exit the + * dialpad). + */ + private Runnable mAutoFullscreenRunnable = + new Runnable() { + @Override + public void run() { + if (mAutoFullScreenPending + && !InCallPresenter.getInstance().isDialpadVisible() + && mIsVideoMode) { + + LogUtil.v("VideoCallPresenter.mAutoFullScreenRunnable", "entering fullscreen mode"); + InCallPresenter.getInstance().setFullScreen(true); + mAutoFullScreenPending = false; + } else { + LogUtil.v( + "VideoCallPresenter.mAutoFullScreenRunnable", + "skipping scheduled fullscreen mode."); + } + } + }; + + private boolean isVideoCallScreenUiReady; + + private static boolean isCameraRequired(int videoState, int sessionModificationState) { + return VideoProfile.isBidirectional(videoState) + || VideoProfile.isTransmissionEnabled(videoState) + || isVideoUpgrade(sessionModificationState); + } + + /** + * Determines if the incoming video surface should be shown based on the current videoState and + * callState. The video surface is shown when incoming video is not paused, the call is active, + * and video reception is enabled. + * + * @param videoState The current video state. + * @param callState The current call state. + * @return {@code true} if the incoming video surface should be shown, {@code false} otherwise. + */ + public static boolean showIncomingVideo(int videoState, int callState) { + if (!CompatUtils.isVideoCompatible()) { + return false; + } + + boolean isPaused = VideoProfile.isPaused(videoState); + boolean isCallActive = callState == DialerCall.State.ACTIVE; + + return !isPaused && isCallActive && VideoProfile.isReceptionEnabled(videoState); + } + + /** + * Determines if the outgoing video surface should be shown based on the current videoState. The + * video surface is shown if video transmission is enabled. + * + * @return {@code true} if the the outgoing video surface should be shown, {@code false} + * otherwise. + */ + public static boolean showOutgoingVideo( + Context context, int videoState, int sessionModificationState) { + if (!VideoUtils.hasCameraPermissionAndAllowedByUser(context)) { + LogUtil.i("VideoCallPresenter.showOutgoingVideo", "Camera permission is disabled by user."); + return false; + } + + if (!CompatUtils.isVideoCompatible()) { + return false; + } + + return VideoProfile.isTransmissionEnabled(videoState) + || isVideoUpgrade(sessionModificationState); + } + + private static void updateCameraSelection(DialerCall call) { + LogUtil.v("VideoCallPresenter.updateCameraSelection", "call=" + call); + LogUtil.v("VideoCallPresenter.updateCameraSelection", "call=" + toSimpleString(call)); + + final DialerCall activeCall = CallList.getInstance().getActiveCall(); + int cameraDir; + + // this function should never be called with null call object, however if it happens we + // should handle it gracefully. + if (call == null) { + cameraDir = DialerCall.VideoSettings.CAMERA_DIRECTION_UNKNOWN; + LogUtil.e( + "VideoCallPresenter.updateCameraSelection", + "call is null. Setting camera direction to default value (CAMERA_DIRECTION_UNKNOWN)"); + } + + // Clear camera direction if this is not a video call. + else if (VideoUtils.isAudioCall(call) && !isVideoUpgrade(call)) { + cameraDir = DialerCall.VideoSettings.CAMERA_DIRECTION_UNKNOWN; + call.getVideoSettings().setCameraDir(cameraDir); + } + + // If this is a waiting video call, default to active call's camera, + // since we don't want to change the current camera for waiting call + // without user's permission. + else if (VideoUtils.isVideoCall(activeCall) && VideoUtils.isIncomingVideoCall(call)) { + cameraDir = activeCall.getVideoSettings().getCameraDir(); + } + + // Infer the camera direction from the video state and store it, + // if this is an outgoing video call. + else if (VideoUtils.isOutgoingVideoCall(call) && !isCameraDirectionSet(call)) { + cameraDir = toCameraDirection(call.getVideoState()); + call.getVideoSettings().setCameraDir(cameraDir); + } + + // Use the stored camera dir if this is an outgoing video call for which camera direction + // is set. + else if (VideoUtils.isOutgoingVideoCall(call)) { + cameraDir = call.getVideoSettings().getCameraDir(); + } + + // Infer the camera direction from the video state and store it, + // if this is an active video call and camera direction is not set. + else if (VideoUtils.isActiveVideoCall(call) && !isCameraDirectionSet(call)) { + cameraDir = toCameraDirection(call.getVideoState()); + call.getVideoSettings().setCameraDir(cameraDir); + } + + // Use the stored camera dir if this is an active video call for which camera direction + // is set. + else if (VideoUtils.isActiveVideoCall(call)) { + cameraDir = call.getVideoSettings().getCameraDir(); + } + + // For all other cases infer the camera direction but don't store it in the call object. + else { + cameraDir = toCameraDirection(call.getVideoState()); + } + + LogUtil.i( + "VideoCallPresenter.updateCameraSelection", + "setting camera direction to %d, call: %s", + cameraDir, + call); + final InCallCameraManager cameraManager = + InCallPresenter.getInstance().getInCallCameraManager(); + cameraManager.setUseFrontFacingCamera( + cameraDir == DialerCall.VideoSettings.CAMERA_DIRECTION_FRONT_FACING); + } + + private static int toCameraDirection(int videoState) { + return VideoProfile.isTransmissionEnabled(videoState) + && !VideoProfile.isBidirectional(videoState) + ? DialerCall.VideoSettings.CAMERA_DIRECTION_BACK_FACING + : DialerCall.VideoSettings.CAMERA_DIRECTION_FRONT_FACING; + } + + private static boolean isCameraDirectionSet(DialerCall call) { + return VideoUtils.isVideoCall(call) + && call.getVideoSettings().getCameraDir() + != DialerCall.VideoSettings.CAMERA_DIRECTION_UNKNOWN; + } + + private static String toSimpleString(DialerCall call) { + return call == null ? null : call.toSimpleString(); + } + + /** + * Initializes the presenter. + * + * @param context The current context. + */ + @Override + public void initVideoCallScreenDelegate(Context context, VideoCallScreen videoCallScreen) { + mContext = context; + mVideoCallScreen = videoCallScreen; + mIsAutoFullscreenEnabled = + mContext.getResources().getBoolean(R.bool.video_call_auto_fullscreen); + mAutoFullscreenTimeoutMillis = + mContext.getResources().getInteger(R.integer.video_call_auto_fullscreen_timeout); + } + + /** Called when the user interface is ready to be used. */ + @Override + public void onVideoCallScreenUiReady() { + LogUtil.v("VideoCallPresenter.onVideoCallScreenUiReady", ""); + Assert.checkState(!isVideoCallScreenUiReady); + + // Do not register any listeners if video calling is not compatible to safeguard against + // any accidental calls of video calling code. + if (!CompatUtils.isVideoCompatible()) { + return; + } + + mDeviceOrientation = InCallOrientationEventListener.getCurrentOrientation(); + + // Register for call state changes last + InCallPresenter.getInstance().addListener(this); + InCallPresenter.getInstance().addDetailsListener(this); + InCallPresenter.getInstance().addIncomingCallListener(this); + InCallPresenter.getInstance().addOrientationListener(this); + // To get updates of video call details changes + InCallPresenter.getInstance().addInCallEventListener(this); + InCallPresenter.getInstance().getLocalVideoSurfaceTexture().setDelegate(new LocalDelegate()); + InCallPresenter.getInstance().getRemoteVideoSurfaceTexture().setDelegate(new RemoteDelegate()); + + // Register for surface and video events from {@link InCallVideoCallListener}s. + InCallVideoCallCallbackNotifier.getInstance().addSurfaceChangeListener(this); + InCallVideoCallCallbackNotifier.getInstance().addVideoEventListener(this); + mCurrentVideoState = VideoProfile.STATE_AUDIO_ONLY; + mCurrentCallState = DialerCall.State.INVALID; + + InCallPresenter.InCallState inCallState = InCallPresenter.getInstance().getInCallState(); + onStateChange(inCallState, inCallState, CallList.getInstance()); + isVideoCallScreenUiReady = true; + } + + /** Called when the user interface is no longer ready to be used. */ + @Override + public void onVideoCallScreenUiUnready() { + LogUtil.v("VideoCallPresenter.onVideoCallScreenUiUnready", ""); + Assert.checkState(isVideoCallScreenUiReady); + + if (!CompatUtils.isVideoCompatible()) { + return; + } + + cancelAutoFullScreen(); + + InCallPresenter.getInstance().removeListener(this); + InCallPresenter.getInstance().removeDetailsListener(this); + InCallPresenter.getInstance().removeIncomingCallListener(this); + InCallPresenter.getInstance().removeOrientationListener(this); + InCallPresenter.getInstance().removeInCallEventListener(this); + InCallPresenter.getInstance().getLocalVideoSurfaceTexture().setDelegate(null); + + InCallVideoCallCallbackNotifier.getInstance().removeSurfaceChangeListener(this); + InCallVideoCallCallbackNotifier.getInstance().removeVideoEventListener(this); + + // Ensure that the call's camera direction is updated (most likely to UNKNOWN). Normally this + // happens after any call state changes but we're unregistering from InCallPresenter above so + // we won't get any more call state changes. See b/32957114. + if (mPrimaryCall != null) { + updateCameraSelection(mPrimaryCall); + } + + isVideoCallScreenUiReady = false; + } + + /** + * Handles clicks on the video surfaces. If not currently in fullscreen mode, will set fullscreen. + */ + private void onSurfaceClick() { + LogUtil.i("VideoCallPresenter.onSurfaceClick", ""); + cancelAutoFullScreen(); + if (!InCallPresenter.getInstance().isFullscreen()) { + InCallPresenter.getInstance().setFullScreen(true); + } else { + InCallPresenter.getInstance().setFullScreen(false); + maybeAutoEnterFullscreen(mPrimaryCall); + // If Activity is not multiwindow, fullscreen will be driven by SystemUI visibility changes + // instead. See #onSystemUiVisibilityChange(boolean) + + // TODO (keyboardr): onSystemUiVisibilityChange isn't being called the first time + // visibility changes after orientation change, so this is currently always done as a backup. + } + } + + @Override + public void onSystemUiVisibilityChange(boolean visible) { + // If the SystemUI has changed to be visible, take us out of fullscreen mode + LogUtil.i("VideoCallPresenter.onSystemUiVisibilityChange", "visible: " + visible); + if (visible) { + InCallPresenter.getInstance().setFullScreen(false); + maybeAutoEnterFullscreen(mPrimaryCall); + } + } + + @Override + public VideoSurfaceTexture getLocalVideoSurfaceTexture() { + return InCallPresenter.getInstance().getLocalVideoSurfaceTexture(); + } + + @Override + public VideoSurfaceTexture getRemoteVideoSurfaceTexture() { + return InCallPresenter.getInstance().getRemoteVideoSurfaceTexture(); + } + + @Override + public int getDeviceOrientation() { + return mDeviceOrientation; + } + + /** + * This should only be called when user approved the camera permission, which is local action and + * does NOT change any call states. + */ + @Override + public void onCameraPermissionGranted() { + LogUtil.i("VideoCallPresenter.onCameraPermissionGranted", ""); + VideoUtils.setCameraAllowedByUser(mContext); + enableCamera(mPrimaryCall.getVideoCall(), isCameraRequired()); + showVideoUi( + mPrimaryCall.getVideoState(), + mPrimaryCall.getState(), + mPrimaryCall.getSessionModificationState(), + mPrimaryCall.isRemotelyHeld()); + InCallPresenter.getInstance().getInCallCameraManager().onCameraPermissionGranted(); + } + + /** + * Called when the user interacts with the UI. If a fullscreen timer is pending then we start the + * timer from scratch to avoid having the UI disappear while the user is interacting with it. + */ + @Override + public void resetAutoFullscreenTimer() { + if (mAutoFullScreenPending) { + LogUtil.i("VideoCallPresenter.resetAutoFullscreenTimer", "resetting"); + mHandler.removeCallbacks(mAutoFullscreenRunnable); + mHandler.postDelayed(mAutoFullscreenRunnable, mAutoFullscreenTimeoutMillis); + } + } + + /** + * Handles incoming calls. + * + * @param oldState The old in call state. + * @param newState The new in call state. + * @param call The call. + */ + @Override + public void onIncomingCall( + InCallPresenter.InCallState oldState, InCallPresenter.InCallState newState, DialerCall call) { + // same logic should happen as with onStateChange() + onStateChange(oldState, newState, CallList.getInstance()); + } + + /** + * Handles state changes (including incoming calls) + * + * @param newState The in call state. + * @param callList The call list. + */ + @Override + public void onStateChange( + InCallPresenter.InCallState oldState, + InCallPresenter.InCallState newState, + CallList callList) { + LogUtil.v( + "VideoCallPresenter.onStateChange", + "oldState: %s, newState: %s, isVideoMode: %b", + oldState, + newState, + isVideoMode()); + + if (newState == InCallPresenter.InCallState.NO_CALLS) { + if (isVideoMode()) { + exitVideoMode(); + } + + InCallPresenter.getInstance().cleanupSurfaces(); + } + + // Determine the primary active call). + DialerCall primary = null; + + // Determine the call which is the focus of the user's attention. In the case of an + // incoming call waiting call, the primary call is still the active video call, however + // the determination of whether we should be in fullscreen mode is based on the type of the + // incoming call, not the active video call. + DialerCall currentCall = null; + + if (newState == InCallPresenter.InCallState.INCOMING) { + // We don't want to replace active video call (primary call) + // with a waiting call, since user may choose to ignore/decline the waiting call and + // this should have no impact on current active video call, that is, we should not + // change the camera or UI unless the waiting VT call becomes active. + primary = callList.getActiveCall(); + currentCall = callList.getIncomingCall(); + if (!VideoUtils.isActiveVideoCall(primary)) { + primary = callList.getIncomingCall(); + } + } else if (newState == InCallPresenter.InCallState.OUTGOING) { + currentCall = primary = callList.getOutgoingCall(); + } else if (newState == InCallPresenter.InCallState.PENDING_OUTGOING) { + currentCall = primary = callList.getPendingOutgoingCall(); + } else if (newState == InCallPresenter.InCallState.INCALL) { + currentCall = primary = callList.getActiveCall(); + } + + final boolean primaryChanged = !Objects.equals(mPrimaryCall, primary); + LogUtil.i( + "VideoCallPresenter.onStateChange", + "primaryChanged: %b, primary: %s, mPrimaryCall: %s", + primaryChanged, + primary, + mPrimaryCall); + if (primaryChanged) { + onPrimaryCallChanged(primary); + } else if (mPrimaryCall != null) { + updateVideoCall(primary); + } + updateCallCache(primary); + + // If the call context changed, potentially exit fullscreen or schedule auto enter of + // fullscreen mode. + // If the current call context is no longer a video call, exit fullscreen mode. + maybeExitFullscreen(currentCall); + // Schedule auto-enter of fullscreen mode if the current call context is a video call + maybeAutoEnterFullscreen(currentCall); + } + + /** + * Handles a change to the fullscreen mode of the app. + * + * @param isFullscreenMode {@code true} if the app is now fullscreen, {@code false} otherwise. + */ + @Override + public void onFullscreenModeChanged(boolean isFullscreenMode) { + cancelAutoFullScreen(); + if (mPrimaryCall != null) { + updateFullscreenAndGreenScreenMode( + mPrimaryCall.getState(), mPrimaryCall.getSessionModificationState()); + } else { + updateFullscreenAndGreenScreenMode( + State.INVALID, DialerCall.SESSION_MODIFICATION_STATE_NO_REQUEST); + } + } + + private void checkForVideoStateChange(DialerCall call) { + final boolean shouldShowVideoUi = shouldShowVideoUiForCall(call); + final boolean hasVideoStateChanged = mCurrentVideoState != call.getVideoState(); + + LogUtil.v( + "VideoCallPresenter.checkForVideoStateChange", + "shouldShowVideoUi: %b, hasVideoStateChanged: %b, isVideoMode: %b, previousVideoState: %s," + + " newVideoState: %s", + shouldShowVideoUi, + hasVideoStateChanged, + isVideoMode(), + VideoProfile.videoStateToString(mCurrentVideoState), + VideoProfile.videoStateToString(call.getVideoState())); + if (!hasVideoStateChanged) { + return; + } + + updateCameraSelection(call); + + if (shouldShowVideoUi) { + adjustVideoMode(call); + } else if (isVideoMode()) { + exitVideoMode(); + } + } + + private void checkForCallStateChange(DialerCall call) { + final boolean shouldShowVideoUi = shouldShowVideoUiForCall(call); + final boolean hasCallStateChanged = + mCurrentCallState != call.getState() || mIsRemotelyHeld != call.isRemotelyHeld(); + mIsRemotelyHeld = call.isRemotelyHeld(); + + LogUtil.v( + "VideoCallPresenter.checkForCallStateChange", + "shouldShowVideoUi: %b, hasCallStateChanged: %b, isVideoMode: %b", + shouldShowVideoUi, + hasCallStateChanged, + isVideoMode()); + + if (!hasCallStateChanged) { + return; + } + + if (shouldShowVideoUi) { + final InCallCameraManager cameraManager = + InCallPresenter.getInstance().getInCallCameraManager(); + + String prevCameraId = cameraManager.getActiveCameraId(); + updateCameraSelection(call); + String newCameraId = cameraManager.getActiveCameraId(); + + if (!Objects.equals(prevCameraId, newCameraId) && VideoUtils.isActiveVideoCall(call)) { + enableCamera(call.getVideoCall(), true); + } + } + + // Make sure we hide or show the video UI if needed. + showVideoUi( + call.getVideoState(), + call.getState(), + call.getSessionModificationState(), + call.isRemotelyHeld()); + } + + private void onPrimaryCallChanged(DialerCall newPrimaryCall) { + final boolean shouldShowVideoUi = shouldShowVideoUiForCall(newPrimaryCall); + final boolean isVideoMode = isVideoMode(); + + LogUtil.v( + "VideoCallPresenter.onPrimaryCallChanged", + "shouldShowVideoUi: %b, isVideoMode: %b", + shouldShowVideoUi, + isVideoMode); + + if (!shouldShowVideoUi && isVideoMode) { + // Terminate video mode if new primary call is not a video call + // and we are currently in video mode. + LogUtil.i("VideoCallPresenter.onPrimaryCallChanged", "exiting video mode..."); + exitVideoMode(); + } else if (shouldShowVideoUi) { + LogUtil.i("VideoCallPresenter.onPrimaryCallChanged", "entering video mode..."); + + updateCameraSelection(newPrimaryCall); + adjustVideoMode(newPrimaryCall); + } + checkForOrientationAllowedChange(newPrimaryCall); + } + + private boolean isVideoMode() { + return mIsVideoMode; + } + + private void updateCallCache(DialerCall call) { + if (call == null) { + mCurrentVideoState = VideoProfile.STATE_AUDIO_ONLY; + mCurrentCallState = DialerCall.State.INVALID; + mVideoCall = null; + mPrimaryCall = null; + } else { + mCurrentVideoState = call.getVideoState(); + mVideoCall = call.getVideoCall(); + mCurrentCallState = call.getState(); + mPrimaryCall = call; + } + } + + /** + * Handles changes to the details of the call. The {@link VideoCallPresenter} is interested in + * changes to the video state. + * + * @param call The call for which the details changed. + * @param details The new call details. + */ + @Override + public void onDetailsChanged(DialerCall call, android.telecom.Call.Details details) { + LogUtil.v( + "VideoCallPresenter.onDetailsChanged", + "call: %s, details: %s, mPrimaryCall: %s", + call, + details, + mPrimaryCall); + if (call == null) { + return; + } + // If the details change is not for the currently active call no update is required. + if (!call.equals(mPrimaryCall)) { + LogUtil.v("VideoCallPresenter.onDetailsChanged", "details not for current active call"); + return; + } + + updateVideoCall(call); + + updateCallCache(call); + } + + private void updateVideoCall(DialerCall call) { + checkForVideoCallChange(call); + checkForVideoStateChange(call); + checkForCallStateChange(call); + checkForOrientationAllowedChange(call); + updateFullscreenAndGreenScreenMode(call.getState(), call.getSessionModificationState()); + } + + private void checkForOrientationAllowedChange(@Nullable DialerCall call) { + InCallPresenter.getInstance() + .setInCallAllowsOrientationChange(VideoUtils.isVideoCall(call) || isVideoUpgrade(call)); + } + + private void updateFullscreenAndGreenScreenMode( + int callState, @SessionModificationState int sessionModificationState) { + if (mVideoCallScreen != null) { + boolean shouldShowFullscreen = InCallPresenter.getInstance().isFullscreen(); + boolean shouldShowGreenScreen = + callState == State.DIALING + || callState == State.CONNECTING + || callState == State.INCOMING + || isVideoUpgrade(sessionModificationState); + mVideoCallScreen.updateFullscreenAndGreenScreenMode( + shouldShowFullscreen, shouldShowGreenScreen); + } + } + + /** Checks for a change to the video call and changes it if required. */ + private void checkForVideoCallChange(DialerCall call) { + final VideoCall videoCall = call.getVideoCall(); + LogUtil.v( + "VideoCallPresenter.checkForVideoCallChange", + "videoCall: %s, mVideoCall: %s", + videoCall, + mVideoCall); + if (!Objects.equals(videoCall, mVideoCall)) { + changeVideoCall(call); + } + } + + /** + * Handles a change to the video call. Sets the surfaces on the previous call to null and sets the + * surfaces on the new video call accordingly. + * + * @param call The new video call. + */ + private void changeVideoCall(DialerCall call) { + final VideoCall videoCall = call == null ? null : call.getVideoCall(); + LogUtil.i( + "VideoCallPresenter.changeVideoCall", + "videoCall: %s, mVideoCall: %s", + videoCall, + mVideoCall); + final boolean hasChanged = mVideoCall == null && videoCall != null; + + mVideoCall = videoCall; + if (mVideoCall == null) { + LogUtil.v("VideoCallPresenter.changeVideoCall", "video call or primary call is null. Return"); + return; + } + + if (shouldShowVideoUiForCall(call) && hasChanged) { + adjustVideoMode(call); + } + } + + private boolean isCameraRequired() { + return mPrimaryCall != null + && isCameraRequired( + mPrimaryCall.getVideoState(), mPrimaryCall.getSessionModificationState()); + } + + /** + * Adjusts the current video mode by setting up the preview and display surfaces as necessary. + * Expected to be called whenever the video state associated with a call changes (e.g. a user + * turns their camera on or off) to ensure the correct surfaces are shown/hidden. TODO: Need + * to adjust size and orientation of preview surface here. + */ + private void adjustVideoMode(DialerCall call) { + VideoCall videoCall = call.getVideoCall(); + int newVideoState = call.getVideoState(); + + LogUtil.i( + "VideoCallPresenter.adjustVideoMode", + "videoCall: %s, videoState: %d", + videoCall, + newVideoState); + if (mVideoCallScreen == null) { + LogUtil.e("VideoCallPresenter.adjustVideoMode", "error VideoCallScreen is null so returning"); + return; + } + + showVideoUi( + newVideoState, call.getState(), call.getSessionModificationState(), call.isRemotelyHeld()); + + // Communicate the current camera to telephony and make a request for the camera + // capabilities. + if (videoCall != null) { + Surface surface = getRemoteVideoSurfaceTexture().getSavedSurface(); + if (surface != null) { + LogUtil.v( + "VideoCallPresenter.adjustVideoMode", "calling setDisplaySurface with: " + surface); + videoCall.setDisplaySurface(surface); + } + + Assert.checkState( + mDeviceOrientation != InCallOrientationEventListener.SCREEN_ORIENTATION_UNKNOWN); + videoCall.setDeviceOrientation(mDeviceOrientation); + enableCamera(videoCall, isCameraRequired(newVideoState, call.getSessionModificationState())); + } + int previousVideoState = mCurrentVideoState; + mCurrentVideoState = newVideoState; + mIsVideoMode = true; + + // adjustVideoMode may be called if we are already in a 1-way video state. In this case + // we do not want to trigger auto-fullscreen mode. + if (!VideoUtils.isVideoCall(previousVideoState) && VideoUtils.isVideoCall(newVideoState)) { + maybeAutoEnterFullscreen(call); + } + } + + private static boolean shouldShowVideoUiForCall(@Nullable DialerCall call) { + if (call == null) { + return false; + } + + if (VideoUtils.isVideoCall(call)) { + return true; + } + + if (isVideoUpgrade(call)) { + return true; + } + + return false; + } + + private void enableCamera(VideoCall videoCall, boolean isCameraRequired) { + LogUtil.v( + "VideoCallPresenter.enableCamera", + "videoCall: %s, enabling: %b", + videoCall, + isCameraRequired); + if (videoCall == null) { + LogUtil.i("VideoCallPresenter.enableCamera", "videoCall is null."); + return; + } + + boolean hasCameraPermission = VideoUtils.hasCameraPermissionAndAllowedByUser(mContext); + if (!hasCameraPermission) { + videoCall.setCamera(null); + mPreviewSurfaceState = PreviewSurfaceState.NONE; + // TODO: Inform remote party that the video is off. This is similar to b/30256571. + } else if (isCameraRequired) { + InCallCameraManager cameraManager = InCallPresenter.getInstance().getInCallCameraManager(); + videoCall.setCamera(cameraManager.getActiveCameraId()); + mPreviewSurfaceState = PreviewSurfaceState.CAMERA_SET; + videoCall.requestCameraCapabilities(); + } else { + mPreviewSurfaceState = PreviewSurfaceState.NONE; + videoCall.setCamera(null); + } + } + + /** Exits video mode by hiding the video surfaces and making other adjustments (eg. audio). */ + private void exitVideoMode() { + LogUtil.i("VideoCallPresenter.exitVideoMode", ""); + + showVideoUi( + VideoProfile.STATE_AUDIO_ONLY, + DialerCall.State.ACTIVE, + DialerCall.SESSION_MODIFICATION_STATE_NO_REQUEST, + false /* isRemotelyHeld */); + enableCamera(mVideoCall, false); + InCallPresenter.getInstance().setFullScreen(false); + + mIsVideoMode = false; + } + + /** + * Based on the current video state and call state, show or hide the incoming and outgoing video + * surfaces. The outgoing video surface is shown any time video is transmitting. The incoming + * video surface is shown whenever the video is un-paused and active. + * + * @param videoState The video state. + * @param callState The call state. + */ + private void showVideoUi( + int videoState, + int callState, + @SessionModificationState int sessionModificationState, + boolean isRemotelyHeld) { + if (mVideoCallScreen == null) { + LogUtil.e("VideoCallPresenter.showVideoUi", "videoCallScreen is null returning"); + return; + } + boolean showIncomingVideo = showIncomingVideo(videoState, callState); + boolean showOutgoingVideo = showOutgoingVideo(mContext, videoState, sessionModificationState); + LogUtil.i( + "VideoCallPresenter.showVideoUi", + "showIncoming: %b, showOutgoing: %b, isRemotelyHeld: %b", + showIncomingVideo, + showOutgoingVideo, + isRemotelyHeld); + updateRemoteVideoSurfaceDimensions(); + mVideoCallScreen.showVideoViews(showOutgoingVideo, showIncomingVideo, isRemotelyHeld); + + InCallPresenter.getInstance().enableScreenTimeout(VideoProfile.isAudioOnly(videoState)); + updateFullscreenAndGreenScreenMode(callState, sessionModificationState); + } + + /** + * Handles peer video pause state changes. + * + * @param call The call which paused or un-pausedvideo transmission. + * @param paused {@code True} when the video transmission is paused, {@code false} when video + * transmission resumes. + */ + @Override + public void onPeerPauseStateChanged(DialerCall call, boolean paused) { + if (!call.equals(mPrimaryCall)) { + return; + } + } + + /** + * Handles peer video dimension changes. + * + * @param call The call which experienced a peer video dimension change. + * @param width The new peer video width . + * @param height The new peer video height. + */ + @Override + public void onUpdatePeerDimensions(DialerCall call, int width, int height) { + LogUtil.i("VideoCallPresenter.onUpdatePeerDimensions", "width: %d, height: %d", width, height); + if (mVideoCallScreen == null) { + LogUtil.e("VideoCallPresenter.onUpdatePeerDimensions", "videoCallScreen is null"); + return; + } + if (!call.equals(mPrimaryCall)) { + LogUtil.e( + "VideoCallPresenter.onUpdatePeerDimensions", "current call is not equal to primary"); + return; + } + + // Change size of display surface to match the peer aspect ratio + if (width > 0 && height > 0 && mVideoCallScreen != null) { + getRemoteVideoSurfaceTexture().setSourceVideoDimensions(new Point(width, height)); + mVideoCallScreen.onRemoteVideoDimensionsChanged(); + } + } + + /** + * Handles any video quality changes in the call. + * + * @param call The call which experienced a video quality change. + * @param videoQuality The new video call quality. + */ + @Override + public void onVideoQualityChanged(DialerCall call, int videoQuality) { + // No-op + } + + /** + * Handles a change to the dimensions of the local camera. Receiving the camera capabilities + * triggers the creation of the video + * + * @param call The call which experienced the camera dimension change. + * @param width The new camera video width. + * @param height The new camera video height. + */ + @Override + public void onCameraDimensionsChange(DialerCall call, int width, int height) { + LogUtil.i( + "VideoCallPresenter.onCameraDimensionsChange", + "call: %s, width: %d, height: %d", + call, + width, + height); + if (mVideoCallScreen == null) { + LogUtil.e("VideoCallPresenter.onCameraDimensionsChange", "ui is null"); + return; + } + + if (!call.equals(mPrimaryCall)) { + LogUtil.e("VideoCallPresenter.onCameraDimensionsChange", "not the primary call"); + return; + } + + mPreviewSurfaceState = PreviewSurfaceState.CAPABILITIES_RECEIVED; + changePreviewDimensions(width, height); + + // Check if the preview surface is ready yet; if it is, set it on the {@code VideoCall}. + // If it not yet ready, it will be set when when creation completes. + Surface surface = getLocalVideoSurfaceTexture().getSavedSurface(); + if (surface != null) { + mPreviewSurfaceState = PreviewSurfaceState.SURFACE_SET; + mVideoCall.setPreviewSurface(surface); + } + } + + /** + * Changes the dimensions of the preview surface. + * + * @param width The new width. + * @param height The new height. + */ + private void changePreviewDimensions(int width, int height) { + if (mVideoCallScreen == null) { + return; + } + + // Resize the surface used to display the preview video + getLocalVideoSurfaceTexture().setSurfaceDimensions(new Point(width, height)); + mVideoCallScreen.onLocalVideoDimensionsChanged(); + } + + /** + * Called when call session event is raised. + * + * @param event The call session event. + */ + @Override + public void onCallSessionEvent(int event) { + switch (event) { + case Connection.VideoProvider.SESSION_EVENT_RX_PAUSE: + LogUtil.v("VideoCallPresenter.onCallSessionEvent", "rx_pause"); + break; + case Connection.VideoProvider.SESSION_EVENT_RX_RESUME: + LogUtil.v("VideoCallPresenter.onCallSessionEvent", "rx_resume"); + break; + case Connection.VideoProvider.SESSION_EVENT_CAMERA_FAILURE: + LogUtil.v("VideoCallPresenter.onCallSessionEvent", "camera_failure"); + break; + case Connection.VideoProvider.SESSION_EVENT_CAMERA_READY: + LogUtil.v("VideoCallPresenter.onCallSessionEvent", "camera_ready"); + break; + default: + LogUtil.v("VideoCallPresenter.onCallSessionEvent", "unknown event = : " + event); + break; + } + } + + /** + * Handles a change to the call data usage + * + * @param dataUsage call data usage value + */ + @Override + public void onCallDataUsageChange(long dataUsage) { + LogUtil.v("VideoCallPresenter.onCallDataUsageChange", "dataUsage=" + dataUsage); + } + + /** + * Handles changes to the device orientation. + * + * @param orientation The screen orientation of the device (one of: {@link + * InCallOrientationEventListener#SCREEN_ORIENTATION_0}, {@link + * InCallOrientationEventListener#SCREEN_ORIENTATION_90}, {@link + * InCallOrientationEventListener#SCREEN_ORIENTATION_180}, {@link + * InCallOrientationEventListener#SCREEN_ORIENTATION_270}). + */ + @Override + public void onDeviceOrientationChanged(int orientation) { + LogUtil.i( + "VideoCallPresenter.onDeviceOrientationChanged", + "orientation: %d -> %d", + mDeviceOrientation, + orientation); + mDeviceOrientation = orientation; + + if (mVideoCallScreen == null) { + LogUtil.e("VideoCallPresenter.onDeviceOrientationChanged", "videoCallScreen is null"); + return; + } + + Point previewDimensions = getLocalVideoSurfaceTexture().getSurfaceDimensions(); + if (previewDimensions == null) { + return; + } + LogUtil.v( + "VideoCallPresenter.onDeviceOrientationChanged", + "orientation: %d, size: %s", + orientation, + previewDimensions); + changePreviewDimensions(previewDimensions.x, previewDimensions.y); + + mVideoCallScreen.onLocalVideoOrientationChanged(); + } + + /** + * Exits fullscreen mode if the current call context has changed to a non-video call. + * + * @param call The call. + */ + protected void maybeExitFullscreen(DialerCall call) { + if (call == null) { + return; + } + + if (!VideoUtils.isVideoCall(call) || call.getState() == DialerCall.State.INCOMING) { + LogUtil.i("VideoCallPresenter.maybeExitFullscreen", "exiting fullscreen"); + InCallPresenter.getInstance().setFullScreen(false); + } + } + + /** + * Schedules auto-entering of fullscreen mode. Will not enter full screen mode if any of the + * following conditions are met: 1. No call 2. DialerCall is not active 3. The current video state + * is not bi-directional. 4. Already in fullscreen mode 5. In accessibility mode + * + * @param call The current call. + */ + protected void maybeAutoEnterFullscreen(DialerCall call) { + if (!mIsAutoFullscreenEnabled) { + return; + } + + if (call == null + || call.getState() != DialerCall.State.ACTIVE + || !VideoUtils.isBidirectionalVideoCall(call) + || InCallPresenter.getInstance().isFullscreen() + || (mContext != null && AccessibilityUtil.isTouchExplorationEnabled(mContext))) { + // Ensure any previously scheduled attempt to enter fullscreen is cancelled. + cancelAutoFullScreen(); + return; + } + + if (mAutoFullScreenPending) { + LogUtil.v("VideoCallPresenter.maybeAutoEnterFullscreen", "already pending."); + return; + } + LogUtil.v("VideoCallPresenter.maybeAutoEnterFullscreen", "scheduled"); + mAutoFullScreenPending = true; + mHandler.removeCallbacks(mAutoFullscreenRunnable); + mHandler.postDelayed(mAutoFullscreenRunnable, mAutoFullscreenTimeoutMillis); + } + + /** Cancels pending auto fullscreen mode. */ + @Override + public void cancelAutoFullScreen() { + if (!mAutoFullScreenPending) { + LogUtil.v("VideoCallPresenter.cancelAutoFullScreen", "none pending."); + return; + } + LogUtil.v("VideoCallPresenter.cancelAutoFullScreen", "cancelling pending"); + mAutoFullScreenPending = false; + mHandler.removeCallbacks(mAutoFullscreenRunnable); + } + + private void updateRemoteVideoSurfaceDimensions() { + Activity activity = mVideoCallScreen.getVideoCallScreenFragment().getActivity(); + if (activity != null) { + Point screenSize = new Point(); + activity.getWindowManager().getDefaultDisplay().getSize(screenSize); + getRemoteVideoSurfaceTexture().setSurfaceDimensions(screenSize); + } + } + + private static boolean isVideoUpgrade(DialerCall call) { + return VideoUtils.hasSentVideoUpgradeRequest(call) + || VideoUtils.hasReceivedVideoUpgradeRequest(call); + } + + private static boolean isVideoUpgrade(@SessionModificationState int state) { + return VideoUtils.hasSentVideoUpgradeRequest(state) + || VideoUtils.hasReceivedVideoUpgradeRequest(state); + } + + private class LocalDelegate implements VideoSurfaceDelegate { + @Override + public void onSurfaceCreated(VideoSurfaceTexture videoCallSurface) { + if (mVideoCallScreen == null) { + LogUtil.e("VideoCallPresenter.LocalDelegate.onSurfaceCreated", "no UI"); + return; + } + if (mVideoCall == null) { + LogUtil.e("VideoCallPresenter.LocalDelegate.onSurfaceCreated", "no video call"); + return; + } + + // If the preview surface has just been created and we have already received camera + // capabilities, but not yet set the surface, we will set the surface now. + if (mPreviewSurfaceState == PreviewSurfaceState.CAPABILITIES_RECEIVED) { + mPreviewSurfaceState = PreviewSurfaceState.SURFACE_SET; + mVideoCall.setPreviewSurface(videoCallSurface.getSavedSurface()); + } else if (mPreviewSurfaceState == PreviewSurfaceState.NONE && isCameraRequired()) { + enableCamera(mVideoCall, true); + } + } + + @Override + public void onSurfaceReleased(VideoSurfaceTexture videoCallSurface) { + if (mVideoCall == null) { + LogUtil.e("VideoCallPresenter.LocalDelegate.onSurfaceReleased", "no video call"); + return; + } + + mVideoCall.setPreviewSurface(null); + enableCamera(mVideoCall, false); + } + + @Override + public void onSurfaceDestroyed(VideoSurfaceTexture videoCallSurface) { + if (mVideoCall == null) { + LogUtil.e("VideoCallPresenter.LocalDelegate.onSurfaceDestroyed", "no video call"); + return; + } + + boolean isChangingConfigurations = InCallPresenter.getInstance().isChangingConfigurations(); + if (!isChangingConfigurations) { + enableCamera(mVideoCall, false); + } else { + LogUtil.i( + "VideoCallPresenter.LocalDelegate.onSurfaceDestroyed", + "activity is being destroyed due to configuration changes. Not closing the camera."); + } + } + + @Override + public void onSurfaceClick(VideoSurfaceTexture videoCallSurface) { + VideoCallPresenter.this.onSurfaceClick(); + } + } + + private class RemoteDelegate implements VideoSurfaceDelegate { + @Override + public void onSurfaceCreated(VideoSurfaceTexture videoCallSurface) { + if (mVideoCallScreen == null) { + LogUtil.e("VideoCallPresenter.RemoteDelegate.onSurfaceCreated", "no UI"); + return; + } + if (mVideoCall == null) { + LogUtil.e("VideoCallPresenter.RemoteDelegate.onSurfaceCreated", "no video call"); + return; + } + mVideoCall.setDisplaySurface(videoCallSurface.getSavedSurface()); + } + + @Override + public void onSurfaceReleased(VideoSurfaceTexture videoCallSurface) { + if (mVideoCall == null) { + LogUtil.e("VideoCallPresenter.RemoteDelegate.onSurfaceReleased", "no video call"); + return; + } + mVideoCall.setDisplaySurface(null); + } + + @Override + public void onSurfaceDestroyed(VideoSurfaceTexture videoCallSurface) {} + + @Override + public void onSurfaceClick(VideoSurfaceTexture videoCallSurface) { + VideoCallPresenter.this.onSurfaceClick(); + } + } + + /** Defines the state of the preview surface negotiation with the telephony layer. */ + private static class PreviewSurfaceState { + + /** + * The camera has not yet been set on the {@link VideoCall}; negotiation has not yet started. + */ + private static final int NONE = 0; + + /** + * The camera has been set on the {@link VideoCall}, but camera capabilities have not yet been + * received. + */ + private static final int CAMERA_SET = 1; + + /** + * The camera capabilties have been received from telephony, but the surface has not yet been + * set on the {@link VideoCall}. + */ + private static final int CAPABILITIES_RECEIVED = 2; + + /** The surface has been set on the {@link VideoCall}. */ + private static final int SURFACE_SET = 3; + } +} diff --git a/java/com/android/incallui/VideoPauseController.java b/java/com/android/incallui/VideoPauseController.java new file mode 100644 index 000000000..2b4357704 --- /dev/null +++ b/java/com/android/incallui/VideoPauseController.java @@ -0,0 +1,416 @@ +/* + * 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; + +import android.support.annotation.NonNull; +import android.telecom.VideoProfile; +import com.android.incallui.InCallPresenter.InCallState; +import com.android.incallui.InCallPresenter.InCallStateListener; +import com.android.incallui.InCallPresenter.IncomingCallListener; +import com.android.incallui.call.CallList; +import com.android.incallui.call.DialerCall; +import com.android.incallui.call.DialerCall.State; +import com.android.incallui.call.VideoUtils; +import java.util.Objects; + +/** + * This class is responsible for generating video pause/resume requests when the InCall UI is sent + * to the background and subsequently brought back to the foreground. + */ +class VideoPauseController implements InCallStateListener, IncomingCallListener { + + private static final String TAG = "VideoPauseController"; + private static VideoPauseController sVideoPauseController; + private InCallPresenter mInCallPresenter; + /** The current call context, if applicable. */ + private CallContext mPrimaryCallContext = null; + /** + * Tracks whether the application is in the background. {@code True} if the application is in the + * background, {@code false} otherwise. + */ + private boolean mIsInBackground = false; + + /** + * Singleton accessor for the {@link VideoPauseController}. + * + * @return Singleton instance of the {@link VideoPauseController}. + */ + /*package*/ + static synchronized VideoPauseController getInstance() { + if (sVideoPauseController == null) { + sVideoPauseController = new VideoPauseController(); + } + return sVideoPauseController; + } + + /** + * Determines if a given call is the same one stored in a {@link CallContext}. + * + * @param call The call. + * @param callContext The call context. + * @return {@code true} if the {@link DialerCall} is the same as the one referenced in the {@link + * CallContext}. + */ + private static boolean areSame(DialerCall call, CallContext callContext) { + if (call == null && callContext == null) { + return true; + } else if (call == null || callContext == null) { + return false; + } + return call.equals(callContext.getCall()); + } + + /** + * Determines if a video call can be paused. Only a video call which is active can be paused. + * + * @param callContext The call context to check. + * @return {@code true} if the call is an active video call. + */ + private static boolean canVideoPause(CallContext callContext) { + return isVideoCall(callContext) && callContext.getState() == DialerCall.State.ACTIVE; + } + + /** + * Determines if a call referenced by a {@link CallContext} is a video call. + * + * @param callContext The call context. + * @return {@code true} if the call is a video call, {@code false} otherwise. + */ + private static boolean isVideoCall(CallContext callContext) { + return callContext != null && VideoUtils.isVideoCall(callContext.getVideoState()); + } + + /** + * Determines if call is in incoming/waiting state. + * + * @param call The call context. + * @return {@code true} if the call is in incoming or waiting state, {@code false} otherwise. + */ + private static boolean isIncomingCall(CallContext call) { + return call != null && isIncomingCall(call.getCall()); + } + + /** + * Determines if a call is in incoming/waiting state. + * + * @param call The call. + * @return {@code true} if the call is in incoming or waiting state, {@code false} otherwise. + */ + private static boolean isIncomingCall(DialerCall call) { + return call != null + && (call.getState() == DialerCall.State.CALL_WAITING + || call.getState() == DialerCall.State.INCOMING); + } + + /** + * Determines if a call is dialing. + * + * @param call The call context. + * @return {@code true} if the call is dialing, {@code false} otherwise. + */ + private static boolean isDialing(CallContext call) { + return call != null && DialerCall.State.isDialing(call.getState()); + } + + /** + * Configures the {@link VideoPauseController} to listen to call events. Configured via the {@link + * com.android.incallui.InCallPresenter}. + * + * @param inCallPresenter The {@link com.android.incallui.InCallPresenter}. + */ + public void setUp(@NonNull InCallPresenter inCallPresenter) { + log("setUp"); + mInCallPresenter = Objects.requireNonNull(inCallPresenter); + mInCallPresenter.addListener(this); + mInCallPresenter.addIncomingCallListener(this); + } + + /** + * Cleans up the {@link VideoPauseController} by removing all listeners and clearing its internal + * state. Called from {@link com.android.incallui.InCallPresenter}. + */ + public void tearDown() { + log("tearDown..."); + mInCallPresenter.removeListener(this); + mInCallPresenter.removeIncomingCallListener(this); + clear(); + } + + /** Clears the internal state for the {@link VideoPauseController}. */ + private void clear() { + mInCallPresenter = null; + mPrimaryCallContext = null; + mIsInBackground = false; + } + + /** + * Handles changes in the {@link InCallState}. Triggers pause and resumption of video for the + * current foreground call. + * + * @param oldState The previous {@link InCallState}. + * @param newState The current {@link InCallState}. + * @param callList List of current call. + */ + @Override + public void onStateChange(InCallState oldState, InCallState newState, CallList callList) { + log("onStateChange, OldState=" + oldState + " NewState=" + newState); + + DialerCall call; + if (newState == InCallState.INCOMING) { + call = callList.getIncomingCall(); + } else if (newState == InCallState.WAITING_FOR_ACCOUNT) { + call = callList.getWaitingForAccountCall(); + } else if (newState == InCallState.PENDING_OUTGOING) { + call = callList.getPendingOutgoingCall(); + } else if (newState == InCallState.OUTGOING) { + call = callList.getOutgoingCall(); + } else { + call = callList.getActiveCall(); + } + + boolean hasPrimaryCallChanged = !areSame(call, mPrimaryCallContext); + boolean canVideoPause = VideoUtils.canVideoPause(call); + log("onStateChange, hasPrimaryCallChanged=" + hasPrimaryCallChanged); + log("onStateChange, canVideoPause=" + canVideoPause); + log("onStateChange, IsInBackground=" + mIsInBackground); + + if (hasPrimaryCallChanged) { + onPrimaryCallChanged(call); + return; + } + + if (isDialing(mPrimaryCallContext) && canVideoPause && mIsInBackground) { + // Bring UI to foreground if outgoing request becomes active while UI is in + // background. + bringToForeground(); + } else if (!isVideoCall(mPrimaryCallContext) && canVideoPause && mIsInBackground) { + // Bring UI to foreground if VoLTE call becomes active while UI is in + // background. + bringToForeground(); + } + + updatePrimaryCallContext(call); + } + + /** + * Handles a change to the primary call. + * + * <p>Reject incoming or hangup dialing call: Where the previous call was an incoming call or a + * call in dialing state, resume the new primary call. DialerCall swap: Where the new primary call + * is incoming, pause video on the previous primary call. + * + * @param call The new primary call. + */ + private void onPrimaryCallChanged(DialerCall call) { + log("onPrimaryCallChanged: New call = " + call); + log("onPrimaryCallChanged: Old call = " + mPrimaryCallContext); + log("onPrimaryCallChanged, IsInBackground=" + mIsInBackground); + + if (areSame(call, mPrimaryCallContext)) { + throw new IllegalStateException(); + } + final boolean canVideoPause = VideoUtils.canVideoPause(call); + + if ((isIncomingCall(mPrimaryCallContext) + || isDialing(mPrimaryCallContext) + || (call != null && VideoProfile.isPaused(call.getVideoState()))) + && canVideoPause + && !mIsInBackground) { + // Send resume request for the active call, if user rejects incoming call, ends dialing + // call, or the call was previously in a paused state and UI is in the foreground. + sendRequest(call, true); + } else if (isIncomingCall(call) && canVideoPause(mPrimaryCallContext)) { + // Send pause request if there is an active video call, and we just received a new + // incoming call. + sendRequest(mPrimaryCallContext.getCall(), false); + } + + updatePrimaryCallContext(call); + } + + /** + * Handles new incoming calls by triggering a change in the primary call. + * + * @param oldState the old {@link InCallState}. + * @param newState the new {@link InCallState}. + * @param call the incoming call. + */ + @Override + public void onIncomingCall(InCallState oldState, InCallState newState, DialerCall call) { + log("onIncomingCall, OldState=" + oldState + " NewState=" + newState + " DialerCall=" + call); + + if (areSame(call, mPrimaryCallContext)) { + return; + } + + onPrimaryCallChanged(call); + } + + /** + * Caches a reference to the primary call and stores its previous state. + * + * @param call The new primary call. + */ + private void updatePrimaryCallContext(DialerCall call) { + if (call == null) { + mPrimaryCallContext = null; + } else if (mPrimaryCallContext != null) { + mPrimaryCallContext.update(call); + } else { + mPrimaryCallContext = new CallContext(call); + } + } + + /** + * Called when UI goes in/out of the foreground. + * + * @param showing true if UI is in the foreground, false otherwise. + */ + public void onUiShowing(boolean showing) { + if (mInCallPresenter == null) { + return; + } + + final boolean isInCall = mInCallPresenter.getInCallState() == InCallState.INCALL; + if (showing) { + onResume(isInCall); + } else { + onPause(isInCall); + } + } + + /** + * Called when UI is brought to the foreground. Sends a session modification request to resume the + * outgoing video. + * + * @param isInCall {@code true} if we are in an active call. A resume request is only sent to the + * video provider if we are in a call. + */ + private void onResume(boolean isInCall) { + log("onResume"); + + mIsInBackground = false; + if (canVideoPause(mPrimaryCallContext) && isInCall) { + sendRequest(mPrimaryCallContext.getCall(), true); + } else { + log("onResume. Ignoring..."); + } + } + + /** + * Called when UI is sent to the background. Sends a session modification request to pause the + * outgoing video. + * + * @param isInCall {@code true} if we are in an active call. A pause request is only sent to the + * video provider if we are in a call. + */ + private void onPause(boolean isInCall) { + log("onPause"); + + mIsInBackground = true; + if (canVideoPause(mPrimaryCallContext) && isInCall) { + sendRequest(mPrimaryCallContext.getCall(), false); + } else { + log("onPause, Ignoring..."); + } + } + + private void bringToForeground() { + if (mInCallPresenter != null) { + log("Bringing UI to foreground"); + mInCallPresenter.bringToForeground(false); + } else { + loge("InCallPresenter is null. Cannot bring UI to foreground"); + } + } + + /** + * Sends Pause/Resume request. + * + * @param call DialerCall to be paused/resumed. + * @param resume If true resume request will be sent, otherwise pause request. + */ + private void sendRequest(DialerCall call, boolean resume) { + // Check if this call supports pause/un-pause. + if (!call.can(android.telecom.Call.Details.CAPABILITY_CAN_PAUSE_VIDEO)) { + return; + } + + if (resume) { + log("sending resume request, call=" + call); + call.getVideoCall().sendSessionModifyRequest(VideoUtils.makeVideoUnPauseProfile(call)); + } else { + log("sending pause request, call=" + call); + call.getVideoCall().sendSessionModifyRequest(VideoUtils.makeVideoPauseProfile(call)); + } + } + + /** + * Logs a debug message. + * + * @param msg The message. + */ + private void log(String msg) { + Log.d(this, TAG + msg); + } + + /** + * Logs an error message. + * + * @param msg The message. + */ + private void loge(String msg) { + Log.e(this, TAG + msg); + } + + /** Keeps track of the current active/foreground call. */ + private static class CallContext { + + private int mState = State.INVALID; + private int mVideoState; + private DialerCall mCall; + + public CallContext(@NonNull DialerCall call) { + Objects.requireNonNull(call); + update(call); + } + + public void update(@NonNull DialerCall call) { + mCall = Objects.requireNonNull(call); + mState = call.getState(); + mVideoState = call.getVideoState(); + } + + public int getState() { + return mState; + } + + public int getVideoState() { + return mVideoState; + } + + @Override + public String toString() { + return String.format( + "CallContext {CallId=%s, State=%s, VideoState=%d}", mCall.getId(), mState, mVideoState); + } + + public DialerCall getCall() { + return mCall; + } + } +} diff --git a/java/com/android/incallui/answer/bindings/AnswerBindings.java b/java/com/android/incallui/answer/bindings/AnswerBindings.java new file mode 100644 index 000000000..f7a7a0a95 --- /dev/null +++ b/java/com/android/incallui/answer/bindings/AnswerBindings.java @@ -0,0 +1,29 @@ +/* + * 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.bindings; + +import com.android.incallui.answer.impl.AnswerFragment; +import com.android.incallui.answer.protocol.AnswerScreen; + +/** Bindings for answer module. */ +public class AnswerBindings { + + public static AnswerScreen createAnswerScreen( + String callId, int videoState, boolean isVideoUpgradeRequest) { + return AnswerFragment.newInstance(callId, videoState, isVideoUpgradeRequest); + } +} 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); +} diff --git a/java/com/android/incallui/answer/protocol/AnswerScreen.java b/java/com/android/incallui/answer/protocol/AnswerScreen.java new file mode 100644 index 000000000..0c374eb7f --- /dev/null +++ b/java/com/android/incallui/answer/protocol/AnswerScreen.java @@ -0,0 +1,38 @@ +/* + * 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.protocol; + +import android.support.v4.app.Fragment; +import java.util.List; + +/** Interface for the answer module. */ +public interface AnswerScreen { + + String getCallId(); + + int getVideoState(); + + boolean isVideoUpgradeRequest(); + + void setTextResponses(List<String> textResponses); + + boolean hasPendingDialogs(); + + void dismissPendingDialogs(); + + Fragment getAnswerScreenFragment(); +} diff --git a/java/com/android/incallui/answer/protocol/AnswerScreenDelegate.java b/java/com/android/incallui/answer/protocol/AnswerScreenDelegate.java new file mode 100644 index 000000000..9934497cf --- /dev/null +++ b/java/com/android/incallui/answer/protocol/AnswerScreenDelegate.java @@ -0,0 +1,44 @@ +/* + * 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.protocol; + +import android.support.annotation.FloatRange; + +/** Callbacks implemented by the container app for this module. */ +public interface AnswerScreenDelegate { + + void onAnswerScreenUnready(); + + void onDismissDialog(); + + void onRejectCallWithMessage(String message); + + void onAnswer(int videoState); + + void onReject(); + + /** + * Sets the window background color based on foreground call's theme and the given progress. This + * is called from the answer UI to animate the accept and reject action. + * + * <p>When the user is rejecting we animate the background color to a mostly transparent gray. The + * end effect is that the home screen shows through. + * + * @param progress float from -1 to 1. -1 is fully rejected, 1 is fully accepted, and 0 is neutral + */ + void updateWindowBackgroundColor(@FloatRange(from = -1f, to = 1.0f) float progress); +} diff --git a/java/com/android/incallui/answer/protocol/AnswerScreenDelegateFactory.java b/java/com/android/incallui/answer/protocol/AnswerScreenDelegateFactory.java new file mode 100644 index 000000000..a09cb1a40 --- /dev/null +++ b/java/com/android/incallui/answer/protocol/AnswerScreenDelegateFactory.java @@ -0,0 +1,23 @@ +/* + * 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.protocol; + +/** Used to create an instance of the delegate, should be implemented by the container activity. */ +public interface AnswerScreenDelegateFactory { + + AnswerScreenDelegate newAnswerScreenDelegate(AnswerScreen answerScreen); +} diff --git a/java/com/android/incallui/answerproximitysensor/AnswerProximitySensor.java b/java/com/android/incallui/answerproximitysensor/AnswerProximitySensor.java new file mode 100644 index 000000000..edc3db34b --- /dev/null +++ b/java/com/android/incallui/answerproximitysensor/AnswerProximitySensor.java @@ -0,0 +1,150 @@ +/* + * 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.answerproximitysensor; + +import android.content.Context; +import android.hardware.display.DisplayManager; +import android.os.PowerManager; +import android.view.Display; +import com.android.dialer.common.ConfigProviderBindings; +import com.android.dialer.common.LogUtil; +import com.android.incallui.call.DialerCall; +import com.android.incallui.call.DialerCall.SessionModificationState; +import com.android.incallui.call.DialerCall.State; +import com.android.incallui.call.DialerCallListener; + +/** + * This class prevents users from accidentally answering calls by keeping the screen off until the + * proximity sensor is unblocked. If the screen is already on or if this is a call waiting call then + * nothing is done. + */ +public class AnswerProximitySensor + implements DialerCallListener, AnswerProximityWakeLock.ScreenOnListener { + + private static final String CONFIG_ANSWER_PROXIMITY_SENSOR_ENABLED = + "answer_proximity_sensor_enabled"; + private static final String CONFIG_ANSWER_PSEUDO_PROXIMITY_WAKE_LOCK_ENABLED = + "answer_pseudo_proximity_wake_lock_enabled"; + + private final DialerCall call; + private final AnswerProximityWakeLock answerProximityWakeLock; + + public static boolean shouldUse(Context context, DialerCall call) { + // Don't use the AnswerProximitySensor for call waiting and other states. Those states are + // handled by the general ProximitySensor code. + if (call.getState() != State.INCOMING) { + LogUtil.i("AnswerProximitySensor.shouldUse", "call state is not incoming"); + return false; + } + + if (!ConfigProviderBindings.get(context) + .getBoolean(CONFIG_ANSWER_PROXIMITY_SENSOR_ENABLED, true)) { + LogUtil.i("AnswerProximitySensor.shouldUse", "disabled by config"); + return false; + } + + if (!context + .getSystemService(PowerManager.class) + .isWakeLockLevelSupported(PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK)) { + LogUtil.i("AnswerProximitySensor.shouldUse", "wake lock level not supported"); + return false; + } + + if (isDefaultDisplayOn(context)) { + LogUtil.i("AnswerProximitySensor.shouldUse", "display is already on"); + return false; + } + + return true; + } + + public AnswerProximitySensor( + Context context, DialerCall call, PseudoScreenState pseudoScreenState) { + this.call = call; + + LogUtil.i("AnswerProximitySensor.constructor", "acquiring lock"); + if (ConfigProviderBindings.get(context) + .getBoolean(CONFIG_ANSWER_PSEUDO_PROXIMITY_WAKE_LOCK_ENABLED, true)) { + answerProximityWakeLock = new PseudoProximityWakeLock(context, pseudoScreenState); + } else { + // TODO: choose a wake lock implementation base on framework/device. + // These bugs requires the PseudoProximityWakeLock workaround: + // b/30439151 Proximity sensor not working on M + // b/31499931 fautly touch input when screen is off on marlin/sailfish + answerProximityWakeLock = new SystemProximityWakeLock(context); + } + answerProximityWakeLock.setScreenOnListener(this); + answerProximityWakeLock.acquire(); + + call.addListener(this); + } + + private void cleanup() { + call.removeListener(this); + releaseProximityWakeLock(); + } + + private void releaseProximityWakeLock() { + if (answerProximityWakeLock.isHeld()) { + LogUtil.i("AnswerProximitySensor.releaseProximityWakeLock", "releasing lock"); + answerProximityWakeLock.release(); + } + } + + private static boolean isDefaultDisplayOn(Context context) { + Display display = + context.getSystemService(DisplayManager.class).getDisplay(Display.DEFAULT_DISPLAY); + return display.getState() == Display.STATE_ON; + } + + @Override + public void onDialerCallDisconnect() { + LogUtil.i("AnswerProximitySensor.onDialerCallDisconnect", null); + cleanup(); + } + + @Override + public void onDialerCallUpdate() { + if (call.getState() != State.INCOMING) { + LogUtil.i("AnswerProximitySensor.onDialerCallUpdate", "no longer incoming, cleaning up"); + cleanup(); + } + } + + @Override + public void onDialerCallChildNumberChange() {} + + @Override + public void onDialerCallLastForwardedNumberChange() {} + + @Override + public void onDialerCallUpgradeToVideo() {} + + @Override + public void onWiFiToLteHandover() {} + + @Override + public void onHandoverToWifiFailure() {} + + @Override + public void onDialerCallSessionModificationStateChange(@SessionModificationState int state) {} + + @Override + public void onScreenOn() { + cleanup(); + } +} diff --git a/java/com/android/incallui/answerproximitysensor/AnswerProximityWakeLock.java b/java/com/android/incallui/answerproximitysensor/AnswerProximityWakeLock.java new file mode 100644 index 000000000..94abe9c85 --- /dev/null +++ b/java/com/android/incallui/answerproximitysensor/AnswerProximityWakeLock.java @@ -0,0 +1,37 @@ +/* + * 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.answerproximitysensor; + +/** + * Interface to wrap around the {@link android.os.PowerManager.WakeLock} for custom implementations. + */ +public interface AnswerProximityWakeLock { + + /** Called when the wake lock turned the screen back on. */ + interface ScreenOnListener { + + void onScreenOn(); + } + + void acquire(); + + void release(); + + boolean isHeld(); + + void setScreenOnListener(ScreenOnListener listener); +} diff --git a/java/com/android/incallui/answerproximitysensor/PseudoProximityWakeLock.java b/java/com/android/incallui/answerproximitysensor/PseudoProximityWakeLock.java new file mode 100644 index 000000000..c7844d47d --- /dev/null +++ b/java/com/android/incallui/answerproximitysensor/PseudoProximityWakeLock.java @@ -0,0 +1,85 @@ +/* + * 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.answerproximitysensor; + +import android.content.Context; +import android.hardware.Sensor; +import android.hardware.SensorEvent; +import android.hardware.SensorEventListener; +import android.hardware.SensorManager; +import android.support.annotation.Nullable; +import com.android.dialer.common.LogUtil; + +/** + * A fake PROXIMITY_SCREEN_OFF_WAKE_LOCK implemented by the app. It will use {@link + * PseudoScreenState} to fake a black screen when the proximity sensor is near. + */ +public class PseudoProximityWakeLock implements AnswerProximityWakeLock, SensorEventListener { + + private final Context context; + private final PseudoScreenState pseudoScreenState; + private final Sensor proximitySensor; + + @Nullable private ScreenOnListener listener; + private boolean isHeld; + + public PseudoProximityWakeLock(Context context, PseudoScreenState pseudoScreenState) { + this.context = context; + this.pseudoScreenState = pseudoScreenState; + pseudoScreenState.setOn(true); + proximitySensor = + context.getSystemService(SensorManager.class).getDefaultSensor(Sensor.TYPE_PROXIMITY); + } + + @Override + public void acquire() { + isHeld = true; + context + .getSystemService(SensorManager.class) + .registerListener(this, proximitySensor, SensorManager.SENSOR_DELAY_NORMAL); + } + + @Override + public void release() { + isHeld = false; + context.getSystemService(SensorManager.class).unregisterListener(this); + pseudoScreenState.setOn(true); + } + + @Override + public boolean isHeld() { + return isHeld; + } + + @Override + public void setScreenOnListener(ScreenOnListener listener) { + this.listener = listener; + } + + @Override + public void onSensorChanged(SensorEvent sensorEvent) { + boolean near = sensorEvent.values[0] < sensorEvent.sensor.getMaximumRange(); + LogUtil.i("AnswerProximitySensor.PseudoProximityWakeLock.onSensorChanged", "near: " + near); + pseudoScreenState.setOn(!near); + if (!near && listener != null) { + listener.onScreenOn(); + } + } + + @Override + public void onAccuracyChanged(Sensor sensor, int i) {} +} diff --git a/java/com/android/incallui/answerproximitysensor/PseudoScreenState.java b/java/com/android/incallui/answerproximitysensor/PseudoScreenState.java new file mode 100644 index 000000000..eda0ee720 --- /dev/null +++ b/java/com/android/incallui/answerproximitysensor/PseudoScreenState.java @@ -0,0 +1,66 @@ +/* + * 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.answerproximitysensor; + +import android.util.ArraySet; +import java.util.Set; + +/** + * Stores a fake screen on/off state for the {@link InCallActivity}. If InCallActivity see the state + * is off, it will draw a black view over the activity pretending the screen is off. + * + * <p>If the screen is already touched when the screen is turned on, the OS behavior is sending a + * new DOWN event once the point started moving and then behave as a normal gesture. To prevent + * accidental answer/rejects, touches that started when the screen is off should be ignored. + * + * <p>b/31499931 on certain devices with N-DR1, if the screen is already touched when the screen is + * turned on, a "DOWN MOVE UP" will be sent for each movement before the touch is actually released. + * These events is hard to discern from other normal events, and keeping the screen on reduces its' + * probability. + */ +public class PseudoScreenState { + + /** Notifies when the on state has changed. */ + public interface StateChangedListener { + void onPseudoScreenStateChanged(boolean isOn); + } + + private final Set<StateChangedListener> listeners = new ArraySet<>(); + + private boolean on = true; + + public boolean isOn() { + return on; + } + + public void setOn(boolean value) { + if (on != value) { + on = value; + for (StateChangedListener listener : listeners) { + listener.onPseudoScreenStateChanged(on); + } + } + } + + public void addListener(StateChangedListener listener) { + listeners.add(listener); + } + + public void removeListener(StateChangedListener listener) { + listeners.remove(listener); + } +} diff --git a/java/com/android/incallui/answerproximitysensor/SystemProximityWakeLock.java b/java/com/android/incallui/answerproximitysensor/SystemProximityWakeLock.java new file mode 100644 index 000000000..776e9a42d --- /dev/null +++ b/java/com/android/incallui/answerproximitysensor/SystemProximityWakeLock.java @@ -0,0 +1,90 @@ +/* + * 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.answerproximitysensor; + +import android.content.Context; +import android.hardware.display.DisplayManager; +import android.hardware.display.DisplayManager.DisplayListener; +import android.os.PowerManager; +import android.support.annotation.Nullable; +import android.view.Display; +import com.android.dialer.common.LogUtil; + +/** The normal PROXIMITY_SCREEN_OFF_WAKE_LOCK provided by the OS. */ +public class SystemProximityWakeLock implements AnswerProximityWakeLock, DisplayListener { + + private static final String TAG = "SystemProximityWakeLock"; + + private final Context context; + private final PowerManager.WakeLock wakeLock; + + @Nullable private ScreenOnListener listener; + + public SystemProximityWakeLock(Context context) { + this.context = context; + wakeLock = + context + .getSystemService(PowerManager.class) + .newWakeLock(PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK, TAG); + } + + @Override + public void acquire() { + wakeLock.acquire(); + context.getSystemService(DisplayManager.class).registerDisplayListener(this, null); + } + + @Override + public void release() { + wakeLock.release(); + context.getSystemService(DisplayManager.class).unregisterDisplayListener(this); + } + + @Override + public boolean isHeld() { + return wakeLock.isHeld(); + } + + @Override + public void setScreenOnListener(ScreenOnListener listener) { + this.listener = listener; + } + + @Override + public void onDisplayAdded(int displayId) {} + + @Override + public void onDisplayRemoved(int displayId) {} + + @Override + public void onDisplayChanged(int displayId) { + if (displayId == Display.DEFAULT_DISPLAY) { + if (isDefaultDisplayOn(context)) { + LogUtil.i("SystemProximityWakeLock.onDisplayChanged", "display turned on"); + if (listener != null) { + listener.onScreenOn(); + } + } + } + } + + private static boolean isDefaultDisplayOn(Context context) { + Display display = + context.getSystemService(DisplayManager.class).getDisplay(Display.DEFAULT_DISPLAY); + return display.getState() != Display.STATE_OFF; + } +} diff --git a/java/com/android/incallui/async/PausableExecutor.java b/java/com/android/incallui/async/PausableExecutor.java new file mode 100644 index 000000000..e10757e67 --- /dev/null +++ b/java/com/android/incallui/async/PausableExecutor.java @@ -0,0 +1,56 @@ +/* + * 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.async; + +import java.util.concurrent.Executor; + +/** + * Executor that can be used to easily synchronize testing and production code. Production code + * should call {@link #milestone()} at points in the code where the state of the system is worthy of + * testing. In a test scenario, this method will pause execution until the test acknowledges the + * milestone through the use of {@link #ackMilestoneForTesting()}. + */ +public interface PausableExecutor extends Executor { + + /** + * Method called from asynchronous production code to inform this executor that it has reached a + * point that puts the system into a state worth testing. TestableExecutors intended for use in a + * testing environment should cause the calling thread to block. In the production environment + * this should be a no-op. + */ + void milestone(); + + /** + * Method called from the test code to inform this executor that the state of the production + * system at the current milestone has been sufficiently tested. Every milestone must be + * acknowledged. + */ + void ackMilestoneForTesting(); + + /** + * Method called from the test code to inform this executor that the tests are finished with all + * milestones. Future calls to {@link #milestone()} or {@link #awaitMilestoneForTesting()} should + * return immediately. + */ + void ackAllMilestonesForTesting(); + + /** + * Method called from the test code to block until a milestone has been reached in the production + * code. + */ + void awaitMilestoneForTesting() throws InterruptedException; +} diff --git a/java/com/android/incallui/async/PausableExecutorImpl.java b/java/com/android/incallui/async/PausableExecutorImpl.java new file mode 100644 index 000000000..687606129 --- /dev/null +++ b/java/com/android/incallui/async/PausableExecutorImpl.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.async; + +import java.util.concurrent.Executors; + +/** {@link PausableExecutor} intended for use in production environments. */ +public class PausableExecutorImpl implements PausableExecutor { + + @Override + public void milestone() {} + + @Override + public void ackMilestoneForTesting() {} + + @Override + public void ackAllMilestonesForTesting() {} + + @Override + public void awaitMilestoneForTesting() {} + + @Override + public void execute(Runnable command) { + Executors.newSingleThreadExecutor().execute(command); + } +} diff --git a/java/com/android/incallui/audioroute/AndroidManifest.xml b/java/com/android/incallui/audioroute/AndroidManifest.xml new file mode 100644 index 000000000..36431f1ee --- /dev/null +++ b/java/com/android/incallui/audioroute/AndroidManifest.xml @@ -0,0 +1,3 @@ +<manifest + package="com.android.incallui.audioroute"> +</manifest> diff --git a/java/com/android/incallui/audioroute/AudioRouteSelectorDialogFragment.java b/java/com/android/incallui/audioroute/AudioRouteSelectorDialogFragment.java new file mode 100644 index 000000000..c757477f1 --- /dev/null +++ b/java/com/android/incallui/audioroute/AudioRouteSelectorDialogFragment.java @@ -0,0 +1,114 @@ +/* + * 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.audioroute; + +import android.app.Dialog; +import android.content.Context; +import android.content.res.ColorStateList; +import android.graphics.PorterDuff.Mode; +import android.os.Bundle; +import android.support.annotation.Nullable; +import android.support.design.widget.BottomSheetDialogFragment; +import android.telecom.CallAudioState; +import android.view.LayoutInflater; +import android.view.View; +import android.view.View.OnClickListener; +import android.view.ViewGroup; +import android.view.WindowManager; +import android.widget.TextView; +import com.android.dialer.common.FragmentUtils; +import com.android.dialer.common.LogUtil; + +/** Shows picker for audio routes */ +public class AudioRouteSelectorDialogFragment extends BottomSheetDialogFragment { + + private static final String ARG_AUDIO_STATE = "audio_state"; + + /** Called when an audio route is picked */ + public interface AudioRouteSelectorPresenter { + void onAudioRouteSelected(int audioRoute); + } + + public static AudioRouteSelectorDialogFragment newInstance(CallAudioState audioState) { + AudioRouteSelectorDialogFragment fragment = new AudioRouteSelectorDialogFragment(); + Bundle args = new Bundle(); + args.putParcelable(ARG_AUDIO_STATE, audioState); + fragment.setArguments(args); + return fragment; + } + + @Override + public void onAttach(Context context) { + super.onAttach(context); + FragmentUtils.checkParent(this, AudioRouteSelectorPresenter.class); + } + + @Override + public Dialog onCreateDialog(final Bundle savedInstanceState) { + LogUtil.i("AudioRouteSelectorDialogFragment.onCreateDialog", null); + Dialog dialog = super.onCreateDialog(savedInstanceState); + dialog.getWindow().addFlags(WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED); + return dialog; + } + + @Nullable + @Override + public View onCreateView( + LayoutInflater layoutInflater, @Nullable ViewGroup viewGroup, @Nullable Bundle bundle) { + View view = layoutInflater.inflate(R.layout.audioroute_selector, viewGroup, false); + CallAudioState audioState = getArguments().getParcelable(ARG_AUDIO_STATE); + + initItem( + (TextView) view.findViewById(R.id.audioroute_bluetooth), + CallAudioState.ROUTE_BLUETOOTH, + audioState); + initItem( + (TextView) view.findViewById(R.id.audioroute_speaker), + CallAudioState.ROUTE_SPEAKER, + audioState); + initItem( + (TextView) view.findViewById(R.id.audioroute_headset), + CallAudioState.ROUTE_WIRED_HEADSET, + audioState); + initItem( + (TextView) view.findViewById(R.id.audioroute_earpiece), + CallAudioState.ROUTE_EARPIECE, + audioState); + return view; + } + + private void initItem(TextView item, final int itemRoute, CallAudioState audioState) { + int selectedColor = getResources().getColor(R.color.dialer_theme_color); + if ((audioState.getSupportedRouteMask() & itemRoute) == 0) { + item.setVisibility(View.GONE); + } else if (audioState.getRoute() == itemRoute) { + item.setTextColor(selectedColor); + item.setCompoundDrawableTintList(ColorStateList.valueOf(selectedColor)); + item.setCompoundDrawableTintMode(Mode.SRC_ATOP); + } + item.setOnClickListener( + new OnClickListener() { + @Override + public void onClick(View v) { + dismiss(); + FragmentUtils.getParentUnsafe( + AudioRouteSelectorDialogFragment.this, AudioRouteSelectorPresenter.class) + .onAudioRouteSelected(itemRoute); + } + }); + } +} diff --git a/java/com/android/incallui/audioroute/res/drawable-hdpi/ic_phone_audio_grey600_24dp.png b/java/com/android/incallui/audioroute/res/drawable-hdpi/ic_phone_audio_grey600_24dp.png Binary files differnew file mode 100644 index 000000000..4ea921a3e --- /dev/null +++ b/java/com/android/incallui/audioroute/res/drawable-hdpi/ic_phone_audio_grey600_24dp.png diff --git a/java/com/android/incallui/audioroute/res/drawable-mdpi/ic_phone_audio_grey600_24dp.png b/java/com/android/incallui/audioroute/res/drawable-mdpi/ic_phone_audio_grey600_24dp.png Binary files differnew file mode 100644 index 000000000..acef550ac --- /dev/null +++ b/java/com/android/incallui/audioroute/res/drawable-mdpi/ic_phone_audio_grey600_24dp.png diff --git a/java/com/android/incallui/audioroute/res/drawable-xhdpi/ic_phone_audio_grey600_24dp.png b/java/com/android/incallui/audioroute/res/drawable-xhdpi/ic_phone_audio_grey600_24dp.png Binary files differnew file mode 100644 index 000000000..a30aa5c0c --- /dev/null +++ b/java/com/android/incallui/audioroute/res/drawable-xhdpi/ic_phone_audio_grey600_24dp.png diff --git a/java/com/android/incallui/audioroute/res/drawable-xxhdpi/ic_phone_audio_grey600_24dp.png b/java/com/android/incallui/audioroute/res/drawable-xxhdpi/ic_phone_audio_grey600_24dp.png Binary files differnew file mode 100644 index 000000000..beb85a80a --- /dev/null +++ b/java/com/android/incallui/audioroute/res/drawable-xxhdpi/ic_phone_audio_grey600_24dp.png diff --git a/java/com/android/incallui/audioroute/res/layout/audioroute_selector.xml b/java/com/android/incallui/audioroute/res/layout/audioroute_selector.xml new file mode 100644 index 000000000..ef2220e8f --- /dev/null +++ b/java/com/android/incallui/audioroute/res/layout/audioroute_selector.xml @@ -0,0 +1,37 @@ +<?xml version="1.0" encoding="utf-8"?> +<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="vertical" + tools:layout_gravity="bottom"> + <TextView + android:id="@+id/audioroute_bluetooth" + style="@style/AudioRouteItem" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:drawableStart="@drawable/quantum_ic_bluetooth_audio_grey600_24" + android:text="@string/audioroute_bluetooth"/> + <TextView + android:id="@+id/audioroute_speaker" + style="@style/AudioRouteItem" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:drawableStart="@drawable/quantum_ic_volume_up_grey600_24" + android:text="@string/audioroute_speaker"/> + <TextView + android:id="@+id/audioroute_earpiece" + style="@style/AudioRouteItem" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:drawableStart="@drawable/ic_phone_audio_grey600_24dp" + android:text="@string/audioroute_phone"/> + <TextView + android:id="@+id/audioroute_headset" + style="@style/AudioRouteItem" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:drawableStart="@drawable/quantum_ic_headset_grey600_24" + android:text="@string/audioroute_headset"/> + +</LinearLayout> diff --git a/java/com/android/incallui/audioroute/res/values/strings.xml b/java/com/android/incallui/audioroute/res/values/strings.xml new file mode 100644 index 000000000..b16639354 --- /dev/null +++ b/java/com/android/incallui/audioroute/res/values/strings.xml @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <string name="audioroute_bluetooth">Bluetooth</string> + <string name="audioroute_speaker">Speaker</string> + <string name="audioroute_phone">Phone</string> + <string name="audioroute_headset">Wired headset</string> +</resources> diff --git a/java/com/android/incallui/audioroute/res/values/styles.xml b/java/com/android/incallui/audioroute/res/values/styles.xml new file mode 100644 index 000000000..4484b7092 --- /dev/null +++ b/java/com/android/incallui/audioroute/res/values/styles.xml @@ -0,0 +1,14 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + + <style name="AudioRouteItem"> + <item name="android:padding">16dp</item> + <item name="android:background">?android:selectableItemBackground</item> + <item name="android:drawablePadding">24dp</item> + <item name="android:gravity">center_vertical</item> + <item name="android:textAppearance"> + @style/TextAppearance.AppCompat.Light.Widget.PopupMenu.Large + </item> + <item name="android:textColor">?android:textColorSecondary</item> + </style> +</resources> diff --git a/java/com/android/incallui/autoresizetext/AndroidManifest.xml b/java/com/android/incallui/autoresizetext/AndroidManifest.xml new file mode 100644 index 000000000..53a8961e4 --- /dev/null +++ b/java/com/android/incallui/autoresizetext/AndroidManifest.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 + --> +<manifest xmlns:android="http://schemas.android.com/apk/res/android" + package="com.android.incallui.autoresizetext"> + + <uses-sdk + android:minSdkVersion="23" + android:targetSdkVersion="25"/> + + <application /> +</manifest> diff --git a/java/com/android/incallui/autoresizetext/AutoResizeTextView.java b/java/com/android/incallui/autoresizetext/AutoResizeTextView.java new file mode 100644 index 000000000..eedcbe5bb --- /dev/null +++ b/java/com/android/incallui/autoresizetext/AutoResizeTextView.java @@ -0,0 +1,316 @@ +/* + * 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.autoresizetext; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.RectF; +import android.os.Build.VERSION; +import android.os.Build.VERSION_CODES; +import android.text.Layout.Alignment; +import android.text.StaticLayout; +import android.text.TextPaint; +import android.util.AttributeSet; +import android.util.DisplayMetrics; +import android.util.SparseIntArray; +import android.util.TypedValue; +import android.widget.TextView; +import javax.annotation.Nullable; + +/** + * A TextView that automatically scales its text to completely fill its allotted width. + * + * <p>Note: In some edge cases, the binary search algorithm to find the best fit may slightly + * overshoot / undershoot its constraints. See b/26704434. No minimal repro case has been + * found yet. A known workaround is the solution provided on StackOverflow: + * http://stackoverflow.com/a/5535672 + */ +public class AutoResizeTextView extends TextView { + private static final int NO_LINE_LIMIT = -1; + private static final float DEFAULT_MIN_TEXT_SIZE = 16.0f; + private static final int DEFAULT_RESIZE_STEP_UNIT = TypedValue.COMPLEX_UNIT_PX; + + private final DisplayMetrics displayMetrics = getResources().getDisplayMetrics(); + private final RectF availableSpaceRect = new RectF(); + private final SparseIntArray textSizesCache = new SparseIntArray(); + private final TextPaint textPaint = new TextPaint(); + private int resizeStepUnit = DEFAULT_RESIZE_STEP_UNIT; + private float minTextSize = DEFAULT_MIN_TEXT_SIZE; + private float maxTextSize; + private int maxWidth; + private int maxLines; + private float lineSpacingMultiplier = 1.0f; + private float lineSpacingExtra = 0.0f; + + public AutoResizeTextView(Context context) { + super(context, null, 0); + initialize(context, null, 0, 0); + } + + public AutoResizeTextView(Context context, AttributeSet attrs) { + super(context, attrs, 0); + initialize(context, attrs, 0, 0); + } + + public AutoResizeTextView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + initialize(context, attrs, defStyleAttr, 0); + } + + public AutoResizeTextView( + Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + initialize(context, attrs, defStyleAttr, defStyleRes); + } + + private void initialize( + Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) { + TypedArray typedArray = context.getTheme().obtainStyledAttributes( + attrs, R.styleable.AutoResizeTextView, defStyleAttr, defStyleRes); + readAttrs(typedArray); + textPaint.set(getPaint()); + } + + /** Overridden because getMaxLines is only defined in JB+. */ + @Override + public final int getMaxLines() { + if (VERSION.SDK_INT >= VERSION_CODES.JELLY_BEAN) { + return super.getMaxLines(); + } else { + return maxLines; + } + } + + /** Overridden because getMaxLines is only defined in JB+. */ + @Override + public final void setMaxLines(int maxLines) { + super.setMaxLines(maxLines); + this.maxLines = maxLines; + } + + /** Overridden because getLineSpacingMultiplier is only defined in JB+. */ + @Override + public final float getLineSpacingMultiplier() { + if (VERSION.SDK_INT >= VERSION_CODES.JELLY_BEAN) { + return super.getLineSpacingMultiplier(); + } else { + return lineSpacingMultiplier; + } + } + + /** Overridden because getLineSpacingExtra is only defined in JB+. */ + @Override + public final float getLineSpacingExtra() { + if (VERSION.SDK_INT >= VERSION_CODES.JELLY_BEAN) { + return super.getLineSpacingExtra(); + } else { + return lineSpacingExtra; + } + } + + /** + * Overridden because getLineSpacingMultiplier and getLineSpacingExtra are only defined in JB+. + */ + @Override + public final void setLineSpacing(float add, float mult) { + super.setLineSpacing(add, mult); + lineSpacingMultiplier = mult; + lineSpacingExtra = add; + } + + /** + * Although this overrides the setTextSize method from the TextView base class, it changes the + * semantics a bit: Calling setTextSize now specifies the maximum text size to be used by this + * view. If the text can't fit with that text size, the text size will be scaled down, up to the + * minimum text size specified in {@link #setMinTextSize}. + * + * <p>Note that the final size unit will be truncated to the nearest integer value of the + * specified unit. + */ + @Override + public final void setTextSize(int unit, float size) { + float maxTextSize = TypedValue.applyDimension(unit, size, displayMetrics); + if (this.maxTextSize != maxTextSize) { + this.maxTextSize = maxTextSize; + // TODO: It's not actually necessary to clear the whole cache here. To optimize cache + // deletion we'd have to delete all entries in the cache with a value equal or larger than + // MIN(old_max_size, new_max_size) when changing maxTextSize; and all entries with a value + // equal or smaller than MAX(old_min_size, new_min_size) when changing minTextSize. + textSizesCache.clear(); + requestLayout(); + } + } + + /** + * Sets the lower text size limit and invalidate the view. + * + * <p>The parameters follow the same behavior as they do in {@link #setTextSize}. + * + * <p>Note that the final size unit will be truncated to the nearest integer value of the + * specified unit. + */ + public final void setMinTextSize(int unit, float size) { + float minTextSize = TypedValue.applyDimension(unit, size, displayMetrics); + if (this.minTextSize != minTextSize) { + this.minTextSize = minTextSize; + textSizesCache.clear(); + requestLayout(); + } + } + + /** + * Sets the unit to use as step units when computing the resized font size. This view's text + * contents will always be rendered as a whole integer value in the unit specified here. For + * example, if the unit is {@link TypedValue#COMPLEX_UNIT_SP}, then the text size may end up + * being 13sp or 14sp, but never 13.5sp. + * + * <p>By default, the AutoResizeTextView uses the unit {@link TypedValue#COMPLEX_UNIT_PX}. + * + * @param unit the unit type to use; must be a known unit type from {@link TypedValue}. + */ + public final void setResizeStepUnit(int unit) { + if (resizeStepUnit != unit) { + resizeStepUnit = unit; + requestLayout(); + } + } + + private void readAttrs(TypedArray typedArray) { + resizeStepUnit = typedArray.getInt( + R.styleable.AutoResizeTextView_autoResizeText_resizeStepUnit, DEFAULT_RESIZE_STEP_UNIT); + minTextSize = (int) typedArray.getDimension( + R.styleable.AutoResizeTextView_autoResizeText_minTextSize, DEFAULT_MIN_TEXT_SIZE); + maxTextSize = (int) getTextSize(); + } + + private void adjustTextSize() { + int maxWidth = getMeasuredWidth() - getPaddingLeft() - getPaddingRight(); + int maxHeight = getMeasuredHeight() - getPaddingBottom() - getPaddingTop(); + + if (maxWidth <= 0 || maxHeight <= 0) { + return; + } + + this.maxWidth = maxWidth; + availableSpaceRect.right = maxWidth; + availableSpaceRect.bottom = maxHeight; + int minSizeInStepSizeUnits = (int) Math.ceil(convertToResizeStepUnits(minTextSize)); + int maxSizeInStepSizeUnits = (int) Math.floor(convertToResizeStepUnits(maxTextSize)); + float textSize = computeTextSize( + minSizeInStepSizeUnits, maxSizeInStepSizeUnits, availableSpaceRect); + super.setTextSize(resizeStepUnit, textSize); + } + + private boolean suggestedSizeFitsInSpace(float suggestedSizeInPx, RectF availableSpace) { + textPaint.setTextSize(suggestedSizeInPx); + String text = getText().toString(); + int maxLines = getMaxLines(); + if (maxLines == 1) { + // If single line, check the line's height and width. + return textPaint.getFontSpacing() <= availableSpace.bottom + && textPaint.measureText(text) <= availableSpace.right; + } else { + // If multiline, lay the text out, then check the number of lines, the layout's height, + // and each line's width. + StaticLayout layout = new StaticLayout(text, + textPaint, + maxWidth, + Alignment.ALIGN_NORMAL, + getLineSpacingMultiplier(), + getLineSpacingExtra(), + true); + + // Return false if we need more than maxLines. The text is obviously too big in this case. + if (maxLines != NO_LINE_LIMIT && layout.getLineCount() > maxLines) { + return false; + } + // Return false if the height of the layout is too big. + return layout.getHeight() <= availableSpace.bottom; + } + } + + /** + * Computes the final text size to use for this text view, factoring in any previously + * cached computations. + * + * @param minSize the minimum text size to allow, in units of {@link #resizeStepUnit} + * @param maxSize the maximum text size to allow, in units of {@link #resizeStepUnit} + */ + private float computeTextSize(int minSize, int maxSize, RectF availableSpace) { + CharSequence text = getText(); + if (text != null && textSizesCache.get(text.hashCode()) != 0) { + return textSizesCache.get(text.hashCode()); + } + int size = binarySearchSizes(minSize, maxSize, availableSpace); + textSizesCache.put(text == null ? 0 : text.hashCode(), size); + return size; + } + + /** + * Performs a binary search to find the largest font size that will still fit within the size + * available to this view. + * @param minSize the minimum text size to allow, in units of {@link #resizeStepUnit} + * @param maxSize the maximum text size to allow, in units of {@link #resizeStepUnit} + */ + private int binarySearchSizes(int minSize, int maxSize, RectF availableSpace) { + int bestSize = minSize; + int low = minSize + 1; + int high = maxSize; + int sizeToTry; + while (low <= high) { + sizeToTry = (low + high) / 2; + float dimension = TypedValue.applyDimension(resizeStepUnit, sizeToTry, displayMetrics); + if (suggestedSizeFitsInSpace(dimension, availableSpace)) { + bestSize = low; + low = sizeToTry + 1; + } else { + high = sizeToTry - 1; + bestSize = high; + } + } + return bestSize; + } + + private float convertToResizeStepUnits(float dimension) { + // To figure out the multiplier between a raw dimension and the resizeStepUnit, we invert the + // conversion of 1 resizeStepUnit to a raw dimension. + float multiplier = 1 / TypedValue.applyDimension(resizeStepUnit, 1, displayMetrics); + return dimension * multiplier; + } + + @Override + protected final void onTextChanged( + final CharSequence text, final int start, final int before, final int after) { + super.onTextChanged(text, start, before, after); + adjustTextSize(); + } + + @Override + protected final void onSizeChanged(int width, int height, int oldWidth, int oldHeight) { + super.onSizeChanged(width, height, oldWidth, oldHeight); + if (width != oldWidth || height != oldHeight) { + textSizesCache.clear(); + adjustTextSize(); + } + } + + @Override + protected final void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + adjustTextSize(); + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + } +} diff --git a/java/com/android/incallui/autoresizetext/res/values/attrs.xml b/java/com/android/incallui/autoresizetext/res/values/attrs.xml new file mode 100644 index 000000000..e62feb9c8 --- /dev/null +++ b/java/com/android/incallui/autoresizetext/res/values/attrs.xml @@ -0,0 +1,47 @@ +<?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> + <declare-styleable name="AutoResizeTextView"> + <!-- + The unit to use when computing step increments for the resize operation. That is, the + resized text will be guaranteed to be a whole number (integer) value in the unit + specified. For example, if the unit is scaled pixels (sp), then the font size might be + 13sp or 14sp, but not 13.5sp. + + The enum values must match the values from android.util.TypedValue. + --> + <attr name="autoResizeText_resizeStepUnit" format="enum"> + <!-- Must match TypedValue.COMPLEX_UNIT_PX. --> + <enum name="unitPx" value="0" /> + <!-- Must match TypedValue.COMPLEX_UNIT_DIP. --> + <enum name="unitDip" value="1" /> + <!-- Must match TypedValue.COMPLEX_UNIT_SP. --> + <enum name="unitSp" value="2" /> + <!-- Must match TypedValue.COMPLEX_UNIT_PT. --> + <enum name="unitPt" value="3" /> + <!-- Must match TypedValue.COMPLEX_UNIT_IN. --> + <enum name="unitIn" value="4" /> + <!-- Must match TypedValue.COMPLEX_UNIT_MM. --> + <enum name="unitMm" value="5" /> + </attr> + <!-- + The minimum text size to use in this view. Text size will be scale down to fit the text + in this view, but no smaller than the minimum size specified in this attribute. + --> + <attr name="autoResizeText_minTextSize" format="dimension" /> + </declare-styleable> +</resources> diff --git a/java/com/android/incallui/baseui/BaseFragment.java b/java/com/android/incallui/baseui/BaseFragment.java new file mode 100644 index 000000000..58b8c6f8d --- /dev/null +++ b/java/com/android/incallui/baseui/BaseFragment.java @@ -0,0 +1,75 @@ +/* + * Copyright (C) 2013 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.baseui; + +import android.os.Bundle; +import android.support.v4.app.Fragment; + +/** Parent for all fragments that use Presenters and Ui design. */ +public abstract class BaseFragment<T extends Presenter<U>, U extends Ui> extends Fragment { + + private static final String KEY_FRAGMENT_HIDDEN = "key_fragment_hidden"; + + private T mPresenter; + + protected BaseFragment() { + mPresenter = createPresenter(); + } + + public abstract T createPresenter(); + + public abstract U getUi(); + + /** + * Presenter will be available after onActivityCreated(). + * + * @return The presenter associated with this fragment. + */ + public T getPresenter() { + return mPresenter; + } + + @Override + public void onActivityCreated(Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + mPresenter.onUiReady(getUi()); + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + if (savedInstanceState != null) { + mPresenter.onRestoreInstanceState(savedInstanceState); + if (savedInstanceState.getBoolean(KEY_FRAGMENT_HIDDEN)) { + getFragmentManager().beginTransaction().hide(this).commit(); + } + } + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + mPresenter.onUiDestroy(getUi()); + } + + @Override + public void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + mPresenter.onSaveInstanceState(outState); + outState.putBoolean(KEY_FRAGMENT_HIDDEN, isHidden()); + } +} diff --git a/java/com/android/incallui/baseui/Presenter.java b/java/com/android/incallui/baseui/Presenter.java new file mode 100644 index 000000000..581ad47c7 --- /dev/null +++ b/java/com/android/incallui/baseui/Presenter.java @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2013 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.baseui; + +import android.os.Bundle; + +/** Base class for Presenters. */ +public abstract class Presenter<U extends Ui> { + + private U mUi; + + /** + * Called after the UI view has been created. That is when fragment.onViewCreated() is called. + * + * @param ui The Ui implementation that is now ready to be used. + */ + public void onUiReady(U ui) { + mUi = ui; + } + + /** Called when the UI view is destroyed in Fragment.onDestroyView(). */ + public final void onUiDestroy(U ui) { + onUiUnready(ui); + mUi = null; + } + + /** + * To be overriden by Presenter implementations. Called when the fragment is being destroyed but + * before ui is set to null. + */ + public void onUiUnready(U ui) {} + + public void onSaveInstanceState(Bundle outState) {} + + public void onRestoreInstanceState(Bundle savedInstanceState) {} + + public U getUi() { + return mUi; + } +} diff --git a/java/com/android/incallui/baseui/Ui.java b/java/com/android/incallui/baseui/Ui.java new file mode 100644 index 000000000..439e41550 --- /dev/null +++ b/java/com/android/incallui/baseui/Ui.java @@ -0,0 +1,20 @@ +/* + * Copyright (C) 2013 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.baseui; + +/** Base class for all presenter ui. */ +public interface Ui {} diff --git a/java/com/android/incallui/bindings/ContactUtils.java b/java/com/android/incallui/bindings/ContactUtils.java new file mode 100644 index 000000000..d2d365d81 --- /dev/null +++ b/java/com/android/incallui/bindings/ContactUtils.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.bindings; + +import android.location.Address; +import android.util.Pair; +import java.util.Calendar; +import java.util.List; + +/** Utility functions to help manipulate contact data. */ +public interface ContactUtils { + + boolean retrieveContactInteractionsFromLookupKey(String lookupKey, Listener listener); + + interface Listener { + + void onContactInteractionsFound(Address address, List<Pair<Calendar, Calendar>> openingHours); + } +} diff --git a/java/com/android/incallui/bindings/DistanceHelper.java b/java/com/android/incallui/bindings/DistanceHelper.java new file mode 100644 index 000000000..6b2200dca --- /dev/null +++ b/java/com/android/incallui/bindings/DistanceHelper.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.bindings; + +import android.location.Address; + +/** Superclass for a helper class to get the current location and distance to other locations. */ +public interface DistanceHelper { + + float DISTANCE_NOT_FOUND = -1; + float MILES_PER_METER = (float) 0.000621371192; + float KILOMETERS_PER_METER = (float) 0.001; + + void cleanUp(); + + float calculateDistance(Address address); + + interface Listener { + + void onLocationReady(); + } +} diff --git a/java/com/android/incallui/bindings/InCallUiBindings.java b/java/com/android/incallui/bindings/InCallUiBindings.java new file mode 100644 index 000000000..d3d3a8b37 --- /dev/null +++ b/java/com/android/incallui/bindings/InCallUiBindings.java @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.incallui.bindings; + +import android.content.Context; +import android.content.Intent; +import android.support.annotation.Nullable; +import com.android.dialer.common.ConfigProvider; + +/** This interface allows the container application to customize the in call UI. */ +public interface InCallUiBindings { + + @Nullable + PhoneNumberService newPhoneNumberService(Context context); + + /** @return An {@link Intent} to be broadcast when the InCallUI is visible. */ + @Nullable + Intent getUiReadyBroadcastIntent(Context context); + + /** + * @return An {@link Intent} to be broadcast when the call state button in the InCallUI is touched + * while in a call. + */ + @Nullable + Intent getCallStateButtonBroadcastIntent(Context context); + + @Nullable + DistanceHelper newDistanceHelper(Context context, DistanceHelper.Listener listener); + + @Nullable + ContactUtils getContactUtilsInstance(Context context); + + ConfigProvider getConfigProvider(); +} diff --git a/java/com/android/incallui/bindings/InCallUiBindingsFactory.java b/java/com/android/incallui/bindings/InCallUiBindingsFactory.java new file mode 100644 index 000000000..57c186d90 --- /dev/null +++ b/java/com/android/incallui/bindings/InCallUiBindingsFactory.java @@ -0,0 +1,26 @@ +/* + * 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.bindings; + +/** + * This interface should be implementated by the Application subclass. It allows the in call UI + * module to get references to the InCallUiBindings. + */ +public interface InCallUiBindingsFactory { + + InCallUiBindings newInCallUiBindings(); +} diff --git a/java/com/android/incallui/bindings/InCallUiBindingsStub.java b/java/com/android/incallui/bindings/InCallUiBindingsStub.java new file mode 100644 index 000000000..7b42fb375 --- /dev/null +++ b/java/com/android/incallui/bindings/InCallUiBindingsStub.java @@ -0,0 +1,81 @@ +/* + * 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.bindings; + +import android.content.Context; +import android.content.Intent; +import android.support.annotation.Nullable; +import com.android.dialer.common.ConfigProvider; + +/** Default implementation for InCallUi bindings. */ +public class InCallUiBindingsStub implements InCallUiBindings { + private ConfigProvider configProvider; + + @Override + @Nullable + public PhoneNumberService newPhoneNumberService(Context context) { + return null; + } + + @Override + @Nullable + public Intent getUiReadyBroadcastIntent(Context context) { + return null; + } + + @Override + @Nullable + public Intent getCallStateButtonBroadcastIntent(Context context) { + return null; + } + + @Override + @Nullable + public DistanceHelper newDistanceHelper(Context context, DistanceHelper.Listener listener) { + return null; + } + + @Override + @Nullable + public ContactUtils getContactUtilsInstance(Context context) { + return null; + } + + @Override + public ConfigProvider getConfigProvider() { + if (configProvider == null) { + configProvider = + new ConfigProvider() { + @Override + public String getString(String key, String defaultValue) { + return defaultValue; + } + + @Override + public long getLong(String key, long defaultValue) { + return defaultValue; + } + + @Override + public boolean getBoolean(String key, boolean defaultValue) { + return defaultValue; + } + }; + } + return configProvider; + } +} diff --git a/java/com/android/incallui/bindings/PhoneNumberService.java b/java/com/android/incallui/bindings/PhoneNumberService.java new file mode 100644 index 000000000..bd2741a1d --- /dev/null +++ b/java/com/android/incallui/bindings/PhoneNumberService.java @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2013 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.bindings; + +import android.graphics.Bitmap; + +/** Provides phone number lookup services. */ +public interface PhoneNumberService { + + /** + * Get a phone number number asynchronously. + * + * @param phoneNumber The phone number to lookup. + * @param listener The listener to notify when the phone number lookup is complete. + * @param imageListener The listener to notify when the image lookup is complete. + */ + void getPhoneNumberInfo( + String phoneNumber, + NumberLookupListener listener, + ImageLookupListener imageListener, + boolean isIncoming); + + interface NumberLookupListener { + + /** + * Callback when a phone number has been looked up. + * + * @param info The looked up information. Or (@literal null} if there are no results. + */ + void onPhoneNumberInfoComplete(PhoneNumberInfo info); + } + + interface ImageLookupListener { + + /** + * Callback when a image has been fetched. + * + * @param bitmap The fetched image. + */ + void onImageFetchComplete(Bitmap bitmap); + } + + interface PhoneNumberInfo { + + String getDisplayName(); + + String getNumber(); + + int getPhoneType(); + + String getPhoneLabel(); + + String getNormalizedNumber(); + + String getImageUrl(); + + String getLookupKey(); + + boolean isBusiness(); + + int getLookupSource(); + } +} diff --git a/java/com/android/incallui/call/CallList.java b/java/com/android/incallui/call/CallList.java new file mode 100644 index 000000000..862c71cf9 --- /dev/null +++ b/java/com/android/incallui/call/CallList.java @@ -0,0 +1,763 @@ +/* + * 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.call; + +import android.content.Context; +import android.os.Handler; +import android.os.Message; +import android.os.Trace; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.annotation.VisibleForTesting; +import android.support.v4.os.BuildCompat; +import android.telecom.Call; +import android.telecom.DisconnectCause; +import android.telecom.PhoneAccount; +import android.util.ArrayMap; +import com.android.contacts.common.GeoUtil; +import com.android.dialer.blocking.FilteredNumberAsyncQueryHandler; +import com.android.dialer.blocking.FilteredNumbersUtil; +import com.android.dialer.common.Assert; +import com.android.dialer.common.LogUtil; +import com.android.dialer.logging.Logger; +import com.android.dialer.logging.nano.DialerImpression; +import com.android.dialer.shortcuts.ShortcutUsageReporter; +import com.android.dialer.spam.Spam; +import com.android.dialer.spam.SpamBindings; +import com.android.incallui.call.DialerCall.SessionModificationState; +import com.android.incallui.call.DialerCall.State; +import com.android.incallui.latencyreport.LatencyReport; +import com.android.incallui.util.TelecomCallUtil; +import java.util.Collections; +import java.util.Iterator; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Maintains the list of active calls and notifies interested classes of changes to the call list as + * they are received from the telephony stack. Primary listener of changes to this class is + * InCallPresenter. + */ +public class CallList implements DialerCallDelegate { + + private static final int DISCONNECTED_CALL_SHORT_TIMEOUT_MS = 200; + private static final int DISCONNECTED_CALL_MEDIUM_TIMEOUT_MS = 2000; + private static final int DISCONNECTED_CALL_LONG_TIMEOUT_MS = 5000; + + private static final int EVENT_DISCONNECTED_TIMEOUT = 1; + + private static CallList sInstance = new CallList(); + + private final Map<String, DialerCall> mCallById = new ArrayMap<>(); + private final Map<android.telecom.Call, DialerCall> mCallByTelecomCall = new ArrayMap<>(); + + /** + * ConcurrentHashMap constructor params: 8 is initial table size, 0.9f is load factor before + * resizing, 1 means we only expect a single thread to access the map so make only a single shard + */ + private final Set<Listener> mListeners = + Collections.newSetFromMap(new ConcurrentHashMap<Listener, Boolean>(8, 0.9f, 1)); + + private final Set<DialerCall> mPendingDisconnectCalls = + Collections.newSetFromMap(new ConcurrentHashMap<DialerCall, Boolean>(8, 0.9f, 1)); + /** Handles the timeout for destroying disconnected calls. */ + private final Handler mHandler = + new Handler() { + @Override + public void handleMessage(Message msg) { + switch (msg.what) { + case EVENT_DISCONNECTED_TIMEOUT: + LogUtil.d("CallList.handleMessage", "EVENT_DISCONNECTED_TIMEOUT ", msg.obj); + finishDisconnectedCall((DialerCall) msg.obj); + break; + default: + LogUtil.e("CallList.handleMessage", "Message not expected: " + msg.what); + break; + } + } + }; + + /** + * USED ONLY FOR TESTING Testing-only constructor. Instance should only be acquired through + * getInstance(). + */ + @VisibleForTesting + public CallList() {} + + /** Static singleton accessor method. */ + public static CallList getInstance() { + return sInstance; + } + + public void onCallAdded( + final Context context, final android.telecom.Call telecomCall, LatencyReport latencyReport) { + Trace.beginSection("onCallAdded"); + final DialerCall call = + new DialerCall(context, this, telecomCall, latencyReport, true /* registerCallback */); + final DialerCallListenerImpl dialerCallListener = new DialerCallListenerImpl(call); + call.addListener(dialerCallListener); + LogUtil.d("CallList.onCallAdded", "callState=" + call.getState()); + if (Spam.get(context).isSpamEnabled()) { + String number = TelecomCallUtil.getNumber(telecomCall); + Spam.get(context) + .checkSpamStatus( + number, + null, + new SpamBindings.Listener() { + @Override + public void onComplete(boolean isSpam) { + if (isSpam) { + if (call.getState() != DialerCall.State.INCOMING + && call.getState() != DialerCall.State.CALL_WAITING) { + LogUtil.i( + "CallList.onCallAdded", + "marking spam call as not spam because it's not an incoming call"); + isSpam = false; + } else if (isPotentialEmergencyCallback(context, call)) { + LogUtil.i( + "CallList.onCallAdded", + "marking spam call as not spam because an emergency call was made on this" + + " device recently"); + isSpam = false; + } + } + + Logger.get(context) + .logCallImpression( + isSpam + ? DialerImpression.Type.INCOMING_SPAM_CALL + : DialerImpression.Type.INCOMING_NON_SPAM_CALL, + call.getUniqueCallId(), + call.getTimeAddedMs()); + call.setSpam(isSpam); + dialerCallListener.onDialerCallUpdate(); + } + }); + + updateUserMarkedSpamStatus(call, context, number, dialerCallListener); + } + + FilteredNumberAsyncQueryHandler filteredNumberAsyncQueryHandler = + new FilteredNumberAsyncQueryHandler(context); + + filteredNumberAsyncQueryHandler.isBlockedNumber( + new FilteredNumberAsyncQueryHandler.OnCheckBlockedListener() { + @Override + public void onCheckComplete(Integer id) { + if (id != null && id != FilteredNumberAsyncQueryHandler.INVALID_ID) { + call.setBlockedStatus(true); + dialerCallListener.onDialerCallUpdate(); + } + } + }, + call.getNumber(), + GeoUtil.getCurrentCountryIso(context)); + + if (call.getState() == DialerCall.State.INCOMING + || call.getState() == DialerCall.State.CALL_WAITING) { + onIncoming(call); + } else { + dialerCallListener.onDialerCallUpdate(); + } + + if (call.getState() != State.INCOMING) { + // Only report outgoing calls + ShortcutUsageReporter.onOutgoingCallAdded(context, call.getNumber()); + } + + Trace.endSection(); + } + + private static boolean isPotentialEmergencyCallback(Context context, DialerCall call) { + if (BuildCompat.isAtLeastO()) { + return call.isPotentialEmergencyCallback(); + } else { + long timestampMillis = FilteredNumbersUtil.getLastEmergencyCallTimeMillis(context); + return call.isInEmergencyCallbackWindow(timestampMillis); + } + } + + @Override + public DialerCall getDialerCallFromTelecomCall(Call telecomCall) { + return mCallByTelecomCall.get(telecomCall); + } + + public void updateUserMarkedSpamStatus( + final DialerCall call, + final Context context, + String number, + final DialerCallListenerImpl dialerCallListener) { + + Spam.get(context) + .checkUserMarkedNonSpamStatus( + number, + null, + new SpamBindings.Listener() { + @Override + public void onComplete(boolean isInUserWhiteList) { + call.setIsInUserWhiteList(isInUserWhiteList); + } + }); + + Spam.get(context) + .checkGlobalSpamListStatus( + number, + null, + new SpamBindings.Listener() { + @Override + public void onComplete(boolean isInGlobalSpamList) { + call.setIsInGlobalSpamList(isInGlobalSpamList); + } + }); + + Spam.get(context) + .checkUserMarkedSpamStatus( + number, + null, + new SpamBindings.Listener() { + @Override + public void onComplete(boolean isInUserSpamList) { + call.setIsInUserSpamList(isInUserSpamList); + } + }); + } + + public void onCallRemoved(Context context, android.telecom.Call telecomCall) { + if (mCallByTelecomCall.containsKey(telecomCall)) { + DialerCall call = mCallByTelecomCall.get(telecomCall); + Assert.checkArgument(!call.isExternalCall()); + + // Don't log an already logged call. logCall() might be called multiple times + // for the same call due to b/24109437. + if (call.getLogState() != null && !call.getLogState().isLogged) { + getLegacyBindings(context).logCall(call); + call.getLogState().isLogged = true; + } + + if (updateCallInMap(call)) { + LogUtil.w( + "CallList.onCallRemoved", "Removing call not previously disconnected " + call.getId()); + } + } + } + + InCallUiLegacyBindings getLegacyBindings(Context context) { + Objects.requireNonNull(context); + + Context application = context.getApplicationContext(); + InCallUiLegacyBindings legacyInstance = null; + if (application instanceof InCallUiLegacyBindingsFactory) { + legacyInstance = ((InCallUiLegacyBindingsFactory) application).newInCallUiLegacyBindings(); + } + + if (legacyInstance == null) { + legacyInstance = new InCallUiLegacyBindingsStub(); + } + return legacyInstance; + } + + /** + * Handles the case where an internal call has become an exteral call. We need to + * + * @param context + * @param telecomCall + */ + public void onInternalCallMadeExternal(Context context, android.telecom.Call telecomCall) { + + if (mCallByTelecomCall.containsKey(telecomCall)) { + DialerCall call = mCallByTelecomCall.get(telecomCall); + + // Don't log an already logged call. logCall() might be called multiple times + // for the same call due to b/24109437. + if (call.getLogState() != null && !call.getLogState().isLogged) { + getLegacyBindings(context).logCall(call); + call.getLogState().isLogged = true; + } + + // When removing a call from the call list because it became an external call, we need to + // ensure the callback is unregistered -- this is normally only done when calls disconnect. + // However, the call won't be disconnected in this case. Also, logic in updateCallInMap + // would just re-add the call anyways. + call.unregisterCallback(); + mCallById.remove(call.getId()); + mCallByTelecomCall.remove(telecomCall); + } + } + + /** Called when a single call has changed. */ + private void onIncoming(DialerCall call) { + if (updateCallInMap(call)) { + LogUtil.i("CallList.onIncoming", String.valueOf(call)); + } + + for (Listener listener : mListeners) { + listener.onIncomingCall(call); + } + } + + public void addListener(@NonNull Listener listener) { + Objects.requireNonNull(listener); + + mListeners.add(listener); + + // Let the listener know about the active calls immediately. + listener.onCallListChange(this); + } + + public void removeListener(@Nullable Listener listener) { + if (listener != null) { + mListeners.remove(listener); + } + } + + /** + * TODO: Change so that this function is not needed. Instead of assuming there is an active call, + * the code should rely on the status of a specific DialerCall and allow the presenters to update + * the DialerCall object when the active call changes. + */ + public DialerCall getIncomingOrActive() { + DialerCall retval = getIncomingCall(); + if (retval == null) { + retval = getActiveCall(); + } + return retval; + } + + public DialerCall getOutgoingOrActive() { + DialerCall retval = getOutgoingCall(); + if (retval == null) { + retval = getActiveCall(); + } + return retval; + } + + /** A call that is waiting for {@link PhoneAccount} selection */ + public DialerCall getWaitingForAccountCall() { + return getFirstCallWithState(DialerCall.State.SELECT_PHONE_ACCOUNT); + } + + public DialerCall getPendingOutgoingCall() { + return getFirstCallWithState(DialerCall.State.CONNECTING); + } + + public DialerCall getOutgoingCall() { + DialerCall call = getFirstCallWithState(DialerCall.State.DIALING); + if (call == null) { + call = getFirstCallWithState(DialerCall.State.REDIALING); + } + if (call == null) { + call = getFirstCallWithState(DialerCall.State.PULLING); + } + return call; + } + + public DialerCall getActiveCall() { + return getFirstCallWithState(DialerCall.State.ACTIVE); + } + + public DialerCall getSecondActiveCall() { + return getCallWithState(DialerCall.State.ACTIVE, 1); + } + + public DialerCall getBackgroundCall() { + return getFirstCallWithState(DialerCall.State.ONHOLD); + } + + public DialerCall getDisconnectedCall() { + return getFirstCallWithState(DialerCall.State.DISCONNECTED); + } + + public DialerCall getDisconnectingCall() { + return getFirstCallWithState(DialerCall.State.DISCONNECTING); + } + + public DialerCall getSecondBackgroundCall() { + return getCallWithState(DialerCall.State.ONHOLD, 1); + } + + public DialerCall getActiveOrBackgroundCall() { + DialerCall call = getActiveCall(); + if (call == null) { + call = getBackgroundCall(); + } + return call; + } + + public DialerCall getIncomingCall() { + DialerCall call = getFirstCallWithState(DialerCall.State.INCOMING); + if (call == null) { + call = getFirstCallWithState(DialerCall.State.CALL_WAITING); + } + + return call; + } + + public DialerCall getFirstCall() { + DialerCall result = getIncomingCall(); + if (result == null) { + result = getPendingOutgoingCall(); + } + if (result == null) { + result = getOutgoingCall(); + } + if (result == null) { + result = getFirstCallWithState(DialerCall.State.ACTIVE); + } + if (result == null) { + result = getDisconnectingCall(); + } + if (result == null) { + result = getDisconnectedCall(); + } + return result; + } + + public boolean hasLiveCall() { + DialerCall call = getFirstCall(); + return call != null && call != getDisconnectingCall() && call != getDisconnectedCall(); + } + + /** + * Returns the first call found in the call map with the upgrade to video modification state. + * + * @return The first call with the upgrade to video state. + */ + public DialerCall getVideoUpgradeRequestCall() { + for (DialerCall call : mCallById.values()) { + if (call.getSessionModificationState() + == DialerCall.SESSION_MODIFICATION_STATE_RECEIVED_UPGRADE_TO_VIDEO_REQUEST) { + return call; + } + } + return null; + } + + public DialerCall getCallById(String callId) { + return mCallById.get(callId); + } + + /** Returns first call found in the call map with the specified state. */ + public DialerCall getFirstCallWithState(int state) { + return getCallWithState(state, 0); + } + + /** + * Returns the [position]th call found in the call map with the specified state. TODO: Improve + * this logic to sort by call time. + */ + public DialerCall getCallWithState(int state, int positionToFind) { + DialerCall retval = null; + int position = 0; + for (DialerCall call : mCallById.values()) { + if (call.getState() == state) { + if (position >= positionToFind) { + retval = call; + break; + } else { + position++; + } + } + } + + return retval; + } + + /** + * This is called when the service disconnects, either expectedly or unexpectedly. For the + * expected case, it's because we have no calls left. For the unexpected case, it is likely a + * crash of phone and we need to clean up our calls manually. Without phone, there can be no + * active calls, so this is relatively safe thing to do. + */ + public void clearOnDisconnect() { + for (DialerCall call : mCallById.values()) { + final int state = call.getState(); + if (state != DialerCall.State.IDLE + && state != DialerCall.State.INVALID + && state != DialerCall.State.DISCONNECTED) { + + call.setState(DialerCall.State.DISCONNECTED); + call.setDisconnectCause(new DisconnectCause(DisconnectCause.UNKNOWN)); + updateCallInMap(call); + } + } + notifyGenericListeners(); + } + + /** + * Called when the user has dismissed an error dialog. This indicates acknowledgement of the + * disconnect cause, and that any pending disconnects should immediately occur. + */ + public void onErrorDialogDismissed() { + final Iterator<DialerCall> iterator = mPendingDisconnectCalls.iterator(); + while (iterator.hasNext()) { + DialerCall call = iterator.next(); + iterator.remove(); + finishDisconnectedCall(call); + } + } + + /** + * Processes an update for a single call. + * + * @param call The call to update. + */ + private void onUpdateCall(DialerCall call) { + LogUtil.d("CallList.onUpdateCall", String.valueOf(call)); + if (!mCallById.containsKey(call.getId()) && call.isExternalCall()) { + // When a regular call becomes external, it is removed from the call list, and there may be + // pending updates to Telecom which are queued up on the Telecom call's handler which we no + // longer wish to cause updates to the call in the CallList. Bail here if the list of tracked + // calls doesn't contain the call which received the update. + return; + } + + if (updateCallInMap(call)) { + LogUtil.i("CallList.onUpdateCall", String.valueOf(call)); + } + } + + /** + * Sends a generic notification to all listeners that something has changed. It is up to the + * listeners to call back to determine what changed. + */ + private void notifyGenericListeners() { + for (Listener listener : mListeners) { + listener.onCallListChange(this); + } + } + + private void notifyListenersOfDisconnect(DialerCall call) { + for (Listener listener : mListeners) { + listener.onDisconnect(call); + } + } + + /** + * Updates the call entry in the local map. + * + * @return false if no call previously existed and no call was added, otherwise true. + */ + private boolean updateCallInMap(DialerCall call) { + Objects.requireNonNull(call); + + boolean updated = false; + + if (call.getState() == DialerCall.State.DISCONNECTED) { + // update existing (but do not add!!) disconnected calls + if (mCallById.containsKey(call.getId())) { + // For disconnected calls, we want to keep them alive for a few seconds so that the + // UI has a chance to display anything it needs when a call is disconnected. + + // Set up a timer to destroy the call after X seconds. + final Message msg = mHandler.obtainMessage(EVENT_DISCONNECTED_TIMEOUT, call); + mHandler.sendMessageDelayed(msg, getDelayForDisconnect(call)); + mPendingDisconnectCalls.add(call); + + mCallById.put(call.getId(), call); + mCallByTelecomCall.put(call.getTelecomCall(), call); + updated = true; + } + } else if (!isCallDead(call)) { + mCallById.put(call.getId(), call); + mCallByTelecomCall.put(call.getTelecomCall(), call); + updated = true; + } else if (mCallById.containsKey(call.getId())) { + mCallById.remove(call.getId()); + mCallByTelecomCall.remove(call.getTelecomCall()); + updated = true; + } + + return updated; + } + + private int getDelayForDisconnect(DialerCall call) { + if (call.getState() != DialerCall.State.DISCONNECTED) { + throw new IllegalStateException(); + } + + final int cause = call.getDisconnectCause().getCode(); + final int delay; + switch (cause) { + case DisconnectCause.LOCAL: + delay = DISCONNECTED_CALL_SHORT_TIMEOUT_MS; + break; + case DisconnectCause.REMOTE: + case DisconnectCause.ERROR: + delay = DISCONNECTED_CALL_MEDIUM_TIMEOUT_MS; + break; + case DisconnectCause.REJECTED: + case DisconnectCause.MISSED: + case DisconnectCause.CANCELED: + // no delay for missed/rejected incoming calls and canceled outgoing calls. + delay = 0; + break; + default: + delay = DISCONNECTED_CALL_LONG_TIMEOUT_MS; + break; + } + + return delay; + } + + private boolean isCallDead(DialerCall call) { + final int state = call.getState(); + return DialerCall.State.IDLE == state || DialerCall.State.INVALID == state; + } + + /** Sets up a call for deletion and notifies listeners of change. */ + private void finishDisconnectedCall(DialerCall call) { + if (mPendingDisconnectCalls.contains(call)) { + mPendingDisconnectCalls.remove(call); + } + call.setState(DialerCall.State.IDLE); + updateCallInMap(call); + notifyGenericListeners(); + } + + /** + * Notifies all video calls of a change in device orientation. + * + * @param rotation The new rotation angle (in degrees). + */ + public void notifyCallsOfDeviceRotation(int rotation) { + for (DialerCall call : mCallById.values()) { + // First, ensure that the call videoState has video enabled (there is no need to set + // device orientation on a voice call which has not yet been upgraded to video). + // Second, ensure a VideoCall is set on the call so that the change can be sent to the + // provider (a VideoCall can be present for a call that does not currently have video, + // but can be upgraded to video). + + // NOTE: is it necessary to use this order because getVideoCall references the class + // VideoProfile which is not available on APIs <23 (M). + if (VideoUtils.isVideoCall(call) && call.getVideoCall() != null) { + call.getVideoCall().setDeviceOrientation(rotation); + } + } + } + + public void onInCallUiShown(boolean forFullScreenIntent) { + for (DialerCall call : mCallById.values()) { + call.getLatencyReport().onInCallUiShown(forFullScreenIntent); + } + } + + /** Listener interface for any class that wants to be notified of changes to the call list. */ + public interface Listener { + + /** + * Called when a new incoming call comes in. This is the only method that gets called for + * incoming calls. Listeners that want to perform an action on incoming call should respond in + * this method because {@link #onCallListChange} does not automatically get called for incoming + * calls. + */ + void onIncomingCall(DialerCall call); + + /** + * Called when a new modify call request comes in This is the only method that gets called for + * modify requests. + */ + void onUpgradeToVideo(DialerCall call); + + /** Called when the session modification state of a call changes. */ + void onSessionModificationStateChange(@SessionModificationState int newState); + + /** + * Called anytime there are changes to the call list. The change can be switching call states, + * updating information, etc. This method will NOT be called for new incoming calls and for + * calls that switch to disconnected state. Listeners must add actions to those method + * implementations if they want to deal with those actions. + */ + void onCallListChange(CallList callList); + + /** + * Called when a call switches to the disconnected state. This is the only method that will get + * called upon disconnection. + */ + void onDisconnect(DialerCall call); + + void onWiFiToLteHandover(DialerCall call); + + /** + * Called when a user is in a video call and the call is unable to be handed off successfully to + * WiFi + */ + void onHandoverToWifiFailed(DialerCall call); + } + + private class DialerCallListenerImpl implements DialerCallListener { + + private final DialerCall mCall; + + DialerCallListenerImpl(DialerCall call) { + Assert.isNotNull(call); + mCall = call; + } + + @Override + public void onDialerCallDisconnect() { + if (updateCallInMap(mCall)) { + LogUtil.i("DialerCallListenerImpl.onDialerCallDisconnect", String.valueOf(mCall)); + // notify those listening for all disconnects + notifyListenersOfDisconnect(mCall); + } + } + + @Override + public void onDialerCallUpdate() { + Trace.beginSection("onUpdate"); + onUpdateCall(mCall); + notifyGenericListeners(); + Trace.endSection(); + } + + @Override + public void onDialerCallChildNumberChange() {} + + @Override + public void onDialerCallLastForwardedNumberChange() {} + + @Override + public void onDialerCallUpgradeToVideo() { + for (Listener listener : mListeners) { + listener.onUpgradeToVideo(mCall); + } + } + + @Override + public void onWiFiToLteHandover() { + for (Listener listener : mListeners) { + listener.onWiFiToLteHandover(mCall); + } + } + + @Override + public void onHandoverToWifiFailure() { + for (Listener listener : mListeners) { + listener.onHandoverToWifiFailed(mCall); + } + } + + @Override + public void onDialerCallSessionModificationStateChange(@SessionModificationState int state) { + for (Listener listener : mListeners) { + listener.onSessionModificationStateChange(state); + } + } + } +} diff --git a/java/com/android/incallui/call/DialerCall.java b/java/com/android/incallui/call/DialerCall.java new file mode 100644 index 000000000..bd8f006dd --- /dev/null +++ b/java/com/android/incallui/call/DialerCall.java @@ -0,0 +1,1401 @@ +/* + * Copyright (C) 2013 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.call; + +import android.content.Context; +import android.hardware.camera2.CameraCharacteristics; +import android.net.Uri; +import android.os.Build.VERSION; +import android.os.Build.VERSION_CODES; +import android.os.Bundle; +import android.os.Trace; +import android.support.annotation.IntDef; +import android.support.annotation.Nullable; +import android.telecom.Call; +import android.telecom.Call.Details; +import android.telecom.Connection; +import android.telecom.DisconnectCause; +import android.telecom.GatewayInfo; +import android.telecom.InCallService.VideoCall; +import android.telecom.PhoneAccount; +import android.telecom.PhoneAccountHandle; +import android.telecom.StatusHints; +import android.telecom.TelecomManager; +import android.telecom.VideoProfile; +import android.telephony.PhoneNumberUtils; +import android.text.TextUtils; +import com.android.contacts.common.compat.CallCompat; +import com.android.contacts.common.compat.TelephonyManagerCompat; +import com.android.contacts.common.compat.telecom.TelecomManagerCompat; +import com.android.dialer.callintent.CallIntentParser; +import com.android.dialer.callintent.nano.CallInitiationType; +import com.android.dialer.callintent.nano.CallSpecificAppData; +import com.android.dialer.common.Assert; +import com.android.dialer.common.ConfigProviderBindings; +import com.android.dialer.common.LogUtil; +import com.android.dialer.logging.nano.ContactLookupResult; +import com.android.dialer.util.CallUtil; +import com.android.incallui.latencyreport.LatencyReport; +import com.android.incallui.util.TelecomCallUtil; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.TimeUnit; + +/** Describes a single call and its state. */ +public class DialerCall { + + public static final int CALL_HISTORY_STATUS_UNKNOWN = 0; + public static final int CALL_HISTORY_STATUS_PRESENT = 1; + public static final int CALL_HISTORY_STATUS_NOT_PRESENT = 2; + private static final String ID_PREFIX = "DialerCall_"; + private static final String CONFIG_EMERGENCY_CALLBACK_WINDOW_MILLIS = + "emergency_callback_window_millis"; + private static int sIdCounter = 0; + + /** + * The unique call ID for every call. This will help us to identify each call and allow us the + * ability to stitch impressions to calls if needed. + */ + private final String uniqueCallId = UUID.randomUUID().toString(); + + private final Call mTelecomCall; + private final LatencyReport mLatencyReport; + private final String mId; + private final List<String> mChildCallIds = new ArrayList<>(); + private final VideoSettings mVideoSettings = new VideoSettings(); + private final LogState mLogState = new LogState(); + private final Context mContext; + private final DialerCallDelegate mDialerCallDelegate; + private final List<DialerCallListener> mListeners = new CopyOnWriteArrayList<>(); + private final List<CannedTextResponsesLoadedListener> mCannedTextResponsesLoadedListeners = + new CopyOnWriteArrayList<>(); + + private boolean mIsEmergencyCall; + private Uri mHandle; + private int mState = State.INVALID; + private DisconnectCause mDisconnectCause; + + private boolean hasShownWiFiToLteHandoverToast; + private boolean doNotShowDialogForHandoffToWifiFailure; + + @SessionModificationState private int mSessionModificationState; + private int mVideoState; + /** mRequestedVideoState is used to store requested upgrade / downgrade video state */ + private int mRequestedVideoState = VideoProfile.STATE_AUDIO_ONLY; + + private InCallVideoCallCallback mVideoCallCallback; + private boolean mIsVideoCallCallbackRegistered; + private String mChildNumber; + private String mLastForwardedNumber; + private String mCallSubject; + private PhoneAccountHandle mPhoneAccountHandle; + @CallHistoryStatus private int mCallHistoryStatus = CALL_HISTORY_STATUS_UNKNOWN; + private boolean mIsSpam; + private boolean mIsBlocked; + private boolean isInUserSpamList; + private boolean isInUserWhiteList; + private boolean isInGlobalSpamList; + private boolean didShowCameraPermission; + private String callProviderLabel; + private String callbackNumber; + + public static String getNumberFromHandle(Uri handle) { + return handle == null ? "" : handle.getSchemeSpecificPart(); + } + + /** + * Whether the call is put on hold by remote party. This is different than the {@link + * State.ONHOLD} state which indicates that the call is being held locally on the device. + */ + private boolean isRemotelyHeld; + + /** + * Indicates whether the phone account associated with this call supports specifying a call + * subject. + */ + private boolean mIsCallSubjectSupported; + + private final Call.Callback mTelecomCallCallback = + new Call.Callback() { + @Override + public void onStateChanged(Call call, int newState) { + LogUtil.v("TelecomCallCallback.onStateChanged", "call=" + call + " newState=" + newState); + update(); + } + + @Override + public void onParentChanged(Call call, Call newParent) { + LogUtil.v( + "TelecomCallCallback.onParentChanged", "call=" + call + " newParent=" + newParent); + update(); + } + + @Override + public void onChildrenChanged(Call call, List<Call> children) { + update(); + } + + @Override + public void onDetailsChanged(Call call, Call.Details details) { + LogUtil.v("TelecomCallCallback.onStateChanged", " call=" + call + " details=" + details); + update(); + } + + @Override + public void onCannedTextResponsesLoaded(Call call, List<String> cannedTextResponses) { + LogUtil.v( + "TelecomCallCallback.onStateChanged", + "call=" + call + " cannedTextResponses=" + cannedTextResponses); + for (CannedTextResponsesLoadedListener listener : mCannedTextResponsesLoadedListeners) { + listener.onCannedTextResponsesLoaded(DialerCall.this); + } + } + + @Override + public void onPostDialWait(Call call, String remainingPostDialSequence) { + LogUtil.v( + "TelecomCallCallback.onStateChanged", + "call=" + call + " remainingPostDialSequence=" + remainingPostDialSequence); + update(); + } + + @Override + public void onVideoCallChanged(Call call, VideoCall videoCall) { + LogUtil.v( + "TelecomCallCallback.onStateChanged", "call=" + call + " videoCall=" + videoCall); + update(); + } + + @Override + public void onCallDestroyed(Call call) { + LogUtil.v("TelecomCallCallback.onStateChanged", "call=" + call); + call.unregisterCallback(this); + } + + @Override + public void onConferenceableCallsChanged(Call call, List<Call> conferenceableCalls) { + LogUtil.v( + "DialerCall.onConferenceableCallsChanged", + "call %s, conferenceable calls: %d", + call, + conferenceableCalls.size()); + update(); + } + + @Override + public void onConnectionEvent(android.telecom.Call call, String event, Bundle extras) { + LogUtil.v( + "DialerCall.onConnectionEvent", + "Call: " + call + ", Event: " + event + ", Extras: " + extras); + switch (event) { + // The Previous attempt to Merge two calls together has failed in Telecom. We must + // now update the UI to possibly re-enable the Merge button based on the number of + // currently conferenceable calls available or Connection Capabilities. + case android.telecom.Connection.EVENT_CALL_MERGE_FAILED: + update(); + break; + case TelephonyManagerCompat.EVENT_HANDOVER_VIDEO_FROM_WIFI_TO_LTE: + notifyWiFiToLteHandover(); + break; + case TelephonyManagerCompat.EVENT_HANDOVER_TO_WIFI_FAILED: + notifyHandoverToWifiFailed(); + break; + case TelephonyManagerCompat.EVENT_CALL_REMOTELY_HELD: + isRemotelyHeld = true; + update(); + break; + case TelephonyManagerCompat.EVENT_CALL_REMOTELY_UNHELD: + isRemotelyHeld = false; + update(); + break; + default: + break; + } + } + }; + private long mTimeAddedMs; + + public DialerCall( + Context context, + DialerCallDelegate dialerCallDelegate, + Call telecomCall, + LatencyReport latencyReport, + boolean registerCallback) { + Assert.isNotNull(context); + mContext = context; + mDialerCallDelegate = dialerCallDelegate; + mTelecomCall = telecomCall; + mLatencyReport = latencyReport; + mId = ID_PREFIX + Integer.toString(sIdCounter++); + + updateFromTelecomCall(registerCallback); + + if (registerCallback) { + mTelecomCall.registerCallback(mTelecomCallCallback); + } + + mTimeAddedMs = System.currentTimeMillis(); + parseCallSpecificAppData(); + } + + private static int translateState(int state) { + switch (state) { + case Call.STATE_NEW: + case Call.STATE_CONNECTING: + return DialerCall.State.CONNECTING; + case Call.STATE_SELECT_PHONE_ACCOUNT: + return DialerCall.State.SELECT_PHONE_ACCOUNT; + case Call.STATE_DIALING: + return DialerCall.State.DIALING; + case Call.STATE_PULLING_CALL: + return DialerCall.State.PULLING; + case Call.STATE_RINGING: + return DialerCall.State.INCOMING; + case Call.STATE_ACTIVE: + return DialerCall.State.ACTIVE; + case Call.STATE_HOLDING: + return DialerCall.State.ONHOLD; + case Call.STATE_DISCONNECTED: + return DialerCall.State.DISCONNECTED; + case Call.STATE_DISCONNECTING: + return DialerCall.State.DISCONNECTING; + default: + return DialerCall.State.INVALID; + } + } + + public static boolean areSame(DialerCall call1, DialerCall call2) { + if (call1 == null && call2 == null) { + return true; + } else if (call1 == null || call2 == null) { + return false; + } + + // otherwise compare call Ids + return call1.getId().equals(call2.getId()); + } + + public static boolean areSameNumber(DialerCall call1, DialerCall call2) { + if (call1 == null && call2 == null) { + return true; + } else if (call1 == null || call2 == null) { + return false; + } + + // otherwise compare call Numbers + return TextUtils.equals(call1.getNumber(), call2.getNumber()); + } + + public void addListener(DialerCallListener listener) { + Assert.isMainThread(); + mListeners.add(listener); + } + + public void removeListener(DialerCallListener listener) { + Assert.isMainThread(); + mListeners.remove(listener); + } + + public void addCannedTextResponsesLoadedListener(CannedTextResponsesLoadedListener listener) { + Assert.isMainThread(); + mCannedTextResponsesLoadedListeners.add(listener); + } + + public void removeCannedTextResponsesLoadedListener(CannedTextResponsesLoadedListener listener) { + Assert.isMainThread(); + mCannedTextResponsesLoadedListeners.remove(listener); + } + + public void notifyWiFiToLteHandover() { + LogUtil.i("DialerCall.notifyWiFiToLteHandover", ""); + for (DialerCallListener listener : mListeners) { + listener.onWiFiToLteHandover(); + } + } + + public void notifyHandoverToWifiFailed() { + LogUtil.i("DialerCall.notifyHandoverToWifiFailed", ""); + for (DialerCallListener listener : mListeners) { + listener.onHandoverToWifiFailure(); + } + } + + /* package-private */ Call getTelecomCall() { + return mTelecomCall; + } + + public StatusHints getStatusHints() { + return mTelecomCall.getDetails().getStatusHints(); + } + + /** + * @return video settings of the call, null if the call is not a video call. + * @see VideoProfile + */ + public VideoSettings getVideoSettings() { + return mVideoSettings; + } + + private void update() { + Trace.beginSection("Update"); + int oldState = getState(); + // We want to potentially register a video call callback here. + updateFromTelecomCall(true /* registerCallback */); + if (oldState != getState() && getState() == DialerCall.State.DISCONNECTED) { + for (DialerCallListener listener : mListeners) { + listener.onDialerCallDisconnect(); + } + } else { + for (DialerCallListener listener : mListeners) { + listener.onDialerCallUpdate(); + } + } + Trace.endSection(); + } + + private void updateFromTelecomCall(boolean registerCallback) { + LogUtil.v("DialerCall.updateFromTelecomCall", mTelecomCall.toString()); + final int translatedState = translateState(mTelecomCall.getState()); + if (mState != State.BLOCKED) { + setState(translatedState); + setDisconnectCause(mTelecomCall.getDetails().getDisconnectCause()); + maybeCancelVideoUpgrade(mTelecomCall.getDetails().getVideoState()); + } + + if (registerCallback && mTelecomCall.getVideoCall() != null) { + if (mVideoCallCallback == null) { + mVideoCallCallback = new InCallVideoCallCallback(this); + } + mTelecomCall.getVideoCall().registerCallback(mVideoCallCallback); + mIsVideoCallCallbackRegistered = true; + } + + mChildCallIds.clear(); + final int numChildCalls = mTelecomCall.getChildren().size(); + for (int i = 0; i < numChildCalls; i++) { + mChildCallIds.add( + mDialerCallDelegate + .getDialerCallFromTelecomCall(mTelecomCall.getChildren().get(i)) + .getId()); + } + + // The number of conferenced calls can change over the course of the call, so use the + // maximum number of conferenced child calls as the metric for conference call usage. + mLogState.conferencedCalls = Math.max(numChildCalls, mLogState.conferencedCalls); + + updateFromCallExtras(mTelecomCall.getDetails().getExtras()); + + // If the handle of the call has changed, update state for the call determining if it is an + // emergency call. + Uri newHandle = mTelecomCall.getDetails().getHandle(); + if (!Objects.equals(mHandle, newHandle)) { + mHandle = newHandle; + updateEmergencyCallState(); + } + + // If the phone account handle of the call is set, cache capability bit indicating whether + // the phone account supports call subjects. + PhoneAccountHandle newPhoneAccountHandle = mTelecomCall.getDetails().getAccountHandle(); + if (!Objects.equals(mPhoneAccountHandle, newPhoneAccountHandle)) { + mPhoneAccountHandle = newPhoneAccountHandle; + + if (mPhoneAccountHandle != null) { + PhoneAccount phoneAccount = + mContext.getSystemService(TelecomManager.class).getPhoneAccount(mPhoneAccountHandle); + if (phoneAccount != null) { + mIsCallSubjectSupported = + phoneAccount.hasCapabilities(PhoneAccount.CAPABILITY_CALL_SUBJECT); + } + } + } + + if (mSessionModificationState + == DialerCall.SESSION_MODIFICATION_STATE_WAITING_FOR_UPGRADE_TO_VIDEO_RESPONSE + && isVideoCall()) { + // We find out in {@link InCallVideoCallCallback.onSessionModifyResponseReceived} + // whether the video upgrade request was accepted. We don't clear the session modification + // state right away though to avoid having the UI switch from video to voice to video. + // Once the underlying telecom call updates to video mode it's safe to clear the state. + LogUtil.i( + "DialerCall.updateFromTelecomCall", + "upgraded to video, clearing session modification state"); + setSessionModificationState(DialerCall.SESSION_MODIFICATION_STATE_NO_REQUEST); + } + } + + /** + * Tests corruption of the {@code callExtras} bundle by calling {@link + * Bundle#containsKey(String)}. If the bundle is corrupted a {@link IllegalArgumentException} will + * be thrown and caught by this function. + * + * @param callExtras the bundle to verify + * @return {@code true} if the bundle is corrupted, {@code false} otherwise. + */ + protected boolean areCallExtrasCorrupted(Bundle callExtras) { + /** + * There's currently a bug in Telephony service (b/25613098) that could corrupt the extras + * bundle, resulting in a IllegalArgumentException while validating data under {@link + * Bundle#containsKey(String)}. + */ + try { + callExtras.containsKey(Connection.EXTRA_CHILD_ADDRESS); + return false; + } catch (IllegalArgumentException e) { + LogUtil.e( + "DialerCall.areCallExtrasCorrupted", "callExtras is corrupted, ignoring exception", e); + return true; + } + } + + protected void updateFromCallExtras(Bundle callExtras) { + if (callExtras == null || areCallExtrasCorrupted(callExtras)) { + /** + * If the bundle is corrupted, abandon information update as a work around. These are not + * critical for the dialer to function. + */ + return; + } + // Check for a change in the child address and notify any listeners. + if (callExtras.containsKey(Connection.EXTRA_CHILD_ADDRESS)) { + String childNumber = callExtras.getString(Connection.EXTRA_CHILD_ADDRESS); + if (!Objects.equals(childNumber, mChildNumber)) { + mChildNumber = childNumber; + for (DialerCallListener listener : mListeners) { + listener.onDialerCallChildNumberChange(); + } + } + } + + // Last forwarded number comes in as an array of strings. We want to choose the + // last item in the array. The forwarding numbers arrive independently of when the + // call is originally set up, so we need to notify the the UI of the change. + if (callExtras.containsKey(Connection.EXTRA_LAST_FORWARDED_NUMBER)) { + ArrayList<String> lastForwardedNumbers = + callExtras.getStringArrayList(Connection.EXTRA_LAST_FORWARDED_NUMBER); + + if (lastForwardedNumbers != null) { + String lastForwardedNumber = null; + if (!lastForwardedNumbers.isEmpty()) { + lastForwardedNumber = lastForwardedNumbers.get(lastForwardedNumbers.size() - 1); + } + + if (!Objects.equals(lastForwardedNumber, mLastForwardedNumber)) { + mLastForwardedNumber = lastForwardedNumber; + for (DialerCallListener listener : mListeners) { + listener.onDialerCallLastForwardedNumberChange(); + } + } + } + } + + // DialerCall subject is present in the extras at the start of call, so we do not need to + // notify any other listeners of this. + if (callExtras.containsKey(Connection.EXTRA_CALL_SUBJECT)) { + String callSubject = callExtras.getString(Connection.EXTRA_CALL_SUBJECT); + if (!Objects.equals(mCallSubject, callSubject)) { + mCallSubject = callSubject; + } + } + } + + /** + * Determines if a received upgrade to video request should be cancelled. This can happen if + * another InCall UI responds to the upgrade to video request. + * + * @param newVideoState The new video state. + */ + private void maybeCancelVideoUpgrade(int newVideoState) { + boolean isVideoStateChanged = mVideoState != newVideoState; + + if (mSessionModificationState + == DialerCall.SESSION_MODIFICATION_STATE_RECEIVED_UPGRADE_TO_VIDEO_REQUEST + && isVideoStateChanged) { + + LogUtil.i("DialerCall.maybeCancelVideoUpgrade", "cancelling upgrade notification"); + setSessionModificationState(DialerCall.SESSION_MODIFICATION_STATE_NO_REQUEST); + } + mVideoState = newVideoState; + } + + public String getId() { + return mId; + } + + public boolean hasShownWiFiToLteHandoverToast() { + return hasShownWiFiToLteHandoverToast; + } + + public void setHasShownWiFiToLteHandoverToast() { + hasShownWiFiToLteHandoverToast = true; + } + + public boolean showWifiHandoverAlertAsToast() { + return doNotShowDialogForHandoffToWifiFailure; + } + + public void setDoNotShowDialogForHandoffToWifiFailure(boolean bool) { + doNotShowDialogForHandoffToWifiFailure = bool; + } + + public long getTimeAddedMs() { + return mTimeAddedMs; + } + + @Nullable + public String getNumber() { + return TelecomCallUtil.getNumber(mTelecomCall); + } + + public void blockCall() { + mTelecomCall.reject(false, null); + setState(State.BLOCKED); + } + + @Nullable + public Uri getHandle() { + return mTelecomCall == null ? null : mTelecomCall.getDetails().getHandle(); + } + + public boolean isEmergencyCall() { + return mIsEmergencyCall; + } + + public boolean isPotentialEmergencyCallback() { + // The property PROPERTY_EMERGENCY_CALLBACK_MODE is only set for CDMA calls when the system + // is actually in emergency callback mode (ie data is disabled). + if (hasProperty(Details.PROPERTY_EMERGENCY_CALLBACK_MODE)) { + return true; + } + // We want to treat any incoming call that arrives a short time after an outgoing emergency call + // as a potential emergency callback. + if (getExtras() != null + && getExtras().getLong(TelecomManagerCompat.EXTRA_LAST_EMERGENCY_CALLBACK_TIME_MILLIS, 0) + > 0) { + long lastEmergencyCallMillis = + getExtras().getLong(TelecomManagerCompat.EXTRA_LAST_EMERGENCY_CALLBACK_TIME_MILLIS, 0); + if (isInEmergencyCallbackWindow(lastEmergencyCallMillis)) { + return true; + } + } + return false; + } + + boolean isInEmergencyCallbackWindow(long timestampMillis) { + long emergencyCallbackWindowMillis = + ConfigProviderBindings.get(mContext) + .getLong(CONFIG_EMERGENCY_CALLBACK_WINDOW_MILLIS, TimeUnit.MINUTES.toMillis(5)); + return System.currentTimeMillis() - timestampMillis < emergencyCallbackWindowMillis; + } + + public int getState() { + if (mTelecomCall != null && mTelecomCall.getParent() != null) { + return State.CONFERENCED; + } else { + return mState; + } + } + + public void setState(int state) { + mState = state; + if (mState == State.INCOMING) { + mLogState.isIncoming = true; + } else if (mState == State.DISCONNECTED) { + mLogState.duration = + getConnectTimeMillis() == 0 ? 0 : System.currentTimeMillis() - getConnectTimeMillis(); + } + } + + public int getNumberPresentation() { + return mTelecomCall == null ? -1 : mTelecomCall.getDetails().getHandlePresentation(); + } + + public int getCnapNamePresentation() { + return mTelecomCall == null ? -1 : mTelecomCall.getDetails().getCallerDisplayNamePresentation(); + } + + @Nullable + public String getCnapName() { + return mTelecomCall == null ? null : getTelecomCall().getDetails().getCallerDisplayName(); + } + + public Bundle getIntentExtras() { + return mTelecomCall.getDetails().getIntentExtras(); + } + + @Nullable + public Bundle getExtras() { + return mTelecomCall == null ? null : mTelecomCall.getDetails().getExtras(); + } + + /** @return The child number for the call, or {@code null} if none specified. */ + public String getChildNumber() { + return mChildNumber; + } + + /** @return The last forwarded number for the call, or {@code null} if none specified. */ + public String getLastForwardedNumber() { + return mLastForwardedNumber; + } + + /** @return The call subject, or {@code null} if none specified. */ + public String getCallSubject() { + return mCallSubject; + } + + /** + * @return {@code true} if the call's phone account supports call subjects, {@code false} + * otherwise. + */ + public boolean isCallSubjectSupported() { + return mIsCallSubjectSupported; + } + + /** Returns call disconnect cause, defined by {@link DisconnectCause}. */ + public DisconnectCause getDisconnectCause() { + if (mState == State.DISCONNECTED || mState == State.IDLE) { + return mDisconnectCause; + } + + return new DisconnectCause(DisconnectCause.UNKNOWN); + } + + public void setDisconnectCause(DisconnectCause disconnectCause) { + mDisconnectCause = disconnectCause; + mLogState.disconnectCause = mDisconnectCause; + } + + /** Returns the possible text message responses. */ + public List<String> getCannedSmsResponses() { + return mTelecomCall.getCannedTextResponses(); + } + + /** Checks if the call supports the given set of capabilities supplied as a bit mask. */ + public boolean can(int capabilities) { + int supportedCapabilities = mTelecomCall.getDetails().getCallCapabilities(); + + if ((capabilities & Call.Details.CAPABILITY_MERGE_CONFERENCE) != 0) { + // We allow you to merge if the capabilities allow it or if it is a call with + // conferenceable calls. + if (mTelecomCall.getConferenceableCalls().isEmpty() + && ((Call.Details.CAPABILITY_MERGE_CONFERENCE & supportedCapabilities) == 0)) { + // Cannot merge calls if there are no calls to merge with. + return false; + } + capabilities &= ~Call.Details.CAPABILITY_MERGE_CONFERENCE; + } + return (capabilities == (capabilities & supportedCapabilities)); + } + + public boolean hasProperty(int property) { + return mTelecomCall.getDetails().hasProperty(property); + } + + public String getUniqueCallId() { + return uniqueCallId; + } + + /** Gets the time when the call first became active. */ + public long getConnectTimeMillis() { + return mTelecomCall.getDetails().getConnectTimeMillis(); + } + + public boolean isConferenceCall() { + return hasProperty(Call.Details.PROPERTY_CONFERENCE); + } + + @Nullable + public GatewayInfo getGatewayInfo() { + return mTelecomCall == null ? null : mTelecomCall.getDetails().getGatewayInfo(); + } + + @Nullable + public PhoneAccountHandle getAccountHandle() { + return mTelecomCall == null ? null : mTelecomCall.getDetails().getAccountHandle(); + } + + /** + * @return The {@link VideoCall} instance associated with the {@link Call}. Will return {@code + * null} until {@link #updateFromTelecomCall(boolean)} has registered a valid callback on the + * {@link VideoCall}. + */ + public VideoCall getVideoCall() { + return mTelecomCall == null || !mIsVideoCallCallbackRegistered + ? null + : mTelecomCall.getVideoCall(); + } + + public List<String> getChildCallIds() { + return mChildCallIds; + } + + public String getParentId() { + Call parentCall = mTelecomCall.getParent(); + if (parentCall != null) { + return mDialerCallDelegate.getDialerCallFromTelecomCall(parentCall).getId(); + } + return null; + } + + public int getVideoState() { + return mTelecomCall.getDetails().getVideoState(); + } + + public boolean isVideoCall() { + return CallUtil.isVideoEnabled(mContext) && VideoUtils.isVideoCall(getVideoState()); + } + + /** + * Determines if the call handle is an emergency number or not and caches the result to avoid + * repeated calls to isEmergencyNumber. + */ + private void updateEmergencyCallState() { + mIsEmergencyCall = TelecomCallUtil.isEmergencyCall(mTelecomCall); + } + + /** + * Gets the video state which was requested via a session modification request. + * + * @return The video state. + */ + public int getRequestedVideoState() { + return mRequestedVideoState; + } + + /** + * Handles incoming session modification requests. Stores the pending video request and sets the + * session modification state to {@link + * DialerCall#SESSION_MODIFICATION_STATE_RECEIVED_UPGRADE_TO_VIDEO_REQUEST} so that we can keep + * track of the fact the request was received. Only upgrade requests require user confirmation and + * will be handled by this method. The remote user can turn off their own camera without + * confirmation. + * + * @param videoState The requested video state. + */ + public void setRequestedVideoState(int videoState) { + LogUtil.v("DialerCall.setRequestedVideoState", "videoState: " + videoState); + if (videoState == getVideoState()) { + LogUtil.e("DialerCall.setRequestedVideoState", "clearing session modification state"); + setSessionModificationState(DialerCall.SESSION_MODIFICATION_STATE_NO_REQUEST); + return; + } + + mRequestedVideoState = videoState; + setSessionModificationState( + DialerCall.SESSION_MODIFICATION_STATE_RECEIVED_UPGRADE_TO_VIDEO_REQUEST); + for (DialerCallListener listener : mListeners) { + listener.onDialerCallUpgradeToVideo(); + } + + LogUtil.i( + "DialerCall.setRequestedVideoState", + "mSessionModificationState: %d, videoState: %d", + mSessionModificationState, + videoState); + update(); + } + + /** + * Gets the current video session modification state. + * + * @return The session modification state. + */ + @SessionModificationState + public int getSessionModificationState() { + return mSessionModificationState; + } + + /** + * Set the session modification state. Used to keep track of pending video session modification + * operations and to inform listeners of these changes. + * + * @param state the new session modification state. + */ + public void setSessionModificationState(@SessionModificationState int state) { + boolean hasChanged = mSessionModificationState != state; + if (hasChanged) { + LogUtil.i( + "DialerCall.setSessionModificationState", "%d -> %d", mSessionModificationState, state); + mSessionModificationState = state; + for (DialerCallListener listener : mListeners) { + listener.onDialerCallSessionModificationStateChange(state); + } + } + } + + public LogState getLogState() { + return mLogState; + } + + /** + * Determines if the call is an external call. + * + * <p>An external call is one which does not exist locally for the {@link + * android.telecom.ConnectionService} it is associated with. + * + * <p>External calls are only supported in N and higher. + * + * @return {@code true} if the call is an external call, {@code false} otherwise. + */ + public boolean isExternalCall() { + return VERSION.SDK_INT >= VERSION_CODES.N + && hasProperty(CallCompat.Details.PROPERTY_IS_EXTERNAL_CALL); + } + + /** + * Determines if the external call is pullable. + * + * <p>An external call is one which does not exist locally for the {@link + * android.telecom.ConnectionService} it is associated with. An external call may be "pullable", + * which means that the user can request it be transferred to the current device. + * + * <p>External calls are only supported in N and higher. + * + * @return {@code true} if the call is an external call, {@code false} otherwise. + */ + public boolean isPullableExternalCall() { + return VERSION.SDK_INT >= VERSION_CODES.N + && (mTelecomCall.getDetails().getCallCapabilities() + & CallCompat.Details.CAPABILITY_CAN_PULL_CALL) + == CallCompat.Details.CAPABILITY_CAN_PULL_CALL; + } + + /** + * Determines if answering this call will cause an ongoing video call to be dropped. + * + * @return {@code true} if answering this call will drop an ongoing video call, {@code false} + * otherwise. + */ + public boolean answeringDisconnectsForegroundVideoCall() { + Bundle extras = getExtras(); + if (extras == null + || !extras.containsKey(CallCompat.Details.EXTRA_ANSWERING_DROPS_FOREGROUND_CALL)) { + return false; + } + return extras.getBoolean(CallCompat.Details.EXTRA_ANSWERING_DROPS_FOREGROUND_CALL); + } + + private void parseCallSpecificAppData() { + if (isExternalCall()) { + return; + } + + mLogState.callSpecificAppData = CallIntentParser.getCallSpecificAppData(getIntentExtras()); + if (mLogState.callSpecificAppData == null) { + mLogState.callSpecificAppData = new CallSpecificAppData(); + mLogState.callSpecificAppData.callInitiationType = + CallInitiationType.Type.EXTERNAL_INITIATION; + } + if (getState() == State.INCOMING) { + mLogState.callSpecificAppData.callInitiationType = + CallInitiationType.Type.INCOMING_INITIATION; + } + } + + @Override + public String toString() { + if (mTelecomCall == null) { + // This should happen only in testing since otherwise we would never have a null + // Telecom call. + return String.valueOf(mId); + } + + return String.format( + Locale.US, + "[%s, %s, %s, %s, children:%s, parent:%s, " + + "conferenceable:%s, videoState:%s, mSessionModificationState:%d, VideoSettings:%s]", + mId, + State.toString(getState()), + Details.capabilitiesToString(mTelecomCall.getDetails().getCallCapabilities()), + Details.propertiesToString(mTelecomCall.getDetails().getCallProperties()), + mChildCallIds, + getParentId(), + this.mTelecomCall.getConferenceableCalls(), + VideoProfile.videoStateToString(mTelecomCall.getDetails().getVideoState()), + mSessionModificationState, + getVideoSettings()); + } + + public String toSimpleString() { + return super.toString(); + } + + @CallHistoryStatus + public int getCallHistoryStatus() { + return mCallHistoryStatus; + } + + public void setCallHistoryStatus(@CallHistoryStatus int callHistoryStatus) { + mCallHistoryStatus = callHistoryStatus; + } + + public boolean didShowCameraPermission() { + return didShowCameraPermission; + } + + public void setDidShowCameraPermission(boolean didShow) { + didShowCameraPermission = didShow; + } + + public boolean isInGlobalSpamList() { + return isInGlobalSpamList; + } + + public void setIsInGlobalSpamList(boolean inSpamList) { + isInGlobalSpamList = inSpamList; + } + + public boolean isInUserSpamList() { + return isInUserSpamList; + } + + public void setIsInUserSpamList(boolean inSpamList) { + isInUserSpamList = inSpamList; + } + + public boolean isInUserWhiteList() { + return isInUserWhiteList; + } + + public void setIsInUserWhiteList(boolean inWhiteList) { + isInUserWhiteList = inWhiteList; + } + + public boolean isSpam() { + return mIsSpam; + } + + public void setSpam(boolean isSpam) { + mIsSpam = isSpam; + } + + public boolean isBlocked() { + return mIsBlocked; + } + + public void setBlockedStatus(boolean isBlocked) { + mIsBlocked = isBlocked; + } + + public boolean isRemotelyHeld() { + return isRemotelyHeld; + } + + public boolean isIncoming() { + return mLogState.isIncoming; + } + + public LatencyReport getLatencyReport() { + return mLatencyReport; + } + + public void unregisterCallback() { + mTelecomCall.unregisterCallback(mTelecomCallCallback); + } + + public void acceptUpgradeRequest(int videoState) { + LogUtil.i("DialerCall.acceptUpgradeRequest", "videoState: " + videoState); + VideoProfile videoProfile = new VideoProfile(videoState); + getVideoCall().sendSessionModifyResponse(videoProfile); + setSessionModificationState(DialerCall.SESSION_MODIFICATION_STATE_NO_REQUEST); + } + + public void declineUpgradeRequest() { + LogUtil.i("DialerCall.declineUpgradeRequest", ""); + VideoProfile videoProfile = new VideoProfile(getVideoState()); + getVideoCall().sendSessionModifyResponse(videoProfile); + setSessionModificationState(DialerCall.SESSION_MODIFICATION_STATE_NO_REQUEST); + } + + public void phoneAccountSelected(PhoneAccountHandle accountHandle, boolean setDefault) { + LogUtil.i( + "DialerCall.phoneAccountSelected", + "accountHandle: %s, setDefault: %b", + accountHandle, + setDefault); + mTelecomCall.phoneAccountSelected(accountHandle, setDefault); + } + + public void disconnect() { + LogUtil.i("DialerCall.disconnect", ""); + setState(DialerCall.State.DISCONNECTING); + for (DialerCallListener listener : mListeners) { + listener.onDialerCallUpdate(); + } + mTelecomCall.disconnect(); + } + + public void hold() { + LogUtil.i("DialerCall.hold", ""); + mTelecomCall.hold(); + } + + public void unhold() { + LogUtil.i("DialerCall.unhold", ""); + mTelecomCall.unhold(); + } + + public void splitFromConference() { + LogUtil.i("DialerCall.splitFromConference", ""); + mTelecomCall.splitFromConference(); + } + + public void answer(int videoState) { + LogUtil.i("DialerCall.answer", "videoState: " + videoState); + mTelecomCall.answer(videoState); + } + + public void reject(boolean rejectWithMessage, String message) { + LogUtil.i("DialerCall.reject", ""); + mTelecomCall.reject(rejectWithMessage, message); + } + + /** Return the string label to represent the call provider */ + public String getCallProviderLabel() { + if (callProviderLabel == null) { + PhoneAccount account = getPhoneAccount(); + if (account != null && !TextUtils.isEmpty(account.getLabel())) { + List<PhoneAccountHandle> accounts = + mContext.getSystemService(TelecomManager.class).getCallCapablePhoneAccounts(); + if (accounts != null && accounts.size() > 1) { + callProviderLabel = account.getLabel().toString(); + } + } + if (callProviderLabel == null) { + callProviderLabel = ""; + } + } + return callProviderLabel; + } + + private PhoneAccount getPhoneAccount() { + PhoneAccountHandle accountHandle = getAccountHandle(); + if (accountHandle == null) { + return null; + } + return mContext.getSystemService(TelecomManager.class).getPhoneAccount(accountHandle); + } + + public String getCallbackNumber() { + if (callbackNumber == null) { + // Show the emergency callback number if either: + // 1. This is an emergency call. + // 2. The phone is in Emergency Callback Mode, which means we should show the callback + // number. + boolean showCallbackNumber = hasProperty(Details.PROPERTY_EMERGENCY_CALLBACK_MODE); + + if (isEmergencyCall() || showCallbackNumber) { + callbackNumber = getSubscriptionNumber(); + } else { + StatusHints statusHints = getTelecomCall().getDetails().getStatusHints(); + if (statusHints != null) { + Bundle extras = statusHints.getExtras(); + if (extras != null) { + callbackNumber = extras.getString(TelecomManager.EXTRA_CALL_BACK_NUMBER); + } + } + } + + String simNumber = + mContext.getSystemService(TelecomManager.class).getLine1Number(getAccountHandle()); + if (!showCallbackNumber && PhoneNumberUtils.compare(callbackNumber, simNumber)) { + LogUtil.v( + "DialerCall.getCallbackNumber", + "numbers are the same (and callback number is not being forced to show);" + + " not showing the callback number"); + callbackNumber = ""; + } + if (callbackNumber == null) { + callbackNumber = ""; + } + } + return callbackNumber; + } + + private String getSubscriptionNumber() { + // If it's an emergency call, and they're not populating the callback number, + // then try to fall back to the phone sub info (to hopefully get the SIM's + // number directly from the telephony layer). + PhoneAccountHandle accountHandle = getAccountHandle(); + if (accountHandle != null) { + PhoneAccount account = + mContext.getSystemService(TelecomManager.class).getPhoneAccount(accountHandle); + if (account != null) { + return getNumberFromHandle(account.getSubscriptionAddress()); + } + } + return null; + } + + /** + * Specifies whether a number is in the call history or not. {@link #CALL_HISTORY_STATUS_UNKNOWN} + * means there is no result. + */ + @IntDef({ + CALL_HISTORY_STATUS_UNKNOWN, + CALL_HISTORY_STATUS_PRESENT, + CALL_HISTORY_STATUS_NOT_PRESENT + }) + @Retention(RetentionPolicy.SOURCE) + public @interface CallHistoryStatus {} + + /* Defines different states of this call */ + public static class State { + + public static final int INVALID = 0; + public static final int NEW = 1; /* The call is new. */ + public static final int IDLE = 2; /* The call is idle. Nothing active */ + public static final int ACTIVE = 3; /* There is an active call */ + public static final int INCOMING = 4; /* A normal incoming phone call */ + public static final int CALL_WAITING = 5; /* Incoming call while another is active */ + public static final int DIALING = 6; /* An outgoing call during dial phase */ + public static final int REDIALING = 7; /* Subsequent dialing attempt after a failure */ + public static final int ONHOLD = 8; /* An active phone call placed on hold */ + public static final int DISCONNECTING = 9; /* A call is being ended. */ + public static final int DISCONNECTED = 10; /* State after a call disconnects */ + public static final int CONFERENCED = 11; /* DialerCall part of a conference call */ + public static final int SELECT_PHONE_ACCOUNT = 12; /* Waiting for account selection */ + public static final int CONNECTING = 13; /* Waiting for Telecom broadcast to finish */ + public static final int BLOCKED = 14; /* The number was found on the block list */ + public static final int PULLING = 15; /* An external call being pulled to the device */ + + public static boolean isConnectingOrConnected(int state) { + switch (state) { + case ACTIVE: + case INCOMING: + case CALL_WAITING: + case CONNECTING: + case DIALING: + case PULLING: + case REDIALING: + case ONHOLD: + case CONFERENCED: + return true; + default: + } + return false; + } + + public static boolean isDialing(int state) { + return state == DIALING || state == PULLING || state == REDIALING; + } + + public static String toString(int state) { + switch (state) { + case INVALID: + return "INVALID"; + case NEW: + return "NEW"; + case IDLE: + return "IDLE"; + case ACTIVE: + return "ACTIVE"; + case INCOMING: + return "INCOMING"; + case CALL_WAITING: + return "CALL_WAITING"; + case DIALING: + return "DIALING"; + case PULLING: + return "PULLING"; + case REDIALING: + return "REDIALING"; + case ONHOLD: + return "ONHOLD"; + case DISCONNECTING: + return "DISCONNECTING"; + case DISCONNECTED: + return "DISCONNECTED"; + case CONFERENCED: + return "CONFERENCED"; + case SELECT_PHONE_ACCOUNT: + return "SELECT_PHONE_ACCOUNT"; + case CONNECTING: + return "CONNECTING"; + case BLOCKED: + return "BLOCKED"; + default: + return "UNKNOWN"; + } + } + } + + /** + * Defines different states of session modify requests, which are used to upgrade to video, or + * downgrade to audio. + */ + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + SESSION_MODIFICATION_STATE_NO_REQUEST, + SESSION_MODIFICATION_STATE_WAITING_FOR_UPGRADE_TO_VIDEO_RESPONSE, + SESSION_MODIFICATION_STATE_REQUEST_FAILED, + SESSION_MODIFICATION_STATE_RECEIVED_UPGRADE_TO_VIDEO_REQUEST, + SESSION_MODIFICATION_STATE_UPGRADE_TO_VIDEO_REQUEST_TIMED_OUT, + SESSION_MODIFICATION_STATE_UPGRADE_TO_VIDEO_REQUEST_FAILED, + SESSION_MODIFICATION_STATE_REQUEST_REJECTED, + SESSION_MODIFICATION_STATE_WAITING_FOR_RESPONSE + }) + public @interface SessionModificationState {} + + public static final int SESSION_MODIFICATION_STATE_NO_REQUEST = 0; + public static final int SESSION_MODIFICATION_STATE_WAITING_FOR_UPGRADE_TO_VIDEO_RESPONSE = 1; + public static final int SESSION_MODIFICATION_STATE_REQUEST_FAILED = 2; + public static final int SESSION_MODIFICATION_STATE_RECEIVED_UPGRADE_TO_VIDEO_REQUEST = 3; + public static final int SESSION_MODIFICATION_STATE_UPGRADE_TO_VIDEO_REQUEST_TIMED_OUT = 4; + public static final int SESSION_MODIFICATION_STATE_UPGRADE_TO_VIDEO_REQUEST_FAILED = 5; + public static final int SESSION_MODIFICATION_STATE_REQUEST_REJECTED = 6; + public static final int SESSION_MODIFICATION_STATE_WAITING_FOR_RESPONSE = 7; + + public static class VideoSettings { + + public static final int CAMERA_DIRECTION_UNKNOWN = -1; + public static final int CAMERA_DIRECTION_FRONT_FACING = CameraCharacteristics.LENS_FACING_FRONT; + public static final int CAMERA_DIRECTION_BACK_FACING = CameraCharacteristics.LENS_FACING_BACK; + + private int mCameraDirection = CAMERA_DIRECTION_UNKNOWN; + + /** + * Gets the camera direction. if camera direction is set to CAMERA_DIRECTION_UNKNOWN, the video + * state of the call should be used to infer the camera direction. + * + * @see {@link CameraCharacteristics#LENS_FACING_FRONT} + * @see {@link CameraCharacteristics#LENS_FACING_BACK} + */ + public int getCameraDir() { + return mCameraDirection; + } + + /** + * Sets the camera direction. if camera direction is set to CAMERA_DIRECTION_UNKNOWN, the video + * state of the call should be used to infer the camera direction. + * + * @see {@link CameraCharacteristics#LENS_FACING_FRONT} + * @see {@link CameraCharacteristics#LENS_FACING_BACK} + */ + public void setCameraDir(int cameraDirection) { + if (cameraDirection == CAMERA_DIRECTION_FRONT_FACING + || cameraDirection == CAMERA_DIRECTION_BACK_FACING) { + mCameraDirection = cameraDirection; + } else { + mCameraDirection = CAMERA_DIRECTION_UNKNOWN; + } + } + + @Override + public String toString() { + return "(CameraDir:" + getCameraDir() + ")"; + } + } + + /** + * Tracks any state variables that is useful for logging. There is some amount of overlap with + * existing call member variables, but this duplication helps to ensure that none of these logging + * variables will interface with/and affect call logic. + */ + public static class LogState { + + public DisconnectCause disconnectCause; + public boolean isIncoming = false; + public int contactLookupResult = ContactLookupResult.Type.UNKNOWN_LOOKUP_RESULT_TYPE; + public CallSpecificAppData callSpecificAppData; + // If this was a conference call, the total number of calls involved in the conference. + public int conferencedCalls = 0; + public long duration = 0; + public boolean isLogged = false; + + private static String lookupToString(int lookupType) { + switch (lookupType) { + case ContactLookupResult.Type.LOCAL_CONTACT: + return "Local"; + case ContactLookupResult.Type.LOCAL_CACHE: + return "Cache"; + case ContactLookupResult.Type.REMOTE: + return "Remote"; + case ContactLookupResult.Type.EMERGENCY: + return "Emergency"; + case ContactLookupResult.Type.VOICEMAIL: + return "Voicemail"; + default: + return "Not found"; + } + } + + private static String initiationToString(CallSpecificAppData callSpecificAppData) { + if (callSpecificAppData == null) { + return "null"; + } + switch (callSpecificAppData.callInitiationType) { + case CallInitiationType.Type.INCOMING_INITIATION: + return "Incoming"; + case CallInitiationType.Type.DIALPAD: + return "Dialpad"; + case CallInitiationType.Type.SPEED_DIAL: + return "Speed Dial"; + case CallInitiationType.Type.REMOTE_DIRECTORY: + return "Remote Directory"; + case CallInitiationType.Type.SMART_DIAL: + return "Smart Dial"; + case CallInitiationType.Type.REGULAR_SEARCH: + return "Regular Search"; + case CallInitiationType.Type.CALL_LOG: + return "DialerCall Log"; + case CallInitiationType.Type.CALL_LOG_FILTER: + return "DialerCall Log Filter"; + case CallInitiationType.Type.VOICEMAIL_LOG: + return "Voicemail Log"; + case CallInitiationType.Type.CALL_DETAILS: + return "DialerCall Details"; + case CallInitiationType.Type.QUICK_CONTACTS: + return "Quick Contacts"; + case CallInitiationType.Type.EXTERNAL_INITIATION: + return "External"; + case CallInitiationType.Type.LAUNCHER_SHORTCUT: + return "Launcher Shortcut"; + default: + return "Unknown: " + callSpecificAppData.callInitiationType; + } + } + + @Override + public String toString() { + return String.format( + Locale.US, + "[" + + "%s, " // DisconnectCause toString already describes the object type + + "isIncoming: %s, " + + "contactLookup: %s, " + + "callInitiation: %s, " + + "duration: %s" + + "]", + disconnectCause, + isIncoming, + lookupToString(contactLookupResult), + initiationToString(callSpecificAppData), + duration); + } + } + + /** Called when canned text responses have been loaded. */ + public interface CannedTextResponsesLoadedListener { + void onCannedTextResponsesLoaded(DialerCall call); + } +} diff --git a/java/com/android/incallui/call/DialerCallDelegate.java b/java/com/android/incallui/call/DialerCallDelegate.java new file mode 100644 index 000000000..463b4916a --- /dev/null +++ b/java/com/android/incallui/call/DialerCallDelegate.java @@ -0,0 +1,25 @@ +/* + * 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.call; + +import android.telecom.Call; + +/** Callback from the call module to the container. */ +public interface DialerCallDelegate { + + DialerCall getDialerCallFromTelecomCall(Call telecomCall); +} diff --git a/java/com/android/incallui/call/DialerCallListener.java b/java/com/android/incallui/call/DialerCallListener.java new file mode 100644 index 000000000..b426cd72e --- /dev/null +++ b/java/com/android/incallui/call/DialerCallListener.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.call; + +import com.android.incallui.call.DialerCall.SessionModificationState; + +/** Used to monitor state changes in a dialer call. */ +public interface DialerCallListener { + + void onDialerCallDisconnect(); + + void onDialerCallUpdate(); + + void onDialerCallChildNumberChange(); + + void onDialerCallLastForwardedNumberChange(); + + void onDialerCallUpgradeToVideo(); + + void onDialerCallSessionModificationStateChange(@SessionModificationState int state); + + void onWiFiToLteHandover(); + + void onHandoverToWifiFailure(); +} diff --git a/java/com/android/incallui/call/ExternalCallList.java b/java/com/android/incallui/call/ExternalCallList.java new file mode 100644 index 000000000..52a7a304b --- /dev/null +++ b/java/com/android/incallui/call/ExternalCallList.java @@ -0,0 +1,136 @@ +/* + * 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.call; + +import android.os.Handler; +import android.os.Looper; +import android.support.annotation.NonNull; +import android.telecom.Call; +import android.util.ArraySet; +import com.android.contacts.common.compat.CallCompat; +import com.android.dialer.common.LogUtil; +import java.util.Collections; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Tracks the external calls known to the InCall UI. + * + * <p>External calls are those with {@code android.telecom.Call.Details#PROPERTY_IS_EXTERNAL_CALL}. + */ +public class ExternalCallList { + + private final Set<Call> mExternalCalls = new ArraySet<>(); + private final Set<ExternalCallListener> mExternalCallListeners = + Collections.newSetFromMap(new ConcurrentHashMap<ExternalCallListener, Boolean>(8, 0.9f, 1)); + /** Handles {@link android.telecom.Call.Callback} callbacks. */ + private final Call.Callback mTelecomCallCallback = + new Call.Callback() { + @Override + public void onDetailsChanged(Call call, Call.Details details) { + notifyExternalCallUpdated(call); + } + }; + + /** Begins tracking an external call and notifies listeners of the new call. */ + public void onCallAdded(Call telecomCall) { + if (!telecomCall.getDetails().hasProperty(CallCompat.Details.PROPERTY_IS_EXTERNAL_CALL)) { + throw new IllegalArgumentException(); + } + mExternalCalls.add(telecomCall); + telecomCall.registerCallback(mTelecomCallCallback, new Handler(Looper.getMainLooper())); + notifyExternalCallAdded(telecomCall); + } + + /** Stops tracking an external call and notifies listeners of the removal of the call. */ + public void onCallRemoved(Call telecomCall) { + if (!mExternalCalls.contains(telecomCall)) { + // This can happen on M for external calls from blocked numbers + LogUtil.i("ExternalCallList.onCallRemoved", "attempted to remove unregistered call"); + return; + } + mExternalCalls.remove(telecomCall); + telecomCall.unregisterCallback(mTelecomCallCallback); + notifyExternalCallRemoved(telecomCall); + } + + /** Adds a new listener to external call events. */ + public void addExternalCallListener(@NonNull ExternalCallListener listener) { + mExternalCallListeners.add(listener); + } + + /** Removes a listener to external call events. */ + public void removeExternalCallListener(@NonNull ExternalCallListener listener) { + if (!mExternalCallListeners.contains(listener)) { + LogUtil.i( + "ExternalCallList.removeExternalCallListener", + "attempt to remove unregistered listener."); + } + mExternalCallListeners.remove(listener); + } + + public boolean isCallTracked(@NonNull android.telecom.Call telecomCall) { + return mExternalCalls.contains(telecomCall); + } + + /** Notifies listeners of the addition of a new external call. */ + private void notifyExternalCallAdded(Call call) { + for (ExternalCallListener listener : mExternalCallListeners) { + listener.onExternalCallAdded(call); + } + } + + /** Notifies listeners of the removal of an external call. */ + private void notifyExternalCallRemoved(Call call) { + for (ExternalCallListener listener : mExternalCallListeners) { + listener.onExternalCallRemoved(call); + } + } + + /** Notifies listeners of changes to an external call. */ + private void notifyExternalCallUpdated(Call call) { + if (!call.getDetails().hasProperty(CallCompat.Details.PROPERTY_IS_EXTERNAL_CALL)) { + // A previous external call has been pulled and is now a regular call, so we will remove + // it from the external call listener and ensure that the CallList is informed of the + // change. + onCallRemoved(call); + + for (ExternalCallListener listener : mExternalCallListeners) { + listener.onExternalCallPulled(call); + } + } else { + for (ExternalCallListener listener : mExternalCallListeners) { + listener.onExternalCallUpdated(call); + } + } + } + + /** + * Defines events which the {@link ExternalCallList} exposes to interested components (e.g. {@link + * com.android.incallui.ExternalCallNotifier ExternalCallNotifier}). + */ + public interface ExternalCallListener { + + void onExternalCallAdded(Call call); + + void onExternalCallRemoved(Call call); + + void onExternalCallUpdated(Call call); + + void onExternalCallPulled(Call call); + } +} diff --git a/java/com/android/incallui/call/InCallServiceListener.java b/java/com/android/incallui/call/InCallServiceListener.java new file mode 100644 index 000000000..e48ce9d79 --- /dev/null +++ b/java/com/android/incallui/call/InCallServiceListener.java @@ -0,0 +1,40 @@ +/* + * 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.call; + +import android.telecom.InCallService; + +/** + * Interface implemented by In-Call components that maintain a reference to the Telecom API {@code + * InCallService} object. Clarifies the expectations associated with the relevant method calls. + */ +public interface InCallServiceListener { + + /** + * Called once at {@code InCallService} startup time with a valid instance. At that time, there + * will be no existing {@code DialerCall}s. + * + * @param inCallService The {@code InCallService} object. + */ + void setInCallService(InCallService inCallService); + + /** + * Called once at {@code InCallService} shutdown time. At that time, any {@code DialerCall}s will + * have transitioned through the disconnected state and will no longer exist. + */ + void clearInCallService(); +} diff --git a/java/com/android/incallui/call/InCallUiLegacyBindings.java b/java/com/android/incallui/call/InCallUiLegacyBindings.java new file mode 100644 index 000000000..1b0ed4542 --- /dev/null +++ b/java/com/android/incallui/call/InCallUiLegacyBindings.java @@ -0,0 +1,26 @@ +/* + * 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.call; + +/** + * These are old bindings between InCallUi and the container application. All new bindings should be + * added to the bindings module and not here. + */ +public interface InCallUiLegacyBindings { + + void logCall(DialerCall call); +} diff --git a/java/com/android/incallui/call/InCallUiLegacyBindingsFactory.java b/java/com/android/incallui/call/InCallUiLegacyBindingsFactory.java new file mode 100644 index 000000000..8604976f7 --- /dev/null +++ b/java/com/android/incallui/call/InCallUiLegacyBindingsFactory.java @@ -0,0 +1,26 @@ +/* + * 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.call; + +/** + * This interface should be implementated by the Application subclass. It allows the in call UI + * module to get references to the InCallUiLegacyBindings. + */ +public interface InCallUiLegacyBindingsFactory { + + InCallUiLegacyBindings newInCallUiLegacyBindings(); +} diff --git a/java/com/android/incallui/call/InCallUiLegacyBindingsStub.java b/java/com/android/incallui/call/InCallUiLegacyBindingsStub.java new file mode 100644 index 000000000..8869c64b2 --- /dev/null +++ b/java/com/android/incallui/call/InCallUiLegacyBindingsStub.java @@ -0,0 +1,24 @@ +/* + * 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.call; + +/** Default implementation for in call UI legacy bindings. */ +public class InCallUiLegacyBindingsStub implements InCallUiLegacyBindings { + + @Override + public void logCall(DialerCall call) {} +} diff --git a/java/com/android/incallui/call/InCallVideoCallCallback.java b/java/com/android/incallui/call/InCallVideoCallCallback.java new file mode 100644 index 000000000..f897ac9dd --- /dev/null +++ b/java/com/android/incallui/call/InCallVideoCallCallback.java @@ -0,0 +1,197 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.android.incallui.call; + +import android.os.Handler; +import android.support.annotation.Nullable; +import android.telecom.Connection; +import android.telecom.Connection.VideoProvider; +import android.telecom.InCallService.VideoCall; +import android.telecom.VideoProfile; +import android.telecom.VideoProfile.CameraCapabilities; +import com.android.dialer.common.LogUtil; +import com.android.incallui.call.DialerCall.SessionModificationState; + +/** Implements the InCallUI VideoCall Callback. */ +public class InCallVideoCallCallback extends VideoCall.Callback implements Runnable { + + private static final int CLEAR_FAILED_REQUEST_TIMEOUT_MILLIS = 4000; + + private final DialerCall call; + @Nullable private Handler handler; + @SessionModificationState private int newSessionModificationState; + + public InCallVideoCallCallback(DialerCall call) { + this.call = call; + } + + @Override + public void onSessionModifyRequestReceived(VideoProfile videoProfile) { + LogUtil.i( + "InCallVideoCallCallback.onSessionModifyRequestReceived", "videoProfile: " + videoProfile); + int previousVideoState = VideoUtils.getUnPausedVideoState(call.getVideoState()); + int newVideoState = VideoUtils.getUnPausedVideoState(videoProfile.getVideoState()); + + boolean wasVideoCall = VideoUtils.isVideoCall(previousVideoState); + boolean isVideoCall = VideoUtils.isVideoCall(newVideoState); + + if (wasVideoCall && !isVideoCall) { + LogUtil.v( + "InCallVideoCallCallback.onSessionModifyRequestReceived", + "call downgraded to " + newVideoState); + } else if (previousVideoState != newVideoState) { + InCallVideoCallCallbackNotifier.getInstance().upgradeToVideoRequest(call, newVideoState); + } + } + + /** + * @param status Status of the session modify request. Valid values are {@link + * Connection.VideoProvider#SESSION_MODIFY_REQUEST_SUCCESS}, {@link + * Connection.VideoProvider#SESSION_MODIFY_REQUEST_FAIL}, {@link + * Connection.VideoProvider#SESSION_MODIFY_REQUEST_INVALID} + * @param responseProfile The actual profile changes made by the peer device. + */ + @Override + public void onSessionModifyResponseReceived( + int status, VideoProfile requestedProfile, VideoProfile responseProfile) { + LogUtil.i( + "InCallVideoCallCallback.onSessionModifyResponseReceived", + "status: %d, " + + "requestedProfile: %s, responseProfile: %s, current session modification state: %d", + status, + requestedProfile, + responseProfile, + call.getSessionModificationState()); + + if (call.getSessionModificationState() + == DialerCall.SESSION_MODIFICATION_STATE_WAITING_FOR_UPGRADE_TO_VIDEO_RESPONSE) { + if (handler == null) { + handler = new Handler(); + } else { + handler.removeCallbacks(this); + } + + newSessionModificationState = getDialerSessionModifyStateTelecomStatus(status); + if (status != VideoProvider.SESSION_MODIFY_REQUEST_SUCCESS) { + // This will update the video UI to display the error message. + call.setSessionModificationState(newSessionModificationState); + } + + // Wait for 4 seconds and then clean the session modification state. This allows the video UI + // to stay up so that the user can read the error message. + // + // If the other person accepted the upgrade request then this will keep the video UI up until + // the call's video state change. Without this we would switch to the voice call and then + // switch back to video UI. + handler.postDelayed(this, CLEAR_FAILED_REQUEST_TIMEOUT_MILLIS); + } else if (call.getSessionModificationState() + == DialerCall.SESSION_MODIFICATION_STATE_RECEIVED_UPGRADE_TO_VIDEO_REQUEST) { + call.setSessionModificationState(DialerCall.SESSION_MODIFICATION_STATE_NO_REQUEST); + } else if (call.getSessionModificationState() + == DialerCall.SESSION_MODIFICATION_STATE_WAITING_FOR_RESPONSE) { + call.setSessionModificationState(getDialerSessionModifyStateTelecomStatus(status)); + } else { + LogUtil.i( + "InCallVideoCallCallback.onSessionModifyResponseReceived", + "call is not waiting for " + "response, doing nothing"); + } + } + + @SessionModificationState + private int getDialerSessionModifyStateTelecomStatus(int telecomStatus) { + switch (telecomStatus) { + case VideoProvider.SESSION_MODIFY_REQUEST_SUCCESS: + return DialerCall.SESSION_MODIFICATION_STATE_NO_REQUEST; + case VideoProvider.SESSION_MODIFY_REQUEST_FAIL: + case VideoProvider.SESSION_MODIFY_REQUEST_INVALID: + // Check if it's already video call, which means the request is not video upgrade request. + if (VideoUtils.isVideoCall(call.getVideoState())) { + return DialerCall.SESSION_MODIFICATION_STATE_REQUEST_FAILED; + } else { + return DialerCall.SESSION_MODIFICATION_STATE_UPGRADE_TO_VIDEO_REQUEST_FAILED; + } + case VideoProvider.SESSION_MODIFY_REQUEST_TIMED_OUT: + return DialerCall.SESSION_MODIFICATION_STATE_UPGRADE_TO_VIDEO_REQUEST_TIMED_OUT; + case VideoProvider.SESSION_MODIFY_REQUEST_REJECTED_BY_REMOTE: + return DialerCall.SESSION_MODIFICATION_STATE_REQUEST_REJECTED; + default: + LogUtil.e( + "InCallVideoCallCallback.getDialerSessionModifyStateTelecomStatus", + "unknown status: %d", + telecomStatus); + return DialerCall.SESSION_MODIFICATION_STATE_REQUEST_FAILED; + } + } + + @Override + public void onCallSessionEvent(int event) { + InCallVideoCallCallbackNotifier.getInstance().callSessionEvent(event); + } + + @Override + public void onPeerDimensionsChanged(int width, int height) { + InCallVideoCallCallbackNotifier.getInstance().peerDimensionsChanged(call, width, height); + } + + @Override + public void onVideoQualityChanged(int videoQuality) { + InCallVideoCallCallbackNotifier.getInstance().videoQualityChanged(call, videoQuality); + } + + /** + * Handles a change to the call data usage. No implementation as the in-call UI does not display + * data usage. + * + * @param dataUsage The updated data usage. + */ + @Override + public void onCallDataUsageChanged(long dataUsage) { + LogUtil.v("InCallVideoCallCallback.onCallDataUsageChanged", "dataUsage = " + dataUsage); + InCallVideoCallCallbackNotifier.getInstance().callDataUsageChanged(dataUsage); + } + + /** + * Handles changes to the camera capabilities. No implementation as the in-call UI does not make + * use of camera capabilities. + * + * @param cameraCapabilities The changed camera capabilities. + */ + @Override + public void onCameraCapabilitiesChanged(CameraCapabilities cameraCapabilities) { + if (cameraCapabilities != null) { + InCallVideoCallCallbackNotifier.getInstance() + .cameraDimensionsChanged( + call, cameraCapabilities.getWidth(), cameraCapabilities.getHeight()); + } + } + + /** + * Called 4 seconds after the remote user responds to the video upgrade request. We use this to + * clear the session modify state. + */ + @Override + public void run() { + if (call.getSessionModificationState() == newSessionModificationState) { + LogUtil.i("InCallVideoCallCallback.onSessionModifyResponseReceived", "clearing state"); + call.setSessionModificationState(DialerCall.SESSION_MODIFICATION_STATE_NO_REQUEST); + } else { + LogUtil.i( + "InCallVideoCallCallback.onSessionModifyResponseReceived", + "session modification state has changed, not clearing state"); + } + } +} diff --git a/java/com/android/incallui/call/InCallVideoCallCallbackNotifier.java b/java/com/android/incallui/call/InCallVideoCallCallbackNotifier.java new file mode 100644 index 000000000..4a949263c --- /dev/null +++ b/java/com/android/incallui/call/InCallVideoCallCallbackNotifier.java @@ -0,0 +1,279 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.android.incallui.call; + +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import com.android.dialer.common.LogUtil; +import java.util.Collections; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Class used by {@link InCallService.VideoCallCallback} to notify interested parties of incoming + * events. + */ +public class InCallVideoCallCallbackNotifier { + + /** Singleton instance of this class. */ + private static InCallVideoCallCallbackNotifier sInstance = new InCallVideoCallCallbackNotifier(); + + /** + * ConcurrentHashMap constructor params: 8 is initial table size, 0.9f is load factor before + * resizing, 1 means we only expect a single thread to access the map so make only a single shard + */ + private final Set<SessionModificationListener> mSessionModificationListeners = + Collections.newSetFromMap( + new ConcurrentHashMap<SessionModificationListener, Boolean>(8, 0.9f, 1)); + + private final Set<VideoEventListener> mVideoEventListeners = + Collections.newSetFromMap(new ConcurrentHashMap<VideoEventListener, Boolean>(8, 0.9f, 1)); + private final Set<SurfaceChangeListener> mSurfaceChangeListeners = + Collections.newSetFromMap(new ConcurrentHashMap<SurfaceChangeListener, Boolean>(8, 0.9f, 1)); + + /** Private constructor. Instance should only be acquired through getInstance(). */ + private InCallVideoCallCallbackNotifier() {} + + /** Static singleton accessor method. */ + public static InCallVideoCallCallbackNotifier getInstance() { + return sInstance; + } + + /** + * Adds a new {@link SessionModificationListener}. + * + * @param listener The listener. + */ + public void addSessionModificationListener(@NonNull SessionModificationListener listener) { + Objects.requireNonNull(listener); + mSessionModificationListeners.add(listener); + } + + /** + * Remove a {@link SessionModificationListener}. + * + * @param listener The listener. + */ + public void removeSessionModificationListener(@Nullable SessionModificationListener listener) { + if (listener != null) { + mSessionModificationListeners.remove(listener); + } + } + + /** + * Adds a new {@link VideoEventListener}. + * + * @param listener The listener. + */ + public void addVideoEventListener(@NonNull VideoEventListener listener) { + Objects.requireNonNull(listener); + mVideoEventListeners.add(listener); + } + + /** + * Remove a {@link VideoEventListener}. + * + * @param listener The listener. + */ + public void removeVideoEventListener(@Nullable VideoEventListener listener) { + if (listener != null) { + mVideoEventListeners.remove(listener); + } + } + + /** + * Adds a new {@link SurfaceChangeListener}. + * + * @param listener The listener. + */ + public void addSurfaceChangeListener(@NonNull SurfaceChangeListener listener) { + Objects.requireNonNull(listener); + mSurfaceChangeListeners.add(listener); + } + + /** + * Remove a {@link SurfaceChangeListener}. + * + * @param listener The listener. + */ + public void removeSurfaceChangeListener(@Nullable SurfaceChangeListener listener) { + if (listener != null) { + mSurfaceChangeListeners.remove(listener); + } + } + + /** + * Inform listeners of an upgrade to video request for a call. + * + * @param call The call. + * @param videoState The video state we want to upgrade to. + */ + public void upgradeToVideoRequest(DialerCall call, int videoState) { + LogUtil.v( + "InCallVideoCallCallbackNotifier.upgradeToVideoRequest", + "call = " + call + " new video state = " + videoState); + for (SessionModificationListener listener : mSessionModificationListeners) { + listener.onUpgradeToVideoRequest(call, videoState); + } + } + + /** + * Inform listeners of a call session event. + * + * @param event The call session event. + */ + public void callSessionEvent(int event) { + for (VideoEventListener listener : mVideoEventListeners) { + listener.onCallSessionEvent(event); + } + } + + /** + * Inform listeners of a downgrade to audio. + * + * @param call The call. + * @param paused The paused state. + */ + public void peerPausedStateChanged(DialerCall call, boolean paused) { + for (VideoEventListener listener : mVideoEventListeners) { + listener.onPeerPauseStateChanged(call, paused); + } + } + + /** + * Inform listeners of any change in the video quality of the call + * + * @param call The call. + * @param videoQuality The updated video quality of the call. + */ + public void videoQualityChanged(DialerCall call, int videoQuality) { + for (VideoEventListener listener : mVideoEventListeners) { + listener.onVideoQualityChanged(call, videoQuality); + } + } + + /** + * Inform listeners of a change to peer dimensions. + * + * @param call The call. + * @param width New peer width. + * @param height New peer height. + */ + public void peerDimensionsChanged(DialerCall call, int width, int height) { + for (SurfaceChangeListener listener : mSurfaceChangeListeners) { + listener.onUpdatePeerDimensions(call, width, height); + } + } + + /** + * Inform listeners of a change to camera dimensions. + * + * @param call The call. + * @param width The new camera video width. + * @param height The new camera video height. + */ + public void cameraDimensionsChanged(DialerCall call, int width, int height) { + for (SurfaceChangeListener listener : mSurfaceChangeListeners) { + listener.onCameraDimensionsChange(call, width, height); + } + } + + /** + * Inform listeners of a change to call data usage. + * + * @param dataUsage data usage value + */ + public void callDataUsageChanged(long dataUsage) { + for (VideoEventListener listener : mVideoEventListeners) { + listener.onCallDataUsageChange(dataUsage); + } + } + + /** Listener interface for any class that wants to be notified of upgrade to video request. */ + public interface SessionModificationListener { + + /** + * Called when a peer request is received to upgrade an audio-only call to a video call. + * + * @param call The call the request was received for. + * @param videoState The requested video state. + */ + void onUpgradeToVideoRequest(DialerCall call, int videoState); + } + + /** + * Listener interface for any class that wants to be notified of video events, including pause and + * un-pause of peer video, video quality changes. + */ + public interface VideoEventListener { + + /** + * Called when the peer pauses or un-pauses video transmission. + * + * @param call The call which paused or un-paused video transmission. + * @param paused {@code True} when the video transmission is paused, {@code false} otherwise. + */ + void onPeerPauseStateChanged(DialerCall call, boolean paused); + + /** + * Called when the video quality changes. + * + * @param call The call whose video quality changes. + * @param videoCallQuality - values are QUALITY_HIGH, MEDIUM, LOW and UNKNOWN. + */ + void onVideoQualityChanged(DialerCall call, int videoCallQuality); + + /* + * Called when call data usage value is requested or when call data usage value is updated + * because of a call state change + * + * @param dataUsage call data usage value + */ + void onCallDataUsageChange(long dataUsage); + + /** + * Called when call session event is raised. + * + * @param event The call session event. + */ + void onCallSessionEvent(int event); + } + + /** + * Listener interface for any class that wants to be notified of changes to the video surfaces. + */ + public interface SurfaceChangeListener { + + /** + * Called when the peer video feed changes dimensions. This can occur when the peer rotates + * their device, changing the aspect ratio of the video signal. + * + * @param call The call which experienced a peer video + */ + void onUpdatePeerDimensions(DialerCall call, int width, int height); + + /** + * Called when the local camera changes dimensions. This occurs when a change in camera occurs. + * + * @param call The call which experienced the camera dimension change. + * @param width The new camera video width. + * @param height The new camera video height. + */ + void onCameraDimensionsChange(DialerCall call, int width, int height); + } +} diff --git a/java/com/android/incallui/call/TelecomAdapter.java b/java/com/android/incallui/call/TelecomAdapter.java new file mode 100644 index 000000000..ebf4ecf4f --- /dev/null +++ b/java/com/android/incallui/call/TelecomAdapter.java @@ -0,0 +1,160 @@ +/* + * 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.call; + +import android.content.ActivityNotFoundException; +import android.content.Intent; +import android.os.Looper; +import android.support.annotation.MainThread; +import android.telecom.InCallService; +import com.android.dialer.common.LogUtil; +import java.util.List; + +/** Wrapper around Telecom APIs. */ +public final class TelecomAdapter implements InCallServiceListener { + + private static final String ADD_CALL_MODE_KEY = "add_call_mode"; + + private static TelecomAdapter sInstance; + private InCallService mInCallService; + + private TelecomAdapter() {} + + @MainThread + public static TelecomAdapter getInstance() { + if (!Looper.getMainLooper().isCurrentThread()) { + throw new IllegalStateException(); + } + if (sInstance == null) { + sInstance = new TelecomAdapter(); + } + return sInstance; + } + + @Override + public void setInCallService(InCallService inCallService) { + mInCallService = inCallService; + } + + @Override + public void clearInCallService() { + mInCallService = null; + } + + private android.telecom.Call getTelecomCallById(String callId) { + DialerCall call = CallList.getInstance().getCallById(callId); + return call == null ? null : call.getTelecomCall(); + } + + public void mute(boolean shouldMute) { + if (mInCallService != null) { + mInCallService.setMuted(shouldMute); + } else { + LogUtil.e("TelecomAdapter.mute", "mInCallService is null"); + } + } + + public void setAudioRoute(int route) { + if (mInCallService != null) { + mInCallService.setAudioRoute(route); + } else { + LogUtil.e("TelecomAdapter.setAudioRoute", "mInCallService is null"); + } + } + + public void merge(String callId) { + android.telecom.Call call = getTelecomCallById(callId); + if (call != null) { + List<android.telecom.Call> conferenceable = call.getConferenceableCalls(); + if (!conferenceable.isEmpty()) { + call.conference(conferenceable.get(0)); + } else { + if (call.getDetails().can(android.telecom.Call.Details.CAPABILITY_MERGE_CONFERENCE)) { + call.mergeConference(); + } + } + } else { + LogUtil.e("TelecomAdapter.merge", "call not in call list " + callId); + } + } + + public void swap(String callId) { + android.telecom.Call call = getTelecomCallById(callId); + if (call != null) { + if (call.getDetails().can(android.telecom.Call.Details.CAPABILITY_SWAP_CONFERENCE)) { + call.swapConference(); + } + } else { + LogUtil.e("TelecomAdapter.swap", "call not in call list " + callId); + } + } + + public void addCall() { + if (mInCallService != null) { + Intent intent = new Intent(Intent.ACTION_DIAL); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + + // when we request the dialer come up, we also want to inform + // it that we're going through the "add call" option from the + // InCallScreen / PhoneUtils. + intent.putExtra(ADD_CALL_MODE_KEY, true); + try { + LogUtil.d("TelecomAdapter.addCall", "Sending the add DialerCall intent"); + mInCallService.startActivity(intent); + } catch (ActivityNotFoundException e) { + // This is rather rare but possible. + // Note: this method is used even when the phone is encrypted. At that moment + // the system may not find any Activity which can accept this Intent. + LogUtil.e("TelecomAdapter.addCall", "Activity for adding calls isn't found.", e); + } + } + } + + public void playDtmfTone(String callId, char digit) { + android.telecom.Call call = getTelecomCallById(callId); + if (call != null) { + call.playDtmfTone(digit); + } else { + LogUtil.e("TelecomAdapter.playDtmfTone", "call not in call list " + callId); + } + } + + public void stopDtmfTone(String callId) { + android.telecom.Call call = getTelecomCallById(callId); + if (call != null) { + call.stopDtmfTone(); + } else { + LogUtil.e("TelecomAdapter.stopDtmfTone", "call not in call list " + callId); + } + } + + public void postDialContinue(String callId, boolean proceed) { + android.telecom.Call call = getTelecomCallById(callId); + if (call != null) { + call.postDialContinue(proceed); + } else { + LogUtil.e("TelecomAdapter.postDialContinue", "call not in call list " + callId); + } + } + + public boolean canAddCall() { + if (mInCallService != null) { + return mInCallService.canAddCall(); + } + return false; + } +} diff --git a/java/com/android/incallui/call/VideoUtils.java b/java/com/android/incallui/call/VideoUtils.java new file mode 100644 index 000000000..80fbfb1cc --- /dev/null +++ b/java/com/android/incallui/call/VideoUtils.java @@ -0,0 +1,151 @@ +/* + * 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.call; + +import android.content.Context; +import android.content.pm.PackageManager; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v4.content.ContextCompat; +import android.telecom.VideoProfile; +import com.android.dialer.compat.CompatUtils; +import com.android.dialer.util.DialerUtils; +import com.android.incallui.call.DialerCall.SessionModificationState; +import java.util.Objects; + +public class VideoUtils { + + private static final String PREFERENCE_CAMERA_ALLOWED_BY_USER = "camera_allowed_by_user"; + + public static boolean isVideoCall(@Nullable DialerCall call) { + return call != null && isVideoCall(call.getVideoState()); + } + + public static boolean isVideoCall(int videoState) { + return CompatUtils.isVideoCompatible() + && (VideoProfile.isTransmissionEnabled(videoState) + || VideoProfile.isReceptionEnabled(videoState)); + } + + public static boolean hasSentVideoUpgradeRequest(@Nullable DialerCall call) { + return call != null && hasSentVideoUpgradeRequest(call.getSessionModificationState()); + } + + public static boolean hasSentVideoUpgradeRequest(@SessionModificationState int state) { + return state == DialerCall.SESSION_MODIFICATION_STATE_WAITING_FOR_UPGRADE_TO_VIDEO_RESPONSE + || state == DialerCall.SESSION_MODIFICATION_STATE_UPGRADE_TO_VIDEO_REQUEST_FAILED + || state == DialerCall.SESSION_MODIFICATION_STATE_REQUEST_REJECTED + || state == DialerCall.SESSION_MODIFICATION_STATE_UPGRADE_TO_VIDEO_REQUEST_TIMED_OUT; + } + + public static boolean hasReceivedVideoUpgradeRequest(@Nullable DialerCall call) { + return call != null && hasReceivedVideoUpgradeRequest(call.getSessionModificationState()); + } + + public static boolean hasReceivedVideoUpgradeRequest(@SessionModificationState int state) { + return state == DialerCall.SESSION_MODIFICATION_STATE_RECEIVED_UPGRADE_TO_VIDEO_REQUEST; + } + + public static boolean isBidirectionalVideoCall(DialerCall call) { + return CompatUtils.isVideoCompatible() && VideoProfile.isBidirectional(call.getVideoState()); + } + + public static boolean isTransmissionEnabled(DialerCall call) { + if (!CompatUtils.isVideoCompatible()) { + return false; + } + + return VideoProfile.isTransmissionEnabled(call.getVideoState()); + } + + public static boolean isIncomingVideoCall(DialerCall call) { + if (!VideoUtils.isVideoCall(call)) { + return false; + } + final int state = call.getState(); + return (state == DialerCall.State.INCOMING) || (state == DialerCall.State.CALL_WAITING); + } + + public static boolean isActiveVideoCall(DialerCall call) { + return VideoUtils.isVideoCall(call) && call.getState() == DialerCall.State.ACTIVE; + } + + public static boolean isOutgoingVideoCall(DialerCall call) { + if (!VideoUtils.isVideoCall(call)) { + return false; + } + final int state = call.getState(); + return DialerCall.State.isDialing(state) + || state == DialerCall.State.CONNECTING + || state == DialerCall.State.SELECT_PHONE_ACCOUNT; + } + + public static boolean isAudioCall(DialerCall call) { + if (!CompatUtils.isVideoCompatible()) { + return true; + } + + return call != null && VideoProfile.isAudioOnly(call.getVideoState()); + } + + // TODO (ims-vt) Check if special handling is needed for CONF calls. + public static boolean canVideoPause(DialerCall call) { + return isVideoCall(call) && call.getState() == DialerCall.State.ACTIVE; + } + + public static VideoProfile makeVideoPauseProfile(@NonNull DialerCall call) { + Objects.requireNonNull(call); + if (VideoProfile.isAudioOnly(call.getVideoState())) { + throw new IllegalStateException(); + } + return new VideoProfile(getPausedVideoState(call.getVideoState())); + } + + public static VideoProfile makeVideoUnPauseProfile(@NonNull DialerCall call) { + Objects.requireNonNull(call); + return new VideoProfile(getUnPausedVideoState(call.getVideoState())); + } + + public static int getUnPausedVideoState(int videoState) { + return videoState & (~VideoProfile.STATE_PAUSED); + } + + public static int getPausedVideoState(int videoState) { + return videoState | VideoProfile.STATE_PAUSED; + } + + public static boolean hasCameraPermissionAndAllowedByUser(@NonNull Context context) { + return isCameraAllowedByUser(context) && hasCameraPermission(context); + } + + public static boolean hasCameraPermission(@NonNull Context context) { + return ContextCompat.checkSelfPermission(context, android.Manifest.permission.CAMERA) + == PackageManager.PERMISSION_GRANTED; + } + + public static boolean isCameraAllowedByUser(@NonNull Context context) { + return DialerUtils.getDefaultSharedPreferenceForDeviceProtectedStorageContext(context) + .getBoolean(PREFERENCE_CAMERA_ALLOWED_BY_USER, false); + } + + public static void setCameraAllowedByUser(@NonNull Context context) { + DialerUtils.getDefaultSharedPreferenceForDeviceProtectedStorageContext(context) + .edit() + .putBoolean(PREFERENCE_CAMERA_ALLOWED_BY_USER, true) + .apply(); + } +} diff --git a/java/com/android/incallui/commontheme/AndroidManifest.xml b/java/com/android/incallui/commontheme/AndroidManifest.xml new file mode 100644 index 000000000..1d5914f07 --- /dev/null +++ b/java/com/android/incallui/commontheme/AndroidManifest.xml @@ -0,0 +1,3 @@ +<manifest + package="com.android.incallui.commontheme"> +</manifest> diff --git a/java/com/android/incallui/commontheme/res/animator/button_state.xml b/java/com/android/incallui/commontheme/res/animator/button_state.xml new file mode 100644 index 000000000..70958d610 --- /dev/null +++ b/java/com/android/incallui/commontheme/res/animator/button_state.xml @@ -0,0 +1,30 @@ +<?xml version="1.0" encoding="utf-8"?> +<selector xmlns:android="http://schemas.android.com/apk/res/android"> + <item android:state_pressed="true" android:state_enabled="true"> + <set> + <objectAnimator android:propertyName="translationZ" + android:duration="100" + android:valueTo="4dp" + android:valueType="floatType"/> + <objectAnimator android:propertyName="elevation" + android:duration="0" + android:valueTo="@dimen/incall_call_button_elevation" + android:valueType="floatType"/> + </set> + </item> + <!-- base state --> + <item android:state_enabled="true"> + <set> + <objectAnimator android:propertyName="translationZ" + android:duration="100" + android:valueTo="0" + android:startDelay="100" + android:valueType="floatType"/> + <objectAnimator android:propertyName="elevation" + android:duration="0" + android:valueTo="@dimen/incall_call_button_elevation" + android:valueType="floatType" /> + </set> + </item> + ... +</selector>
\ No newline at end of file diff --git a/java/com/android/incallui/commontheme/res/animator/disabled_alpha.xml b/java/com/android/incallui/commontheme/res/animator/disabled_alpha.xml new file mode 100644 index 000000000..8d78f0017 --- /dev/null +++ b/java/com/android/incallui/commontheme/res/animator/disabled_alpha.xml @@ -0,0 +1,22 @@ +<?xml version="1.0" encoding="utf-8"?> +<selector xmlns:android="http://schemas.android.com/apk/res/android"> + + <item android:state_enabled="false"> + <set> + <objectAnimator + android:propertyName="alpha" + android:duration="@android:integer/config_shortAnimTime" + android:valueTo=".3f" + android:valueType="floatType"/> + </set> + </item> + <item> + <set> + <objectAnimator + android:propertyName="alpha" + android:duration="@android:integer/config_shortAnimTime" + android:valueTo="1f" + android:valueType="floatType"/> + </set> + </item> +</selector> diff --git a/java/com/android/incallui/commontheme/res/color/incall_button_ripple.xml b/java/com/android/incallui/commontheme/res/color/incall_button_ripple.xml new file mode 100644 index 000000000..cd474c5e5 --- /dev/null +++ b/java/com/android/incallui/commontheme/res/color/incall_button_ripple.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<selector xmlns:android="http://schemas.android.com/apk/res/android"> + <item android:color="#80888888" android:state_checked="true"/> + <item android:color="#80ffffff"/> +</selector> diff --git a/java/com/android/incallui/commontheme/res/color/incall_button_white.xml b/java/com/android/incallui/commontheme/res/color/incall_button_white.xml new file mode 100644 index 000000000..5df441ff0 --- /dev/null +++ b/java/com/android/incallui/commontheme/res/color/incall_button_white.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<selector xmlns:android="http://schemas.android.com/apk/res/android"> + <item android:color="@android:color/white" android:state_enabled="true"/> + <item android:color="#99ffffff" android:state_enabled="false"/> +</selector> diff --git a/java/com/android/incallui/commontheme/res/drawable-hdpi/ic_phone_audio_white_36dp.png b/java/com/android/incallui/commontheme/res/drawable-hdpi/ic_phone_audio_white_36dp.png Binary files differnew file mode 100644 index 000000000..26f3fe001 --- /dev/null +++ b/java/com/android/incallui/commontheme/res/drawable-hdpi/ic_phone_audio_white_36dp.png diff --git a/java/com/android/incallui/commontheme/res/drawable-mdpi/ic_phone_audio_white_36dp.png b/java/com/android/incallui/commontheme/res/drawable-mdpi/ic_phone_audio_white_36dp.png Binary files differnew file mode 100644 index 000000000..5b0a9d663 --- /dev/null +++ b/java/com/android/incallui/commontheme/res/drawable-mdpi/ic_phone_audio_white_36dp.png diff --git a/java/com/android/incallui/commontheme/res/drawable-xhdpi/ic_phone_audio_white_36dp.png b/java/com/android/incallui/commontheme/res/drawable-xhdpi/ic_phone_audio_white_36dp.png Binary files differnew file mode 100644 index 000000000..d595b190d --- /dev/null +++ b/java/com/android/incallui/commontheme/res/drawable-xhdpi/ic_phone_audio_white_36dp.png diff --git a/java/com/android/incallui/commontheme/res/drawable-xxhdpi/ic_phone_audio_white_36dp.png b/java/com/android/incallui/commontheme/res/drawable-xxhdpi/ic_phone_audio_white_36dp.png Binary files differnew file mode 100644 index 000000000..fb7cf161b --- /dev/null +++ b/java/com/android/incallui/commontheme/res/drawable-xxhdpi/ic_phone_audio_white_36dp.png diff --git a/java/com/android/incallui/commontheme/res/drawable-xxxhdpi/ic_phone_audio_white_36dp.png b/java/com/android/incallui/commontheme/res/drawable-xxxhdpi/ic_phone_audio_white_36dp.png Binary files differnew file mode 100644 index 000000000..4bb58d9f5 --- /dev/null +++ b/java/com/android/incallui/commontheme/res/drawable-xxxhdpi/ic_phone_audio_white_36dp.png diff --git a/java/com/android/incallui/commontheme/res/drawable/answer_answer_background.xml b/java/com/android/incallui/commontheme/res/drawable/answer_answer_background.xml new file mode 100644 index 000000000..090506aa6 --- /dev/null +++ b/java/com/android/incallui/commontheme/res/drawable/answer_answer_background.xml @@ -0,0 +1,10 @@ +<?xml version="1.0" encoding="utf-8"?> +<ripple xmlns:android="http://schemas.android.com/apk/res/android" + android:color="#80FFFFFF"> + <item> + <shape + android:shape="oval"> + <solid android:color="#09ad00"/> + </shape> + </item> +</ripple> diff --git a/java/com/android/incallui/commontheme/res/drawable/answer_decline_background.xml b/java/com/android/incallui/commontheme/res/drawable/answer_decline_background.xml new file mode 100644 index 000000000..abfd56ecf --- /dev/null +++ b/java/com/android/incallui/commontheme/res/drawable/answer_decline_background.xml @@ -0,0 +1,10 @@ +<?xml version="1.0" encoding="utf-8"?> +<ripple xmlns:android="http://schemas.android.com/apk/res/android" + android:color="#80FFFFFF"> + <item> + <shape + android:shape="oval"> + <solid android:color="#DF0000"/> + </shape> + </item> +</ripple> diff --git a/java/com/android/incallui/commontheme/res/drawable/incall_end_call_background.xml b/java/com/android/incallui/commontheme/res/drawable/incall_end_call_background.xml new file mode 100644 index 000000000..3c9f4bc0b --- /dev/null +++ b/java/com/android/incallui/commontheme/res/drawable/incall_end_call_background.xml @@ -0,0 +1,10 @@ +<?xml version="1.0" encoding="utf-8"?> +<ripple xmlns:android="http://schemas.android.com/apk/res/android" + android:color="#80FFFFFF"> + <item> + <shape + android:shape="oval"> + <solid android:color="#FFDF0000"/> + </shape> + </item> +</ripple> diff --git a/java/com/android/incallui/commontheme/res/values-w260dp-h520dp/dimens.xml b/java/com/android/incallui/commontheme/res/values-w260dp-h520dp/dimens.xml new file mode 100644 index 000000000..e1390597a --- /dev/null +++ b/java/com/android/incallui/commontheme/res/values-w260dp-h520dp/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="incall_end_call_button_size">64dp</dimen> + <drawable name="incall_end_call_icon">@drawable/quantum_ic_call_end_white_36</drawable> +</resources> diff --git a/java/com/android/incallui/commontheme/res/values-w520dp-h260dp-land/dimens.xml b/java/com/android/incallui/commontheme/res/values-w520dp-h260dp-land/dimens.xml new file mode 100644 index 000000000..e1390597a --- /dev/null +++ b/java/com/android/incallui/commontheme/res/values-w520dp-h260dp-land/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="incall_end_call_button_size">64dp</dimen> + <drawable name="incall_end_call_icon">@drawable/quantum_ic_call_end_white_36</drawable> +</resources> diff --git a/java/com/android/incallui/commontheme/res/values/colors.xml b/java/com/android/incallui/commontheme/res/values/colors.xml new file mode 100644 index 000000000..d38e34716 --- /dev/null +++ b/java/com/android/incallui/commontheme/res/values/colors.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- 50% black background drawn over the video to make it easier to see text and buttons. --> + <color name="videocall_overlay_background_color">#7E000000</color> +</resources>
\ No newline at end of file diff --git a/java/com/android/incallui/commontheme/res/values/dimens.xml b/java/com/android/incallui/commontheme/res/values/dimens.xml new file mode 100644 index 000000000..649ba2cde --- /dev/null +++ b/java/com/android/incallui/commontheme/res/values/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="incall_end_call_button_size">48dp</dimen> + <dimen name="incall_call_button_elevation">8dp</dimen> + <drawable name="incall_end_call_icon">@drawable/quantum_ic_call_end_white_24</drawable> +</resources> diff --git a/java/com/android/incallui/commontheme/res/values/strings.xml b/java/com/android/incallui/commontheme/res/values/strings.xml new file mode 100644 index 000000000..6f346a34d --- /dev/null +++ b/java/com/android/incallui/commontheme/res/values/strings.xml @@ -0,0 +1,35 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + + <string name="incall_content_description_end_call">End call</string> + + <string name="incall_content_description_muted">Muted</string> + + <string name="incall_content_description_unmuted">Unmuted</string> + + <string name="incall_content_description_swap_calls">Swap calls</string> + + <string name="incall_content_description_merge_calls">Merge calls</string> + + <string name="incall_content_description_earpiece">Handset earpiece</string> + + <string name="incall_content_description_speaker">Speaker</string> + + <string name="incall_content_description_bluetooth">Bluetooth</string> + + <string name="incall_content_description_headset">Wired headset</string> + + <!-- Text for the onscreen "Hold" button when it is not selected. Pressing it will put + the call on hold. --> + <string name="incall_content_description_hold">Hold call</string> + <!-- Text for the onscreen "Hold" button when it is selected. Pressing it will resume + the call from a previously held state. --> + <string name="incall_content_description_unhold">Resume call</string> + + <string name="incall_content_description_video_on">Video on</string> + + <string name="incall_content_description_video_off">Video off</string> + + <string name="incall_content_description_swap_video">Swap video</string> + +</resources> diff --git a/java/com/android/incallui/commontheme/res/values/styles.xml b/java/com/android/incallui/commontheme/res/values/styles.xml new file mode 100644 index 000000000..311f9cf4b --- /dev/null +++ b/java/com/android/incallui/commontheme/res/values/styles.xml @@ -0,0 +1,58 @@ +<?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> + + <style name="Dialer.Incall.TextAppearance.Large"> + <item name="android:textColor">?android:textColorPrimary</item> + <item name="android:textSize">36sp</item> + <item name="android:fontFamily">sans-serif-light</item> + </style> + + <style name="Dialer.Incall.TextAppearance.Label"> + <item name="android:textColor">?android:textColorPrimary</item> + <item name="android:textSize">12sp</item> + </style> + + <style name="Dialer.Incall.TextAppearance" parent="android:TextAppearance.Material"> + <item name="android:textColor">?android:textColorSecondary</item> + <item name="android:textSize">18sp</item> + </style> + + <style name="Incall.Button.End" parent="android:Widget.Material.Button"> + <item name="android:background">@drawable/incall_end_call_background</item> + <item name="android:elevation">8dp</item> + <item name="android:layout_height">@dimen/incall_end_call_button_size</item> + <item name="android:layout_width">@dimen/incall_end_call_button_size</item> + <item name="android:padding">8dp</item> + <item name="android:src">@drawable/incall_end_call_icon</item> + <item name="android:stateListAnimator">@animator/disabled_alpha</item> + </style> + + <style name="Answer.Button" parent="android:Widget.Material.Button"> + <item name="android:stateListAnimator">@animator/button_state</item> + </style> + + <style name="Answer.Button.Answer"> + <item name="android:background">@drawable/answer_answer_background</item> + </style> + + <style name="Answer.Button.Decline"> + <item name="android:background">@drawable/answer_decline_background</item> + </style> + +</resources> diff --git a/java/com/android/incallui/contactgrid/AndroidManifest.xml b/java/com/android/incallui/contactgrid/AndroidManifest.xml new file mode 100644 index 000000000..520010548 --- /dev/null +++ b/java/com/android/incallui/contactgrid/AndroidManifest.xml @@ -0,0 +1,3 @@ +<manifest + package="com.android.incallui.contactgrid"> +</manifest> diff --git a/java/com/android/incallui/contactgrid/BottomRow.java b/java/com/android/incallui/contactgrid/BottomRow.java new file mode 100644 index 000000000..aaf7e8214 --- /dev/null +++ b/java/com/android/incallui/contactgrid/BottomRow.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.contactgrid; + +import android.content.Context; +import android.support.annotation.Nullable; +import android.telephony.PhoneNumberUtils; +import android.text.BidiFormatter; +import android.text.TextDirectionHeuristics; +import android.text.TextUtils; +import com.android.contacts.common.compat.PhoneNumberUtilsCompat; +import com.android.incallui.call.DialerCall.State; +import com.android.incallui.incall.protocol.PrimaryCallState; +import com.android.incallui.incall.protocol.PrimaryInfo; + +/** + * Gets the content of the bottom row. For example: + * + * <ul> + * <li>Mobile +1 (650) 253-0000 + * <li>[HD icon] 00:15 + * <li>Call ended + * <li>Hanging up + * </ul> + */ +public class BottomRow { + + /** Content of the bottom row. */ + public static class Info { + + @Nullable public final CharSequence label; + public final boolean isTimerVisible; + public final boolean isWorkIconVisible; + public final boolean isHdIconVisible; + public final boolean isForwardIconVisible; + public final boolean isSpamIconVisible; + public final boolean shouldPopulateAccessibilityEvent; + + public Info( + @Nullable CharSequence label, + boolean isTimerVisible, + boolean isWorkIconVisible, + boolean isHdIconVisible, + boolean isForwardIconVisible, + boolean isSpamIconVisible, + boolean shouldPopulateAccessibilityEvent) { + this.label = label; + this.isTimerVisible = isTimerVisible; + this.isWorkIconVisible = isWorkIconVisible; + this.isHdIconVisible = isHdIconVisible; + this.isForwardIconVisible = isForwardIconVisible; + this.isSpamIconVisible = isSpamIconVisible; + this.shouldPopulateAccessibilityEvent = shouldPopulateAccessibilityEvent; + } + } + + private BottomRow() {} + + public static Info getInfo(Context context, PrimaryCallState state, PrimaryInfo primaryInfo) { + CharSequence label; + boolean isTimerVisible = state.state == State.ACTIVE; + boolean isForwardIconVisible = state.isForwardedNumber; + boolean isWorkIconVisible = state.isWorkCall; + boolean isHdIconVisible = state.isHdAudioCall && !isForwardIconVisible; + boolean isSpamIconVisible = false; + boolean shouldPopulateAccessibilityEvent = true; + + if (isIncoming(state) && primaryInfo.isSpam) { + label = context.getString(R.string.contact_grid_incoming_suspected_spam); + isSpamIconVisible = true; + isHdIconVisible = false; + } else if (state.state == State.DISCONNECTING) { + // While in the DISCONNECTING state we display a "Hanging up" message in order to make the UI + // feel more responsive. (In GSM it's normal to see a delay of a couple of seconds while + // negotiating the disconnect with the network, so the "Hanging up" state at least lets the + // user know that we're doing something. This state is currently not used with CDMA.) + label = context.getString(R.string.incall_hanging_up); + } else if (state.state == State.DISCONNECTED) { + label = state.disconnectCause.getLabel(); + if (TextUtils.isEmpty(label)) { + label = context.getString(R.string.incall_call_ended); + } + } else if (!TextUtils.isEmpty(state.callbackNumber)) { + // This is used for carriers like Project Fi to show the callback number for emergency calls. + label = + context.getString( + R.string.contact_grid_callback_number, + PhoneNumberUtils.formatNumber(state.callbackNumber)); + isTimerVisible = false; + } else { + label = getLabelForPhoneNumber(primaryInfo); + shouldPopulateAccessibilityEvent = primaryInfo.nameIsNumber; + } + + return new Info( + label, + isTimerVisible, + isWorkIconVisible, + isHdIconVisible, + isForwardIconVisible, + isSpamIconVisible, + shouldPopulateAccessibilityEvent); + } + + private static CharSequence getLabelForPhoneNumber(PrimaryInfo primaryInfo) { + if (primaryInfo.nameIsNumber) { + return primaryInfo.location; + } + if (!TextUtils.isEmpty(primaryInfo.number)) { + CharSequence spannedNumber = spanDisplayNumber(primaryInfo.number); + if (primaryInfo.label == null) { + return spannedNumber; + } else { + return TextUtils.concat(primaryInfo.label, " ", spannedNumber); + } + } + return null; + } + + private static CharSequence spanDisplayNumber(String displayNumber) { + return PhoneNumberUtilsCompat.createTtsSpannable( + BidiFormatter.getInstance().unicodeWrap(displayNumber, TextDirectionHeuristics.LTR)); + } + + private static boolean isIncoming(PrimaryCallState state) { + return state.state == State.INCOMING || state.state == State.CALL_WAITING; + } +} diff --git a/java/com/android/incallui/contactgrid/ContactGridManager.java b/java/com/android/incallui/contactgrid/ContactGridManager.java new file mode 100644 index 000000000..81c225163 --- /dev/null +++ b/java/com/android/incallui/contactgrid/ContactGridManager.java @@ -0,0 +1,315 @@ +/* + * 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.contactgrid; + +import android.content.Context; +import android.os.SystemClock; +import android.support.annotation.Nullable; +import android.text.TextUtils; +import android.view.View; +import android.view.accessibility.AccessibilityEvent; +import android.widget.Chronometer; +import android.widget.ImageView; +import android.widget.TextView; +import android.widget.ViewAnimator; +import com.android.contacts.common.compat.PhoneNumberUtilsCompat; +import com.android.contacts.common.lettertiles.LetterTileDrawable; +import com.android.dialer.common.Assert; +import com.android.dialer.util.DrawableConverter; +import com.android.incallui.incall.protocol.ContactPhotoType; +import com.android.incallui.incall.protocol.PrimaryCallState; +import com.android.incallui.incall.protocol.PrimaryInfo; +import java.util.List; + +/** Utility to manage the Contact grid */ +public class ContactGridManager { + + private final Context context; + private final View contactGridLayout; + + // Row 0: Captain Holt ON HOLD + // Row 0: Calling... + // Row 0: [Wi-Fi icon] Calling via Starbucks Wi-Fi + // Row 0: [Wi-Fi icon] Starbucks Wi-Fi + // Row 0: Hey Jake, pick up! + private ImageView connectionIconImageView; + private TextView statusTextView; + + // Row 1: Jake Peralta [Contact photo] + // Row 1: Walgreens + // Row 1: +1 (650) 253-0000 + private TextView contactNameTextView; + @Nullable private ImageView avatarImageView; + + // Row 2: Mobile +1 (650) 253-0000 + // Row 2: [HD icon] 00:15 + // Row 2: Call ended + // Row 2: Hanging up + // Row 2: [Alert sign] Suspected spam caller + // Row 2: Your emergency callback number: +1 (650) 253-0000 + private ImageView workIconImageView; + private ImageView hdIconImageView; + private ImageView forwardIconImageView; + private ImageView spamIconImageView; + private ViewAnimator bottomTextSwitcher; + private TextView bottomTextView; + private Chronometer bottomTimerView; + private int avatarSize; + private boolean hideAvatar; + private boolean showAnonymousAvatar; + private boolean middleRowVisible = true; + + private PrimaryInfo primaryInfo = PrimaryInfo.createEmptyPrimaryInfo(); + private PrimaryCallState primaryCallState = PrimaryCallState.createEmptyPrimaryCallState(); + private final LetterTileDrawable letterTile; + + + public ContactGridManager( + View view, @Nullable ImageView avatarImageView, int avatarSize, boolean showAnonymousAvatar) { + context = view.getContext(); + Assert.isNotNull(context); + + this.avatarImageView = avatarImageView; + this.avatarSize = avatarSize; + this.showAnonymousAvatar = showAnonymousAvatar; + connectionIconImageView = (ImageView) view.findViewById(R.id.contactgrid_connection_icon); + statusTextView = (TextView) view.findViewById(R.id.contactgrid_status_text); + contactNameTextView = (TextView) view.findViewById(R.id.contactgrid_contact_name); + workIconImageView = (ImageView) view.findViewById(R.id.contactgrid_workIcon); + hdIconImageView = (ImageView) view.findViewById(R.id.contactgrid_hdIcon); + forwardIconImageView = (ImageView) view.findViewById(R.id.contactgrid_forwardIcon); + spamIconImageView = (ImageView) view.findViewById(R.id.contactgrid_spamIcon); + bottomTextSwitcher = (ViewAnimator) view.findViewById(R.id.contactgrid_bottom_text_switcher); + bottomTextView = (TextView) view.findViewById(R.id.contactgrid_bottom_text); + bottomTimerView = (Chronometer) view.findViewById(R.id.contactgrid_bottom_timer); + + contactGridLayout = (View) contactNameTextView.getParent(); + letterTile = new LetterTileDrawable(context.getResources()); + } + + public void show() { + contactGridLayout.setVisibility(View.VISIBLE); + } + + public void hide() { + contactGridLayout.setVisibility(View.GONE); + } + + public void setAvatarHidden(boolean hide) { + if (hide != hideAvatar) { + hideAvatar = hide; + updatePrimaryNameAndPhoto(); + } + } + + public boolean isAvatarHidden() { + return hideAvatar; + } + + public View getContainerView() { + return contactGridLayout; + } + + public void setIsMiddleRowVisible(boolean isMiddleRowVisible) { + if (middleRowVisible == isMiddleRowVisible) { + return; + } + middleRowVisible = isMiddleRowVisible; + + contactNameTextView.setVisibility(isMiddleRowVisible ? View.VISIBLE : View.GONE); + updateAvatarVisibility(); + } + + public void setPrimary(PrimaryInfo primaryInfo) { + this.primaryInfo = primaryInfo; + updatePrimaryNameAndPhoto(); + updateBottomRow(); + } + + public void setCallState(PrimaryCallState primaryCallState) { + this.primaryCallState = primaryCallState; + updatePrimaryNameAndPhoto(); + updateBottomRow(); + updateTopRow(); + } + + public void dispatchPopulateAccessibilityEvent(AccessibilityEvent event) { + dispatchPopulateAccessibilityEvent(event, statusTextView); + dispatchPopulateAccessibilityEvent(event, contactNameTextView); + BottomRow.Info info = BottomRow.getInfo(context, primaryCallState, primaryInfo); + if (info.shouldPopulateAccessibilityEvent) { + dispatchPopulateAccessibilityEvent(event, bottomTextView); + } + } + + public void setAvatarImageView( + @Nullable ImageView avatarImageView, int avatarSize, boolean showAnonymousAvatar) { + this.avatarImageView = avatarImageView; + this.avatarSize = avatarSize; + this.showAnonymousAvatar = showAnonymousAvatar; + updatePrimaryNameAndPhoto(); + } + + private void dispatchPopulateAccessibilityEvent(AccessibilityEvent event, View view) { + final List<CharSequence> eventText = event.getText(); + int size = eventText.size(); + view.dispatchPopulateAccessibilityEvent(event); + // If no text added write null to keep relative position. + if (size == eventText.size()) { + eventText.add(null); + } + } + + private boolean updateAvatarVisibility() { + if (avatarImageView == null) { + return false; + } + + if (!middleRowVisible) { + avatarImageView.setVisibility(View.GONE); + return false; + } + + boolean hasPhoto = + primaryInfo.photo != null && primaryInfo.photoType == ContactPhotoType.CONTACT; + if (!hasPhoto && !showAnonymousAvatar) { + avatarImageView.setVisibility(View.GONE); + return false; + } + + avatarImageView.setVisibility(View.VISIBLE); + return true; + } + + /** + * Updates row 0. For example: + * + * <ul> + * <li>Captain Holt ON HOLD + * <li>Calling... + * <li>[Wi-Fi icon] Calling via Starbucks Wi-Fi + * <li>[Wi-Fi icon] Starbucks Wi-Fi + * <li>Call from + * </ul> + */ + private void updateTopRow() { + TopRow.Info info = TopRow.getInfo(context, primaryCallState); + if (TextUtils.isEmpty(info.label)) { + // Use INVISIBLE here to prevent the rows below this one from moving up and down. + statusTextView.setVisibility(View.INVISIBLE); + statusTextView.setText(null); + } else { + statusTextView.setText(info.label); + statusTextView.setVisibility(View.VISIBLE); + statusTextView.setSingleLine(info.labelIsSingleLine); + } + + if (info.icon == null) { + connectionIconImageView.setVisibility(View.GONE); + } else { + connectionIconImageView.setVisibility(View.VISIBLE); + connectionIconImageView.setImageDrawable(info.icon); + } + } + + /** + * Updates row 1. For example: + * + * <ul> + * <li>Jake Peralta [Contact photo] + * <li>Walgreens + * <li>+1 (650) 253-0000 + * </ul> + */ + private void updatePrimaryNameAndPhoto() { + if (TextUtils.isEmpty(primaryInfo.name)) { + contactNameTextView.setText(null); + } else { + contactNameTextView.setText( + primaryInfo.nameIsNumber + ? PhoneNumberUtilsCompat.createTtsSpannable(primaryInfo.name) + : primaryInfo.name); + + // Set direction of the name field + int nameDirection = View.TEXT_DIRECTION_INHERIT; + if (primaryInfo.nameIsNumber) { + nameDirection = View.TEXT_DIRECTION_LTR; + } + contactNameTextView.setTextDirection(nameDirection); + } + + if (avatarImageView != null) { + if (hideAvatar) { + avatarImageView.setVisibility(View.GONE); + } else if (avatarImageView != null && avatarSize > 0 && updateAvatarVisibility()) { + boolean hasPhoto = + primaryInfo.photo != null && primaryInfo.photoType == ContactPhotoType.CONTACT; + // Contact has a photo, don't render a letter tile. + if (hasPhoto) { + avatarImageView.setBackground( + DrawableConverter.getRoundedDrawable( + context, primaryInfo.photo, avatarSize, avatarSize)); + // Contact has a name, that isn't a number. + } else { + int contactType = + primaryCallState.isVoiceMailNumber + ? LetterTileDrawable.TYPE_VOICEMAIL + : LetterTileDrawable.TYPE_DEFAULT; + letterTile.setCanonicalDialerLetterTileDetails( + primaryInfo.name, + primaryInfo.contactInfoLookupKey, + LetterTileDrawable.SHAPE_CIRCLE, + contactType); + avatarImageView.setBackground(letterTile); + } + } + } + } + + /** + * Updates row 2. For example: + * + * <ul> + * <li>Mobile +1 (650) 253-0000 + * <li>[HD icon] 00:15 + * <li>Call ended + * <li>Hanging up + * </ul> + */ + private void updateBottomRow() { + BottomRow.Info info = BottomRow.getInfo(context, primaryCallState, primaryInfo); + + bottomTextView.setText(info.label); + bottomTextView.setAllCaps(info.isSpamIconVisible); + workIconImageView.setVisibility(info.isWorkIconVisible ? View.VISIBLE : View.GONE); + hdIconImageView.setVisibility(info.isHdIconVisible ? View.VISIBLE : View.GONE); + forwardIconImageView.setVisibility(info.isForwardIconVisible ? View.VISIBLE : View.GONE); + spamIconImageView.setVisibility(info.isSpamIconVisible ? View.VISIBLE : View.GONE); + + if (info.isTimerVisible) { + bottomTextSwitcher.setDisplayedChild(1); + bottomTimerView.setBase( + primaryCallState.connectTimeMillis + - System.currentTimeMillis() + + SystemClock.elapsedRealtime()); + bottomTimerView.start(); + } else { + bottomTextSwitcher.setDisplayedChild(0); + bottomTimerView.stop(); + } + } +} diff --git a/java/com/android/incallui/contactgrid/TopRow.java b/java/com/android/incallui/contactgrid/TopRow.java new file mode 100644 index 000000000..a340fd0a0 --- /dev/null +++ b/java/com/android/incallui/contactgrid/TopRow.java @@ -0,0 +1,168 @@ +/* + * 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.contactgrid; + +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.support.annotation.Nullable; +import android.text.TextUtils; +import com.android.dialer.common.Assert; +import com.android.incallui.call.DialerCall; +import com.android.incallui.call.DialerCall.State; +import com.android.incallui.call.VideoUtils; +import com.android.incallui.incall.protocol.PrimaryCallState; + +/** + * Gets the content of the top row. For example: + * + * <ul> + * <li>Captain Holt ON HOLD + * <li>Calling... + * <li>[Wi-Fi icon] Calling via Starbucks Wi-Fi + * <li>[Wi-Fi icon] Starbucks Wi-Fi + * <li>Call from + * </ul> + */ +public class TopRow { + + /** Content of the top row. */ + public static class Info { + + @Nullable public final CharSequence label; + @Nullable public final Drawable icon; + public final boolean labelIsSingleLine; + + public Info(@Nullable CharSequence label, @Nullable Drawable icon, boolean labelIsSingleLine) { + this.label = label; + this.icon = icon; + this.labelIsSingleLine = labelIsSingleLine; + } + } + + private TopRow() {} + + public static Info getInfo(Context context, PrimaryCallState state) { + CharSequence label = null; + Drawable icon = state.connectionIcon; + boolean labelIsSingleLine = true; + + if (state.isWifi && icon == null) { + icon = context.getDrawable(R.drawable.quantum_ic_network_wifi_white_24); + } + + if (state.state == State.INCOMING || state.state == State.CALL_WAITING) { + // Call from + // [Wi-Fi icon] Video call from + // Hey Jake, pick up! + if (!TextUtils.isEmpty(state.callSubject)) { + label = state.callSubject; + labelIsSingleLine = false; + } else { + label = getLabelForIncoming(context, state); + } + } else if (VideoUtils.hasSentVideoUpgradeRequest(state.sessionModificationState) + || VideoUtils.hasReceivedVideoUpgradeRequest(state.sessionModificationState)) { + label = getLabelForVideoRequest(context, state); + } else if (state.state == State.PULLING) { + label = context.getString(R.string.incall_transferring); + } else if (state.state == State.DIALING || state.state == State.CONNECTING) { + // [Wi-Fi icon] Calling via Google Guest + // Calling... + label = getLabelForDialing(context, state); + } else if (state.state == State.ACTIVE && state.isRemotelyHeld) { + label = context.getString(R.string.incall_remotely_held); + } else { + // Video calling... + // [Wi-Fi icon] Starbucks Wi-Fi + label = getConnectionLabel(state); + } + + return new Info(label, icon, labelIsSingleLine); + } + + private static CharSequence getLabelForIncoming(Context context, PrimaryCallState state) { + if (VideoUtils.isVideoCall(state.videoState)) { + return getLabelForIncomingVideo(context, state.isWifi); + } else if (state.isWifi && !TextUtils.isEmpty(state.connectionLabel)) { + return state.connectionLabel; + } else if (isAccount(state)) { + return context.getString(R.string.contact_grid_incoming_via_template, state.connectionLabel); + } else if (state.isWorkCall) { + return context.getString(R.string.contact_grid_incoming_work_call); + } else { + return context.getString(R.string.contact_grid_incoming_voice_call); + } + } + + private static CharSequence getLabelForIncomingVideo(Context context, boolean isWifi) { + if (isWifi) { + return context.getString(R.string.contact_grid_incoming_wifi_video_call); + } else { + return context.getString(R.string.contact_grid_incoming_video_call); + } + } + + private static CharSequence getLabelForDialing(Context context, PrimaryCallState state) { + if (!TextUtils.isEmpty(state.connectionLabel) && !state.isWifi) { + return context.getString(R.string.incall_calling_via_template, state.connectionLabel); + } else { + if (VideoUtils.isVideoCall(state.videoState)) { + if (state.isWifi) { + return context.getString(R.string.incall_wifi_video_call_requesting); + } else { + return context.getString(R.string.incall_video_call_requesting); + } + } + return context.getString(R.string.incall_connecting); + } + } + + private static CharSequence getConnectionLabel(PrimaryCallState state) { + if (!TextUtils.isEmpty(state.connectionLabel) + && (isAccount(state) || state.isWifi || state.isConference)) { + // We normally don't show a "call state label" at all when active + // (but we can use the call state label to display the provider name). + return state.connectionLabel; + } else { + return null; + } + } + + private static CharSequence getLabelForVideoRequest(Context context, PrimaryCallState state) { + switch (state.sessionModificationState) { + case DialerCall.SESSION_MODIFICATION_STATE_WAITING_FOR_UPGRADE_TO_VIDEO_RESPONSE: + return context.getString(R.string.incall_video_call_requesting); + case DialerCall.SESSION_MODIFICATION_STATE_REQUEST_FAILED: + case DialerCall.SESSION_MODIFICATION_STATE_UPGRADE_TO_VIDEO_REQUEST_FAILED: + return context.getString(R.string.incall_video_call_request_failed); + case DialerCall.SESSION_MODIFICATION_STATE_REQUEST_REJECTED: + return context.getString(R.string.incall_video_call_request_rejected); + case DialerCall.SESSION_MODIFICATION_STATE_UPGRADE_TO_VIDEO_REQUEST_TIMED_OUT: + return context.getString(R.string.incall_video_call_request_timed_out); + case DialerCall.SESSION_MODIFICATION_STATE_RECEIVED_UPGRADE_TO_VIDEO_REQUEST: + return getLabelForIncomingVideo(context, state.isWifi); + case DialerCall.SESSION_MODIFICATION_STATE_NO_REQUEST: + default: + Assert.fail(); + return null; + } + } + + private static boolean isAccount(PrimaryCallState state) { + return !TextUtils.isEmpty(state.connectionLabel) && TextUtils.isEmpty(state.gatewayNumber); + } +} diff --git a/java/com/android/incallui/contactgrid/res/layout/incall_contactgrid_bottom_row.xml b/java/com/android/incallui/contactgrid/res/layout/incall_contactgrid_bottom_row.xml new file mode 100644 index 000000000..3900be556 --- /dev/null +++ b/java/com/android/incallui/contactgrid/res/layout/incall_contactgrid_bottom_row.xml @@ -0,0 +1,71 @@ +<?xml version="1.0" encoding="utf-8"?> +<LinearLayout + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:orientation="horizontal" + android:gravity="center_horizontal" + tools:showIn="@layout/incall_contact_grid"> + <ImageView + android:id="@id/contactgrid_workIcon" + android:layout_width="24dp" + android:layout_height="24dp" + android:layout_marginEnd="8dp" + android:scaleType="fitCenter" + android:src="@drawable/ic_work_profile" + android:tint="#ffffff" + tools:visibility="gone" + /> + <ImageView + android:id="@id/contactgrid_hdIcon" + android:layout_width="24dp" + android:layout_height="24dp" + android:layout_marginEnd="8dp" + android:scaleType="fitCenter" + android:src="@drawable/quantum_ic_hd_white_24" + tools:visibility="gone" + /> + <ImageView + android:id="@id/contactgrid_forwardIcon" + android:layout_width="24dp" + android:layout_height="24dp" + android:layout_marginEnd="8dp" + android:scaleType="fitCenter" + android:src="@drawable/quantum_ic_forward_white_24" + tools:visibility="gone" + /> + <ImageView + android:id="@+id/contactgrid_spamIcon" + android:layout_width="24dp" + android:layout_height="24dp" + android:layout_marginEnd="8dp" + android:scaleType="fitCenter" + android:src="@drawable/quantum_ic_report_white_18" + tools:visibility="gone" + /> + <ViewAnimator + android:id="@+id/contactgrid_bottom_text_switcher" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginBottom="2dp" + android:measureAllChildren="false"> + <TextView + android:id="@+id/contactgrid_bottom_text" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center_horizontal" + android:singleLine="true" + android:textAppearance="@style/Dialer.Incall.TextAppearance" + tools:gravity="start" + tools:text="Mobile +1 (650) 253-0000"/> + <Chronometer + android:id="@+id/contactgrid_bottom_timer" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center_horizontal" + android:singleLine="true" + android:textAppearance="@style/Dialer.Incall.TextAppearance" + tools:gravity="center"/> + </ViewAnimator> +</LinearLayout> diff --git a/java/com/android/incallui/contactgrid/res/layout/incall_contactgrid_top_row.xml b/java/com/android/incallui/contactgrid/res/layout/incall_contactgrid_top_row.xml new file mode 100644 index 000000000..59359c9c1 --- /dev/null +++ b/java/com/android/incallui/contactgrid/res/layout/incall_contactgrid_top_row.xml @@ -0,0 +1,26 @@ +<?xml version="1.0" encoding="utf-8"?> +<LinearLayout + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:gravity="center" + android:orientation="horizontal" + tools:showIn="@layout/incall_contact_grid"> + <ImageView + android:id="@id/contactgrid_connection_icon" + android:layout_width="24dp" + android:layout_height="24dp" + android:layout_marginEnd="10dp" + android:scaleType="fitCenter" + tools:src="@android:drawable/sym_def_app_icon" + tools:visibility="visible" + /> + <TextView + android:id="@id/contactgrid_status_text" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:singleLine="true" + android:textAppearance="@style/Dialer.Incall.TextAppearance" + tools:text="Captain Holt"/> +</LinearLayout> diff --git a/java/com/android/incallui/contactgrid/res/values/ids.xml b/java/com/android/incallui/contactgrid/res/values/ids.xml new file mode 100644 index 000000000..821dc9d98 --- /dev/null +++ b/java/com/android/incallui/contactgrid/res/values/ids.xml @@ -0,0 +1,31 @@ +<?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> + <item name="contactgrid_connection_icon" type="id"/> + <item name="contactgrid_status_text" type="id"/> + <item name="contactgrid_contact_name" type="id"/> + <item name="contactgrid_workIcon" type="id"/> + <item name="contactgrid_hdIcon" type="id"/> + <item name="contactgrid_forwardIcon" type="id"/> + <item name="contactgrid_spamIcon" type="id"/> + <item name="contactgrid_bottom_text" type="id"/> + <item name="contactgrid_bottom_timer" type="id"/> + <item name="contactgrid_avatar" type="id"/> + <item name="contactgrid_top_row" type="id"/> + <item name="contactgrid_bottom_row" type="id"/> +</resources> diff --git a/java/com/android/incallui/contactgrid/res/values/strings.xml b/java/com/android/incallui/contactgrid/res/values/strings.xml new file mode 100644 index 000000000..385f843b1 --- /dev/null +++ b/java/com/android/incallui/contactgrid/res/values/strings.xml @@ -0,0 +1,69 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + + <!-- Title displayed in the overlay for outgoing calls which include the name of the provider. + [CHAR LIMIT=40] --> + <string name="incall_calling_via_template">Calling via <xliff:g id="provider_name">%s</xliff:g></string> + + <!-- Displayed above the contact name during an outgoing phone call. Indicates that the call is + in the connecting stage. --> + <string name="incall_connecting">Calling…</string> + + <!-- Displayed above the contact name when an external call is being pulled to the local + device. --> + <string name="incall_transferring">Transferring…</string> + + <!-- Displayed above the contact name when the user requests an upgrade from a voice call to a + video call. --> + <string name="incall_video_call_requesting">Video calling…</string> + + <!-- Displayed above the contact name when the user requests an upgrade from a voice call to a + Wi-Fi video call. --> + <string name="incall_wifi_video_call_requesting">Wi-Fi video calling…</string> + + <!-- Displayed above the contact name when the user's video upgrade failed due to an unknown + reason. --> + <string name="incall_video_call_request_failed">Unable to connect</string> + + <!-- Displayed above the contact name when the user's video upgrade was declined by the remote + party. --> + <string name="incall_video_call_request_rejected">Call declined</string> + + <!-- Displayed above the contact name when no response was received for the user's upgrade + requests and we timed out. --> + <string name="incall_video_call_request_timed_out">Call timed out</string> + + <!-- In-call screen: status label for a call that's in the process of hanging up + [CHAR LIMIT=25] --> + <string name="incall_hanging_up">Hanging up</string> + + <!-- In-call screen: status label displayed briefly after a call ends [CHAR LIMIT=25] --> + <string name="incall_call_ended">Call ended</string> + + <!-- In-call screen: label shown at the top of the screen when a call is on hold by the remote + party [CHAR LIMIT=25] --> + <string name="incall_remotely_held">On hold</string> + + <!-- Displayed in the answer call screen for incoming video calls. --> + <string name="contact_grid_incoming_video_call">Video call from</string> + + <!-- Displayed in the answer call screen for incoming video calls over Wi-F. --> + <string name="contact_grid_incoming_wifi_video_call">Wi-Fi video call from</string> + + <!-- Displayed in the answer call screen for incoming voice calls. --> + <string name="contact_grid_incoming_voice_call">Call from</string> + + <!-- Displayed in the answer call screen for incoming voice calls. --> + <string name="contact_grid_incoming_work_call">Work call from</string> + + <!-- Displayed in the answer call screen for incoming calls via a phone account. --> + <string name="contact_grid_incoming_via_template">Incoming via <xliff:g id="provider_name">%s</xliff:g></string> + + <!-- Displayed in the answer call screen for incoming spam calls. --> + <string name="contact_grid_incoming_suspected_spam">Suspected spam caller</string> + + <!-- In-call screen: string shown to the user when their outgoing number is different than the + number reported by TelephonyManager#getLine1Number(). This is used for carriers like + Project Fi so that users can give their number to emergency responders. --> + <string name="contact_grid_callback_number">Callback number: <xliff:g id="dark_number">%1$s</xliff:g></string> +</resources> diff --git a/java/com/android/incallui/hold/AndroidManifest.xml b/java/com/android/incallui/hold/AndroidManifest.xml new file mode 100644 index 000000000..2aedce903 --- /dev/null +++ b/java/com/android/incallui/hold/AndroidManifest.xml @@ -0,0 +1,3 @@ +<manifest + package="com.android.incallui.hold"> +</manifest> diff --git a/java/com/android/incallui/hold/OnHoldFragment.java b/java/com/android/incallui/hold/OnHoldFragment.java new file mode 100644 index 000000000..c6952131b --- /dev/null +++ b/java/com/android/incallui/hold/OnHoldFragment.java @@ -0,0 +1,102 @@ +/* + * 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.hold; + +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v4.app.Fragment; +import android.telephony.PhoneNumberUtils; +import android.text.BidiFormatter; +import android.text.TextDirectionHeuristics; +import android.transition.TransitionManager; +import android.view.LayoutInflater; +import android.view.View; +import android.view.View.OnAttachStateChangeListener; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.TextView; +import com.android.dialer.common.Assert; +import com.android.incallui.incall.protocol.SecondaryInfo; + +/** Shows banner UI for background call */ +public class OnHoldFragment extends Fragment { + + private static final String ARG_INFO = "info"; + private boolean padTopInset = true; + private int topInset; + + public static OnHoldFragment newInstance(@NonNull SecondaryInfo info) { + OnHoldFragment fragment = new OnHoldFragment(); + Bundle args = new Bundle(); + args.putParcelable(ARG_INFO, info); + fragment.setArguments(args); + return fragment; + } + + @Nullable + @Override + public View onCreateView( + LayoutInflater layoutInflater, @Nullable ViewGroup viewGroup, @Nullable Bundle bundle) { + final View view = layoutInflater.inflate(R.layout.incall_on_hold_banner, viewGroup, false); + + SecondaryInfo secondaryInfo = getArguments().getParcelable(ARG_INFO); + secondaryInfo = Assert.isNotNull(secondaryInfo); + + ((TextView) view.findViewById(R.id.hold_contact_name)) + .setText( + secondaryInfo.nameIsNumber + ? PhoneNumberUtils.createTtsSpannable( + BidiFormatter.getInstance() + .unicodeWrap(secondaryInfo.name, TextDirectionHeuristics.LTR)) + : secondaryInfo.name); + ((ImageView) view.findViewById(R.id.hold_phone_icon)) + .setImageResource( + secondaryInfo.isVideoCall + ? R.drawable.quantum_ic_videocam_white_18 + : R.drawable.quantum_ic_call_white_18); + view.addOnAttachStateChangeListener( + new OnAttachStateChangeListener() { + @Override + public void onViewAttachedToWindow(View v) { + topInset = v.getRootWindowInsets().getSystemWindowInsetTop(); + applyInset(); + } + + @Override + public void onViewDetachedFromWindow(View v) {} + }); + return view; + } + + public void setPadTopInset(boolean padTopInset) { + this.padTopInset = padTopInset; + applyInset(); + } + + private void applyInset() { + if (getView() == null) { + return; + } + + int newPadding = padTopInset ? topInset : 0; + if (newPadding != getView().getPaddingTop()) { + TransitionManager.beginDelayedTransition(((ViewGroup) getView().getParent())); + getView().setPadding(0, newPadding, 0, 0); + } + } +} diff --git a/java/com/android/incallui/hold/res/layout/incall_on_hold_banner.xml b/java/com/android/incallui/hold/res/layout/incall_on_hold_banner.xml new file mode 100644 index 000000000..c213af5da --- /dev/null +++ b/java/com/android/incallui/hold/res/layout/incall_on_hold_banner.xml @@ -0,0 +1,46 @@ +<?xml version="1.0" encoding="utf-8"?> +<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="wrap_content" + android:background="#CC212121" + android:fitsSystemWindows="true"> + <LinearLayout + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:paddingStart="24dp" + android:paddingEnd="24dp" + android:paddingTop="16dp" + android:paddingBottom="16dp" + android:gravity="center_vertical"> + + <ImageView + android:id="@+id/hold_phone_icon" + android:layout_width="18dp" + android:layout_height="18dp" + android:src="@drawable/quantum_ic_call_white_18" + android:contentDescription="@null"/> + + <TextView + android:id="@+id/hold_contact_name" + style="@style/Dialer.Incall.TextAppearance" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_weight="1" + android:layout_marginStart="8dp" + android:layout_marginEnd="24dp" + android:ellipsize="end" + android:singleLine="true" + android:textColor="@android:color/white" + tools:text="Jake Peralta Really Longname"/> + + <TextView + style="@style/Dialer.Incall.TextAppearance" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:textAllCaps="true" + android:textColor="@android:color/white" + android:text="@string/incall_on_hold"/> + </LinearLayout> +</FrameLayout> diff --git a/java/com/android/incallui/hold/res/values/strings.xml b/java/com/android/incallui/hold/res/values/strings.xml new file mode 100644 index 000000000..2e66bcf6c --- /dev/null +++ b/java/com/android/incallui/hold/res/values/strings.xml @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + + <string name="incall_on_hold">On hold</string> + +</resources> diff --git a/java/com/android/incallui/incall/bindings/InCallBindings.java b/java/com/android/incallui/incall/bindings/InCallBindings.java new file mode 100644 index 000000000..8bbbc68e1 --- /dev/null +++ b/java/com/android/incallui/incall/bindings/InCallBindings.java @@ -0,0 +1,28 @@ +/* + * 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.incall.bindings; + +import com.android.incallui.incall.impl.InCallFragment; +import com.android.incallui.incall.protocol.InCallScreen; + +/** Bindings for the in call module. */ +public class InCallBindings { + + public static InCallScreen createInCallScreen() { + return new InCallFragment(); + } +} diff --git a/java/com/android/incallui/incall/impl/AndroidManifest.xml b/java/com/android/incallui/incall/impl/AndroidManifest.xml new file mode 100644 index 000000000..a0e3110d8 --- /dev/null +++ b/java/com/android/incallui/incall/impl/AndroidManifest.xml @@ -0,0 +1,3 @@ +<manifest + package="com.android.incall.incall.impl"> +</manifest> diff --git a/java/com/android/incallui/incall/impl/AutoValue_MappedButtonConfig_MappingInfo.java b/java/com/android/incallui/incall/impl/AutoValue_MappedButtonConfig_MappingInfo.java new file mode 100644 index 000000000..addebc484 --- /dev/null +++ b/java/com/android/incallui/incall/impl/AutoValue_MappedButtonConfig_MappingInfo.java @@ -0,0 +1,135 @@ +/* + * 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.incall.impl; + +import javax.annotation.Generated; + +@Generated("com.google.auto.value.processor.AutoValueProcessor") + final class AutoValue_MappedButtonConfig_MappingInfo extends MappedButtonConfig.MappingInfo { + + private final int slot; + private final int slotOrder; + private final int conflictOrder; + + private AutoValue_MappedButtonConfig_MappingInfo( + int slot, + int slotOrder, + int conflictOrder) { + this.slot = slot; + this.slotOrder = slotOrder; + this.conflictOrder = conflictOrder; + } + + @Override + public int getSlot() { + return slot; + } + + @Override + public int getSlotOrder() { + return slotOrder; + } + + @Override + public int getConflictOrder() { + return conflictOrder; + } + + @Override + public String toString() { + return "MappingInfo{" + + "slot=" + slot + ", " + + "slotOrder=" + slotOrder + ", " + + "conflictOrder=" + conflictOrder + + "}"; + } + + @Override + public boolean equals(Object o) { + if (o == this) { + return true; + } + if (o instanceof MappedButtonConfig.MappingInfo) { + MappedButtonConfig.MappingInfo that = (MappedButtonConfig.MappingInfo) o; + return (this.slot == that.getSlot()) + && (this.slotOrder == that.getSlotOrder()) + && (this.conflictOrder == that.getConflictOrder()); + } + return false; + } + + @Override + public int hashCode() { + int h = 1; + h *= 1000003; + h ^= this.slot; + h *= 1000003; + h ^= this.slotOrder; + h *= 1000003; + h ^= this.conflictOrder; + return h; + } + + static final class Builder extends MappedButtonConfig.MappingInfo.Builder { + private Integer slot; + private Integer slotOrder; + private Integer conflictOrder; + Builder() { + } + private Builder(MappedButtonConfig.MappingInfo source) { + this.slot = source.getSlot(); + this.slotOrder = source.getSlotOrder(); + this.conflictOrder = source.getConflictOrder(); + } + @Override + public MappedButtonConfig.MappingInfo.Builder setSlot(int slot) { + this.slot = slot; + return this; + } + @Override + public MappedButtonConfig.MappingInfo.Builder setSlotOrder(int slotOrder) { + this.slotOrder = slotOrder; + return this; + } + @Override + public MappedButtonConfig.MappingInfo.Builder setConflictOrder(int conflictOrder) { + this.conflictOrder = conflictOrder; + return this; + } + @Override + public MappedButtonConfig.MappingInfo build() { + String missing = ""; + if (this.slot == null) { + missing += " slot"; + } + if (this.slotOrder == null) { + missing += " slotOrder"; + } + if (this.conflictOrder == null) { + missing += " conflictOrder"; + } + if (!missing.isEmpty()) { + throw new IllegalStateException("Missing required properties:" + missing); + } + return new AutoValue_MappedButtonConfig_MappingInfo( + this.slot, + this.slotOrder, + this.conflictOrder); + } + } + +} diff --git a/java/com/android/incallui/incall/impl/ButtonChooser.java b/java/com/android/incallui/incall/impl/ButtonChooser.java new file mode 100644 index 000000000..55b82f015 --- /dev/null +++ b/java/com/android/incallui/incall/impl/ButtonChooser.java @@ -0,0 +1,114 @@ +/* + * 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.incall.impl; + +import android.support.annotation.NonNull; +import com.android.dialer.common.Assert; +import com.android.incallui.incall.protocol.InCallButtonIds; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import javax.annotation.concurrent.Immutable; + +/** + * Determines where logical buttons should be placed in the {@link InCallFragment} based on the + * provided mapping. + * + * <p>The button placement returned by a call to {@link #getButtonPlacement(int, Set)} is created as + * follows: one button is placed at each UI slot, using the provided mapping to resolve conflicts. + * Any allowed buttons that were not chosen for their desired slot are filled in at the end of the + * list until it becomes the proper size. + */ +@Immutable +final class ButtonChooser { + + private final MappedButtonConfig config; + + public ButtonChooser(@NonNull MappedButtonConfig config) { + this.config = Assert.isNotNull(config); + } + + /** + * Returns the buttons that should be shown in the {@link InCallFragment}, ordered appropriately. + * + * @param numUiButtons the number of ui buttons available. + * @param allowedButtons the {@link InCallButtonIds} that can be shown. + * @param disabledButtons the {@link InCallButtonIds} that can be shown but in disabled stats. + * @return an immutable list whose size is at most {@code numUiButtons}, containing the buttons to + * show. + */ + @NonNull + public List<Integer> getButtonPlacement( + int numUiButtons, + @NonNull Set<Integer> allowedButtons, + @NonNull Set<Integer> disabledButtons) { + Assert.isNotNull(allowedButtons); + Assert.checkArgument(numUiButtons >= 0); + + if (numUiButtons == 0 || allowedButtons.isEmpty()) { + return Collections.emptyList(); + } + + List<Integer> placedButtons = new ArrayList<>(); + List<Integer> conflicts = new ArrayList<>(); + placeButtonsInSlots(numUiButtons, allowedButtons, placedButtons, conflicts); + placeConflictsInOpenSlots( + numUiButtons, allowedButtons, disabledButtons, placedButtons, conflicts); + return Collections.unmodifiableList(placedButtons); + } + + private void placeButtonsInSlots( + int numUiButtons, + @NonNull Set<Integer> allowedButtons, + @NonNull List<Integer> placedButtons, + @NonNull List<Integer> conflicts) { + List<Integer> configuredSlots = config.getOrderedMappedSlots(); + for (int i = 0; i < configuredSlots.size() && placedButtons.size() < numUiButtons; ++i) { + int slotNumber = configuredSlots.get(i); + List<Integer> potentialButtons = config.getButtonsForSlot(slotNumber); + Collections.sort(potentialButtons, config.getSlotComparator()); + for (int j = 0; j < potentialButtons.size(); ++j) { + if (allowedButtons.contains(potentialButtons.get(j))) { + placedButtons.add(potentialButtons.get(j)); + conflicts.addAll(potentialButtons.subList(j + 1, potentialButtons.size())); + break; + } + } + } + } + + private void placeConflictsInOpenSlots( + int numUiButtons, + @NonNull Set<Integer> allowedButtons, + @NonNull Set<Integer> disabledButtons, + @NonNull List<Integer> placedButtons, + @NonNull List<Integer> conflicts) { + Collections.sort(conflicts, config.getConflictComparator()); + for (Integer conflict : conflicts) { + if (placedButtons.size() >= numUiButtons) { + return; + } + // If the conflict button is allowed but disabled, don't place it since it probably will + // move when it's enabled. + if (!allowedButtons.contains(conflict) || disabledButtons.contains(conflict)) { + continue; + } + placedButtons.add(conflict); + } + } +} diff --git a/java/com/android/incallui/incall/impl/ButtonChooserFactory.java b/java/com/android/incallui/incall/impl/ButtonChooserFactory.java new file mode 100644 index 000000000..1b168a6f7 --- /dev/null +++ b/java/com/android/incallui/incall/impl/ButtonChooserFactory.java @@ -0,0 +1,100 @@ +/* + * 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.incall.impl; + +import android.support.v4.util.ArrayMap; +import android.telephony.TelephonyManager; +import com.android.incallui.incall.impl.MappedButtonConfig.MappingInfo; +import com.android.incallui.incall.protocol.InCallButtonIds; +import java.util.Map; + +/** + * Creates {@link ButtonChooser} objects, based on the current network and phone type. + */ +class ButtonChooserFactory { + + /** + * Creates the appropriate {@link ButtonChooser} based on the given information. + * + * @param voiceNetworkType the result of a call to {@link TelephonyManager#getVoiceNetworkType()}. + * @param isWiFi {@code true} if the call is made over WiFi, {@code false} otherwise. + * @param phoneType the result of a call to {@link TelephonyManager#getPhoneType()}. + * @return the ButtonChooser. + */ + public static ButtonChooser newButtonChooser( + int voiceNetworkType, boolean isWiFi, int phoneType) { + if (voiceNetworkType == TelephonyManager.NETWORK_TYPE_LTE || isWiFi) { + return newImsAndWiFiButtonChooser(); + } + + if (phoneType == TelephonyManager.PHONE_TYPE_CDMA) { + return newCdmaButtonChooser(); + } + + if (phoneType == TelephonyManager.PHONE_TYPE_GSM) { + return newGsmButtonChooser(); + } + + return newImsAndWiFiButtonChooser(); + } + + private static ButtonChooser newImsAndWiFiButtonChooser() { + Map<Integer, MappingInfo> mapping = createCommonMapping(); + mapping.put( + InCallButtonIds.BUTTON_MANAGE_VOICE_CONFERENCE, + MappingInfo.builder(4).setSlotOrder(0).build()); + mapping.put( + InCallButtonIds.BUTTON_UPGRADE_TO_VIDEO, MappingInfo.builder(4).setSlotOrder(10).build()); + mapping.put( + InCallButtonIds.BUTTON_SWITCH_TO_SECONDARY, MappingInfo.builder(5).setSlotOrder(0).build()); + mapping.put(InCallButtonIds.BUTTON_HOLD, MappingInfo.builder(5).setSlotOrder(10).build()); + + return new ButtonChooser(new MappedButtonConfig(mapping)); + } + + private static ButtonChooser newCdmaButtonChooser() { + Map<Integer, MappingInfo> mapping = createCommonMapping(); + mapping.put( + InCallButtonIds.BUTTON_MANAGE_VOICE_CONFERENCE, + MappingInfo.builder(4).setSlotOrder(0).build()); + mapping.put(InCallButtonIds.BUTTON_SWAP, MappingInfo.builder(5).setSlotOrder(0).build()); + + return new ButtonChooser(new MappedButtonConfig(mapping)); + } + + private static ButtonChooser newGsmButtonChooser() { + Map<Integer, MappingInfo> mapping = createCommonMapping(); + mapping.put( + InCallButtonIds.BUTTON_SWITCH_TO_SECONDARY, MappingInfo.builder(4).setSlotOrder(0).build()); + mapping.put( + InCallButtonIds.BUTTON_MANAGE_VOICE_CONFERENCE, + MappingInfo.builder(4).setSlotOrder(10).build()); + mapping.put(InCallButtonIds.BUTTON_HOLD, MappingInfo.builder(5).setSlotOrder(0).build()); + + return new ButtonChooser(new MappedButtonConfig(mapping)); + } + + private static Map<Integer, MappingInfo> createCommonMapping() { + Map<Integer, MappingInfo> mapping = new ArrayMap<>(); + mapping.put(InCallButtonIds.BUTTON_MUTE, MappingInfo.builder(0).build()); + mapping.put(InCallButtonIds.BUTTON_DIALPAD, MappingInfo.builder(1).build()); + mapping.put(InCallButtonIds.BUTTON_AUDIO, MappingInfo.builder(2).build()); + mapping.put(InCallButtonIds.BUTTON_MERGE, MappingInfo.builder(3).setSlotOrder(0).build()); + mapping.put(InCallButtonIds.BUTTON_ADD_CALL, MappingInfo.builder(3).build()); + return mapping; + } +} diff --git a/java/com/android/incallui/incall/impl/ButtonController.java b/java/com/android/incallui/incall/impl/ButtonController.java new file mode 100644 index 000000000..95a38be44 --- /dev/null +++ b/java/com/android/incallui/incall/impl/ButtonController.java @@ -0,0 +1,584 @@ +/* + * 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.incall.impl; + +import android.support.annotation.CallSuper; +import android.support.annotation.DrawableRes; +import android.support.annotation.NonNull; +import android.support.annotation.StringRes; +import android.telecom.CallAudioState; +import android.text.TextUtils; +import android.view.View; +import android.view.View.OnClickListener; +import com.android.dialer.common.Assert; +import com.android.incallui.incall.impl.CheckableLabeledButton.OnCheckedChangeListener; +import com.android.incallui.incall.protocol.InCallButtonIds; +import com.android.incallui.incall.protocol.InCallButtonUiDelegate; +import com.android.incallui.incall.protocol.InCallScreenDelegate; + +/** Manages a single button. */ +interface ButtonController { + + boolean isEnabled(); + + void setEnabled(boolean isEnabled); + + boolean isAllowed(); + + void setAllowed(boolean isAllowed); + + void setChecked(boolean isChecked); + + @InCallButtonIds + int getInCallButtonId(); + + void setButton(CheckableLabeledButton button); + + final class Controllers { + + private static void resetButton(CheckableLabeledButton button) { + if (button != null) { + button.setOnCheckedChangeListener(null); + button.setOnClickListener(null); + } + } + } + + abstract class CheckableButtonController implements ButtonController, OnCheckedChangeListener { + + @NonNull protected final InCallButtonUiDelegate delegate; + @InCallButtonIds protected final int buttonId; + @StringRes protected final int checkedDescription; + @StringRes protected final int uncheckedDescription; + protected boolean isEnabled; + protected boolean isAllowed; + protected boolean isChecked; + protected CheckableLabeledButton button; + + protected CheckableButtonController( + @NonNull InCallButtonUiDelegate delegate, + @InCallButtonIds int buttonId, + @StringRes int checkedContentDescription, + @StringRes int uncheckedContentDescription) { + Assert.isNotNull(delegate); + this.delegate = delegate; + this.buttonId = buttonId; + this.checkedDescription = checkedContentDescription; + this.uncheckedDescription = uncheckedContentDescription; + } + + @Override + public boolean isEnabled() { + return isEnabled; + } + + @Override + public void setEnabled(boolean isEnabled) { + this.isEnabled = isEnabled; + if (button != null) { + button.setEnabled(isEnabled); + } + } + + @Override + public boolean isAllowed() { + return isAllowed; + } + + @Override + public void setAllowed(boolean isAllowed) { + this.isAllowed = isAllowed; + if (button != null) { + button.setVisibility(isAllowed ? View.VISIBLE : View.INVISIBLE); + } + } + + @Override + public void setChecked(boolean isChecked) { + this.isChecked = isChecked; + if (button != null) { + button.setChecked(isChecked); + } + } + + @Override + @InCallButtonIds + public int getInCallButtonId() { + return buttonId; + } + + @Override + @CallSuper + public void setButton(CheckableLabeledButton button) { + Controllers.resetButton(this.button); + + this.button = button; + if (button != null) { + button.setEnabled(isEnabled); + button.setVisibility(isAllowed ? View.VISIBLE : View.INVISIBLE); + button.setChecked(isChecked); + button.setOnClickListener(null); + button.setOnCheckedChangeListener(this); + button.setContentDescription( + button.getContext().getText(isChecked ? checkedDescription : uncheckedDescription)); + button.setShouldShowMoreIndicator(false); + } + } + + @Override + public void onCheckedChanged(CheckableLabeledButton checkableLabeledButton, boolean isChecked) { + button.setContentDescription( + button.getContext().getText(isChecked ? checkedDescription : uncheckedDescription)); + doCheckedChanged(isChecked); + } + + protected abstract void doCheckedChanged(boolean isChecked); + } + + abstract class SimpleCheckableButtonController extends CheckableButtonController { + + @StringRes private final int label; + @DrawableRes private final int icon; + + protected SimpleCheckableButtonController( + @NonNull InCallButtonUiDelegate delegate, + @InCallButtonIds int buttonId, + @StringRes int checkedContentDescription, + @StringRes int uncheckedContentDescription, + @StringRes int label, + @DrawableRes int icon) { + super( + delegate, + buttonId, + checkedContentDescription == 0 ? label : checkedContentDescription, + uncheckedContentDescription == 0 ? label : uncheckedContentDescription); + this.label = label; + this.icon = icon; + } + + @Override + @CallSuper + public void setButton(CheckableLabeledButton button) { + super.setButton(button); + if (button != null) { + button.setLabelText(label); + button.setIconDrawable(icon); + } + } + } + + abstract class NonCheckableButtonController implements ButtonController, OnClickListener { + + protected final InCallButtonUiDelegate delegate; + @InCallButtonIds protected final int buttonId; + @StringRes protected final int contentDescription; + protected boolean isEnabled; + protected boolean isAllowed; + protected CheckableLabeledButton button; + + protected NonCheckableButtonController( + InCallButtonUiDelegate delegate, + @InCallButtonIds int buttonId, + @StringRes int contentDescription) { + this.delegate = delegate; + this.buttonId = buttonId; + this.contentDescription = contentDescription; + } + + @Override + public boolean isEnabled() { + return isEnabled; + } + + @Override + public void setEnabled(boolean isEnabled) { + this.isEnabled = isEnabled; + if (button != null) { + button.setEnabled(isEnabled); + } + } + + @Override + public boolean isAllowed() { + return isAllowed; + } + + @Override + public void setAllowed(boolean isAllowed) { + this.isAllowed = isAllowed; + if (button != null) { + button.setVisibility(isAllowed ? View.VISIBLE : View.INVISIBLE); + } + } + + @Override + public void setChecked(boolean isChecked) { + Assert.fail(); + } + + @Override + @InCallButtonIds + public int getInCallButtonId() { + return buttonId; + } + + @Override + @CallSuper + public void setButton(CheckableLabeledButton button) { + Controllers.resetButton(this.button); + + this.button = button; + if (button != null) { + button.setEnabled(isEnabled); + button.setVisibility(isAllowed ? View.VISIBLE : View.INVISIBLE); + button.setChecked(false); + button.setOnCheckedChangeListener(null); + button.setOnClickListener(this); + button.setContentDescription(button.getContext().getText(contentDescription)); + button.setShouldShowMoreIndicator(false); + } + } + } + + abstract class SimpleNonCheckableButtonController extends NonCheckableButtonController { + + @StringRes private final int label; + @DrawableRes private final int icon; + + protected SimpleNonCheckableButtonController( + InCallButtonUiDelegate delegate, + @InCallButtonIds int buttonId, + @StringRes int contentDescription, + @StringRes int label, + @DrawableRes int icon) { + super(delegate, buttonId, contentDescription == 0 ? label : contentDescription); + this.label = label; + this.icon = icon; + } + + @Override + @CallSuper + public void setButton(CheckableLabeledButton button) { + super.setButton(button); + if (button != null) { + button.setLabelText(label); + button.setIconDrawable(icon); + } + } + } + + class MuteButtonController extends SimpleCheckableButtonController { + + public MuteButtonController(InCallButtonUiDelegate delegate) { + super( + delegate, + InCallButtonIds.BUTTON_MUTE, + R.string.incall_content_description_muted, + R.string.incall_content_description_unmuted, + R.string.incall_label_mute, + R.drawable.quantum_ic_mic_off_white_36); + } + + @Override + public void doCheckedChanged(boolean isChecked) { + delegate.muteClicked(isChecked); + } + } + + class SpeakerButtonController + implements ButtonController, OnCheckedChangeListener, OnClickListener { + + @NonNull private final InCallButtonUiDelegate delegate; + private boolean isEnabled; + private boolean isAllowed; + private boolean isChecked; + private CheckableLabeledButton button; + + @StringRes private int label = R.string.incall_label_speaker; + @DrawableRes private int icon = R.drawable.quantum_ic_volume_up_white_36; + private boolean checkable; + private CharSequence contentDescription; + private CharSequence checkedContentDescription; + private CharSequence uncheckedContentDescription; + + public SpeakerButtonController(@NonNull InCallButtonUiDelegate delegate) { + this.delegate = delegate; + } + + @Override + public boolean isEnabled() { + return isEnabled; + } + + @Override + public void setEnabled(boolean isEnabled) { + this.isEnabled = isEnabled; + if (button != null) { + button.setEnabled(isEnabled && isAllowed); + } + } + + @Override + public boolean isAllowed() { + return isAllowed; + } + + @Override + public void setAllowed(boolean isAllowed) { + this.isAllowed = isAllowed; + if (button != null) { + button.setEnabled(isEnabled && isAllowed); + } + } + + @Override + public void setChecked(boolean isChecked) { + this.isChecked = isChecked; + if (button != null) { + button.setChecked(isChecked); + } + } + + @Override + public int getInCallButtonId() { + return InCallButtonIds.BUTTON_AUDIO; + } + + @Override + public void setButton(CheckableLabeledButton button) { + this.button = button; + if (button != null) { + button.setEnabled(isEnabled && isAllowed); + button.setVisibility(View.VISIBLE); + button.setChecked(isChecked); + button.setOnClickListener(checkable ? null : this); + button.setOnCheckedChangeListener(checkable ? this : null); + button.setLabelText(label); + button.setIconDrawable(icon); + button.setContentDescription( + isChecked ? checkedContentDescription : uncheckedContentDescription); + button.setShouldShowMoreIndicator(!checkable); + } + } + + public void setAudioState(CallAudioState audioState) { + @StringRes int contentDescriptionResId; + if ((audioState.getSupportedRouteMask() & CallAudioState.ROUTE_BLUETOOTH) + == CallAudioState.ROUTE_BLUETOOTH) { + checkable = false; + isChecked = false; + label = R.string.incall_label_audio; + + if ((audioState.getRoute() & CallAudioState.ROUTE_BLUETOOTH) + == CallAudioState.ROUTE_BLUETOOTH) { + icon = R.drawable.quantum_ic_bluetooth_audio_white_36; + contentDescriptionResId = R.string.incall_content_description_bluetooth; + } else if ((audioState.getRoute() & CallAudioState.ROUTE_SPEAKER) + == CallAudioState.ROUTE_SPEAKER) { + icon = R.drawable.quantum_ic_volume_up_white_36; + contentDescriptionResId = R.string.incall_content_description_speaker; + } else if ((audioState.getRoute() & CallAudioState.ROUTE_WIRED_HEADSET) + == CallAudioState.ROUTE_WIRED_HEADSET) { + icon = R.drawable.quantum_ic_headset_white_36; + contentDescriptionResId = R.string.incall_content_description_headset; + } else { + icon = R.drawable.ic_phone_audio_white_36dp; + contentDescriptionResId = R.string.incall_content_description_earpiece; + } + } else { + checkable = true; + isChecked = audioState.getRoute() == CallAudioState.ROUTE_SPEAKER; + label = R.string.incall_label_speaker; + icon = R.drawable.quantum_ic_volume_up_white_36; + contentDescriptionResId = R.string.incall_content_description_speaker; + } + + contentDescription = delegate.getContext().getText(contentDescriptionResId); + checkedContentDescription = + TextUtils.concat( + contentDescription, + delegate.getContext().getText(R.string.incall_talkback_speaker_on)); + uncheckedContentDescription = + TextUtils.concat( + contentDescription, + delegate.getContext().getText(R.string.incall_talkback_speaker_off)); + setButton(button); + } + + @Override + public void onClick(View v) { + delegate.showAudioRouteSelector(); + } + + @Override + public void onCheckedChanged(CheckableLabeledButton checkableLabeledButton, boolean isChecked) { + checkableLabeledButton.setContentDescription( + isChecked ? checkedContentDescription : uncheckedContentDescription); + delegate.toggleSpeakerphone(); + } + } + + class DialpadButtonController extends SimpleCheckableButtonController { + + public DialpadButtonController(@NonNull InCallButtonUiDelegate delegate) { + super( + delegate, + InCallButtonIds.BUTTON_DIALPAD, + 0, + 0, + R.string.incall_label_dialpad, + R.drawable.quantum_ic_dialpad_white_36); + } + + @Override + public void doCheckedChanged(boolean isChecked) { + delegate.showDialpadClicked(isChecked); + } + } + + class HoldButtonController extends SimpleCheckableButtonController { + + public HoldButtonController(@NonNull InCallButtonUiDelegate delegate) { + super( + delegate, + InCallButtonIds.BUTTON_HOLD, + R.string.incall_content_description_unhold, + R.string.incall_content_description_hold, + R.string.incall_label_hold, + R.drawable.quantum_ic_pause_white_36); + } + + @Override + public void doCheckedChanged(boolean isChecked) { + delegate.holdClicked(isChecked); + } + } + + class AddCallButtonController extends SimpleNonCheckableButtonController { + + public AddCallButtonController(@NonNull InCallButtonUiDelegate delegate) { + super( + delegate, + InCallButtonIds.BUTTON_ADD_CALL, + 0, + R.string.incall_label_add_call, + R.drawable.ic_addcall_white); + Assert.isNotNull(delegate); + } + + @Override + public void onClick(View view) { + delegate.addCallClicked(); + } + } + + class SwapButtonController extends SimpleNonCheckableButtonController { + + public SwapButtonController(@NonNull InCallButtonUiDelegate delegate) { + super( + delegate, + InCallButtonIds.BUTTON_SWAP, + R.string.incall_content_description_swap_calls, + R.string.incall_label_swap, + R.drawable.quantum_ic_swap_calls_white_36); + Assert.isNotNull(delegate); + } + + @Override + public void onClick(View view) { + delegate.swapClicked(); + } + } + + class MergeButtonController extends SimpleNonCheckableButtonController { + + public MergeButtonController(@NonNull InCallButtonUiDelegate delegate) { + super( + delegate, + InCallButtonIds.BUTTON_MERGE, + R.string.incall_content_description_merge_calls, + R.string.incall_label_merge, + R.drawable.quantum_ic_call_merge_white_36); + Assert.isNotNull(delegate); + } + + @Override + public void onClick(View view) { + delegate.mergeClicked(); + } + } + + class UpgradeToVideoButtonController extends SimpleNonCheckableButtonController { + + public UpgradeToVideoButtonController(@NonNull InCallButtonUiDelegate delegate) { + super( + delegate, + InCallButtonIds.BUTTON_UPGRADE_TO_VIDEO, + 0, + R.string.incall_label_videocall, + R.drawable.quantum_ic_videocam_white_36); + Assert.isNotNull(delegate); + } + + @Override + public void onClick(View view) { + delegate.changeToVideoClicked(); + } + } + + class ManageConferenceButtonController extends SimpleNonCheckableButtonController { + + private final InCallScreenDelegate inCallScreenDelegate; + + public ManageConferenceButtonController(@NonNull InCallScreenDelegate inCallScreenDelegate) { + super( + null, + InCallButtonIds.BUTTON_MANAGE_VOICE_CONFERENCE, + R.string.a11y_description_incall_label_manage_content, + R.string.incall_label_manage, + R.drawable.quantum_ic_group_white_36); + Assert.isNotNull(inCallScreenDelegate); + this.inCallScreenDelegate = inCallScreenDelegate; + } + + @Override + public void onClick(View view) { + inCallScreenDelegate.onManageConferenceClicked(); + } + } + + class SwitchToSecondaryButtonController extends SimpleNonCheckableButtonController { + + private final InCallScreenDelegate inCallScreenDelegate; + + public SwitchToSecondaryButtonController(InCallScreenDelegate inCallScreenDelegate) { + super( + null, + InCallButtonIds.BUTTON_SWITCH_TO_SECONDARY, + R.string.incall_content_description_swap_calls, + R.string.incall_label_swap, + R.drawable.quantum_ic_swap_calls_white_36); + Assert.isNotNull(inCallScreenDelegate); + this.inCallScreenDelegate = inCallScreenDelegate; + } + + @Override + public void onClick(View view) { + inCallScreenDelegate.onSecondaryInfoClicked(); + } + } +} diff --git a/java/com/android/incallui/incall/impl/CheckableLabeledButton.java b/java/com/android/incallui/incall/impl/CheckableLabeledButton.java new file mode 100644 index 000000000..a681adcb4 --- /dev/null +++ b/java/com/android/incallui/incall/impl/CheckableLabeledButton.java @@ -0,0 +1,286 @@ +/* + * 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.incall.impl; + +import android.animation.AnimatorInflater; +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.PorterDuff.Mode; +import android.graphics.drawable.Drawable; +import android.os.Parcel; +import android.os.Parcelable; +import android.support.annotation.DrawableRes; +import android.support.annotation.StringRes; +import android.text.TextUtils.TruncateAt; +import android.util.AttributeSet; +import android.view.Gravity; +import android.view.SoundEffectConstants; +import android.widget.Checkable; +import android.widget.ImageView; +import android.widget.ImageView.ScaleType; +import android.widget.LinearLayout; +import android.widget.TextView; + +/** A button to show on the incall screen */ +public class CheckableLabeledButton extends LinearLayout implements Checkable { + + private static final int[] CHECKED_STATE_SET = {android.R.attr.state_checked}; + private static final float DISABLED_STATE_OPACITY = .3f; + private boolean broadcasting; + private boolean isChecked; + private OnCheckedChangeListener onCheckedChangeListener; + private ImageView iconView; + private TextView labelView; + private Drawable background; + private Drawable backgroundMore; + + public CheckableLabeledButton(Context context, AttributeSet attrs) { + super(context, attrs); + init(context, attrs); + } + + public CheckableLabeledButton(Context context) { + this(context, null); + } + + private void init(Context context, AttributeSet attrs) { + setOrientation(VERTICAL); + setGravity(Gravity.CENTER_HORIZONTAL); + Drawable icon; + CharSequence labelText; + boolean enabled; + + backgroundMore = getResources().getDrawable(R.drawable.incall_button_background_more, null); + background = getResources().getDrawable(R.drawable.incall_button_background, null); + + TypedArray typedArray = + context.obtainStyledAttributes(attrs, R.styleable.CheckableLabeledButton); + icon = typedArray.getDrawable(R.styleable.CheckableLabeledButton_incall_icon); + labelText = typedArray.getString(R.styleable.CheckableLabeledButton_incall_labelText); + enabled = typedArray.getBoolean(R.styleable.CheckableLabeledButton_android_enabled, true); + typedArray.recycle(); + + int paddingSize = getResources().getDimensionPixelOffset(R.dimen.incall_button_padding); + setPadding(paddingSize, paddingSize, paddingSize, paddingSize); + + int iconSize = getResources().getDimensionPixelSize(R.dimen.incall_labeled_button_size); + + iconView = new ImageView(context, null, android.R.style.Widget_Material_Button_Colored); + LayoutParams iconParams = generateDefaultLayoutParams(); + iconParams.width = iconSize; + iconParams.height = iconSize; + iconView.setLayoutParams(iconParams); + iconView.setScaleType(ScaleType.CENTER_INSIDE); + iconView.setImageDrawable(icon); + iconView.setImageTintMode(Mode.SRC_IN); + iconView.setImageTintList(getResources().getColorStateList(R.color.incall_button_icon, null)); + iconView.setBackground(getResources().getDrawable(R.drawable.incall_button_background, null)); + iconView.setDuplicateParentStateEnabled(true); + iconView.setElevation(getResources().getDimension(R.dimen.incall_button_elevation)); + iconView.setStateListAnimator( + AnimatorInflater.loadStateListAnimator(context, R.animator.incall_button_elevation)); + addView(iconView); + + labelView = new TextView(context); + LayoutParams labelParams = generateDefaultLayoutParams(); + labelParams.width = LayoutParams.WRAP_CONTENT; + labelParams.height = LayoutParams.WRAP_CONTENT; + labelParams.topMargin = + context.getResources().getDimensionPixelOffset(R.dimen.incall_button_label_margin); + labelView.setLayoutParams(labelParams); + labelView.setTextAppearance(R.style.Dialer_Incall_TextAppearance_Label); + labelView.setText(labelText); + labelView.setSingleLine(); + labelView.setMaxEms(9); + labelView.setEllipsize(TruncateAt.END); + labelView.setGravity(Gravity.CENTER); + labelView.setDuplicateParentStateEnabled(true); + addView(labelView); + + setFocusable(true); + setClickable(true); + setEnabled(enabled); + setOutlineProvider(null); + } + + @Override + public void refreshDrawableState() { + super.refreshDrawableState(); + iconView.setAlpha(isEnabled() ? 1f : DISABLED_STATE_OPACITY); + labelView.setAlpha(isEnabled() ? 1f : DISABLED_STATE_OPACITY); + } + + public void setIconDrawable(@DrawableRes int drawableRes) { + iconView.setImageResource(drawableRes); + } + + public void setLabelText(@StringRes int stringRes) { + labelView.setText(stringRes); + } + + /** Shows or hides a little down arrow to indicate that the button will pop up a menu. */ + public void setShouldShowMoreIndicator(boolean shouldShow) { + iconView.setBackground(shouldShow ? backgroundMore : background); + } + + @Override + public boolean isChecked() { + return isChecked; + } + + @Override + public void setChecked(boolean checked) { + performSetChecked(checked); + } + + @Override + public void toggle() { + userRequestedSetChecked(!isChecked()); + } + + @Override + public int[] onCreateDrawableState(int extraSpace) { + final int[] drawableState = super.onCreateDrawableState(extraSpace + 1); + if (isChecked()) { + mergeDrawableStates(drawableState, CHECKED_STATE_SET); + } + return drawableState; + } + + @Override + protected void drawableStateChanged() { + super.drawableStateChanged(); + invalidate(); + } + + public void setOnCheckedChangeListener(OnCheckedChangeListener listener) { + this.onCheckedChangeListener = listener; + } + + @Override + public boolean performClick() { + if (!isCheckable()) { + return super.performClick(); + } + + toggle(); + final boolean handled = super.performClick(); + if (!handled) { + // View only makes a sound effect if the onClickListener was + // called, so we'll need to make one here instead. + playSoundEffect(SoundEffectConstants.CLICK); + } + return handled; + } + + private boolean isCheckable() { + return onCheckedChangeListener != null; + } + + @Override + protected void onRestoreInstanceState(Parcelable state) { + SavedState savedState = (SavedState) state; + super.onRestoreInstanceState(savedState.getSuperState()); + performSetChecked(savedState.isChecked); + requestLayout(); + } + + @Override + protected Parcelable onSaveInstanceState() { + return new SavedState(isChecked(), super.onSaveInstanceState()); + } + + /** + * Called when the state of the button should be updated, this should not be the result of user + * interaction. + * + * @param checked {@code true} if the button should be in the checked state, {@code false} + * otherwise. + */ + private void performSetChecked(boolean checked) { + if (isChecked() == checked) { + return; + } + isChecked = checked; + refreshDrawableState(); + } + + /** + * Called when the user interacts with a button. This should not result in the button updating + * state, rather the request should be propagated to the associated listener. + * + * @param checked {@code true} if the button should be in the checked state, {@code false} + * otherwise. + */ + private void userRequestedSetChecked(boolean checked) { + if (isChecked() == checked) { + return; + } + if (broadcasting) { + return; + } + broadcasting = true; + if (onCheckedChangeListener != null) { + onCheckedChangeListener.onCheckedChanged(this, checked); + } + broadcasting = false; + } + + /** Callback interface to notify when the button's checked state has changed */ + public interface OnCheckedChangeListener { + + void onCheckedChanged(CheckableLabeledButton checkableLabeledButton, boolean isChecked); + } + + private static class SavedState extends BaseSavedState { + + public static final Creator<SavedState> CREATOR = + new Creator<SavedState>() { + @Override + public SavedState createFromParcel(Parcel in) { + return new SavedState(in); + } + + @Override + public SavedState[] newArray(int size) { + return new SavedState[size]; + } + }; + public final boolean isChecked; + + private SavedState(boolean isChecked, Parcelable superState) { + super(superState); + this.isChecked = isChecked; + } + + protected SavedState(Parcel in) { + super(in); + isChecked = in.readByte() != 0; + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + super.writeToParcel(dest, flags); + dest.writeByte((byte) (isChecked ? 1 : 0)); + } + } +} diff --git a/java/com/android/incallui/incall/impl/InCallButtonGridFragment.java b/java/com/android/incallui/incall/impl/InCallButtonGridFragment.java new file mode 100644 index 000000000..db0b5b9b8 --- /dev/null +++ b/java/com/android/incallui/incall/impl/InCallButtonGridFragment.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.incall.impl; + +import android.os.Bundle; +import android.support.annotation.Nullable; +import android.support.v4.app.Fragment; +import android.util.ArraySet; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import com.android.dialer.common.Assert; +import com.android.dialer.common.FragmentUtils; +import com.android.incallui.incall.protocol.InCallButtonIds; +import java.util.List; +import java.util.Set; + +/** Fragment for the in call buttons (mute, speaker, ect.). */ +public class InCallButtonGridFragment extends Fragment { + + private static final int BUTTON_COUNT = 6; + private static final int BUTTONS_PER_ROW = 3; + + private CheckableLabeledButton[] buttons = new CheckableLabeledButton[BUTTON_COUNT]; + private OnButtonGridCreatedListener buttonGridListener; + + public static Fragment newInstance() { + return new InCallButtonGridFragment(); + } + + @Override + public void onCreate(@Nullable Bundle bundle) { + super.onCreate(bundle); + buttonGridListener = FragmentUtils.getParent(this, OnButtonGridCreatedListener.class); + Assert.isNotNull(buttonGridListener); + } + + @Nullable + @Override + public View onCreateView( + LayoutInflater inflater, @Nullable ViewGroup parent, @Nullable Bundle bundle) { + View view = inflater.inflate(R.layout.incall_button_grid, parent, false); + + buttons[0] = ((CheckableLabeledButton) view.findViewById(R.id.incall_first_button)); + buttons[1] = ((CheckableLabeledButton) view.findViewById(R.id.incall_second_button)); + buttons[2] = ((CheckableLabeledButton) view.findViewById(R.id.incall_third_button)); + buttons[3] = ((CheckableLabeledButton) view.findViewById(R.id.incall_fourth_button)); + buttons[4] = ((CheckableLabeledButton) view.findViewById(R.id.incall_fifth_button)); + buttons[5] = ((CheckableLabeledButton) view.findViewById(R.id.incall_sixth_button)); + + return view; + } + + @Override + public void onViewCreated(View view, @Nullable Bundle bundle) { + super.onViewCreated(view, bundle); + buttonGridListener.onButtonGridCreated(this); + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + buttonGridListener.onButtonGridDestroyed(); + } + + public void onInCallScreenDialpadVisibilityChange(boolean isShowing) { + for (CheckableLabeledButton button : buttons) { + button.setImportantForAccessibility( + isShowing + ? View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS + : View.IMPORTANT_FOR_ACCESSIBILITY_AUTO); + } + } + + public int updateButtonStates( + List<ButtonController> buttonControllers, + @Nullable ButtonChooser buttonChooser, + int voiceNetworkType, + int phoneType) { + Set<Integer> allowedButtons = new ArraySet<>(); + Set<Integer> disabledButtons = new ArraySet<>(); + for (ButtonController controller : buttonControllers) { + if (controller.isAllowed()) { + allowedButtons.add(controller.getInCallButtonId()); + if (!controller.isEnabled()) { + disabledButtons.add(controller.getInCallButtonId()); + } + } + } + + for (ButtonController controller : buttonControllers) { + controller.setButton(null); + } + + if (buttonChooser == null) { + buttonChooser = + ButtonChooserFactory.newButtonChooser(voiceNetworkType, false /* isWiFi */, phoneType); + } + + int numVisibleButtons = getResources().getInteger(R.integer.incall_num_rows) * BUTTONS_PER_ROW; + List<Integer> buttonsToPlace = + buttonChooser.getButtonPlacement(numVisibleButtons, allowedButtons, disabledButtons); + + for (int i = 0; i < BUTTON_COUNT; ++i) { + if (i >= buttonsToPlace.size()) { + buttons[i].setVisibility(View.INVISIBLE); + continue; + } + @InCallButtonIds int button = buttonsToPlace.get(i); + buttonGridListener.getButtonController(button).setButton(buttons[i]); + } + + return numVisibleButtons; + } + + /** Interface to let the listener know the status of the button grid. */ + public interface OnButtonGridCreatedListener { + void onButtonGridCreated(InCallButtonGridFragment inCallButtonGridFragment); + void onButtonGridDestroyed(); + + ButtonController getButtonController(@InCallButtonIds int id); + } +} diff --git a/java/com/android/incallui/incall/impl/InCallFragment.java b/java/com/android/incallui/incall/impl/InCallFragment.java new file mode 100644 index 000000000..ef8a1edd8 --- /dev/null +++ b/java/com/android/incallui/incall/impl/InCallFragment.java @@ -0,0 +1,501 @@ +/* + * 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.incall.impl; + +import android.Manifest.permission; +import android.content.Context; +import android.content.pm.PackageManager; +import android.os.Build.VERSION; +import android.os.Build.VERSION_CODES; +import android.os.Bundle; +import android.os.Handler; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.design.widget.TabLayout; +import android.support.v4.app.Fragment; +import android.support.v4.app.FragmentTransaction; +import android.support.v4.content.ContextCompat; +import android.support.v4.view.ViewPager; +import android.telecom.CallAudioState; +import android.telephony.TelephonyManager; +import android.view.LayoutInflater; +import android.view.View; +import android.view.View.OnClickListener; +import android.view.ViewGroup; +import android.view.accessibility.AccessibilityEvent; +import android.widget.ImageView; +import android.widget.RelativeLayout; +import android.widget.Toast; +import com.android.dialer.common.Assert; +import com.android.dialer.common.FragmentUtils; +import com.android.dialer.common.LogUtil; +import com.android.dialer.multimedia.MultimediaData; +import com.android.incallui.audioroute.AudioRouteSelectorDialogFragment; +import com.android.incallui.audioroute.AudioRouteSelectorDialogFragment.AudioRouteSelectorPresenter; +import com.android.incallui.contactgrid.ContactGridManager; +import com.android.incallui.hold.OnHoldFragment; +import com.android.incallui.incall.impl.ButtonController.SpeakerButtonController; +import com.android.incallui.incall.impl.InCallButtonGridFragment.OnButtonGridCreatedListener; +import com.android.incallui.incall.protocol.InCallButtonIds; +import com.android.incallui.incall.protocol.InCallButtonIdsExtension; +import com.android.incallui.incall.protocol.InCallButtonUi; +import com.android.incallui.incall.protocol.InCallButtonUiDelegate; +import com.android.incallui.incall.protocol.InCallButtonUiDelegateFactory; +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 java.util.ArrayList; +import java.util.List; + +/** Fragment that shows UI for an ongoing voice call. */ +public class InCallFragment extends Fragment + implements InCallScreen, + InCallButtonUi, + OnClickListener, + AudioRouteSelectorPresenter, + OnButtonGridCreatedListener { + + private List<ButtonController> buttonControllers = new ArrayList<>(); + private View endCallButton; + private TabLayout tabLayout; + private ViewPager pager; + private InCallPagerAdapter adapter; + private ContactGridManager contactGridManager; + private InCallScreenDelegate inCallScreenDelegate; + private InCallButtonUiDelegate inCallButtonUiDelegate; + private InCallButtonGridFragment inCallButtonGridFragment; + @Nullable private ButtonChooser buttonChooser; + private SecondaryInfo savedSecondaryInfo; + private int voiceNetworkType; + private int phoneType; + private boolean stateRestored; + + private static boolean isSupportedButton(@InCallButtonIds int id) { + return id == InCallButtonIds.BUTTON_AUDIO + || id == InCallButtonIds.BUTTON_MUTE + || id == InCallButtonIds.BUTTON_DIALPAD + || id == InCallButtonIds.BUTTON_HOLD + || id == InCallButtonIds.BUTTON_SWAP + || id == InCallButtonIds.BUTTON_UPGRADE_TO_VIDEO + || id == InCallButtonIds.BUTTON_ADD_CALL + || id == InCallButtonIds.BUTTON_MERGE + || id == InCallButtonIds.BUTTON_MANAGE_VOICE_CONFERENCE; + } + + @Override + public void onAttach(Context context) { + super.onAttach(context); + if (savedSecondaryInfo != null) { + setSecondary(savedSecondaryInfo); + } + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + inCallButtonUiDelegate = + FragmentUtils.getParent(this, InCallButtonUiDelegateFactory.class) + .newInCallButtonUiDelegate(); + if (savedInstanceState != null) { + inCallButtonUiDelegate.onRestoreInstanceState(savedInstanceState); + stateRestored = true; + } + } + + @Nullable + @Override + public View onCreateView( + @NonNull LayoutInflater layoutInflater, + @Nullable ViewGroup viewGroup, + @Nullable Bundle bundle) { + LogUtil.i("InCallFragment.onCreateView", null); + final View view = layoutInflater.inflate(R.layout.frag_incall_voice, viewGroup, false); + contactGridManager = + new ContactGridManager( + view, + (ImageView) view.findViewById(R.id.contactgrid_avatar), + getResources().getDimensionPixelSize(R.dimen.incall_avatar_size), + true /* showAnonymousAvatar */); + + tabLayout = (TabLayout) view.findViewById(R.id.incall_tab_dots); + pager = (ViewPager) view.findViewById(R.id.incall_pager); + + endCallButton = view.findViewById(R.id.incall_end_call); + endCallButton.setOnClickListener(this); + + if (ContextCompat.checkSelfPermission(getContext(), permission.READ_PHONE_STATE) + != PackageManager.PERMISSION_GRANTED) { + voiceNetworkType = TelephonyManager.NETWORK_TYPE_UNKNOWN; + } else { + + voiceNetworkType = + VERSION.SDK_INT >= VERSION_CODES.N + ? getContext().getSystemService(TelephonyManager.class).getVoiceNetworkType() + : TelephonyManager.NETWORK_TYPE_UNKNOWN; + } + phoneType = getContext().getSystemService(TelephonyManager.class).getPhoneType(); + return view; + } + + @Override + public void onResume() { + super.onResume(); + inCallButtonUiDelegate.refreshMuteState(); + inCallScreenDelegate.onInCallScreenResumed(); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle bundle) { + LogUtil.i("InCallFragment.onViewCreated", null); + super.onViewCreated(view, bundle); + inCallScreenDelegate = + FragmentUtils.getParent(this, InCallScreenDelegateFactory.class).newInCallScreenDelegate(); + Assert.isNotNull(inCallScreenDelegate); + + buttonControllers.add(new ButtonController.MuteButtonController(inCallButtonUiDelegate)); + buttonControllers.add(new ButtonController.SpeakerButtonController(inCallButtonUiDelegate)); + buttonControllers.add(new ButtonController.DialpadButtonController(inCallButtonUiDelegate)); + buttonControllers.add(new ButtonController.HoldButtonController(inCallButtonUiDelegate)); + buttonControllers.add(new ButtonController.AddCallButtonController(inCallButtonUiDelegate)); + buttonControllers.add(new ButtonController.SwapButtonController(inCallButtonUiDelegate)); + buttonControllers.add(new ButtonController.MergeButtonController(inCallButtonUiDelegate)); + buttonControllers.add( + new ButtonController.UpgradeToVideoButtonController(inCallButtonUiDelegate)); + buttonControllers.add( + new ButtonController.ManageConferenceButtonController(inCallScreenDelegate)); + buttonControllers.add( + new ButtonController.SwitchToSecondaryButtonController(inCallScreenDelegate)); + + inCallScreenDelegate.onInCallScreenDelegateInit(this); + inCallScreenDelegate.onInCallScreenReady(); + } + + @Override + public void onDestroy() { + super.onDestroy(); + inCallScreenDelegate.onInCallScreenUnready(); + } + + @Override + public void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + inCallButtonUiDelegate.onSaveInstanceState(outState); + } + + @Override + public void onClick(View view) { + if (view == endCallButton) { + LogUtil.i("InCallFragment.onClick", "end call button clicked"); + inCallScreenDelegate.onEndCallClicked(); + } else { + LogUtil.e("InCallFragment.onClick", "unknown view: " + view); + Assert.fail(); + } + } + + @Override + public void setPrimary(@NonNull PrimaryInfo primaryInfo) { + LogUtil.i("InCallFragment.setPrimary", primaryInfo.toString()); + if (adapter == null) { + initAdapter(primaryInfo.multimediaData); + } + contactGridManager.setPrimary(primaryInfo); + + if (primaryInfo.shouldShowLocation) { + // Hide the avatar to make room for location + contactGridManager.setAvatarHidden(true); + + // Need to widen the contact grid to fit location information + View contactGridView = getView().findViewById(R.id.incall_contact_grid); + ViewGroup.LayoutParams params = contactGridView.getLayoutParams(); + if (params instanceof ViewGroup.MarginLayoutParams) { + ((ViewGroup.MarginLayoutParams) params).setMarginStart(0); + ((ViewGroup.MarginLayoutParams) params).setMarginEnd(0); + } + contactGridView.setLayoutParams(params); + + // Need to let the dialpad move up a little further when location info is being shown + View dialpadView = getView().findViewById(R.id.incall_dialpad_container); + params = dialpadView.getLayoutParams(); + if (params instanceof RelativeLayout.LayoutParams) { + ((RelativeLayout.LayoutParams) params).removeRule(RelativeLayout.BELOW); + } + dialpadView.setLayoutParams(params); + } + } + + private void initAdapter(MultimediaData multimediaData) { + adapter = new InCallPagerAdapter(getChildFragmentManager(), multimediaData); + pager.setAdapter(adapter); + + if (adapter.getCount() > 1) { + tabLayout.setVisibility(pager.getVisibility()); + tabLayout.setupWithViewPager(pager, true); + if (!stateRestored) { + new Handler() + .postDelayed( + new Runnable() { + @Override + public void run() { + // In order to prevent user confusion and educate the user on our UI, we animate + // the view pager to the button grid after 2 seconds show them when the UI is + // that they are more familiar with. + pager.setCurrentItem(adapter.getButtonGridPosition()); + } + }, + 2000); + } + } else { + tabLayout.setVisibility(View.GONE); + } + } + + @Override + public void setSecondary(@NonNull SecondaryInfo secondaryInfo) { + LogUtil.i("InCallFragment.setSecondary", secondaryInfo.toString()); + getButtonController(InCallButtonIds.BUTTON_SWITCH_TO_SECONDARY) + .setEnabled(secondaryInfo.shouldShow); + getButtonController(InCallButtonIds.BUTTON_SWITCH_TO_SECONDARY) + .setAllowed(secondaryInfo.shouldShow); + updateButtonStates(); + + if (!isAdded()) { + savedSecondaryInfo = secondaryInfo; + return; + } + savedSecondaryInfo = null; + FragmentTransaction transaction = getChildFragmentManager().beginTransaction(); + Fragment oldBanner = getChildFragmentManager().findFragmentById(R.id.incall_on_hold_banner); + if (secondaryInfo.shouldShow) { + transaction.replace(R.id.incall_on_hold_banner, OnHoldFragment.newInstance(secondaryInfo)); + } else { + if (oldBanner != null) { + transaction.remove(oldBanner); + } + } + transaction.setCustomAnimations(R.anim.abc_slide_in_top, R.anim.abc_slide_out_top); + transaction.commitAllowingStateLoss(); + } + + @Override + public void setCallState(@NonNull PrimaryCallState primaryCallState) { + LogUtil.i("InCallFragment.setCallState", primaryCallState.toString()); + contactGridManager.setCallState(primaryCallState); + buttonChooser = + ButtonChooserFactory.newButtonChooser(voiceNetworkType, primaryCallState.isWifi, phoneType); + updateButtonStates(); + } + + @Override + public void setEndCallButtonEnabled(boolean enabled, boolean animate) { + if (endCallButton != null) { + endCallButton.setEnabled(enabled); + } + } + + @Override + public void showManageConferenceCallButton(boolean visible) { + getButtonController(InCallButtonIds.BUTTON_MANAGE_VOICE_CONFERENCE).setAllowed(visible); + getButtonController(InCallButtonIds.BUTTON_MANAGE_VOICE_CONFERENCE).setEnabled(visible); + updateButtonStates(); + } + + @Override + public boolean isManageConferenceVisible() { + return getButtonController(InCallButtonIds.BUTTON_MANAGE_VOICE_CONFERENCE).isAllowed(); + } + + @Override + public void dispatchPopulateAccessibilityEvent(AccessibilityEvent event) { + contactGridManager.dispatchPopulateAccessibilityEvent(event); + } + + @Override + public void showNoteSentToast() { + LogUtil.i("InCallFragment.showNoteSentToast", null); + Toast.makeText(getContext(), R.string.incall_note_sent, Toast.LENGTH_LONG).show(); + } + + @Override + public void updateInCallScreenColors() {} + + @Override + public void onInCallScreenDialpadVisibilityChange(boolean isShowing) { + LogUtil.i("InCallFragment.onInCallScreenDialpadVisibilityChange", "isShowing: " + isShowing); + // Take note that the dialpad button isShowing + getButtonController(InCallButtonIds.BUTTON_DIALPAD).setChecked(isShowing); + + // This check is needed because there is a race condition where we attempt to update + // ButtonGridFragment before it is ready, so we check whether it is ready first and once it is + // ready, #onButtonGridCreated will mark the dialpad button as isShowing. + if (inCallButtonGridFragment != null) { + // Update the Android Button's state to isShowing. + inCallButtonGridFragment.onInCallScreenDialpadVisibilityChange(isShowing); + } + } + + @Override + public int getAnswerAndDialpadContainerResourceId() { + return R.id.incall_dialpad_container; + } + + @Override + public Fragment getInCallScreenFragment() { + return this; + } + + @Override + public void showButton(@InCallButtonIds int buttonId, boolean show) { + LogUtil.v( + "InCallFragment.showButton", + "buttionId: %s, show: %b", + InCallButtonIdsExtension.toString(buttonId), + show); + if (isSupportedButton(buttonId)) { + getButtonController(buttonId).setAllowed(show); + } + } + + @Override + public void enableButton(@InCallButtonIds int buttonId, boolean enable) { + LogUtil.v( + "InCallFragment.enableButton", + "buttonId: %s, enable: %b", + InCallButtonIdsExtension.toString(buttonId), + enable); + if (isSupportedButton(buttonId)) { + getButtonController(buttonId).setEnabled(enable); + } + } + + @Override + public void setEnabled(boolean enabled) { + LogUtil.v("InCallFragment.setEnabled", "enabled: " + enabled); + for (ButtonController buttonController : buttonControllers) { + buttonController.setEnabled(enabled); + } + } + + @Override + public void setHold(boolean value) { + getButtonController(InCallButtonIds.BUTTON_HOLD).setChecked(value); + } + + @Override + public void setCameraSwitched(boolean isBackFacingCamera) {} + + @Override + public void setVideoPaused(boolean isPaused) {} + + @Override + public void setAudioState(CallAudioState audioState) { + LogUtil.i("InCallFragment.setAudioState", "audioState: " + audioState); + ((SpeakerButtonController) getButtonController(InCallButtonIds.BUTTON_AUDIO)) + .setAudioState(audioState); + getButtonController(InCallButtonIds.BUTTON_MUTE).setChecked(audioState.isMuted()); + } + + @Override + public void updateButtonStates() { + // When the incall screen is ready, this method is called from #setSecondary, even though the + // incall button ui is not ready yet. This method is called again once the incall button ui is + // ready though, so this operation is safe and will be executed asap. + if (inCallButtonGridFragment == null) { + return; + } + int numVisibleButtons = + inCallButtonGridFragment.updateButtonStates( + buttonControllers, buttonChooser, voiceNetworkType, phoneType); + + int visibility = numVisibleButtons == 0 ? View.GONE : View.VISIBLE; + pager.setVisibility(visibility); + if (adapter != null && adapter.getCount() > 1) { + tabLayout.setVisibility(visibility); + } + } + + @Override + public void updateInCallButtonUiColors() {} + + @Override + public Fragment getInCallButtonUiFragment() { + return this; + } + + @Override + public void showAudioRouteSelector() { + AudioRouteSelectorDialogFragment.newInstance(inCallButtonUiDelegate.getCurrentAudioState()) + .show(getChildFragmentManager(), null); + } + + @Override + public void onAudioRouteSelected(int audioRoute) { + inCallButtonUiDelegate.setAudioRoute(audioRoute); + } + + @NonNull + @Override + public ButtonController getButtonController(@InCallButtonIds int id) { + for (ButtonController buttonController : buttonControllers) { + if (buttonController.getInCallButtonId() == id) { + return buttonController; + } + } + Assert.fail(); + return null; + } + + @Override + public void onButtonGridCreated(InCallButtonGridFragment inCallButtonGridFragment) { + LogUtil.i("InCallFragment.onButtonGridCreated", "InCallUiReady"); + this.inCallButtonGridFragment = inCallButtonGridFragment; + inCallButtonUiDelegate.onInCallButtonUiReady(this); + updateButtonStates(); + } + + @Override + public void onButtonGridDestroyed() { + LogUtil.i("InCallFragment.onButtonGridCreated", "InCallUiUnready"); + inCallButtonUiDelegate.onInCallButtonUiUnready(); + this.inCallButtonGridFragment = 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(); + } + } +} diff --git a/java/com/android/incallui/incall/impl/InCallPagerAdapter.java b/java/com/android/incallui/incall/impl/InCallPagerAdapter.java new file mode 100644 index 000000000..50eb4c8c3 --- /dev/null +++ b/java/com/android/incallui/incall/impl/InCallPagerAdapter.java @@ -0,0 +1,59 @@ +/* + * 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.incall.impl; + +import android.support.annotation.Nullable; +import android.support.v4.app.Fragment; +import android.support.v4.app.FragmentManager; +import android.support.v4.app.FragmentPagerAdapter; +import android.text.TextUtils; +import com.android.dialer.multimedia.MultimediaData; +import com.android.incallui.sessiondata.MultimediaFragment; + +/** View pager adapter for in call ui. */ +public class InCallPagerAdapter extends FragmentPagerAdapter { + + @Nullable private final MultimediaData attachments; + + public InCallPagerAdapter(FragmentManager fragmentManager, MultimediaData attachments) { + super(fragmentManager); + this.attachments = attachments; + } + + @Override + public Fragment getItem(int position) { + if (position == getButtonGridPosition()) { + return InCallButtonGridFragment.newInstance(); + } else { + // TODO: handle fragment invalidation for when the data changes. + return MultimediaFragment.newInstance(attachments, true, false); + } + } + + @Override + public int getCount() { + if (attachments != null + && (!TextUtils.isEmpty(attachments.getSubject()) || attachments.hasImageData())) { + return 2; + } + return 1; + } + + public int getButtonGridPosition() { + return getCount() - 1; + } +} diff --git a/java/com/android/incallui/incall/impl/MappedButtonConfig.java b/java/com/android/incallui/incall/impl/MappedButtonConfig.java new file mode 100644 index 000000000..ecdb5dfea --- /dev/null +++ b/java/com/android/incallui/incall/impl/MappedButtonConfig.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.incall.impl; + +import android.support.annotation.NonNull; +import android.support.v4.util.ArrayMap; +import android.util.ArraySet; +import com.android.dialer.common.Assert; +import com.android.incallui.incall.protocol.InCallButtonIds; +import com.android.incallui.incall.protocol.InCallButtonIdsExtension; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Set; +import javax.annotation.concurrent.Immutable; + +/** + * Determines logical button slot and ordering based on a provided mapping. + * + * <p>The provided mapping is declared with the following pieces of information: key, the {@link + * InCallButtonIds} for which the mapping applies; {@link MappingInfo#getSlot()}, the arbitrarily + * indexed slot into which the InCallButtonId desires to be placed; {@link + * MappingInfo#getSlotOrder()}, the slotOrder, used to choose the correct InCallButtonId when + * multiple desire to be placed in the same slot; and {@link MappingInfo#getConflictOrder()}, the + * conflictOrder, used to determine the overall order for InCallButtonIds that weren't chosen for + * their desired slot. + */ +@Immutable +final class MappedButtonConfig { + + @NonNull private final Map<Integer, MappingInfo> mapping; + @NonNull private final List<Integer> orderedMappedSlots; + + /** + * Creates this MappedButtonConfig with the given mapping of {@link InCallButtonIds} to their + * corresponding slots and order. + * + * @param mapping the mapping. + */ + public MappedButtonConfig(@NonNull Map<Integer, MappingInfo> mapping) { + this.mapping = new ArrayMap<>(); + this.mapping.putAll(Assert.isNotNull(mapping)); + this.orderedMappedSlots = findOrderedMappedSlots(); + } + + private List<Integer> findOrderedMappedSlots() { + Set<Integer> slots = new ArraySet<>(); + for (Entry<Integer, MappingInfo> entry : mapping.entrySet()) { + slots.add(entry.getValue().getSlot()); + } + List<Integer> orderedSlots = new ArrayList<>(slots); + Collections.sort(orderedSlots); + return orderedSlots; + } + + /** Returns an immutable list of the slots for which this class has button mapping. */ + @NonNull + public List<Integer> getOrderedMappedSlots() { + if (mapping.isEmpty()) { + return Collections.emptyList(); + } + return Collections.unmodifiableList(orderedMappedSlots); + } + + /** + * Returns a list of {@link InCallButtonIds} that are configured to be placed in the given ui + * slot. The slot can be based from any index, as long as it matches the provided mapping. + */ + @NonNull + public List<Integer> getButtonsForSlot(int slot) { + List<Integer> buttons = new ArrayList<>(); + for (Entry<Integer, MappingInfo> entry : mapping.entrySet()) { + if (entry.getValue().getSlot() == slot) { + buttons.add(entry.getKey()); + } + } + return buttons; + } + + /** + * Returns a {@link Comparator} capable of ordering {@link InCallButtonIds} that are configured to + * be placed in the same slot. InCallButtonIds are sorted based on the natural ordering of {@link + * MappingInfo#getSlotOrder()}. + * + * <p>Note: the returned Comparator's compare method will throw an {@link + * IllegalArgumentException} if called with InCallButtonIds that have no configuration or are not + * to be placed in the same slot. + */ + @NonNull + public Comparator<Integer> getSlotComparator() { + return new Comparator<Integer>() { + @Override + public int compare(Integer lhs, Integer rhs) { + MappingInfo lhsInfo = lookupMappingInfo(lhs); + MappingInfo rhsInfo = lookupMappingInfo(rhs); + if (lhsInfo.getSlot() != rhsInfo.getSlot()) { + throw new IllegalArgumentException("lhs and rhs don't go in the same slot"); + } + return lhsInfo.getSlotOrder() - rhsInfo.getSlotOrder(); + } + }; + } + + /** + * Returns a {@link Comparator} capable of ordering {@link InCallButtonIds} by their conflict + * score. This comparator should be used when multiple InCallButtonIds could have been shown in + * the same slot. InCallButtonIds are sorted based on the natural ordering of {@link + * MappingInfo#getConflictOrder()}. + * + * <p>Note: the returned Comparator's compare method will throw an {@link + * IllegalArgumentException} if called with InCallButtonIds that have no configuration. + */ + @NonNull + public Comparator<Integer> getConflictComparator() { + return new Comparator<Integer>() { + @Override + public int compare(Integer lhs, Integer rhs) { + MappingInfo lhsInfo = lookupMappingInfo(lhs); + MappingInfo rhsInfo = lookupMappingInfo(rhs); + return lhsInfo.getConflictOrder() - rhsInfo.getConflictOrder(); + } + }; + } + + @NonNull + private MappingInfo lookupMappingInfo(@InCallButtonIds int button) { + MappingInfo info = mapping.get(button); + if (info == null) { + throw new IllegalArgumentException( + "Unknown InCallButtonId: " + InCallButtonIdsExtension.toString(button)); + } + return info; + } + + /** Holds information about button mapping. */ + + abstract static class MappingInfo { + + /** The Ui slot into which a given button desires to be placed. */ + public abstract int getSlot(); + + /** + * Returns an integer used to determine which button is chosen for a slot when multiple buttons + * desire to be placed in the same slot. Follows from the natural ordering of integers, i.e. a + * lower slotOrder results in the button being chosen. + */ + public abstract int getSlotOrder(); + + /** + * Returns an integer used to determine the order in which buttons that weren't chosen for their + * desired slot are placed into the Ui. Follows from the natural ordering of integers, i.e. a + * lower conflictOrder results in the button being chosen. + */ + public abstract int getConflictOrder(); + + static Builder builder(int slot) { + return new AutoValue_MappedButtonConfig_MappingInfo.Builder() + .setSlot(slot) + .setSlotOrder(Integer.MAX_VALUE) + .setConflictOrder(Integer.MAX_VALUE); + } + + /** Class used to build instances of {@link MappingInfo}. */ + + abstract static class Builder { + public abstract Builder setSlot(int slot); + + public abstract Builder setSlotOrder(int slotOrder); + + public abstract Builder setConflictOrder(int conflictOrder); + + public abstract MappingInfo build(); + } + } +} diff --git a/java/com/android/incallui/incall/impl/res/animator/incall_button_elevation.xml b/java/com/android/incallui/incall/impl/res/animator/incall_button_elevation.xml new file mode 100644 index 000000000..69215adda --- /dev/null +++ b/java/com/android/incallui/incall/impl/res/animator/incall_button_elevation.xml @@ -0,0 +1,31 @@ +<?xml version="1.0" encoding="utf-8"?> +<selector xmlns:android="http://schemas.android.com/apk/res/android"> + <item + android:state_enabled="true" + android:state_pressed="true"> + <objectAnimator + android:duration="@android:integer/config_shortAnimTime" + android:propertyName="translationZ" + android:valueTo="8dp" + android:valueType="floatType"/> + + </item> + <item + android:state_checked="true" + android:state_enabled="true" + android:state_pressed="false"> + <objectAnimator + android:duration="@android:integer/config_shortAnimTime" + android:propertyName="translationZ" + android:valueTo="4dp" + android:valueType="floatType"/> + + </item> + <item> + <objectAnimator + android:duration="@android:integer/config_shortAnimTime" + android:propertyName="translationZ" + android:valueTo="0dp" + android:valueType="floatType"/> + </item> +</selector> diff --git a/java/com/android/incallui/incall/impl/res/color/incall_button_icon.xml b/java/com/android/incallui/incall/impl/res/color/incall_button_icon.xml new file mode 100644 index 000000000..6d8556759 --- /dev/null +++ b/java/com/android/incallui/incall/impl/res/color/incall_button_icon.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<selector xmlns:android="http://schemas.android.com/apk/res/android"> + <item android:color="#FF01579B" android:state_checked="true"/> + <item android:color="#FFFFFFFF"/> +</selector> diff --git a/java/com/android/incallui/incall/impl/res/drawable-mdpi/ic_addcall_white.png b/java/com/android/incallui/incall/impl/res/drawable-mdpi/ic_addcall_white.png Binary files differnew file mode 100644 index 000000000..a60805258 --- /dev/null +++ b/java/com/android/incallui/incall/impl/res/drawable-mdpi/ic_addcall_white.png diff --git a/java/com/android/incallui/incall/impl/res/drawable-xhdpi/ic_addcall_white.png b/java/com/android/incallui/incall/impl/res/drawable-xhdpi/ic_addcall_white.png Binary files differnew file mode 100644 index 000000000..d2a843c38 --- /dev/null +++ b/java/com/android/incallui/incall/impl/res/drawable-xhdpi/ic_addcall_white.png diff --git a/java/com/android/incallui/incall/impl/res/drawable/incall_button_background.xml b/java/com/android/incallui/incall/impl/res/drawable/incall_button_background.xml new file mode 100644 index 000000000..c8bd29568 --- /dev/null +++ b/java/com/android/incallui/incall/impl/res/drawable/incall_button_background.xml @@ -0,0 +1,22 @@ +<?xml version="1.0" encoding="utf-8"?> +<layer-list xmlns:android="http://schemas.android.com/apk/res/android"> + <item> + <selector> + <item + android:drawable="@drawable/incall_button_background_checked" + android:state_checked="true"/> + <item android:drawable="@drawable/incall_button_background_unchecked"/> + </selector> + </item> + <item> + <ripple android:color="@color/incall_button_ripple"> + <item + android:id="@android:id/mask" + android:gravity="center"> + <shape android:shape="oval"> + <solid android:color="@android:color/white"/> + </shape> + </item> + </ripple> + </item> +</layer-list> diff --git a/java/com/android/incallui/incall/impl/res/drawable/incall_button_background_checked.xml b/java/com/android/incallui/incall/impl/res/drawable/incall_button_background_checked.xml new file mode 100644 index 000000000..73c6947e2 --- /dev/null +++ b/java/com/android/incallui/incall/impl/res/drawable/incall_button_background_checked.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="@color/incall_button_white"/> +</shape> diff --git a/java/com/android/incallui/incall/impl/res/drawable/incall_button_background_more.xml b/java/com/android/incallui/incall/impl/res/drawable/incall_button_background_more.xml new file mode 100644 index 000000000..6755f0fae --- /dev/null +++ b/java/com/android/incallui/incall/impl/res/drawable/incall_button_background_more.xml @@ -0,0 +1,30 @@ +<?xml version="1.0" encoding="utf-8"?> +<layer-list xmlns:android="http://schemas.android.com/apk/res/android"> + <item> + <selector> + <item + android:drawable="@drawable/incall_button_background_checked" + android:state_checked="true"/> + <item android:drawable="@drawable/incall_button_background_unchecked"/> + </selector> + </item> + <item> + <ripple android:color="@color/incall_button_ripple"> + <item + android:id="@android:id/mask" + android:gravity="center"> + <shape android:shape="oval"> + <solid android:color="@android:color/white"/> + </shape> + </item> + </ripple> + </item> + + <!-- This adds a little down arrow to indicate that the button will pop up a menu. Use an explicit + <bitmap> to avoid scaling the icon up to the full size of the button. --> + <item> + <bitmap + android:gravity="end" + android:src="@drawable/quantum_ic_arrow_drop_down_white_18"/> + </item> +</layer-list> diff --git a/java/com/android/incallui/incall/impl/res/drawable/incall_button_background_unchecked.xml b/java/com/android/incallui/incall/impl/res/drawable/incall_button_background_unchecked.xml new file mode 100644 index 000000000..f7ffa4d50 --- /dev/null +++ b/java/com/android/incallui/incall/impl/res/drawable/incall_button_background_unchecked.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="@android:color/transparent"/> +</shape> diff --git a/java/com/android/incallui/incall/impl/res/drawable/incall_ic_add_call.xml b/java/com/android/incallui/incall/impl/res/drawable/incall_ic_add_call.xml new file mode 100644 index 000000000..4daf0527c --- /dev/null +++ b/java/com/android/incallui/incall/impl/res/drawable/incall_ic_add_call.xml @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="utf-8"?> +<selector xmlns:android="http://schemas.android.com/apk/res/android"> + <item android:drawable="@drawable/ic_addcall_white"/> +</selector> diff --git a/java/com/android/incallui/incall/impl/res/drawable/incall_ic_dialpad.xml b/java/com/android/incallui/incall/impl/res/drawable/incall_ic_dialpad.xml new file mode 100644 index 000000000..091142bef --- /dev/null +++ b/java/com/android/incallui/incall/impl/res/drawable/incall_ic_dialpad.xml @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="utf-8"?> +<selector xmlns:android="http://schemas.android.com/apk/res/android"> + <item android:drawable="@drawable/quantum_ic_dialpad_white_36"/> +</selector> diff --git a/java/com/android/incallui/incall/impl/res/drawable/incall_ic_manage.xml b/java/com/android/incallui/incall/impl/res/drawable/incall_ic_manage.xml new file mode 100644 index 000000000..a48e4c4ed --- /dev/null +++ b/java/com/android/incallui/incall/impl/res/drawable/incall_ic_manage.xml @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="utf-8"?> +<selector xmlns:android="http://schemas.android.com/apk/res/android"> + <item android:drawable="@drawable/quantum_ic_group_white_36"/> +</selector> diff --git a/java/com/android/incallui/incall/impl/res/drawable/incall_ic_merge.xml b/java/com/android/incallui/incall/impl/res/drawable/incall_ic_merge.xml new file mode 100644 index 000000000..61d75556e --- /dev/null +++ b/java/com/android/incallui/incall/impl/res/drawable/incall_ic_merge.xml @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="utf-8"?> +<selector xmlns:android="http://schemas.android.com/apk/res/android"> + <item android:drawable="@drawable/quantum_ic_call_merge_white_36"/> +</selector> diff --git a/java/com/android/incallui/incall/impl/res/drawable/incall_ic_pause.xml b/java/com/android/incallui/incall/impl/res/drawable/incall_ic_pause.xml new file mode 100644 index 000000000..6aa8ab8ce --- /dev/null +++ b/java/com/android/incallui/incall/impl/res/drawable/incall_ic_pause.xml @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="utf-8"?> +<selector xmlns:android="http://schemas.android.com/apk/res/android"> + <item android:drawable="@drawable/quantum_ic_pause_white_36"/> +</selector> diff --git a/java/com/android/incallui/incall/impl/res/drawable/tab_indicator_default.xml b/java/com/android/incallui/incall/impl/res/drawable/tab_indicator_default.xml new file mode 100644 index 000000000..6a55b35dc --- /dev/null +++ b/java/com/android/incallui/incall/impl/res/drawable/tab_indicator_default.xml @@ -0,0 +1,12 @@ +<?xml version="1.0" encoding="utf-8"?> +<layer-list xmlns:android="http://schemas.android.com/apk/res/android"> + <item> + <shape + android:innerRadius="0dp" + android:shape="ring" + android:thickness="2dp" + android:useLevel="false"> + <solid android:color="@android:color/darker_gray"/> + </shape> + </item> +</layer-list>
\ No newline at end of file diff --git a/java/com/android/incallui/incall/impl/res/drawable/tab_indicator_selected.xml b/java/com/android/incallui/incall/impl/res/drawable/tab_indicator_selected.xml new file mode 100644 index 000000000..fc673c6ed --- /dev/null +++ b/java/com/android/incallui/incall/impl/res/drawable/tab_indicator_selected.xml @@ -0,0 +1,12 @@ +<?xml version="1.0" encoding="utf-8"?> +<layer-list xmlns:android="http://schemas.android.com/apk/res/android"> + <item> + <shape + android:innerRadius="0dp" + android:shape="ring" + android:thickness="4dp" + android:useLevel="false"> + <solid android:color="@color/background_dialer_white"/> + </shape> + </item> +</layer-list>
\ No newline at end of file diff --git a/java/com/android/incallui/incall/impl/res/drawable/tab_selector.xml b/java/com/android/incallui/incall/impl/res/drawable/tab_selector.xml new file mode 100644 index 000000000..303a49bd8 --- /dev/null +++ b/java/com/android/incallui/incall/impl/res/drawable/tab_selector.xml @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="utf-8"?> +<selector xmlns:android="http://schemas.android.com/apk/res/android"> + <item android:drawable="@drawable/tab_indicator_selected" + android:state_selected="true"/> + <item android:drawable="@drawable/tab_indicator_default"/> +</selector>
\ No newline at end of file diff --git a/java/com/android/incallui/incall/impl/res/layout/call_composer_data_fragment.xml b/java/com/android/incallui/incall/impl/res/layout/call_composer_data_fragment.xml new file mode 100644 index 000000000..335ac8ae2 --- /dev/null +++ b/java/com/android/incallui/incall/impl/res/layout/call_composer_data_fragment.xml @@ -0,0 +1,15 @@ +<?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"> + <TextView + android:id="@+id/subject" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_margin="8dp" + android:padding="8dp" + android:textSize="24sp" + android:textColor="@color/primary_text_color" + android:background="@color/background_dialer_white"/> +</FrameLayout>
\ No newline at end of file diff --git a/java/com/android/incallui/incall/impl/res/layout/frag_incall_voice.xml b/java/com/android/incallui/incall/impl/res/layout/frag_incall_voice.xml new file mode 100644 index 000000000..9b950462c --- /dev/null +++ b/java/com/android/incallui/incall/impl/res/layout/frag_incall_voice.xml @@ -0,0 +1,104 @@ +<?xml version="1.0" encoding="utf-8"?> +<FrameLayout + 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:layout_width="match_parent" + android:layout_height="match_parent"> + + <RelativeLayout + 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:layout_width="match_parent" + android:layout_height="match_parent" + android:fitsSystemWindows="true"> + + <LinearLayout + android:id="@id/incall_contact_grid" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="12dp" + android:layout_marginStart="@dimen/incall_window_margin_horizontal" + android:layout_marginEnd="@dimen/incall_window_margin_horizontal" + android:gravity="center_horizontal" + android:orientation="vertical"> + + <ImageView + android:id="@id/contactgrid_avatar" + android:layout_width="@dimen/incall_avatar_size" + android:layout_height="@dimen/incall_avatar_size" + android:layout_marginBottom="8dp" + android:elevation="2dp"/> + + <include + layout="@layout/incall_contactgrid_top_row" + android:layout_width="wrap_content" + android:layout_height="wrap_content"/> + + <!-- 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="4dp" + android:singleLine="true" + android:textAppearance="@style/Dialer.Incall.TextAppearance.Large" + app:autoResizeText_minTextSize="28sp" + tools:text="Jake Peralta" + tools:ignore="Deprecated"/> + + <include + layout="@layout/incall_contactgrid_bottom_row" + android:layout_width="wrap_content" + android:layout_height="wrap_content"/> + + <FrameLayout + android:id="@+id/incall_location_holder" + android:layout_width="match_parent" + android:layout_height="match_parent"/> + </LinearLayout> + + <android.support.v4.view.ViewPager + android:id="@+id/incall_pager" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:layout_above="@+id/incall_tab_dots" + android:layout_below="@+id/incall_contact_grid" + android:layout_centerHorizontal="true"/> + + <android.support.design.widget.TabLayout + android:id="@+id/incall_tab_dots" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_above="@+id/incall_end_call" + android:visibility="gone" + app:tabBackground="@drawable/tab_selector" + app:tabGravity="center" + app:tabIndicatorHeight="0dp"/> + + <FrameLayout + android:id="@+id/incall_dialpad_container" + style="@style/DialpadContainer" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_alignParentBottom="true" + tools:background="@android:color/white" + tools:visibility="gone"/> + <ImageButton + android:id="@+id/incall_end_call" + style="@style/Incall.Button.End" + android:layout_marginTop="16dp" + android:layout_marginBottom="36dp" + android:layout_alignParentBottom="true" + android:layout_centerHorizontal="true" + android:contentDescription="@string/incall_content_description_end_call"/> + </RelativeLayout> + + <FrameLayout + android:id="@id/incall_on_hold_banner" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_gravity="top"/> +</FrameLayout> diff --git a/java/com/android/incallui/incall/impl/res/layout/incall_button_grid.xml b/java/com/android/incallui/incall/impl/res/layout/incall_button_grid.xml new file mode 100644 index 000000000..59e99440e --- /dev/null +++ b/java/com/android/incallui/incall/impl/res/layout/incall_button_grid.xml @@ -0,0 +1,77 @@ +<?xml version="1.0" encoding="utf-8"?> +<FrameLayout 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:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginStart="@dimen/incall_window_margin_horizontal" + android:layout_marginEnd="@dimen/incall_window_margin_horizontal" + tools:showIn="@layout/frag_incall_voice"> + <GridLayout + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_gravity="center" + android:columnCount="3" + android:orientation="horizontal"> + <com.android.incallui.incall.impl.CheckableLabeledButton + android:id="@+id/incall_first_button" + android:layout_width="0dp" + android:layout_columnWeight="1" + android:enabled="false" + android:gravity="center" + app:incall_labelText="@string/incall_label_mute" + tools:background="#FFFF0000" + tools:layout_height="@dimen/tools_button_height" + tools:layout_width="@dimen/incall_labeled_button_size"/> + <com.android.incallui.incall.impl.CheckableLabeledButton + android:id="@+id/incall_second_button" + android:layout_width="0dp" + android:layout_columnWeight="1" + android:enabled="false" + android:gravity="center" + app:incall_labelText="@string/incall_label_dialpad" + tools:background="#FFFF0000" + tools:layout_height="@dimen/tools_button_height" + tools:layout_width="@dimen/incall_labeled_button_size"/> + <com.android.incallui.incall.impl.CheckableLabeledButton + android:id="@+id/incall_third_button" + android:layout_width="0dp" + android:layout_columnWeight="1" + android:enabled="false" + android:gravity="center" + app:incall_labelText="@string/incall_label_speaker" + tools:background="#FFFF0000" + tools:layout_height="@dimen/tools_button_height" + tools:layout_width="@dimen/incall_labeled_button_size"/> + <com.android.incallui.incall.impl.CheckableLabeledButton + android:id="@+id/incall_fourth_button" + android:layout_marginTop="@dimen/incall_button_vertical_padding" + android:layout_width="0dp" + android:layout_columnWeight="1" + android:gravity="center" + app:incall_labelText="@string/incall_label_add_call" + tools:background="#FFFF0000" + tools:layout_height="@dimen/tools_button_height" + tools:layout_width="@dimen/incall_labeled_button_size"/> + <com.android.incallui.incall.impl.CheckableLabeledButton + android:id="@+id/incall_fifth_button" + android:layout_width="0dp" + android:layout_columnWeight="1" + android:layout_marginTop="@dimen/incall_button_vertical_padding" + android:gravity="center" + app:incall_labelText="@string/incall_label_hold" + tools:background="#FFFF0000" + tools:layout_height="@dimen/tools_button_height" + tools:layout_width="@dimen/incall_labeled_button_size"/> + <com.android.incallui.incall.impl.CheckableLabeledButton + android:id="@+id/incall_sixth_button" + android:layout_width="0dp" + android:layout_columnWeight="1" + android:layout_marginTop="@dimen/incall_button_vertical_padding" + android:gravity="center" + app:incall_labelText="@string/incall_label_videocall" + tools:background="#FFFF0000" + tools:layout_height="@dimen/tools_button_height" + tools:layout_width="@dimen/incall_labeled_button_size"/> + </GridLayout> +</FrameLayout> diff --git a/java/com/android/incallui/incall/impl/res/values-h320dp/dimens.xml b/java/com/android/incallui/incall/impl/res/values-h320dp/dimens.xml new file mode 100644 index 000000000..1fe0c4db9 --- /dev/null +++ b/java/com/android/incallui/incall/impl/res/values-h320dp/dimens.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <bool name="incall_dialpad_allowed">true</bool> + <integer name="incall_num_rows">1</integer> +</resources> diff --git a/java/com/android/incallui/incall/impl/res/values-h385dp/dimens.xml b/java/com/android/incallui/incall/impl/res/values-h385dp/dimens.xml new file mode 100644 index 000000000..aac42c563 --- /dev/null +++ b/java/com/android/incallui/incall/impl/res/values-h385dp/dimens.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <dimen name="incall_avatar_size">64dp</dimen> + <dimen name="incall_avatar_marginBottom">8dp</dimen> +</resources> diff --git a/java/com/android/incallui/incall/impl/res/values-h480dp/dimens.xml b/java/com/android/incallui/incall/impl/res/values-h480dp/dimens.xml new file mode 100644 index 000000000..ef1a800ac --- /dev/null +++ b/java/com/android/incallui/incall/impl/res/values-h480dp/dimens.xml @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <integer name="incall_num_rows">2</integer> +</resources> diff --git a/java/com/android/incallui/incall/impl/res/values-h580dp/dimens.xml b/java/com/android/incallui/incall/impl/res/values-h580dp/dimens.xml new file mode 100644 index 000000000..1f37cd504 --- /dev/null +++ b/java/com/android/incallui/incall/impl/res/values-h580dp/dimens.xml @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <dimen name="incall_avatar_size">88dp</dimen> +</resources> diff --git a/java/com/android/incallui/incall/impl/res/values-h580dp/styles.xml b/java/com/android/incallui/incall/impl/res/values-h580dp/styles.xml new file mode 100644 index 000000000..b58ef4819 --- /dev/null +++ b/java/com/android/incallui/incall/impl/res/values-h580dp/styles.xml @@ -0,0 +1,24 @@ +<?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> + + <style name="DialpadContainer"> + <item name="android:layout_below">@id/incall_contact_grid</item> + <item name="android:layout_marginTop">8dp</item> + </style> +</resources> diff --git a/java/com/android/incallui/incall/impl/res/values-w260dp-h520dp/dimens.xml b/java/com/android/incallui/incall/impl/res/values-w260dp-h520dp/dimens.xml new file mode 100644 index 000000000..e73eb934c --- /dev/null +++ b/java/com/android/incallui/incall/impl/res/values-w260dp-h520dp/dimens.xml @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <dimen name="incall_button_horizontal_padding">16dp</dimen> + <dimen name="incall_button_vertical_padding">16dp</dimen> + <dimen name="incall_labeled_button_size">64dp</dimen> + <dimen name="tools_button_height">92dp</dimen> +</resources> diff --git a/java/com/android/incallui/incall/impl/res/values-w300dp-h540dp/dimens.xml b/java/com/android/incallui/incall/impl/res/values-w300dp-h540dp/dimens.xml new file mode 100644 index 000000000..502ae72dc --- /dev/null +++ b/java/com/android/incallui/incall/impl/res/values-w300dp-h540dp/dimens.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <dimen name="incall_button_horizontal_padding">32dp</dimen> + <dimen name="incall_button_vertical_padding">32dp</dimen> +</resources> diff --git a/java/com/android/incallui/incall/impl/res/values/attrs.xml b/java/com/android/incallui/incall/impl/res/values/attrs.xml new file mode 100644 index 000000000..ed1b2a853 --- /dev/null +++ b/java/com/android/incallui/incall/impl/res/values/attrs.xml @@ -0,0 +1,8 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <declare-styleable name="CheckableLabeledButton"> + <attr format="reference" name="incall_icon"/> + <attr format="string|reference" name="incall_labelText"/> + <attr name="android:enabled"/> + </declare-styleable> +</resources> diff --git a/java/com/android/incallui/incall/impl/res/values/dimens.xml b/java/com/android/incallui/incall/impl/res/values/dimens.xml new file mode 100644 index 000000000..249788785 --- /dev/null +++ b/java/com/android/incallui/incall/impl/res/values/dimens.xml @@ -0,0 +1,17 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <dimen name="incall_button_label_margin">8dp</dimen> + <dimen name="incall_button_elevation">0dp</dimen> + <dimen name="incall_end_call_spacing">116dp</dimen> + <dimen name="incall_button_padding">4dp</dimen> + <dimen name="incall_button_horizontal_padding">8dp</dimen> + <dimen name="incall_button_vertical_padding">8dp</dimen> + <dimen name="incall_avatar_size">0dp</dimen> + <dimen name="incall_avatar_marginBottom">0dp</dimen> + <dimen name="incall_labeled_button_size">48dp</dimen> + <dimen name="tools_button_height">76dp</dimen> + <dimen name="incall_window_margin_horizontal">24dp</dimen> + + <bool name="incall_dialpad_allowed">false</bool> + <integer name="incall_num_rows">0</integer> +</resources> diff --git a/java/com/android/incallui/incall/impl/res/values/ids.xml b/java/com/android/incallui/incall/impl/res/values/ids.xml new file mode 100644 index 000000000..e1368f95d --- /dev/null +++ b/java/com/android/incallui/incall/impl/res/values/ids.xml @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <item name="incall_on_hold_banner" type="id"/> + <item name="incall_button_grid" type="id"/> + <item name="incall_contact_grid" type="id"/> +</resources> diff --git a/java/com/android/incallui/incall/impl/res/values/strings.xml b/java/com/android/incallui/incall/impl/res/values/strings.xml new file mode 100644 index 000000000..054ca9687 --- /dev/null +++ b/java/com/android/incallui/incall/impl/res/values/strings.xml @@ -0,0 +1,56 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + + <!-- Button shown during a phone call to upgrade to video. + [CHAR LIMIT=12] --> + <string name="incall_label_videocall">Video call</string> + + <!-- Button shown during a phone call to put the call on hold. + [CHAR LIMIT=12] --> + <string name="incall_label_hold">Hold</string> + + <!-- Button shown during a phone call to add a new phone call. + [CHAR LIMIT=12] --> + <string name="incall_label_add_call">Add call</string> + + <!-- Button shown during a phone call to mute the microphone. + [CHAR LIMIT=12] --> + <string name="incall_label_mute">Mute</string> + + <!-- Button shown during a phone call to show the dialpad. + [CHAR LIMIT=12] --> + <string name="incall_label_dialpad">Keypad</string> + + <!-- Button shown during a phone to route audio from earpiece to speaker phone. + [CHAR LIMIT=12] --> + <string name="incall_label_speaker">Speaker</string> + + <!-- Talkback text for speaker button status. [CHAR LIMIT=12] --> + <string name="incall_talkback_speaker_on">, is on</string> + + <!-- Talkback text for speaker button status. [CHAR LIMIT=12] --> + <string name="incall_talkback_speaker_off">, is Off</string> + + <!-- Button shown during a phone to merge two ongoing calls. + [CHAR LIMIT=12] --> + <string name="incall_label_merge">Merge</string> + + <!-- Button shown during a phone to show the manage conference call screen. + [CHAR LIMIT=12] --> + <string name="incall_label_manage">Manage</string> + + <string name="a11y_description_incall_label_manage_content">Manage callers</string> + + <!-- Button shown during a phone to swap from the foreground call to the background call. + [CHAR LIMIT=12] --> + <string name="incall_label_swap">Swap</string> + + <!-- Button shown during a phone to switch the audio route. + [CHAR LIMIT=12] --> + <string name="incall_label_audio">Sound</string> + + <!-- Used to inform the user that the note associated with an outgoing call has been sent. + [CHAR LIMIT=32] --> + <string name="incall_note_sent">Note sent</string> + +</resources>
\ No newline at end of file diff --git a/java/com/android/incallui/incall/impl/res/values/styles.xml b/java/com/android/incallui/incall/impl/res/values/styles.xml new file mode 100644 index 000000000..2392574a3 --- /dev/null +++ b/java/com/android/incallui/incall/impl/res/values/styles.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> + + <style name="DialpadContainer"> + <item name="android:layout_alignParentTop">true</item> + </style> +</resources> diff --git a/java/com/android/incallui/incall/protocol/ContactPhotoType.java b/java/com/android/incallui/incall/protocol/ContactPhotoType.java new file mode 100644 index 000000000..d79b7550b --- /dev/null +++ b/java/com/android/incallui/incall/protocol/ContactPhotoType.java @@ -0,0 +1,35 @@ +/* + * 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.incall.protocol; + +import android.support.annotation.IntDef; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** Types of contact photos we can have. */ +@Retention(RetentionPolicy.SOURCE) +@IntDef({ + ContactPhotoType.DEFAULT_PLACEHOLDER, + ContactPhotoType.BUSINESS, + ContactPhotoType.CONTACT, +}) +public @interface ContactPhotoType { + + int DEFAULT_PLACEHOLDER = 0; + int BUSINESS = 1; + int CONTACT = 2; +} diff --git a/java/com/android/incallui/incall/protocol/InCallButtonIds.java b/java/com/android/incallui/incall/protocol/InCallButtonIds.java new file mode 100644 index 000000000..50ebc6413 --- /dev/null +++ b/java/com/android/incallui/incall/protocol/InCallButtonIds.java @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2013 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.incall.protocol; + +import android.support.annotation.IntDef; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** Ids for buttons in the in call UI. */ +@Retention(RetentionPolicy.SOURCE) +@IntDef({ + InCallButtonIds.BUTTON_AUDIO, + InCallButtonIds.BUTTON_MUTE, + InCallButtonIds.BUTTON_DIALPAD, + InCallButtonIds.BUTTON_HOLD, + InCallButtonIds.BUTTON_SWAP, + InCallButtonIds.BUTTON_UPGRADE_TO_VIDEO, + InCallButtonIds.BUTTON_SWITCH_CAMERA, + InCallButtonIds.BUTTON_DOWNGRADE_TO_AUDIO, + InCallButtonIds.BUTTON_ADD_CALL, + InCallButtonIds.BUTTON_MERGE, + InCallButtonIds.BUTTON_PAUSE_VIDEO, + InCallButtonIds.BUTTON_MANAGE_VIDEO_CONFERENCE, + InCallButtonIds.BUTTON_MANAGE_VOICE_CONFERENCE, + InCallButtonIds.BUTTON_SWITCH_TO_SECONDARY, + InCallButtonIds.BUTTON_COUNT, +}) +public @interface InCallButtonIds { + + int BUTTON_AUDIO = 0; + int BUTTON_MUTE = 1; + int BUTTON_DIALPAD = 2; + int BUTTON_HOLD = 3; + int BUTTON_SWAP = 4; + int BUTTON_UPGRADE_TO_VIDEO = 5; + int BUTTON_SWITCH_CAMERA = 6; + int BUTTON_DOWNGRADE_TO_AUDIO = 7; + int BUTTON_ADD_CALL = 8; + int BUTTON_MERGE = 9; + int BUTTON_PAUSE_VIDEO = 10; + int BUTTON_MANAGE_VIDEO_CONFERENCE = 11; + int BUTTON_MANAGE_VOICE_CONFERENCE = 12; + int BUTTON_SWITCH_TO_SECONDARY = 13; + int BUTTON_COUNT = 14; +} diff --git a/java/com/android/incallui/incall/protocol/InCallButtonIdsExtension.java b/java/com/android/incallui/incall/protocol/InCallButtonIdsExtension.java new file mode 100644 index 000000000..6d802e346 --- /dev/null +++ b/java/com/android/incallui/incall/protocol/InCallButtonIdsExtension.java @@ -0,0 +1,61 @@ +/* + * 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.incall.protocol; + +/** Utility class for {@link InCallButtonIds}. */ +public class InCallButtonIdsExtension { + + /** + * Converts the given {@link InCallButtonIds} to a human readable string. + * + * @param id the id to convert. + * @return the human readable string. + */ + public static String toString(@InCallButtonIds int id) { + if (id == InCallButtonIds.BUTTON_AUDIO) { + return "AUDIO"; + } else if (id == InCallButtonIds.BUTTON_MUTE) { + return "MUTE"; + } else if (id == InCallButtonIds.BUTTON_DIALPAD) { + return "DIALPAD"; + } else if (id == InCallButtonIds.BUTTON_HOLD) { + return "HOLD"; + } else if (id == InCallButtonIds.BUTTON_SWAP) { + return "SWAP"; + } else if (id == InCallButtonIds.BUTTON_UPGRADE_TO_VIDEO) { + return "UPGRADE_TO_VIDEO"; + } else if (id == InCallButtonIds.BUTTON_DOWNGRADE_TO_AUDIO) { + return "DOWNGRADE_TO_AUDIO"; + } else if (id == InCallButtonIds.BUTTON_SWITCH_CAMERA) { + return "SWITCH_CAMERA"; + } else if (id == InCallButtonIds.BUTTON_ADD_CALL) { + return "ADD_CALL"; + } else if (id == InCallButtonIds.BUTTON_MERGE) { + return "MERGE"; + } else if (id == InCallButtonIds.BUTTON_PAUSE_VIDEO) { + return "PAUSE_VIDEO"; + } else if (id == InCallButtonIds.BUTTON_MANAGE_VIDEO_CONFERENCE) { + return "MANAGE_VIDEO_CONFERENCE"; + } else if (id == InCallButtonIds.BUTTON_MANAGE_VOICE_CONFERENCE) { + return "MANAGE_VOICE_CONFERENCE"; + } else if (id == InCallButtonIds.BUTTON_SWITCH_TO_SECONDARY) { + return "SWITCH_TO_SECONDARY"; + } else { + return "INVALID_BUTTON: " + id; + } + } +} diff --git a/java/com/android/incallui/incall/protocol/InCallButtonUi.java b/java/com/android/incallui/incall/protocol/InCallButtonUi.java new file mode 100644 index 000000000..96d741af3 --- /dev/null +++ b/java/com/android/incallui/incall/protocol/InCallButtonUi.java @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2013 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.incall.protocol; + +import android.support.v4.app.Fragment; +import android.telecom.CallAudioState; + +/** Interface for the call button UI. */ +public interface InCallButtonUi { + + void showButton(@InCallButtonIds int buttonId, boolean show); + + void enableButton(@InCallButtonIds int buttonId, boolean enable); + + void setEnabled(boolean on); + + void setHold(boolean on); + + void setCameraSwitched(boolean isBackFacingCamera); + + void setVideoPaused(boolean isPaused); + + void setAudioState(CallAudioState audioState); + + /** + * Once showButton() has been called on each of the individual buttons in the UI, call this to + * configure the overflow menu appropriately. + */ + void updateButtonStates(); + + void updateInCallButtonUiColors(); + + Fragment getInCallButtonUiFragment(); + + void showAudioRouteSelector(); +} diff --git a/java/com/android/incallui/incall/protocol/InCallButtonUiDelegate.java b/java/com/android/incallui/incall/protocol/InCallButtonUiDelegate.java new file mode 100644 index 000000000..5e69f0e2d --- /dev/null +++ b/java/com/android/incallui/incall/protocol/InCallButtonUiDelegate.java @@ -0,0 +1,67 @@ +/* + * 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.incall.protocol; + +import android.content.Context; +import android.os.Bundle; +import android.telecom.CallAudioState; + +/** Callbacks from the module out to the container. */ +public interface InCallButtonUiDelegate { + + void onInCallButtonUiReady(InCallButtonUi inCallButtonUi); + + void onInCallButtonUiUnready(); + + void onSaveInstanceState(Bundle outState); + + void onRestoreInstanceState(Bundle savedInstanceState); + + void refreshMuteState(); + + void addCallClicked(); + + void muteClicked(boolean checked); + + void mergeClicked(); + + void holdClicked(boolean checked); + + void swapClicked(); + + void showDialpadClicked(boolean checked); + + void changeToVideoClicked(); + + void switchCameraClicked(boolean useFrontFacingCamera); + + void toggleCameraClicked(); + + void pauseVideoClicked(boolean pause); + + void toggleSpeakerphone(); + + CallAudioState getCurrentAudioState(); + + void setAudioRoute(int route); + + void onEndCallClicked(); + + void showAudioRouteSelector(); + + Context getContext(); +} diff --git a/java/com/android/incallui/incall/protocol/InCallButtonUiDelegateFactory.java b/java/com/android/incallui/incall/protocol/InCallButtonUiDelegateFactory.java new file mode 100644 index 000000000..ca7d11951 --- /dev/null +++ b/java/com/android/incallui/incall/protocol/InCallButtonUiDelegateFactory.java @@ -0,0 +1,23 @@ +/* + * 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.incall.protocol; + +/** Callbacks from the module out to the container. */ +public interface InCallButtonUiDelegateFactory { + + InCallButtonUiDelegate newInCallButtonUiDelegate(); +} diff --git a/java/com/android/incallui/incall/protocol/InCallScreen.java b/java/com/android/incallui/incall/protocol/InCallScreen.java new file mode 100644 index 000000000..612ad26f5 --- /dev/null +++ b/java/com/android/incallui/incall/protocol/InCallScreen.java @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2013 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.incall.protocol; + +import android.support.annotation.NonNull; +import android.support.v4.app.Fragment; +import android.view.accessibility.AccessibilityEvent; + +/** Interface for the call card module. */ +public interface InCallScreen { + + void setPrimary(@NonNull PrimaryInfo primaryInfo); + + void setSecondary(@NonNull SecondaryInfo secondaryInfo); + + void setCallState(@NonNull PrimaryCallState primaryCallState); + + void setEndCallButtonEnabled(boolean enabled, boolean animate); + + void showManageConferenceCallButton(boolean visible); + + boolean isManageConferenceVisible(); + + void dispatchPopulateAccessibilityEvent(AccessibilityEvent event); + + void showNoteSentToast(); + + void updateInCallScreenColors(); + + void onInCallScreenDialpadVisibilityChange(boolean isShowing); + + int getAnswerAndDialpadContainerResourceId(); + + void showLocationUi(Fragment locationUi); + + boolean isShowingLocationUi(); + + Fragment getInCallScreenFragment(); +} diff --git a/java/com/android/incallui/incall/protocol/InCallScreenDelegate.java b/java/com/android/incallui/incall/protocol/InCallScreenDelegate.java new file mode 100644 index 000000000..b39f9f4a2 --- /dev/null +++ b/java/com/android/incallui/incall/protocol/InCallScreenDelegate.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.incall.protocol; + +import android.graphics.drawable.Drawable; + +/** Callbacks from the module out to the container. */ +public interface InCallScreenDelegate { + + void onInCallScreenDelegateInit(InCallScreen inCallScreen); + + void onInCallScreenReady(); + + void onInCallScreenUnready(); + + void onEndCallClicked(); + + void onSecondaryInfoClicked(); + + void onCallStateButtonClicked(); + + void onManageConferenceClicked(); + + void onShrinkAnimationComplete(); + + void onInCallScreenResumed(); + + Drawable getDefaultContactPhotoDrawable(); +} diff --git a/java/com/android/incallui/incall/protocol/InCallScreenDelegateFactory.java b/java/com/android/incallui/incall/protocol/InCallScreenDelegateFactory.java new file mode 100644 index 000000000..6706691c8 --- /dev/null +++ b/java/com/android/incallui/incall/protocol/InCallScreenDelegateFactory.java @@ -0,0 +1,23 @@ +/* + * 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.incall.protocol; + +/** Callbacks from the module out to the container. */ +public interface InCallScreenDelegateFactory { + + InCallScreenDelegate newInCallScreenDelegate(); +} diff --git a/java/com/android/incallui/incall/protocol/PrimaryCallState.java b/java/com/android/incallui/incall/protocol/PrimaryCallState.java new file mode 100644 index 000000000..782090832 --- /dev/null +++ b/java/com/android/incallui/incall/protocol/PrimaryCallState.java @@ -0,0 +1,114 @@ +/* + * 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.incall.protocol; + +import android.graphics.drawable.Drawable; +import android.telecom.DisconnectCause; +import android.telecom.VideoProfile; +import com.android.incallui.call.DialerCall; +import com.android.incallui.call.DialerCall.SessionModificationState; +import java.util.Locale; + +/** State of the primary call. */ +public class PrimaryCallState { + public final int state; + public final int videoState; + @SessionModificationState public final int sessionModificationState; + public final DisconnectCause disconnectCause; + public final String connectionLabel; + public final Drawable connectionIcon; + public final String gatewayNumber; + public final String callSubject; + public final String callbackNumber; + public final boolean isWifi; + public final boolean isConference; + public final boolean isWorkCall; + public final boolean isHdAudioCall; + public final boolean isForwardedNumber; + public final boolean shouldShowContactPhoto; + public final long connectTimeMillis; + public final boolean isVoiceMailNumber; + public final boolean isRemotelyHeld; + + // TODO: Convert to autovalue. b/34502119 + public static PrimaryCallState createEmptyPrimaryCallState() { + return new PrimaryCallState( + DialerCall.State.IDLE, + VideoProfile.STATE_AUDIO_ONLY, + DialerCall.SESSION_MODIFICATION_STATE_NO_REQUEST, + new DisconnectCause(DisconnectCause.UNKNOWN), + null, /* connectionLabel */ + null, /* connectionIcon */ + null, /* gatewayNumber */ + null, /* callSubject */ + null, /* callbackNumber */ + false /* isWifi */, + false /* isConference */, + false /* isWorkCall */, + false /* isHdAudioCall */, + false /* isForwardedNumber */, + false /* shouldShowContactPhoto */, + 0, + false /* isVoiceMailNumber */, + false /* isRemotelyHeld */); + } + + public PrimaryCallState( + int state, + int videoState, + @SessionModificationState int sessionModificationState, + DisconnectCause disconnectCause, + String connectionLabel, + Drawable connectionIcon, + String gatewayNumber, + String callSubject, + String callbackNumber, + boolean isWifi, + boolean isConference, + boolean isWorkCall, + boolean isHdAudioCall, + boolean isForwardedNumber, + boolean shouldShowContactPhoto, + long connectTimeMillis, + boolean isVoiceMailNumber, + boolean isRemotelyHeld) { + this.state = state; + this.videoState = videoState; + this.sessionModificationState = sessionModificationState; + this.disconnectCause = disconnectCause; + this.connectionLabel = connectionLabel; + this.connectionIcon = connectionIcon; + this.gatewayNumber = gatewayNumber; + this.callSubject = callSubject; + this.callbackNumber = callbackNumber; + this.isWifi = isWifi; + this.isConference = isConference; + this.isWorkCall = isWorkCall; + this.isHdAudioCall = isHdAudioCall; + this.isForwardedNumber = isForwardedNumber; + this.shouldShowContactPhoto = shouldShowContactPhoto; + this.connectTimeMillis = connectTimeMillis; + this.isVoiceMailNumber = isVoiceMailNumber; + this.isRemotelyHeld = isRemotelyHeld; + } + + @Override + public String toString() { + return String.format( + Locale.US, "PrimaryCallState, state: %d, connectionLabel: %s", state, connectionLabel); + } +} diff --git a/java/com/android/incallui/incall/protocol/PrimaryInfo.java b/java/com/android/incallui/incall/protocol/PrimaryInfo.java new file mode 100644 index 000000000..1833ed22e --- /dev/null +++ b/java/com/android/incallui/incall/protocol/PrimaryInfo.java @@ -0,0 +1,112 @@ +/* + * 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.incall.protocol; + +import android.graphics.drawable.Drawable; +import android.support.annotation.Nullable; +import com.android.dialer.common.LogUtil; +import com.android.dialer.multimedia.MultimediaData; +import java.util.Locale; + +/** Information about the primary call. */ +public class PrimaryInfo { + @Nullable public final String number; + @Nullable public final String name; + public final boolean nameIsNumber; + // This is from contacts and shows the type of number. For example, "Mobile". + @Nullable public final String label; + @Nullable public final String location; + @Nullable public final Drawable photo; + @ContactPhotoType public final int photoType; + public final boolean isSipCall; + public final boolean isContactPhotoShown; + public final boolean isWorkCall; + public final boolean isSpam; + public final boolean answeringDisconnectsOngoingCall; + public final boolean shouldShowLocation; + // Used for consistent LetterTile coloring. + @Nullable public final String contactInfoLookupKey; + @Nullable public final MultimediaData multimediaData; + + // TODO: Convert to autovalue. b/34502119 + public static PrimaryInfo createEmptyPrimaryInfo() { + return new PrimaryInfo( + null, + null, + false, + null, + null, + null, + ContactPhotoType.DEFAULT_PLACEHOLDER, + false, + false, + false, + false, + false, + false, + null, + null); + } + + public PrimaryInfo( + @Nullable String number, + @Nullable String name, + boolean nameIsNumber, + @Nullable String location, + @Nullable String label, + @Nullable Drawable photo, + @ContactPhotoType int phototType, + boolean isSipCall, + boolean isContactPhotoShown, + boolean isWorkCall, + boolean isSpam, + boolean answeringDisconnectsOngoingCall, + boolean shouldShowLocation, + @Nullable String contactInfoLookupKey, + @Nullable MultimediaData multimediaData) { + this.number = number; + this.name = name; + this.nameIsNumber = nameIsNumber; + this.location = location; + this.label = label; + this.photo = photo; + this.photoType = phototType; + this.isSipCall = isSipCall; + this.isContactPhotoShown = isContactPhotoShown; + this.isWorkCall = isWorkCall; + this.isSpam = isSpam; + this.answeringDisconnectsOngoingCall = answeringDisconnectsOngoingCall; + this.shouldShowLocation = shouldShowLocation; + this.contactInfoLookupKey = contactInfoLookupKey; + this.multimediaData = multimediaData; + } + + @Override + public String toString() { + return String.format( + Locale.US, + "PrimaryInfo, number: %s, name: %s, location: %s, label: %s, " + + "photo: %s, photoType: %d, isPhotoVisible: %b", + LogUtil.sanitizePhoneNumber(number), + LogUtil.sanitizePii(name), + LogUtil.sanitizePii(location), + label, + photo, + photoType, + isContactPhotoShown); + } +} diff --git a/java/com/android/incallui/incall/protocol/SecondaryInfo.java b/java/com/android/incallui/incall/protocol/SecondaryInfo.java new file mode 100644 index 000000000..cadfca6bf --- /dev/null +++ b/java/com/android/incallui/incall/protocol/SecondaryInfo.java @@ -0,0 +1,109 @@ +/* + * 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.incall.protocol; + +import android.os.Parcel; +import android.os.Parcelable; +import com.android.dialer.common.LogUtil; +import java.util.Locale; + +/** Information about the secondary call. */ +public class SecondaryInfo implements Parcelable { + public final boolean shouldShow; + public final String name; + public final boolean nameIsNumber; + public final String label; + public final String providerLabel; + public final boolean isConference; + public final boolean isVideoCall; + public final boolean isFullscreen; + + public static SecondaryInfo createEmptySecondaryInfo(boolean isFullScreen) { + return new SecondaryInfo(false, null, false, null, null, false, false, isFullScreen); + } + + public SecondaryInfo( + boolean shouldShow, + String name, + boolean nameIsNumber, + String label, + String providerLabel, + boolean isConference, + boolean isVideoCall, + boolean isFullscreen) { + this.shouldShow = shouldShow; + this.name = name; + this.nameIsNumber = nameIsNumber; + this.label = label; + this.providerLabel = providerLabel; + this.isConference = isConference; + this.isVideoCall = isVideoCall; + this.isFullscreen = isFullscreen; + } + + @Override + public String toString() { + return String.format( + Locale.US, + "SecondaryInfo, show: %b, name: %s, label: %s, " + "providerLabel: %s", + shouldShow, + LogUtil.sanitizePii(name), + label, + providerLabel); + } + + protected SecondaryInfo(Parcel in) { + shouldShow = in.readByte() != 0; + name = in.readString(); + nameIsNumber = in.readByte() != 0; + label = in.readString(); + providerLabel = in.readString(); + isConference = in.readByte() != 0; + isVideoCall = in.readByte() != 0; + isFullscreen = in.readByte() != 0; + } + + public static final Creator<SecondaryInfo> CREATOR = + new Creator<SecondaryInfo>() { + @Override + public SecondaryInfo createFromParcel(Parcel in) { + return new SecondaryInfo(in); + } + + @Override + public SecondaryInfo[] newArray(int size) { + return new SecondaryInfo[size]; + } + }; + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeByte((byte) (shouldShow ? 1 : 0)); + dest.writeString(name); + dest.writeByte((byte) (nameIsNumber ? 1 : 0)); + dest.writeString(label); + dest.writeString(providerLabel); + dest.writeByte((byte) (isConference ? 1 : 0)); + dest.writeByte((byte) (isVideoCall ? 1 : 0)); + dest.writeByte((byte) (isFullscreen ? 1 : 0)); + } +} diff --git a/java/com/android/incallui/latencyreport/LatencyReport.java b/java/com/android/incallui/latencyreport/LatencyReport.java new file mode 100644 index 000000000..2e1fbd590 --- /dev/null +++ b/java/com/android/incallui/latencyreport/LatencyReport.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.latencyreport; + +import android.os.Bundle; +import android.os.SystemClock; + +/** Tracks latency information for a call. */ +public class LatencyReport { + + public static final long INVALID_TIME = -1; + // The following are hidden constants from android.telecom.TelecomManager. + private static final String EXTRA_CALL_CREATED_TIME_MILLIS = + "android.telecom.extra.CALL_CREATED_TIME_MILLIS"; + private static final String EXTRA_CALL_TELECOM_ROUTING_START_TIME_MILLIS = + "android.telecom.extra.CALL_TELECOM_ROUTING_START_TIME_MILLIS"; + private static final String EXTRA_CALL_TELECOM_ROUTING_END_TIME_MILLIS = + "android.telecom.extra.CALL_TELECOM_ROUTING_END_TIME_MILLIS"; + private final boolean mWasIncoming; + + // Time elapsed since boot when the call was created by the connection service. + private final long mCreatedTimeMillis; + + // Time elapsed since boot when telecom began processing the call. + private final long mTelecomRoutingStartTimeMillis; + + // Time elapsed since boot when telecom finished processing the call. This includes things like + // looking up contact info and call blocking but before showing any UI. + private final long mTelecomRoutingEndTimeMillis; + + // Time elapsed since boot when the call was added to the InCallUi. + private final long mCallAddedTimeMillis; + + // Time elapsed since boot when the call was added and call blocking evaluation was completed. + private long mCallBlockingTimeMillis = INVALID_TIME; + + // Time elapsed since boot when the call notification was shown. + private long mCallNotificationTimeMillis = INVALID_TIME; + + // Time elapsed since boot when the InCallUI was shown. + private long mInCallUiShownTimeMillis = INVALID_TIME; + + // Whether the call was shown to the user as a heads up notification instead of a full screen + // UI. + private boolean mDidDisplayHeadsUpNotification; + + public LatencyReport() { + mWasIncoming = false; + mCreatedTimeMillis = INVALID_TIME; + mTelecomRoutingStartTimeMillis = INVALID_TIME; + mTelecomRoutingEndTimeMillis = INVALID_TIME; + mCallAddedTimeMillis = SystemClock.elapsedRealtime(); + } + + public LatencyReport(android.telecom.Call telecomCall) { + mWasIncoming = telecomCall.getState() == android.telecom.Call.STATE_RINGING; + Bundle extras = telecomCall.getDetails().getIntentExtras(); + if (extras == null) { + mCreatedTimeMillis = INVALID_TIME; + mTelecomRoutingStartTimeMillis = INVALID_TIME; + mTelecomRoutingEndTimeMillis = INVALID_TIME; + } else { + mCreatedTimeMillis = extras.getLong(EXTRA_CALL_CREATED_TIME_MILLIS, INVALID_TIME); + mTelecomRoutingStartTimeMillis = + extras.getLong(EXTRA_CALL_TELECOM_ROUTING_START_TIME_MILLIS, INVALID_TIME); + mTelecomRoutingEndTimeMillis = + extras.getLong(EXTRA_CALL_TELECOM_ROUTING_END_TIME_MILLIS, INVALID_TIME); + } + mCallAddedTimeMillis = SystemClock.elapsedRealtime(); + } + + public boolean getWasIncoming() { + return mWasIncoming; + } + + public long getCreatedTimeMillis() { + return mCreatedTimeMillis; + } + + public long getTelecomRoutingStartTimeMillis() { + return mTelecomRoutingStartTimeMillis; + } + + public long getTelecomRoutingEndTimeMillis() { + return mTelecomRoutingEndTimeMillis; + } + + public long getCallAddedTimeMillis() { + return mCallAddedTimeMillis; + } + + public long getCallBlockingTimeMillis() { + return mCallBlockingTimeMillis; + } + + public void onCallBlockingDone() { + if (mCallBlockingTimeMillis == INVALID_TIME) { + mCallBlockingTimeMillis = SystemClock.elapsedRealtime(); + } + } + + public long getCallNotificationTimeMillis() { + return mCallNotificationTimeMillis; + } + + public void onNotificationShown() { + if (mCallNotificationTimeMillis == INVALID_TIME) { + mCallNotificationTimeMillis = SystemClock.elapsedRealtime(); + } + } + + public long getInCallUiShownTimeMillis() { + return mInCallUiShownTimeMillis; + } + + public void onInCallUiShown(boolean forFullScreenIntent) { + if (mInCallUiShownTimeMillis == INVALID_TIME) { + mInCallUiShownTimeMillis = SystemClock.elapsedRealtime(); + mDidDisplayHeadsUpNotification = mWasIncoming && !forFullScreenIntent; + } + } + + public boolean getDidDisplayHeadsUpNotification() { + return mDidDisplayHeadsUpNotification; + } +} diff --git a/java/com/android/incallui/legacyblocking/BlockedNumberContentObserver.java b/java/com/android/incallui/legacyblocking/BlockedNumberContentObserver.java new file mode 100644 index 000000000..9b5335b69 --- /dev/null +++ b/java/com/android/incallui/legacyblocking/BlockedNumberContentObserver.java @@ -0,0 +1,105 @@ +/* + * 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.legacyblocking; + +import android.content.Context; +import android.database.ContentObserver; +import android.os.Handler; +import android.provider.CallLog; +import android.support.annotation.NonNull; +import com.android.dialer.common.AsyncTaskExecutor; +import com.android.dialer.common.AsyncTaskExecutors; +import com.android.dialer.common.LogUtil; +import java.util.Objects; + +/** + * Observes the {@link CallLog} to delete the CallLog entry for a blocked call after it is added. + * Automatically de-registers itself {@link #TIMEOUT_MS} ms after registration or if the entry is + * found and deleted. + */ +public class BlockedNumberContentObserver extends ContentObserver + implements DeleteBlockedCallTask.Listener { + + /** + * The time after which a {@link BlockedNumberContentObserver} will be automatically unregistered. + */ + public static final int TIMEOUT_MS = 5000; + + @NonNull private final Context context; + @NonNull private final Handler handler; + private final String number; + private final long timeAddedMillis; + private final Runnable timeoutRunnable = + new Runnable() { + @Override + public void run() { + unregister(); + } + }; + + private final AsyncTaskExecutor asyncTaskExecutor = AsyncTaskExecutors.createThreadPoolExecutor(); + + /** + * Creates the BlockedNumberContentObserver to delete the new {@link CallLog} entry from the given + * blocked number. + * + * @param number The blocked number. + * @param timeAddedMillis The time at which the call from the blocked number was placed. + */ + public BlockedNumberContentObserver( + @NonNull Context context, @NonNull Handler handler, String number, long timeAddedMillis) { + super(handler); + this.context = Objects.requireNonNull(context, "context").getApplicationContext(); + this.handler = Objects.requireNonNull(handler); + this.number = number; + this.timeAddedMillis = timeAddedMillis; + } + + @Override + public void onChange(boolean selfChange) { + LogUtil.i( + "BlockedNumberContentObserver.onChange", + "attempting to remove call log entry from blocked number"); + asyncTaskExecutor.submit( + DeleteBlockedCallTask.IDENTIFIER, + new DeleteBlockedCallTask(context, this, number, timeAddedMillis)); + } + + @Override + public void onDeleteBlockedCallTaskComplete(boolean didFindEntry) { + if (didFindEntry) { + unregister(); + } + } + + /** + * Registers this {@link ContentObserver} to listen for changes to the {@link CallLog}. If the + * CallLog entry is not found before {@link #TIMEOUT_MS}, this ContentObserver automatically + * un-registers itself. + */ + public void register() { + LogUtil.i("BlockedNumberContentObserver.register", null); + context.getContentResolver().registerContentObserver(CallLog.CONTENT_URI, true, this); + handler.postDelayed(timeoutRunnable, TIMEOUT_MS); + } + + private void unregister() { + LogUtil.i("BlockedNumberContentObserver.unregister", null); + handler.removeCallbacks(timeoutRunnable); + context.getContentResolver().unregisterContentObserver(this); + } +} diff --git a/java/com/android/incallui/legacyblocking/DeleteBlockedCallTask.java b/java/com/android/incallui/legacyblocking/DeleteBlockedCallTask.java new file mode 100644 index 000000000..a3f2dfa4d --- /dev/null +++ b/java/com/android/incallui/legacyblocking/DeleteBlockedCallTask.java @@ -0,0 +1,124 @@ +/* + * 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.legacyblocking; + +import android.Manifest.permission; +import android.annotation.TargetApi; +import android.content.Context; +import android.content.pm.PackageManager; +import android.database.Cursor; +import android.os.AsyncTask; +import android.os.Build.VERSION_CODES; +import android.provider.CallLog; +import android.support.v4.content.ContextCompat; +import com.android.dialer.common.LogUtil; +import com.android.dialer.telecom.TelecomUtil; +import java.util.Objects; + +/** + * Deletes a blocked call from the call log. This is only used on Android Marshmallow. On later + * versions of the OS, call blocking is implemented in the system and there's no need to mess with + * the call log. + */ +@TargetApi(VERSION_CODES.M) +public class DeleteBlockedCallTask extends AsyncTask<Void, Void, Long> { + + public static final String IDENTIFIER = "DeleteBlockedCallTask"; + + // Try to identify if a call log entry corresponds to a number which was blocked. We match by + // by comparing its creation time to the time it was added in the InCallUi and seeing if they + // fall within a certain threshold. + private static final int MATCH_BLOCKED_CALL_THRESHOLD_MS = 3000; + + private final Context context; + private final Listener listener; + private final String number; + private final long timeAddedMillis; + + /** + * Creates the task to delete the new {@link CallLog} entry from the given blocked number. + * + * @param number The blocked number. + * @param timeAddedMillis The time at which the call from the blocked number was placed. + */ + public DeleteBlockedCallTask( + Context context, Listener listener, String number, long timeAddedMillis) { + this.context = Objects.requireNonNull(context); + this.listener = Objects.requireNonNull(listener); + this.number = number; + this.timeAddedMillis = timeAddedMillis; + } + + @Override + public Long doInBackground(Void... params) { + if (ContextCompat.checkSelfPermission(context, permission.READ_CALL_LOG) + != PackageManager.PERMISSION_GRANTED + || ContextCompat.checkSelfPermission(context, permission.WRITE_CALL_LOG) + != PackageManager.PERMISSION_GRANTED) { + LogUtil.i("DeleteBlockedCallTask.doInBackground", "missing call log permissions"); + return -1L; + } + + // First, lookup the call log entry of the most recent call with this number. + try (Cursor cursor = + context + .getContentResolver() + .query( + TelecomUtil.getCallLogUri(context), + CallLogDeleteBlockedCallQuery.PROJECTION, + CallLog.Calls.NUMBER + "= ?", + new String[] {number}, + CallLog.Calls.DATE + " DESC LIMIT 1")) { + + // If match is found, delete this call log entry and return the call log entry id. + if (cursor != null && cursor.moveToFirst()) { + long creationTime = cursor.getLong(CallLogDeleteBlockedCallQuery.DATE_COLUMN_INDEX); + if (timeAddedMillis > creationTime + && timeAddedMillis - creationTime < MATCH_BLOCKED_CALL_THRESHOLD_MS) { + long callLogEntryId = cursor.getLong(CallLogDeleteBlockedCallQuery.ID_COLUMN_INDEX); + context + .getContentResolver() + .delete( + TelecomUtil.getCallLogUri(context), + CallLog.Calls._ID + " IN (" + callLogEntryId + ")", + null); + return callLogEntryId; + } + } + } + return -1L; + } + + @Override + public void onPostExecute(Long callLogEntryId) { + listener.onDeleteBlockedCallTaskComplete(callLogEntryId >= 0); + } + + /** Callback invoked when delete is complete. */ + public interface Listener { + + void onDeleteBlockedCallTaskComplete(boolean didFindEntry); + } + + private static class CallLogDeleteBlockedCallQuery { + + static final String[] PROJECTION = new String[] {CallLog.Calls._ID, CallLog.Calls.DATE}; + + static final int ID_COLUMN_INDEX = 0; + static final int DATE_COLUMN_INDEX = 1; + } +} diff --git a/java/com/android/incallui/maps/StaticMapBinding.java b/java/com/android/incallui/maps/StaticMapBinding.java new file mode 100644 index 000000000..9d24ef27a --- /dev/null +++ b/java/com/android/incallui/maps/StaticMapBinding.java @@ -0,0 +1,51 @@ +/* + * 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.maps; + +import android.app.Application; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.annotation.VisibleForTesting; + +/** Utility for getting a {@link StaticMapFactory} */ +public class StaticMapBinding { + + @Nullable + public static StaticMapFactory get(@NonNull Application application) { + if (useTestingInstance) { + return testingInstance; + } + if (application instanceof StaticMapFactory) { + return ((StaticMapFactory) application); + } + return null; + } + + private static StaticMapFactory testingInstance; + private static boolean useTestingInstance; + + @VisibleForTesting + public static void setForTesting(@Nullable StaticMapFactory staticMapFactory) { + testingInstance = staticMapFactory; + useTestingInstance = true; + } + + @VisibleForTesting + public static void clearForTesting() { + useTestingInstance = false; + } +} diff --git a/java/com/android/incallui/maps/StaticMapFactory.java b/java/com/android/incallui/maps/StaticMapFactory.java new file mode 100644 index 000000000..a35013886 --- /dev/null +++ b/java/com/android/incallui/maps/StaticMapFactory.java @@ -0,0 +1,28 @@ +/* + * 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.maps; + +import android.location.Location; +import android.support.annotation.NonNull; +import android.support.v4.app.Fragment; + +/** A Factory that can create Fragments for showing a static map */ +public interface StaticMapFactory { + + @NonNull + Fragment getStaticMap(@NonNull Location location); +} diff --git a/java/com/android/incallui/res/anim/activity_open_enter.xml b/java/com/android/incallui/res/anim/activity_open_enter.xml new file mode 100644 index 000000000..71cc096b9 --- /dev/null +++ b/java/com/android/incallui/res/anim/activity_open_enter.xml @@ -0,0 +1,43 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- +/* +** Copyright 2009, 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. +*/ +--> + +<set xmlns:android="http://schemas.android.com/apk/res/android" + android:shareInterpolator="false" + android:zAdjustment="top"> + <alpha + android:duration="300" + android:fillAfter="true" + android:fillBefore="false" + android:fillEnabled="true" + android:fromAlpha="0.0" + android:interpolator="@anim/decelerate_cubic" + android:toAlpha="1.0"/> + <scale + android:duration="300" + android:fillAfter="true" + android:fillBefore="false" + android:fillEnabled="true" + android:fromXScale=".8" + android:fromYScale=".8" + android:interpolator="@anim/decelerate_cubic" + android:pivotX="50%p" + android:pivotY="50%p" + android:toXScale="1.0" + android:toYScale="1.0"/> +</set>
\ No newline at end of file diff --git a/java/com/android/incallui/res/anim/activity_open_exit.xml b/java/com/android/incallui/res/anim/activity_open_exit.xml new file mode 100644 index 000000000..9b36bb358 --- /dev/null +++ b/java/com/android/incallui/res/anim/activity_open_exit.xml @@ -0,0 +1,31 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- +/* +** Copyright 2009, 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. +*/ +--> + +<set xmlns:android="http://schemas.android.com/apk/res/android" + android:background="#ff000000" + android:zAdjustment="normal"> + <alpha + android:duration="300" + android:fillAfter="true" + android:fillBefore="false" + android:fillEnabled="true" + android:fromAlpha="1.0" + android:interpolator="@anim/decelerate_quint" + android:toAlpha="0.0"/> +</set>
\ No newline at end of file diff --git a/java/com/android/incallui/res/anim/decelerate_cubic.xml b/java/com/android/incallui/res/anim/decelerate_cubic.xml new file mode 100644 index 000000000..c2f41597b --- /dev/null +++ b/java/com/android/incallui/res/anim/decelerate_cubic.xml @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- +/* +** Copyright 2010, 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. +*/ +--> + +<decelerateInterpolator xmlns:android="http://schemas.android.com/apk/res/android" + android:factor="1.5"/> diff --git a/java/com/android/incallui/res/anim/decelerate_quint.xml b/java/com/android/incallui/res/anim/decelerate_quint.xml new file mode 100644 index 000000000..e55e99c0b --- /dev/null +++ b/java/com/android/incallui/res/anim/decelerate_quint.xml @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- +/* +** Copyright 2010, 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. +*/ +--> + +<decelerateInterpolator xmlns:android="http://schemas.android.com/apk/res/android" + android:factor="2.5"/> diff --git a/java/com/android/incallui/res/anim/on_going_call.xml b/java/com/android/incallui/res/anim/on_going_call.xml new file mode 100644 index 000000000..3a2e2ba1a --- /dev/null +++ b/java/com/android/incallui/res/anim/on_going_call.xml @@ -0,0 +1,31 @@ +<?xml version="1.0" encoding="utf-8"?> +<animation-list xmlns:android="http://schemas.android.com/apk/res/android" + android:oneshot="false"> + <item + android:drawable="@drawable/ic_ongoing_phone_24px_01" + android:duration="200"/> + <item + android:drawable="@drawable/ic_ongoing_phone_24px_02" + android:duration="200"/> + <item + android:drawable="@drawable/ic_ongoing_phone_24px_03" + android:duration="200"/> + <item + android:drawable="@drawable/ic_ongoing_phone_24px_04" + android:duration="200"/> + <item + android:drawable="@drawable/ic_ongoing_phone_24px_05" + android:duration="200"/> + <item + android:drawable="@drawable/ic_ongoing_phone_24px_06" + android:duration="200"/> + <item + android:drawable="@drawable/ic_ongoing_phone_24px_07" + android:duration="200"/> + <item + android:drawable="@drawable/ic_ongoing_phone_24px_08" + android:duration="200"/> + <item + android:drawable="@drawable/ic_ongoing_phone_24px_09" + android:duration="200"/> +</animation-list>
\ No newline at end of file diff --git a/java/com/android/incallui/res/color/ota_title_color.xml b/java/com/android/incallui/res/color/ota_title_color.xml new file mode 100644 index 000000000..bf36f56b9 --- /dev/null +++ b/java/com/android/incallui/res/color/ota_title_color.xml @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2013 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 + --> + +<selector xmlns:android="http://schemas.android.com/apk/res/android"> + <item android:color="#FFA6C839"/> +</selector> + diff --git a/java/com/android/incallui/res/drawable-hdpi/ic_block_grey600_24dp.png b/java/com/android/incallui/res/drawable-hdpi/ic_block_grey600_24dp.png Binary files differnew file mode 100644 index 000000000..1e9294c12 --- /dev/null +++ b/java/com/android/incallui/res/drawable-hdpi/ic_block_grey600_24dp.png diff --git a/java/com/android/incallui/res/drawable-hdpi/ic_call_end_white_24dp.png b/java/com/android/incallui/res/drawable-hdpi/ic_call_end_white_24dp.png Binary files differnew file mode 100644 index 000000000..757d339c4 --- /dev/null +++ b/java/com/android/incallui/res/drawable-hdpi/ic_call_end_white_24dp.png diff --git a/java/com/android/incallui/res/drawable-hdpi/ic_call_split_white_24dp.png b/java/com/android/incallui/res/drawable-hdpi/ic_call_split_white_24dp.png Binary files differnew file mode 100644 index 000000000..4e3dbf55d --- /dev/null +++ b/java/com/android/incallui/res/drawable-hdpi/ic_call_split_white_24dp.png diff --git a/java/com/android/incallui/res/drawable-hdpi/ic_close_grey600_24dp.png b/java/com/android/incallui/res/drawable-hdpi/ic_close_grey600_24dp.png Binary files differnew file mode 100644 index 000000000..9ab350e9a --- /dev/null +++ b/java/com/android/incallui/res/drawable-hdpi/ic_close_grey600_24dp.png diff --git a/java/com/android/incallui/res/drawable-hdpi/ic_location_on_white_24dp.png b/java/com/android/incallui/res/drawable-hdpi/ic_location_on_white_24dp.png Binary files differnew file mode 100644 index 000000000..7c281c3f5 --- /dev/null +++ b/java/com/android/incallui/res/drawable-hdpi/ic_location_on_white_24dp.png diff --git a/java/com/android/incallui/res/drawable-hdpi/ic_ongoing_phone_24px_01.png b/java/com/android/incallui/res/drawable-hdpi/ic_ongoing_phone_24px_01.png Binary files differnew file mode 100644 index 000000000..e4ff6db13 --- /dev/null +++ b/java/com/android/incallui/res/drawable-hdpi/ic_ongoing_phone_24px_01.png diff --git a/java/com/android/incallui/res/drawable-hdpi/ic_ongoing_phone_24px_02.png b/java/com/android/incallui/res/drawable-hdpi/ic_ongoing_phone_24px_02.png Binary files differnew file mode 100644 index 000000000..bc2b3d2f8 --- /dev/null +++ b/java/com/android/incallui/res/drawable-hdpi/ic_ongoing_phone_24px_02.png diff --git a/java/com/android/incallui/res/drawable-hdpi/ic_ongoing_phone_24px_03.png b/java/com/android/incallui/res/drawable-hdpi/ic_ongoing_phone_24px_03.png Binary files differnew file mode 100644 index 000000000..fa936cbdc --- /dev/null +++ b/java/com/android/incallui/res/drawable-hdpi/ic_ongoing_phone_24px_03.png diff --git a/java/com/android/incallui/res/drawable-hdpi/ic_ongoing_phone_24px_04.png b/java/com/android/incallui/res/drawable-hdpi/ic_ongoing_phone_24px_04.png Binary files differnew file mode 100644 index 000000000..ef5137976 --- /dev/null +++ b/java/com/android/incallui/res/drawable-hdpi/ic_ongoing_phone_24px_04.png diff --git a/java/com/android/incallui/res/drawable-hdpi/ic_ongoing_phone_24px_05.png b/java/com/android/incallui/res/drawable-hdpi/ic_ongoing_phone_24px_05.png Binary files differnew file mode 100644 index 000000000..3712d164d --- /dev/null +++ b/java/com/android/incallui/res/drawable-hdpi/ic_ongoing_phone_24px_05.png diff --git a/java/com/android/incallui/res/drawable-hdpi/ic_ongoing_phone_24px_06.png b/java/com/android/incallui/res/drawable-hdpi/ic_ongoing_phone_24px_06.png Binary files differnew file mode 100644 index 000000000..c6a4216a3 --- /dev/null +++ b/java/com/android/incallui/res/drawable-hdpi/ic_ongoing_phone_24px_06.png diff --git a/java/com/android/incallui/res/drawable-hdpi/ic_ongoing_phone_24px_07.png b/java/com/android/incallui/res/drawable-hdpi/ic_ongoing_phone_24px_07.png Binary files differnew file mode 100644 index 000000000..e4ff6db13 --- /dev/null +++ b/java/com/android/incallui/res/drawable-hdpi/ic_ongoing_phone_24px_07.png diff --git a/java/com/android/incallui/res/drawable-hdpi/ic_ongoing_phone_24px_08.png b/java/com/android/incallui/res/drawable-hdpi/ic_ongoing_phone_24px_08.png Binary files differnew file mode 100644 index 000000000..e4ff6db13 --- /dev/null +++ b/java/com/android/incallui/res/drawable-hdpi/ic_ongoing_phone_24px_08.png diff --git a/java/com/android/incallui/res/drawable-hdpi/ic_ongoing_phone_24px_09.png b/java/com/android/incallui/res/drawable-hdpi/ic_ongoing_phone_24px_09.png Binary files differnew file mode 100644 index 000000000..e4ff6db13 --- /dev/null +++ b/java/com/android/incallui/res/drawable-hdpi/ic_ongoing_phone_24px_09.png diff --git a/java/com/android/incallui/res/drawable-hdpi/ic_person_add_grey600_24dp.png b/java/com/android/incallui/res/drawable-hdpi/ic_person_add_grey600_24dp.png Binary files differnew file mode 100644 index 000000000..185d03393 --- /dev/null +++ b/java/com/android/incallui/res/drawable-hdpi/ic_person_add_grey600_24dp.png diff --git a/java/com/android/incallui/res/drawable-hdpi/ic_phone_paused_white_24dp.png b/java/com/android/incallui/res/drawable-hdpi/ic_phone_paused_white_24dp.png Binary files differnew file mode 100644 index 000000000..a2177f58a --- /dev/null +++ b/java/com/android/incallui/res/drawable-hdpi/ic_phone_paused_white_24dp.png diff --git a/java/com/android/incallui/res/drawable-hdpi/ic_question_mark.png b/java/com/android/incallui/res/drawable-hdpi/ic_question_mark.png Binary files differnew file mode 100644 index 000000000..bd9489c85 --- /dev/null +++ b/java/com/android/incallui/res/drawable-hdpi/ic_question_mark.png diff --git a/java/com/android/incallui/res/drawable-hdpi/ic_schedule_white_24dp.png b/java/com/android/incallui/res/drawable-hdpi/ic_schedule_white_24dp.png Binary files differnew file mode 100644 index 000000000..f3581d104 --- /dev/null +++ b/java/com/android/incallui/res/drawable-hdpi/ic_schedule_white_24dp.png diff --git a/java/com/android/incallui/res/drawable-hdpi/img_business.png b/java/com/android/incallui/res/drawable-hdpi/img_business.png Binary files differnew file mode 100644 index 000000000..f70634262 --- /dev/null +++ b/java/com/android/incallui/res/drawable-hdpi/img_business.png diff --git a/java/com/android/incallui/res/drawable-hdpi/img_conference.png b/java/com/android/incallui/res/drawable-hdpi/img_conference.png Binary files differnew file mode 100644 index 000000000..3d9f683a5 --- /dev/null +++ b/java/com/android/incallui/res/drawable-hdpi/img_conference.png diff --git a/java/com/android/incallui/res/drawable-hdpi/img_no_image.png b/java/com/android/incallui/res/drawable-hdpi/img_no_image.png Binary files differnew file mode 100644 index 000000000..fd0ab3211 --- /dev/null +++ b/java/com/android/incallui/res/drawable-hdpi/img_no_image.png diff --git a/java/com/android/incallui/res/drawable-hdpi/img_phone.png b/java/com/android/incallui/res/drawable-hdpi/img_phone.png Binary files differnew file mode 100644 index 000000000..748312e6e --- /dev/null +++ b/java/com/android/incallui/res/drawable-hdpi/img_phone.png diff --git a/java/com/android/incallui/res/drawable-mdpi/ic_block_grey600_24dp.png b/java/com/android/incallui/res/drawable-mdpi/ic_block_grey600_24dp.png Binary files differnew file mode 100644 index 000000000..edd666b73 --- /dev/null +++ b/java/com/android/incallui/res/drawable-mdpi/ic_block_grey600_24dp.png diff --git a/java/com/android/incallui/res/drawable-mdpi/ic_call_end_white_24dp.png b/java/com/android/incallui/res/drawable-mdpi/ic_call_end_white_24dp.png Binary files differnew file mode 100644 index 000000000..17eb4824e --- /dev/null +++ b/java/com/android/incallui/res/drawable-mdpi/ic_call_end_white_24dp.png diff --git a/java/com/android/incallui/res/drawable-mdpi/ic_call_split_white_24dp.png b/java/com/android/incallui/res/drawable-mdpi/ic_call_split_white_24dp.png Binary files differnew file mode 100644 index 000000000..cb7ee1f35 --- /dev/null +++ b/java/com/android/incallui/res/drawable-mdpi/ic_call_split_white_24dp.png diff --git a/java/com/android/incallui/res/drawable-mdpi/ic_close_grey600_24dp.png b/java/com/android/incallui/res/drawable-mdpi/ic_close_grey600_24dp.png Binary files differnew file mode 100644 index 000000000..73faf52eb --- /dev/null +++ b/java/com/android/incallui/res/drawable-mdpi/ic_close_grey600_24dp.png diff --git a/java/com/android/incallui/res/drawable-mdpi/ic_location_on_white_24dp.png b/java/com/android/incallui/res/drawable-mdpi/ic_location_on_white_24dp.png Binary files differnew file mode 100644 index 000000000..933eb5148 --- /dev/null +++ b/java/com/android/incallui/res/drawable-mdpi/ic_location_on_white_24dp.png diff --git a/java/com/android/incallui/res/drawable-mdpi/ic_ongoing_phone_24px_01.png b/java/com/android/incallui/res/drawable-mdpi/ic_ongoing_phone_24px_01.png Binary files differnew file mode 100644 index 000000000..ae31e047e --- /dev/null +++ b/java/com/android/incallui/res/drawable-mdpi/ic_ongoing_phone_24px_01.png diff --git a/java/com/android/incallui/res/drawable-mdpi/ic_ongoing_phone_24px_02.png b/java/com/android/incallui/res/drawable-mdpi/ic_ongoing_phone_24px_02.png Binary files differnew file mode 100644 index 000000000..67b2b1622 --- /dev/null +++ b/java/com/android/incallui/res/drawable-mdpi/ic_ongoing_phone_24px_02.png diff --git a/java/com/android/incallui/res/drawable-mdpi/ic_ongoing_phone_24px_03.png b/java/com/android/incallui/res/drawable-mdpi/ic_ongoing_phone_24px_03.png Binary files differnew file mode 100644 index 000000000..46abea337 --- /dev/null +++ b/java/com/android/incallui/res/drawable-mdpi/ic_ongoing_phone_24px_03.png diff --git a/java/com/android/incallui/res/drawable-mdpi/ic_ongoing_phone_24px_04.png b/java/com/android/incallui/res/drawable-mdpi/ic_ongoing_phone_24px_04.png Binary files differnew file mode 100644 index 000000000..0d787ffa4 --- /dev/null +++ b/java/com/android/incallui/res/drawable-mdpi/ic_ongoing_phone_24px_04.png diff --git a/java/com/android/incallui/res/drawable-mdpi/ic_ongoing_phone_24px_05.png b/java/com/android/incallui/res/drawable-mdpi/ic_ongoing_phone_24px_05.png Binary files differnew file mode 100644 index 000000000..2da4b40d6 --- /dev/null +++ b/java/com/android/incallui/res/drawable-mdpi/ic_ongoing_phone_24px_05.png diff --git a/java/com/android/incallui/res/drawable-mdpi/ic_ongoing_phone_24px_06.png b/java/com/android/incallui/res/drawable-mdpi/ic_ongoing_phone_24px_06.png Binary files differnew file mode 100644 index 000000000..a34cf4d56 --- /dev/null +++ b/java/com/android/incallui/res/drawable-mdpi/ic_ongoing_phone_24px_06.png diff --git a/java/com/android/incallui/res/drawable-mdpi/ic_ongoing_phone_24px_07.png b/java/com/android/incallui/res/drawable-mdpi/ic_ongoing_phone_24px_07.png Binary files differnew file mode 100644 index 000000000..ae31e047e --- /dev/null +++ b/java/com/android/incallui/res/drawable-mdpi/ic_ongoing_phone_24px_07.png diff --git a/java/com/android/incallui/res/drawable-mdpi/ic_ongoing_phone_24px_08.png b/java/com/android/incallui/res/drawable-mdpi/ic_ongoing_phone_24px_08.png Binary files differnew file mode 100644 index 000000000..ae31e047e --- /dev/null +++ b/java/com/android/incallui/res/drawable-mdpi/ic_ongoing_phone_24px_08.png diff --git a/java/com/android/incallui/res/drawable-mdpi/ic_ongoing_phone_24px_09.png b/java/com/android/incallui/res/drawable-mdpi/ic_ongoing_phone_24px_09.png Binary files differnew file mode 100644 index 000000000..ae31e047e --- /dev/null +++ b/java/com/android/incallui/res/drawable-mdpi/ic_ongoing_phone_24px_09.png diff --git a/java/com/android/incallui/res/drawable-mdpi/ic_person_add_grey600_24dp.png b/java/com/android/incallui/res/drawable-mdpi/ic_person_add_grey600_24dp.png Binary files differnew file mode 100644 index 000000000..ec3237086 --- /dev/null +++ b/java/com/android/incallui/res/drawable-mdpi/ic_person_add_grey600_24dp.png diff --git a/java/com/android/incallui/res/drawable-mdpi/ic_phone_paused_white_24dp.png b/java/com/android/incallui/res/drawable-mdpi/ic_phone_paused_white_24dp.png Binary files differnew file mode 100644 index 000000000..7dc920b2b --- /dev/null +++ b/java/com/android/incallui/res/drawable-mdpi/ic_phone_paused_white_24dp.png diff --git a/java/com/android/incallui/res/drawable-mdpi/ic_question_mark.png b/java/com/android/incallui/res/drawable-mdpi/ic_question_mark.png Binary files differnew file mode 100644 index 000000000..594d0b9f7 --- /dev/null +++ b/java/com/android/incallui/res/drawable-mdpi/ic_question_mark.png diff --git a/java/com/android/incallui/res/drawable-mdpi/ic_schedule_white_24dp.png b/java/com/android/incallui/res/drawable-mdpi/ic_schedule_white_24dp.png Binary files differnew file mode 100644 index 000000000..501ee842e --- /dev/null +++ b/java/com/android/incallui/res/drawable-mdpi/ic_schedule_white_24dp.png diff --git a/java/com/android/incallui/res/drawable-mdpi/img_business.png b/java/com/android/incallui/res/drawable-mdpi/img_business.png Binary files differnew file mode 100644 index 000000000..90738a7ee --- /dev/null +++ b/java/com/android/incallui/res/drawable-mdpi/img_business.png diff --git a/java/com/android/incallui/res/drawable-mdpi/img_conference.png b/java/com/android/incallui/res/drawable-mdpi/img_conference.png Binary files differnew file mode 100644 index 000000000..0694dbd55 --- /dev/null +++ b/java/com/android/incallui/res/drawable-mdpi/img_conference.png diff --git a/java/com/android/incallui/res/drawable-mdpi/img_no_image.png b/java/com/android/incallui/res/drawable-mdpi/img_no_image.png Binary files differnew file mode 100644 index 000000000..014a1c414 --- /dev/null +++ b/java/com/android/incallui/res/drawable-mdpi/img_no_image.png diff --git a/java/com/android/incallui/res/drawable-mdpi/img_phone.png b/java/com/android/incallui/res/drawable-mdpi/img_phone.png Binary files differnew file mode 100644 index 000000000..41a1d339d --- /dev/null +++ b/java/com/android/incallui/res/drawable-mdpi/img_phone.png diff --git a/java/com/android/incallui/res/drawable-xhdpi/ic_block_grey600_24dp.png b/java/com/android/incallui/res/drawable-xhdpi/ic_block_grey600_24dp.png Binary files differnew file mode 100644 index 000000000..36210a8cb --- /dev/null +++ b/java/com/android/incallui/res/drawable-xhdpi/ic_block_grey600_24dp.png diff --git a/java/com/android/incallui/res/drawable-xhdpi/ic_call_end_white_24dp.png b/java/com/android/incallui/res/drawable-xhdpi/ic_call_end_white_24dp.png Binary files differnew file mode 100644 index 000000000..b00d82edd --- /dev/null +++ b/java/com/android/incallui/res/drawable-xhdpi/ic_call_end_white_24dp.png diff --git a/java/com/android/incallui/res/drawable-xhdpi/ic_call_split_white_24dp.png b/java/com/android/incallui/res/drawable-xhdpi/ic_call_split_white_24dp.png Binary files differnew file mode 100644 index 000000000..218cb1214 --- /dev/null +++ b/java/com/android/incallui/res/drawable-xhdpi/ic_call_split_white_24dp.png diff --git a/java/com/android/incallui/res/drawable-xhdpi/ic_close_grey600_24dp.png b/java/com/android/incallui/res/drawable-xhdpi/ic_close_grey600_24dp.png Binary files differnew file mode 100644 index 000000000..a3896c5c6 --- /dev/null +++ b/java/com/android/incallui/res/drawable-xhdpi/ic_close_grey600_24dp.png diff --git a/java/com/android/incallui/res/drawable-xhdpi/ic_location_on_white_24dp.png b/java/com/android/incallui/res/drawable-xhdpi/ic_location_on_white_24dp.png Binary files differnew file mode 100644 index 000000000..814ca8ddc --- /dev/null +++ b/java/com/android/incallui/res/drawable-xhdpi/ic_location_on_white_24dp.png diff --git a/java/com/android/incallui/res/drawable-xhdpi/ic_ongoing_phone_24px_01.png b/java/com/android/incallui/res/drawable-xhdpi/ic_ongoing_phone_24px_01.png Binary files differnew file mode 100644 index 000000000..80ad50b59 --- /dev/null +++ b/java/com/android/incallui/res/drawable-xhdpi/ic_ongoing_phone_24px_01.png diff --git a/java/com/android/incallui/res/drawable-xhdpi/ic_ongoing_phone_24px_02.png b/java/com/android/incallui/res/drawable-xhdpi/ic_ongoing_phone_24px_02.png Binary files differnew file mode 100644 index 000000000..1fb69a477 --- /dev/null +++ b/java/com/android/incallui/res/drawable-xhdpi/ic_ongoing_phone_24px_02.png diff --git a/java/com/android/incallui/res/drawable-xhdpi/ic_ongoing_phone_24px_03.png b/java/com/android/incallui/res/drawable-xhdpi/ic_ongoing_phone_24px_03.png Binary files differnew file mode 100644 index 000000000..2578be1e2 --- /dev/null +++ b/java/com/android/incallui/res/drawable-xhdpi/ic_ongoing_phone_24px_03.png diff --git a/java/com/android/incallui/res/drawable-xhdpi/ic_ongoing_phone_24px_04.png b/java/com/android/incallui/res/drawable-xhdpi/ic_ongoing_phone_24px_04.png Binary files differnew file mode 100644 index 000000000..9a5b91fe5 --- /dev/null +++ b/java/com/android/incallui/res/drawable-xhdpi/ic_ongoing_phone_24px_04.png diff --git a/java/com/android/incallui/res/drawable-xhdpi/ic_ongoing_phone_24px_05.png b/java/com/android/incallui/res/drawable-xhdpi/ic_ongoing_phone_24px_05.png Binary files differnew file mode 100644 index 000000000..69b472b00 --- /dev/null +++ b/java/com/android/incallui/res/drawable-xhdpi/ic_ongoing_phone_24px_05.png diff --git a/java/com/android/incallui/res/drawable-xhdpi/ic_ongoing_phone_24px_06.png b/java/com/android/incallui/res/drawable-xhdpi/ic_ongoing_phone_24px_06.png Binary files differnew file mode 100644 index 000000000..118ea33d0 --- /dev/null +++ b/java/com/android/incallui/res/drawable-xhdpi/ic_ongoing_phone_24px_06.png diff --git a/java/com/android/incallui/res/drawable-xhdpi/ic_ongoing_phone_24px_07.png b/java/com/android/incallui/res/drawable-xhdpi/ic_ongoing_phone_24px_07.png Binary files differnew file mode 100644 index 000000000..80ad50b59 --- /dev/null +++ b/java/com/android/incallui/res/drawable-xhdpi/ic_ongoing_phone_24px_07.png diff --git a/java/com/android/incallui/res/drawable-xhdpi/ic_ongoing_phone_24px_08.png b/java/com/android/incallui/res/drawable-xhdpi/ic_ongoing_phone_24px_08.png Binary files differnew file mode 100644 index 000000000..80ad50b59 --- /dev/null +++ b/java/com/android/incallui/res/drawable-xhdpi/ic_ongoing_phone_24px_08.png diff --git a/java/com/android/incallui/res/drawable-xhdpi/ic_ongoing_phone_24px_09.png b/java/com/android/incallui/res/drawable-xhdpi/ic_ongoing_phone_24px_09.png Binary files differnew file mode 100644 index 000000000..80ad50b59 --- /dev/null +++ b/java/com/android/incallui/res/drawable-xhdpi/ic_ongoing_phone_24px_09.png diff --git a/java/com/android/incallui/res/drawable-xhdpi/ic_person_add_grey600_24dp.png b/java/com/android/incallui/res/drawable-xhdpi/ic_person_add_grey600_24dp.png Binary files differnew file mode 100644 index 000000000..e56481ed7 --- /dev/null +++ b/java/com/android/incallui/res/drawable-xhdpi/ic_person_add_grey600_24dp.png diff --git a/java/com/android/incallui/res/drawable-xhdpi/ic_phone_paused_white_24dp.png b/java/com/android/incallui/res/drawable-xhdpi/ic_phone_paused_white_24dp.png Binary files differnew file mode 100644 index 000000000..a8becf485 --- /dev/null +++ b/java/com/android/incallui/res/drawable-xhdpi/ic_phone_paused_white_24dp.png diff --git a/java/com/android/incallui/res/drawable-xhdpi/ic_question_mark.png b/java/com/android/incallui/res/drawable-xhdpi/ic_question_mark.png Binary files differnew file mode 100644 index 000000000..ec915f610 --- /dev/null +++ b/java/com/android/incallui/res/drawable-xhdpi/ic_question_mark.png diff --git a/java/com/android/incallui/res/drawable-xhdpi/ic_schedule_white_24dp.png b/java/com/android/incallui/res/drawable-xhdpi/ic_schedule_white_24dp.png Binary files differnew file mode 100644 index 000000000..2e27936a4 --- /dev/null +++ b/java/com/android/incallui/res/drawable-xhdpi/ic_schedule_white_24dp.png diff --git a/java/com/android/incallui/res/drawable-xhdpi/img_business.png b/java/com/android/incallui/res/drawable-xhdpi/img_business.png Binary files differnew file mode 100644 index 000000000..7b04d956f --- /dev/null +++ b/java/com/android/incallui/res/drawable-xhdpi/img_business.png diff --git a/java/com/android/incallui/res/drawable-xhdpi/img_conference.png b/java/com/android/incallui/res/drawable-xhdpi/img_conference.png Binary files differnew file mode 100644 index 000000000..b0dbcc2dc --- /dev/null +++ b/java/com/android/incallui/res/drawable-xhdpi/img_conference.png diff --git a/java/com/android/incallui/res/drawable-xhdpi/img_no_image.png b/java/com/android/incallui/res/drawable-xhdpi/img_no_image.png Binary files differnew file mode 100644 index 000000000..4022207d0 --- /dev/null +++ b/java/com/android/incallui/res/drawable-xhdpi/img_no_image.png diff --git a/java/com/android/incallui/res/drawable-xhdpi/img_phone.png b/java/com/android/incallui/res/drawable-xhdpi/img_phone.png Binary files differnew file mode 100644 index 000000000..2e0ceec0f --- /dev/null +++ b/java/com/android/incallui/res/drawable-xhdpi/img_phone.png diff --git a/java/com/android/incallui/res/drawable-xxhdpi/ic_block_grey600_24dp.png b/java/com/android/incallui/res/drawable-xxhdpi/ic_block_grey600_24dp.png Binary files differnew file mode 100644 index 000000000..9f5120373 --- /dev/null +++ b/java/com/android/incallui/res/drawable-xxhdpi/ic_block_grey600_24dp.png diff --git a/java/com/android/incallui/res/drawable-xxhdpi/ic_call_end_white_24dp.png b/java/com/android/incallui/res/drawable-xxhdpi/ic_call_end_white_24dp.png Binary files differnew file mode 100644 index 000000000..aeabe4a81 --- /dev/null +++ b/java/com/android/incallui/res/drawable-xxhdpi/ic_call_end_white_24dp.png diff --git a/java/com/android/incallui/res/drawable-xxhdpi/ic_call_split_white_24dp.png b/java/com/android/incallui/res/drawable-xxhdpi/ic_call_split_white_24dp.png Binary files differnew file mode 100644 index 000000000..5ea577716 --- /dev/null +++ b/java/com/android/incallui/res/drawable-xxhdpi/ic_call_split_white_24dp.png diff --git a/java/com/android/incallui/res/drawable-xxhdpi/ic_close_grey600_24dp.png b/java/com/android/incallui/res/drawable-xxhdpi/ic_close_grey600_24dp.png Binary files differnew file mode 100644 index 000000000..22d7aa55e --- /dev/null +++ b/java/com/android/incallui/res/drawable-xxhdpi/ic_close_grey600_24dp.png diff --git a/java/com/android/incallui/res/drawable-xxhdpi/ic_location_on_white_24dp.png b/java/com/android/incallui/res/drawable-xxhdpi/ic_location_on_white_24dp.png Binary files differnew file mode 100644 index 000000000..078b10d4f --- /dev/null +++ b/java/com/android/incallui/res/drawable-xxhdpi/ic_location_on_white_24dp.png diff --git a/java/com/android/incallui/res/drawable-xxhdpi/ic_ongoing_phone_24px_01.png b/java/com/android/incallui/res/drawable-xxhdpi/ic_ongoing_phone_24px_01.png Binary files differnew file mode 100644 index 000000000..871a1ee75 --- /dev/null +++ b/java/com/android/incallui/res/drawable-xxhdpi/ic_ongoing_phone_24px_01.png diff --git a/java/com/android/incallui/res/drawable-xxhdpi/ic_ongoing_phone_24px_02.png b/java/com/android/incallui/res/drawable-xxhdpi/ic_ongoing_phone_24px_02.png Binary files differnew file mode 100644 index 000000000..028e43b6e --- /dev/null +++ b/java/com/android/incallui/res/drawable-xxhdpi/ic_ongoing_phone_24px_02.png diff --git a/java/com/android/incallui/res/drawable-xxhdpi/ic_ongoing_phone_24px_03.png b/java/com/android/incallui/res/drawable-xxhdpi/ic_ongoing_phone_24px_03.png Binary files differnew file mode 100644 index 000000000..b7dd070e1 --- /dev/null +++ b/java/com/android/incallui/res/drawable-xxhdpi/ic_ongoing_phone_24px_03.png diff --git a/java/com/android/incallui/res/drawable-xxhdpi/ic_ongoing_phone_24px_04.png b/java/com/android/incallui/res/drawable-xxhdpi/ic_ongoing_phone_24px_04.png Binary files differnew file mode 100644 index 000000000..887c803f8 --- /dev/null +++ b/java/com/android/incallui/res/drawable-xxhdpi/ic_ongoing_phone_24px_04.png diff --git a/java/com/android/incallui/res/drawable-xxhdpi/ic_ongoing_phone_24px_05.png b/java/com/android/incallui/res/drawable-xxhdpi/ic_ongoing_phone_24px_05.png Binary files differnew file mode 100644 index 000000000..c6ec16893 --- /dev/null +++ b/java/com/android/incallui/res/drawable-xxhdpi/ic_ongoing_phone_24px_05.png diff --git a/java/com/android/incallui/res/drawable-xxhdpi/ic_ongoing_phone_24px_06.png b/java/com/android/incallui/res/drawable-xxhdpi/ic_ongoing_phone_24px_06.png Binary files differnew file mode 100644 index 000000000..d0b1e8649 --- /dev/null +++ b/java/com/android/incallui/res/drawable-xxhdpi/ic_ongoing_phone_24px_06.png diff --git a/java/com/android/incallui/res/drawable-xxhdpi/ic_ongoing_phone_24px_07.png b/java/com/android/incallui/res/drawable-xxhdpi/ic_ongoing_phone_24px_07.png Binary files differnew file mode 100644 index 000000000..871a1ee75 --- /dev/null +++ b/java/com/android/incallui/res/drawable-xxhdpi/ic_ongoing_phone_24px_07.png diff --git a/java/com/android/incallui/res/drawable-xxhdpi/ic_ongoing_phone_24px_08.png b/java/com/android/incallui/res/drawable-xxhdpi/ic_ongoing_phone_24px_08.png Binary files differnew file mode 100644 index 000000000..871a1ee75 --- /dev/null +++ b/java/com/android/incallui/res/drawable-xxhdpi/ic_ongoing_phone_24px_08.png diff --git a/java/com/android/incallui/res/drawable-xxhdpi/ic_ongoing_phone_24px_09.png b/java/com/android/incallui/res/drawable-xxhdpi/ic_ongoing_phone_24px_09.png Binary files differnew file mode 100644 index 000000000..871a1ee75 --- /dev/null +++ b/java/com/android/incallui/res/drawable-xxhdpi/ic_ongoing_phone_24px_09.png diff --git a/java/com/android/incallui/res/drawable-xxhdpi/ic_person_add_grey600_24dp.png b/java/com/android/incallui/res/drawable-xxhdpi/ic_person_add_grey600_24dp.png Binary files differnew file mode 100644 index 000000000..c17dfe05f --- /dev/null +++ b/java/com/android/incallui/res/drawable-xxhdpi/ic_person_add_grey600_24dp.png diff --git a/java/com/android/incallui/res/drawable-xxhdpi/ic_phone_paused_white_24dp.png b/java/com/android/incallui/res/drawable-xxhdpi/ic_phone_paused_white_24dp.png Binary files differnew file mode 100644 index 000000000..baf0cf27f --- /dev/null +++ b/java/com/android/incallui/res/drawable-xxhdpi/ic_phone_paused_white_24dp.png diff --git a/java/com/android/incallui/res/drawable-xxhdpi/ic_question_mark.png b/java/com/android/incallui/res/drawable-xxhdpi/ic_question_mark.png Binary files differnew file mode 100644 index 000000000..e3f6d285e --- /dev/null +++ b/java/com/android/incallui/res/drawable-xxhdpi/ic_question_mark.png diff --git a/java/com/android/incallui/res/drawable-xxhdpi/ic_schedule_white_24dp.png b/java/com/android/incallui/res/drawable-xxhdpi/ic_schedule_white_24dp.png Binary files differnew file mode 100644 index 000000000..bfc72736a --- /dev/null +++ b/java/com/android/incallui/res/drawable-xxhdpi/ic_schedule_white_24dp.png diff --git a/java/com/android/incallui/res/drawable-xxhdpi/img_business.png b/java/com/android/incallui/res/drawable-xxhdpi/img_business.png Binary files differnew file mode 100644 index 000000000..c17e4c9d8 --- /dev/null +++ b/java/com/android/incallui/res/drawable-xxhdpi/img_business.png diff --git a/java/com/android/incallui/res/drawable-xxhdpi/img_conference.png b/java/com/android/incallui/res/drawable-xxhdpi/img_conference.png Binary files differnew file mode 100644 index 000000000..a8dba5ed0 --- /dev/null +++ b/java/com/android/incallui/res/drawable-xxhdpi/img_conference.png diff --git a/java/com/android/incallui/res/drawable-xxhdpi/img_no_image.png b/java/com/android/incallui/res/drawable-xxhdpi/img_no_image.png Binary files differnew file mode 100644 index 000000000..2cf7f23a0 --- /dev/null +++ b/java/com/android/incallui/res/drawable-xxhdpi/img_no_image.png diff --git a/java/com/android/incallui/res/drawable-xxhdpi/img_phone.png b/java/com/android/incallui/res/drawable-xxhdpi/img_phone.png Binary files differnew file mode 100644 index 000000000..4eaaba509 --- /dev/null +++ b/java/com/android/incallui/res/drawable-xxhdpi/img_phone.png diff --git a/java/com/android/incallui/res/drawable-xxxhdpi/ic_block_grey600_24dp.png b/java/com/android/incallui/res/drawable-xxxhdpi/ic_block_grey600_24dp.png Binary files differnew file mode 100644 index 000000000..01df2b52b --- /dev/null +++ b/java/com/android/incallui/res/drawable-xxxhdpi/ic_block_grey600_24dp.png diff --git a/java/com/android/incallui/res/drawable-xxxhdpi/ic_call_end_white_24dp.png b/java/com/android/incallui/res/drawable-xxxhdpi/ic_call_end_white_24dp.png Binary files differnew file mode 100644 index 000000000..a6e8a7bc1 --- /dev/null +++ b/java/com/android/incallui/res/drawable-xxxhdpi/ic_call_end_white_24dp.png diff --git a/java/com/android/incallui/res/drawable-xxxhdpi/ic_call_split_white_24dp.png b/java/com/android/incallui/res/drawable-xxxhdpi/ic_call_split_white_24dp.png Binary files differnew file mode 100644 index 000000000..600cec8e6 --- /dev/null +++ b/java/com/android/incallui/res/drawable-xxxhdpi/ic_call_split_white_24dp.png diff --git a/java/com/android/incallui/res/drawable-xxxhdpi/ic_close_grey600_24dp.png b/java/com/android/incallui/res/drawable-xxxhdpi/ic_close_grey600_24dp.png Binary files differnew file mode 100644 index 000000000..7d1c061f7 --- /dev/null +++ b/java/com/android/incallui/res/drawable-xxxhdpi/ic_close_grey600_24dp.png diff --git a/java/com/android/incallui/res/drawable-xxxhdpi/ic_location_on_white_24dp.png b/java/com/android/incallui/res/drawable-xxxhdpi/ic_location_on_white_24dp.png Binary files differnew file mode 100644 index 000000000..8bcb6f620 --- /dev/null +++ b/java/com/android/incallui/res/drawable-xxxhdpi/ic_location_on_white_24dp.png diff --git a/java/com/android/incallui/res/drawable-xxxhdpi/ic_person_add_grey600_24dp.png b/java/com/android/incallui/res/drawable-xxxhdpi/ic_person_add_grey600_24dp.png Binary files differnew file mode 100644 index 000000000..e24919737 --- /dev/null +++ b/java/com/android/incallui/res/drawable-xxxhdpi/ic_person_add_grey600_24dp.png diff --git a/java/com/android/incallui/res/drawable-xxxhdpi/ic_question_mark.png b/java/com/android/incallui/res/drawable-xxxhdpi/ic_question_mark.png Binary files differnew file mode 100644 index 000000000..1a6bf1eb3 --- /dev/null +++ b/java/com/android/incallui/res/drawable-xxxhdpi/ic_question_mark.png diff --git a/java/com/android/incallui/res/drawable-xxxhdpi/ic_schedule_white_24dp.png b/java/com/android/incallui/res/drawable-xxxhdpi/ic_schedule_white_24dp.png Binary files differnew file mode 100644 index 000000000..b94f4dfa1 --- /dev/null +++ b/java/com/android/incallui/res/drawable-xxxhdpi/ic_schedule_white_24dp.png diff --git a/java/com/android/incallui/res/drawable-xxxhdpi/img_business.png b/java/com/android/incallui/res/drawable-xxxhdpi/img_business.png Binary files differnew file mode 100644 index 000000000..88f14e999 --- /dev/null +++ b/java/com/android/incallui/res/drawable-xxxhdpi/img_business.png diff --git a/java/com/android/incallui/res/drawable-xxxhdpi/img_conference.png b/java/com/android/incallui/res/drawable-xxxhdpi/img_conference.png Binary files differnew file mode 100644 index 000000000..eb42b5552 --- /dev/null +++ b/java/com/android/incallui/res/drawable-xxxhdpi/img_conference.png diff --git a/java/com/android/incallui/res/drawable-xxxhdpi/img_no_image.png b/java/com/android/incallui/res/drawable-xxxhdpi/img_no_image.png Binary files differnew file mode 100644 index 000000000..216574222 --- /dev/null +++ b/java/com/android/incallui/res/drawable-xxxhdpi/img_no_image.png diff --git a/java/com/android/incallui/res/drawable-xxxhdpi/img_phone.png b/java/com/android/incallui/res/drawable-xxxhdpi/img_phone.png Binary files differnew file mode 100644 index 000000000..7cbfbd75e --- /dev/null +++ b/java/com/android/incallui/res/drawable-xxxhdpi/img_phone.png diff --git a/java/com/android/incallui/res/drawable/img_conference_automirrored.xml b/java/com/android/incallui/res/drawable/img_conference_automirrored.xml new file mode 100644 index 000000000..78b2876bc --- /dev/null +++ b/java/com/android/incallui/res/drawable/img_conference_automirrored.xml @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="utf-8"?> + +<!-- + ~ Copyright (C) 2014 The Android Open Source Project + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License + --> + +<bitmap xmlns:android="http://schemas.android.com/apk/res/android" + android:autoMirrored="true" + android:src="@drawable/img_conference"/>
\ No newline at end of file diff --git a/java/com/android/incallui/res/drawable/img_no_image_automirrored.xml b/java/com/android/incallui/res/drawable/img_no_image_automirrored.xml new file mode 100644 index 000000000..9a9ec9706 --- /dev/null +++ b/java/com/android/incallui/res/drawable/img_no_image_automirrored.xml @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="utf-8"?> + +<!-- + ~ Copyright (C) 2014 The Android Open Source Project + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License + --> + +<bitmap xmlns:android="http://schemas.android.com/apk/res/android" + android:autoMirrored="true" + android:src="@drawable/img_no_image"/>
\ No newline at end of file diff --git a/java/com/android/incallui/res/drawable/incall_background_gradient.xml b/java/com/android/incallui/res/drawable/incall_background_gradient.xml new file mode 100644 index 000000000..5dd927f0f --- /dev/null +++ b/java/com/android/incallui/res/drawable/incall_background_gradient.xml @@ -0,0 +1,8 @@ +<?xml version="1.0" encoding="utf-8"?> +<shape xmlns:android="http://schemas.android.com/apk/res/android"> + <gradient + android:angle="270" + android:startColor="@color/incall_background_gradient_top" + android:centerColor="@color/incall_background_gradient_middle" + android:endColor="@color/incall_background_gradient_bottom"/> +</shape> diff --git a/java/com/android/incallui/res/drawable/spam_notification_icon.xml b/java/com/android/incallui/res/drawable/spam_notification_icon.xml new file mode 100644 index 000000000..266897838 --- /dev/null +++ b/java/com/android/incallui/res/drawable/spam_notification_icon.xml @@ -0,0 +1,34 @@ +<?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 + --> + +<layer-list xmlns:android="http://schemas.android.com/apk/res/android"> + + <item> + <shape android:shape="oval"> + <solid android:color="@color/incall_call_spam_background_color"/> + <size + android:height="@dimen/notification_large_icon_height" + android:width="@dimen/notification_large_icon_width"/> + </shape> + </item> + + <item + android:drawable="@drawable/ic_report_white_36dp" + android:gravity="center"/> + +</layer-list>
\ No newline at end of file diff --git a/java/com/android/incallui/res/drawable/unknown_notification_icon.xml b/java/com/android/incallui/res/drawable/unknown_notification_icon.xml new file mode 100644 index 000000000..5ab07eccd --- /dev/null +++ b/java/com/android/incallui/res/drawable/unknown_notification_icon.xml @@ -0,0 +1,34 @@ +<?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 + --> + +<layer-list xmlns:android="http://schemas.android.com/apk/res/android"> + + <item> + <shape android:shape="oval"> + <solid android:color="@color/unknown_number_color"/> + <size + android:height="@dimen/notification_large_icon_height" + android:width="@dimen/notification_large_icon_width"/> + </shape> + </item> + + <item + android:drawable="@drawable/ic_question_mark" + android:gravity="center"/> + +</layer-list>
\ No newline at end of file diff --git a/java/com/android/incallui/res/layout/activity_manage_conference.xml b/java/com/android/incallui/res/layout/activity_manage_conference.xml new file mode 100644 index 000000000..60512938c --- /dev/null +++ b/java/com/android/incallui/res/layout/activity_manage_conference.xml @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="utf-8"?> +<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" + android:id="@+id/manageConferencePanel" + android:layout_width="match_parent" + android:layout_height="match_parent"> +</FrameLayout> diff --git a/java/com/android/incallui/res/layout/caller_in_conference.xml b/java/com/android/incallui/res/layout/caller_in_conference.xml new file mode 100644 index 000000000..3a6773d20 --- /dev/null +++ b/java/com/android/incallui/res/layout/caller_in_conference.xml @@ -0,0 +1,119 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Copyright (C) 2008 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="64dp" + android:paddingStart="16dp" + android:paddingEnd="8dp" + android:gravity="center_vertical" + android:orientation="horizontal"> + + <!-- Caller information --> + <LinearLayout + android:layout_width="0dp" + android:layout_height="match_parent" + android:layout_weight="1" + android:gravity="center_vertical" + android:orientation="horizontal"> + + <ImageView + android:id="@+id/callerPhoto" + android:layout_width="@dimen/contact_browser_list_item_photo_size" + android:layout_height="@dimen/contact_browser_list_item_photo_size"/> + + <LinearLayout + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginStart="16dp" + android:paddingBottom="2dp" + android:gravity="center_vertical" + android:orientation="vertical"> + + <!-- Name or number of this caller --> + <TextView + android:id="@+id/conferenceCallerName" + android:layout_width="wrap_content" + android:layout_height="match_parent" + android:layout_marginEnd="2dp" + android:singleLine="true" + android:textAppearance="?android:attr/textAppearanceLarge" + android:textColor="@color/conference_call_manager_caller_name_text_color" + android:textSize="16sp"/> + + <!-- Number of this caller if name is supplied above --> + <LinearLayout + android:layout_width="wrap_content" + android:layout_height="match_parent" + android:gravity="bottom" + android:orientation="horizontal"> + + <!-- Number --> + <TextView + android:id="@+id/conferenceCallerNumber" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginEnd="8dp" + android:ellipsize="marquee" + android:singleLine="true" + android:textColor="@color/conference_call_manager_secondary_text_color" + android:textSize="14sp"/> + + <!-- Number type --> + <TextView + android:id="@+id/conferenceCallerNumberType" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:ellipsize="marquee" + android:gravity="start" + android:singleLine="true" + android:textAllCaps="true" + android:textColor="@color/conference_call_manager_secondary_text_color" + android:textSize="12sp"/> + + </LinearLayout> <!-- End of caller number --> + + </LinearLayout> <!-- End of caller information --> + + </LinearLayout> + + <!-- "Separate" (i.e. "go private") button for this caller --> + <ImageView + android:id="@+id/conferenceCallerSeparate" + android:layout_width="@dimen/conference_call_manager_button_dimension" + android:layout_height="@dimen/conference_call_manager_button_dimension" + android:background="?android:selectableItemBackgroundBorderless" + android:clickable="true" + android:contentDescription="@string/goPrivate" + android:scaleType="center" + android:src="@drawable/ic_call_split_white_24dp" + android:tint="@color/conference_call_manager_icon_color"/> + + <!-- "Disconnect" button which terminates the connection with this caller. --> + <ImageButton + android:id="@+id/conferenceCallerDisconnect" + android:layout_width="@dimen/conference_call_manager_button_dimension" + android:layout_height="@dimen/conference_call_manager_button_dimension" + android:layout_marginStart="8dp" + android:background="?android:selectableItemBackgroundBorderless" + android:clickable="true" + android:contentDescription="@string/conference_caller_disconnect_content_description" + android:scaleType="center" + android:src="@drawable/ic_call_end_white_24dp" + android:tint="@color/conference_call_manager_icon_color"/> + +</LinearLayout> <!-- End of single list element --> diff --git a/java/com/android/incallui/res/layout/conference_manager_fragment.xml b/java/com/android/incallui/res/layout/conference_manager_fragment.xml new file mode 100644 index 000000000..c0cc4cdcf --- /dev/null +++ b/java/com/android/incallui/res/layout/conference_manager_fragment.xml @@ -0,0 +1,33 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Copyright (C) 2009 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. +--> + +<!-- The "Manage conference" UI. This panel is displayed (instead of + the inCallPanel) when the user clicks the "Manage conference" + button while on a conference call. --> +<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" + android:id="@+id/manageConferencePanel" + android:layout_width="match_parent" + android:layout_height="match_parent"> + <!-- List of conference participants. --> + <ListView + android:id="@+id/participantList" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:divider="@null" + android:focusable="true" + android:focusableInTouchMode="true" + android:listSelector="@null"/> +</FrameLayout> diff --git a/java/com/android/incallui/res/layout/incall_dialpad_fragment.xml b/java/com/android/incallui/res/layout/incall_dialpad_fragment.xml new file mode 100644 index 000000000..0621d48aa --- /dev/null +++ b/java/com/android/incallui/res/layout/incall_dialpad_fragment.xml @@ -0,0 +1,24 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Copyright (C) 2006 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. +--> + +<view xmlns:android="http://schemas.android.com/apk/res/android" + android:id="@+id/dtmf_twelve_key_dialer_view" + class="com.android.incallui.DialpadFragment$DialpadSlidingLinearLayout" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:orientation="vertical"> + <include layout="@layout/dialpad_view"/> +</view> diff --git a/java/com/android/incallui/res/layout/incall_screen.xml b/java/com/android/incallui/res/layout/incall_screen.xml new file mode 100644 index 000000000..9090fb287 --- /dev/null +++ b/java/com/android/incallui/res/layout/incall_screen.xml @@ -0,0 +1,33 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Copyright (C) 2007 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. +--> + +<!-- In-call Phone UI; see InCallActivity.java. --> +<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="match_parent"> + <FrameLayout + android:id="@+id/main" + android:layout_width="match_parent" + android:layout_height="match_parent"/> + + <View + android:id="@+id/psuedo_black_screen_overlay" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:background="#000000" + android:visibility="gone" + android:keepScreenOn="true"/> +</FrameLayout> diff --git a/java/com/android/incallui/res/layout/video_call_lte_to_wifi_failed.xml b/java/com/android/incallui/res/layout/video_call_lte_to_wifi_failed.xml new file mode 100644 index 000000000..bdc4eaff1 --- /dev/null +++ b/java/com/android/incallui/res/layout/video_call_lte_to_wifi_failed.xml @@ -0,0 +1,28 @@ +<?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="wrap_content" + android:layout_height="wrap_content" + android:padding="25dp" + android:orientation="vertical"> + + <CheckBox + android:id="@+id/video_call_lte_to_wifi_failed_checkbox" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:text="@string/video_call_lte_to_wifi_failed_do_not_show" + android:textSize="@dimen/video_call_lte_to_wifi_failed_do_not_show_text_size"/> +</LinearLayout> diff --git a/java/com/android/incallui/res/values-sw360dp/dimens.xml b/java/com/android/incallui/res/values-sw360dp/dimens.xml new file mode 100644 index 000000000..ad782e809 --- /dev/null +++ b/java/com/android/incallui/res/values-sw360dp/dimens.xml @@ -0,0 +1,32 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2013 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> + + <!-- The InCallUI dialpad will sometimes want digits sizes that are different from dialer. --> + <dimen name="incall_dialpad_key_number_margin_bottom"> + @dimen/dialpad_key_number_default_margin_bottom + </dimen> + <!-- Zero key should have less space between self and text because "+" is smaller --> + <dimen name="incall_dialpad_zero_key_number_margin_bottom"> + @dimen/dialpad_zero_key_number_default_margin_bottom + </dimen> + <dimen name="incall_dialpad_digits_adjustable_text_size">@dimen/dialpad_digits_text_size</dimen> + <dimen name="incall_dialpad_digits_adjustable_height">@dimen/dialpad_digits_height</dimen> + <dimen name="incall_dialpad_key_numbers_size">@dimen/dialpad_key_numbers_default_size</dimen> + +</resources> diff --git a/java/com/android/incallui/res/values-w500dp-land/colors.xml b/java/com/android/incallui/res/values-w500dp-land/colors.xml new file mode 100644 index 000000000..4b0e33ea7 --- /dev/null +++ b/java/com/android/incallui/res/values-w500dp-land/colors.xml @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ 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 + --> + +<resources> + <!-- Background color for status bar. For portrait this will be ignored. --> + <color name="statusbar_background_color">#000000</color> +</resources> diff --git a/java/com/android/incallui/res/values-w500dp-land/dimens.xml b/java/com/android/incallui/res/values-w500dp-land/dimens.xml new file mode 100644 index 000000000..81090fc80 --- /dev/null +++ b/java/com/android/incallui/res/values-w500dp-land/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> + + <!-- Whether or not the landscape mode layout is currently being used --> + <bool name="is_layout_landscape">true</bool> + +</resources> diff --git a/java/com/android/incallui/res/values/animation_constants.xml b/java/com/android/incallui/res/values/animation_constants.xml new file mode 100644 index 000000000..ac50db21c --- /dev/null +++ b/java/com/android/incallui/res/values/animation_constants.xml @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2014 The Android Open Source Project + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License + --> +<resources> + <integer name="reveal_animation_duration">333</integer> +</resources> diff --git a/java/com/android/incallui/res/values/colors.xml b/java/com/android/incallui/res/values/colors.xml new file mode 100644 index 000000000..0c73cdb10 --- /dev/null +++ b/java/com/android/incallui/res/values/colors.xml @@ -0,0 +1,92 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2013 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="incall_action_bar_background_color">@color/dialer_theme_color</color> + <color name="incall_action_bar_text_color">#ffffff</color> + + <!-- Put on top of each photo, implying 80% darker than usual. --> + <color name="on_hold_dim_effect">#cc000000</color> + + <color name="conference_call_manager_caller_name_text_color">#4d4d4d</color> + <color name="conference_call_manager_icon_color">#999999</color> + <!-- Used with some smaller texts in manage conference screen. --> + <color name="conference_call_manager_secondary_text_color">#999999</color> + + <color name="incall_dialpad_background">#ffffff</color> + <color name="incall_dialpad_background_pressed">#ccaaaaaa</color> + <color name="incall_window_scrim">#b2000000</color> + + <!-- Background color for status bar. For portrait this will be ignored. --> + <color name="statusbar_background_color">@color/dialer_theme_color</color> + + <color name="translucent_shadow">#33999999</color> + + <!-- 20% opacity, theme color. --> + <color name="incall_dialpad_touch_tint">@color/dialer_theme_color_20pct</color> + + <!-- Background colors for InCallUI. This is a set of colors which pass WCAG + AA and all have a contrast ratio over 5:1. + + These colors are also used by InCallUIMaterialColorMapUtils to generate + primary activity colors. + + --> + <array name="background_colors"> + <item>#00796B</item> + <item>#3367D6</item> + <item>#303F9F</item> + <item>#7B1FA2</item> + <item>#C2185B</item> + <item>#C53929</item> + <item>#A52714</item> + </array> + + <!-- Darker versions of background_colors, two shades darker. These colors are used for the + status bar. --> + <array name="background_colors_dark"> + <item>#00695C</item> + <item>#2A56C6</item> + <item>#283593</item> + <item>#6A1B9A</item> + <item>#AD1457</item> + <item>#B93221</item> + <item>#841F10</item> + </array> + + <!-- Background color for spam. This color must match one of background_colors above. --> + <color name="incall_call_spam_background_color">@color/blocked_contact_background</color> + + <!-- Ripple color used over light backgrounds. --> + <color name="ripple_light">#40000000</color> + + <!-- Background color for large notification icon in after call from unknown numbers --> + <color name="unknown_number_color">#F4B400</color> + + <color name="incall_background_gradient_top">#E91141BB</color> + <color name="incall_background_gradient_middle">#E91141BB</color> + <color name="incall_background_gradient_bottom">#CC229FEB</color> + + <color name="incall_background_multiwindow">#E91141BB</color> + + <color name="incall_background_gradient_spam_top">#E5A30B0B</color> + <color name="incall_background_gradient_spam_middle">#D6C01111</color> + <color name="incall_background_gradient_spam_bottom">#B8E55135</color> + + <color name="incall_background_multiwindow_spam">#E9C22E2E</color> +</resources> diff --git a/java/com/android/incallui/res/values/config.xml b/java/com/android/incallui/res/values/config.xml new file mode 100644 index 000000000..0f3c983b7 --- /dev/null +++ b/java/com/android/incallui/res/values/config.xml @@ -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 + --> +<resources> + <!-- Determines video calls will automatically enter fullscreen mode after the start of the + call. --> + <bool name="video_call_auto_fullscreen">true</bool> + <!-- The number of milliseconds after which a video call will automatically enter fullscreen + mode (requires video_call_auto_fullscreen to be true). --> + <integer name="video_call_auto_fullscreen_timeout">5000</integer> +</resources> diff --git a/java/com/android/incallui/res/values/dimens.xml b/java/com/android/incallui/res/values/dimens.xml new file mode 100644 index 000000000..18816f645 --- /dev/null +++ b/java/com/android/incallui/res/values/dimens.xml @@ -0,0 +1,66 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2013 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="incall_action_bar_elevation">3dp</dimen> + + <!-- Margin between the bottom of the "call card" photo + and the top of the in-call button cluster. --> + <dimen name="in_call_touch_ui_upper_margin">2dp</dimen> + + <!-- Padding at the top and bottom edges of the "provider information" --> + <dimen name="provider_info_top_bottom_padding">8dp</dimen> + + <!-- Right padding for name and number fields in the call banner. + This padding is used to ensure that ultra-long names or + numbers won't overlap the elapsed time indication. --> + <dimen name="call_banner_name_number_right_padding">50sp</dimen> + + <!-- The InCallUI dialpad will sometimes want digits sizes that are different + from dialer. Note, these are the default sizes for small devices. Larger + screen sizes apply the values in values-sw360dp/dimens.xml. --> + <dimen name="incall_dialpad_key_number_margin_bottom">1dp</dimen> + <!-- Zero key should have less space between self and text because "+" is smaller --> + <dimen name="incall_dialpad_zero_key_number_margin_bottom">0dp</dimen> + <dimen name="incall_dialpad_digits_adjustable_text_size">20sp</dimen> + <dimen name="incall_dialpad_digits_adjustable_height">50dp</dimen> + <dimen name="incall_dialpad_key_numbers_size">36sp</dimen> + + <!-- Dimensions for OTA Call Card --> + <dimen name="otaactivate_layout_marginTop">10dp</dimen> + <dimen name="otalistenprogress_layout_marginTop">5dp</dimen> + <dimen name="otasuccessfail_layout_marginTop">10dp</dimen> + + <!-- Dimension used to possibly down-scale high-res photo into what is suitable + for notification's large icon. --> + <dimen name="notification_icon_size">64dp</dimen> + + <!-- Height of translucent shadow effect --> + <dimen name="translucent_shadow_height">2dp</dimen> + + <!-- The smaller dimension of the video preview. When in portrait orientation this is the + width of the preview. When in landscape, this is the height. --> + <dimen name="video_preview_small_dimension">90dp</dimen> + + <dimen name="conference_call_manager_button_dimension">48dp</dimen> + + <!-- Whether or not the landscape mode layout is currently being used --> + <bool name="is_layout_landscape">false</bool> + + <dimen name="video_call_lte_to_wifi_failed_do_not_show_text_size">16sp</dimen> + +</resources> diff --git a/java/com/android/incallui/res/values/strings.xml b/java/com/android/incallui/res/values/strings.xml new file mode 100644 index 000000000..252d131de --- /dev/null +++ b/java/com/android/incallui/res/values/strings.xml @@ -0,0 +1,367 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2013 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 xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + + <!-- Official label of the phone app, as seen in "Manage Applications" + and other settings UIs. --> + <string name="phoneAppLabel" product="default">Phone</string> + + <!-- Official label for the in-call UI. DO NOT TRANSLATE. --> + <string name="inCallLabel" translate="false">InCallUI</string> + + <!-- In-call screen: status label for a conference call --> + <string name="confCall">Conference call</string> + <!-- In-call screen: call lost dialog text --> + <string name="call_lost">Call dropped</string> + + <!-- MMI dialog strings --> + <!-- Dialog label when an MMI code starts running --> + + <!-- post dial --> + <!-- In-call screen: body text of the dialog that appears when we encounter + the "wait" character in a phone number to be dialed; this dialog asks the + user if it's OK to send the numbers following the "wait". --> + <string name="wait_prompt_str">Send the following tones?\n</string> + <!-- In-call screen: body text of the dialog that appears when we encounter + the "PAUSE" character in a phone number to be dialed; this dialog gives + informative message to the user to show the sending numbers following the "Pause". --> + <string name="pause_prompt_str">Sending tones\n</string> + <!-- In-call screen: button label on the "wait" prompt dialog --> + <string name="send_button">Send</string> + <!-- In-call screen: button label on the "wait" prompt dialog in CDMA Mode--> + <string name="pause_prompt_yes">Yes</string> + <!-- In-call screen: button label on the "wait" prompt dialog in CDMA Mode--> + <string name="pause_prompt_no">No</string> + <!-- In-call screen: on the "wild" character dialog, this is the label + for a text widget that lets the user enter the digits that should + replace the "wild" character. --> + <string name="wild_prompt_str">Replace wild character with</string> + + <!-- In-call screen: status label for a conference call --> + <string name="caller_manage_header">Conference call <xliff:g id="conf_call_time">%s</xliff:g></string> + + <!-- Used in FakePhoneActivity test code. DO NOT TRANSLATE. --> + <string name="fake_phone_activity_phoneNumber_text" translatable="false">(650) 555-1234</string> + <!-- Used in FakePhoneActivity test code. DO NOT TRANSLATE. --> + <string name="fake_phone_activity_infoText_text" translatable="false">Incoming phone number</string> + <!-- Used in FakePhoneActivity test code. DO NOT TRANSLATE. --> + <string name="fake_phone_activity_placeCall_text" translatable="false">Fake Incoming Call</string> + + <!-- Call settings screen, Set voicemail dialog title --> + <string name="voicemail_settings_number_label">Voicemail number</string> + + <!-- Notification strings --> + <!-- The "label" of the in-call Notification for a dialing call, used + as the format string for a Chronometer widget. [CHAR LIMIT=60] --> + <string name="notification_dialing">Dialing</string> + <!-- Missed call notification message used for a single missed call, including + the caller-id info from the missed call --> + <string name="notification_missedCallTicker">Missed call from <xliff:g id="missed_call_from">%s</xliff:g></string> + <!-- The "label" of the in-call Notification for an ongoing call. [CHAR LIMIT=60] --> + <string name="notification_ongoing_call">Ongoing call</string> + <!-- The "label" of the in-call Notification for an ongoing work call. [CHAR LIMIT=60] --> + <string name="notification_ongoing_work_call">Ongoing work call</string> + <!-- The "label" of the in-call Notification for an ongoing call, which is being made over + Wi-Fi. [CHAR LIMIT=60] --> + <string name="notification_ongoing_call_wifi">Ongoing Wi-Fi call</string> + <!-- The "label" of the in-call Notification for an ongoing work call, which is being made + over Wi-Fi. [CHAR LIMIT=60] --> + <string name="notification_ongoing_work_call_wifi">Ongoing Wi-Fi work call</string> + <!-- The "label" of the in-call Notification for a call that's on hold --> + <string name="notification_on_hold">On hold</string> + <!-- The "label" of the in-call Notification for an incoming ringing call. [CHAR LIMIT=60] --> + <string name="notification_incoming_call">Incoming call</string> + <!-- The "label" of the in-call Notification for an incoming ringing call. [CHAR LIMIT=60] --> + <string name="notification_incoming_work_call">Incoming work call</string> + <!-- The "label" of the in-call Notification for an incoming ringing call, + which is being made over Wi-Fi. [CHAR LIMIT=60] --> + <string name="notification_incoming_call_wifi">Incoming Wi-Fi call</string> + <!-- The "label" of the in-call Notification for an incoming ringing work call, + which is being made over Wi-Fi. [CHAR LIMIT=60] --> + <string name="notification_incoming_work_call_wifi">Incoming Wi-Fi work call</string> + <!-- The "label" of the in-call Notification for an incoming ringing spam call. --> + <string name="notification_incoming_spam_call">Incoming suspected spam call</string> + <!-- The "label" of the in-call Notification for upgrading an existing call to a video call. --> + <string name="notification_requesting_video_call">Incoming video request</string> + <!-- Label for the "Voicemail" notification item, when expanded. --> + <string name="notification_voicemail_title">New voicemail</string> + <!-- Label for the expanded "Voicemail" notification item, + including a count of messages. --> + <string name="notification_voicemail_title_count">New voicemail (<xliff:g id="count">%d</xliff:g>)</string> + <!-- Message displayed in the "Voicemail" notification item, allowing the user + to dial the indicated number. --> + <string name="notification_voicemail_text_format">Dial <xliff:g id="voicemail_number">%s</xliff:g></string> + <!-- Message displayed in the "Voicemail" notification item, + indicating that there's no voicemail number available --> + <string name="notification_voicemail_no_vm_number">Voicemail number unknown</string> + <!-- Label for the "No service" notification item, when expanded. --> + <string name="notification_network_selection_title">No service</string> + <!-- Label for the expanded "No service" notification item, including the + operator name set by user --> + <string name="notification_network_selection_text">Selected network (<xliff:g id="operator_name">%s</xliff:g>) unavailable</string> + <!-- Label for the "Answer call" action. This is the displayed label for the action that answers + an incoming call. [CHAR LIMIT=12] --> + <string name="notification_action_answer">Answer</string> + <!-- Label for "end call" Action. + It is displayed in the "Ongoing call" notification, which is shown + when the user is outside the in-call screen while the phone call is still + active. [CHAR LIMIT=12] --> + <string name="notification_action_end_call">Hang up</string> + <!-- Label for "Video Call" notification action. This is a displayed on the notification for an + incoming video call, and answers the call as a video call. [CHAR LIMIT=12] --> + <string name="notification_action_answer_video">Video</string> + <!-- Label for "Voice" notification action. This is a displayed on the notification for an + incoming video call, and answers the call as an audio call. [CHAR LIMIT=12] --> + <string name="notification_action_answer_voice">Voice</string> + <!-- Label for "Accept" notification action. This is somewhat generic, and may refer to + scenarios such as accepting an incoming call or accepting a video call request. + [CHAR LIMIT=12] --> + <string name="notification_action_accept">Accept</string> + <!-- Label for "Dismiss" notification action. This is somewhat generic, and may refer to + scenarios such as declining an incoming call or declining a video call request. + [CHAR LIMIT=12] --> + <string name="notification_action_dismiss">Decline</string> + + <!-- The "label" of the in-call Notification for an ongoing external call. + External calls are a representation of a call which is in progress on the user's other + device (e.g. another phone or a watch). + [CHAR LIMIT=60] --> + <string name="notification_external_call">Ongoing call on another device</string> + <!-- The "label" of the in-call Notification for an ongoing external video call. + External calls are a representation of a call which is in progress on the user's other + device (e.g. another phone or a watch). + [CHAR LIMIT=60] --> + <string name="notification_external_video_call">Ongoing video call on another device</string> + <!-- Notification action displayed for external call notifications. External calls are a + representation of a call which is in progress on the user's other device (e.g. another + phone or a watch). The "take call" action initiates the process of pulling an external + call to the current device. + [CHAR LIMIT=30] --> + <string name="notification_take_call">Take Call</string> + <!-- Notification action displayed for external call notifications. External calls are a + representation of a call which is in progress on the user's other device (e.g. another + phone or a watch). The "take video call" action initiates the process of pulling an external + video call to the current device. + [CHAR LIMIT=30] --> + <string name="notification_take_video_call">Take Video Call</string> + <!-- In-call screen: call failure message displayed in an error dialog --> + <string name="incall_error_power_off">To place a call, first turn off Airplane mode.</string> + <!-- In-call screen: call failure message displayed in an error dialog. + This string is currently unused (see comments in InCallActivity.java.) --> + <string name="incall_error_emergency_only">Not registered on network.</string> + <!-- In-call screen: call failure message displayed in an error dialog --> + <string name="incall_error_out_of_service">Cellular network not available.</string> + <!-- In-call screen: call failure message displayed in an error dialog --> + <string name="incall_error_no_phone_number_supplied">To place a call, enter a valid number.</string> + <!-- In-call screen: call failure message displayed in an error dialog --> + <string name="incall_error_call_failed">Can\'t call.</string> + <!-- In-call screen: status message displayed in a dialog when starting an MMI --> + <string name="incall_status_dialed_mmi">Starting MMI sequence\u2026</string> + <!-- In-call screen: message displayed in an error dialog --> + <string name="incall_error_supp_service_unknown">Service not supported.</string> + <!-- In-call screen: message displayed in an error dialog --> + <string name="incall_error_supp_service_switch">Can\'t switch calls.</string> + <!-- In-call screen: message displayed in an error dialog --> + <string name="incall_error_supp_service_separate">Can\'t separate call.</string> + <!-- In-call screen: message displayed in an error dialog --> + <string name="incall_error_supp_service_transfer">Can\'t transfer.</string> + <!-- In-call screen: message displayed in an error dialog --> + <string name="incall_error_supp_service_conference">Can\'t conference.</string> + <!-- In-call screen: message displayed in an error dialog --> + <string name="incall_error_supp_service_reject">Can\'t reject call.</string> + <!-- In-call screen: message displayed in an error dialog --> + <string name="incall_error_supp_service_hangup">Can\'t release call(s).</string> + + <!-- Dialog title for the "radio enable" UI for emergency calls --> + <string name="emergency_enable_radio_dialog_title">Emergency call</string> + <!-- Status message for the "radio enable" UI for emergency calls --> + <string name="emergency_enable_radio_dialog_message">Turning on radio\u2026</string> + <!-- Status message for the "radio enable" UI for emergency calls --> + <string name="emergency_enable_radio_dialog_retry">No service. Trying again\u2026</string> + + <!-- Dialer text on Emergency Dialer --> + <!-- Emergency dialer: message displayed in an error dialog --> + <string name="dial_emergency_error">Can\'t call. <xliff:g id="non_emergency_number">%s</xliff:g> is not an emergency number.</string> + <!-- Emergency dialer: message displayed in an error dialog --> + <string name="dial_emergency_empty_error">Can\'t call. Dial an emergency number.</string> + + <!-- Displayed in the text entry box in the dialer when in landscape mode to guide the user + to dial using the physical keyboard --> + <string name="dialerKeyboardHintText">Use keyboard to dial</string> + + <!-- Message indicating that Video Started flowing for IMS-VT calls --> + <string name="player_started">Player Started</string> + <!-- Message indicating that Video Stopped flowing for IMS-VT calls --> + <string name="player_stopped">Player Stopped</string> + <!-- Message indicating that camera failure has occurred for the selected camera and + as result camera is not ready --> + <string name="camera_not_ready">Camera not ready</string> + <!-- Message indicating that camera is ready/available --> + <string name="camera_ready">Camera ready</string> + <!-- Message indicating unknown call session event --> + <string name="unknown_call_session_event">"Unkown call session event"</string> + + <!-- For incoming calls, this is a string we can get from a CDMA network instead of + the actual phone number, to indicate there's no number present. DO NOT TRANSLATE. --> + <string-array name="absent_num" translatable="false"> + <item>ABSENT NUMBER</item> + <item>ABSENTNUMBER</item> + </string-array> + + <!-- Preference for Voicemail service provider under "Voicemail" settings. + [CHAR LIMIT=40] --> + <string name="voicemail_provider">Service</string> + + <!-- Preference for Voicemail setting of each provider. + [CHAR LIMIT=40] --> + <string name="voicemail_settings">Setup</string> + + <!-- String to display in voicemail number summary when no voicemail num is set --> + <string name="voicemail_number_not_set"><Not set></string> + + <!-- Title displayed above settings coming after voicemail in the call features screen --> + <string name="other_settings">Other call settings</string> + + <!-- Use this to describe the separate conference call button; currently for screen readers through accessibility. --> + <string name="goPrivate">go private</string> + <!-- Use this to describe the select contact button in EditPhoneNumberPreference; currently for screen readers through accessibility. --> + <string name="selectContact">select contact</string> + + <!-- Dialog title for the vibration settings for voicemail notifications [CHAR LIMIT=40] --> + <string msgid="8731372580674292759" name="voicemail_notification_vibrate_when_title">Vibrate</string> + <!-- Dialog title for the vibration settings for voice mail notifications [CHAR LIMIT=40]--> + <string msgid="8995274609647451109" name="voicemail_notification_vibarte_when_dialog_title">Vibrate</string> + + <!-- Voicemail ringtone title. The user clicks on this preference to select + which sound to play when a voicemail notification is received. + [CHAR LIMIT=30] --> + <string name="voicemail_notification_ringtone_title">Sound</string> + + <!-- The default value value for voicemail notification. --> + <string name="voicemail_notification_vibrate_when_default" translatable="false">never</string> + + <!-- Actual values used in our code for voicemail notifications. DO NOT TRANSLATE --> + <string-array name="voicemail_notification_vibrate_when_values" translatable="false"> + <item>always</item> + <item>silent</item> + <item>never</item> + </string-array> + + <!-- Title for the category "ringtone", which is shown above ringtone and vibration + related settings. + [CHAR LIMIT=30] --> + <string name="preference_category_ringtone">Ringtone & Vibrate</string> + + <!-- Label for "Manage conference call" panel [CHAR LIMIT=40] --> + <string name="manageConferenceLabel">Manage conference call</string> + + <!-- This can be used in any application wanting to disable the text "Emergency number" --> + <string name="emergency_call_dialog_number_for_display">Emergency number</string> + + <!-- Used to inform the user that a call was received via a number other than the primary + phone number associated with their device. [CHAR LIMIT=16] --> + <string name="child_number">via <xliff:g example="650-555-1212" id="child_number">%s</xliff:g></string> + + <!-- Title for the call context with a person-type contact. [CHAR LIMIT=40] --> + <string name="person_contact_context_title">Recent messages</string> + + <!-- Title for the call context with a business-type contact. [CHAR LIMIT=40] --> + <string name="business_contact_context_title">Business info</string> + + <!-- Distance strings for business caller ID context. --> + + <!-- Used to inform the user how far away a location is in miles. [CHAR LIMIT=NONE] --> + <string name="distance_imperial_away"><xliff:g id="distance">%.1f</xliff:g> mi away</string> + <!-- Used to inform the user how far away a location is in kilometers. [CHAR LIMIT=NONE] --> + <string name="distance_metric_away"><xliff:g id="distance">%.1f</xliff:g> km away</string> + <!-- A shortened way to display a business address. Formatted [street address], [city/locality]. --> + <string name="display_address"><xliff:g id="street_address">%1$s</xliff:g>, <xliff:g id="locality">%2$s</xliff:g></string> + <!-- Used to indicate hours of operation for a location as a time span. e.g. "11 am - 9 pm" [CHAR LIMIT=NONE] --> + <string name="open_time_span"><xliff:g id="open_time">%1$s</xliff:g> - <xliff:g id="close_time">%2$s</xliff:g></string> + <!-- Used to indicate a series of opening hours for a location. + This first argument may be one or more time spans. e.g. "11 am - 9 pm, 9 pm - 11 pm" + The second argument is an additional time span. e.g. "11 pm - 1 am" + The string is used to build a list of opening hours. + [CHAR LIMIT=NONE] --> + <string name="opening_hours"><xliff:g id="earlier_times">%1$s</xliff:g>, <xliff:g id="later_time">%2$s</xliff:g></string> + <!-- Used to express when a location will open the next day. [CHAR LIMIT=NONE] --> + <string name="opens_tomorrow_at">Opens tomorrow at <xliff:g id="open_time">%s</xliff:g></string> + <!-- Used to express the next time at which a location will be open today. [CHAR LIMIT=NONE] --> + <string name="opens_today_at">Opens today at <xliff:g id="open_time">%s</xliff:g></string> + <!-- Used to express the next time at which a location will close today. [CHAR LIMIT=NONE] --> + <string name="closes_today_at">Closes at <xliff:g id="close_time">%s</xliff:g></string> + <!-- Used to express the next time at which a location closed today if it is already closed. [CHAR LIMIT=NONE] --> + <string name="closed_today_at">Closed today at <xliff:g id="close_time">%s</xliff:g></string> + <!-- Displayed when a place is open. --> + <string name="open_now">Open now</string> + <!-- Displayed when a place is closed. --> + <string name="closed_now">Closed now</string> + + <!-- Title for the notification to the user after a call from an unknown number ends. [CHAR LIMIT=100] --> + <string name="non_spam_notification_title">Know <xliff:g id="number">%1$s</xliff:g>?</string> + <!-- Title for the notification to the user after a call from an spammer ends. [CHAR LIMIT=100] --> + <string name="spam_notification_title">Is <xliff:g id="number">%1$s</xliff:g> spam?</string> + <!-- Text for the toast shown after the user presses block/report spam. [CHAR LIMIT=100] --> + <string name="spam_notification_block_report_toast_text"><xliff:g id="number">%1$s</xliff:g> blocked and call was reported as spam.</string> + <!-- Text for the toast shown after the user presses not spam. [CHAR LIMIT=100] --> + <string name="spam_notification_not_spam_toast_text">Call from <xliff:g id="number">%1$s</xliff:g> reported as not spam.</string> + <!-- Text displayed in the collapsed notification to the user after a non-spam call ends. [CHAR LIMIT=100] --> + <string name="spam_notification_non_spam_call_collapsed_text">Tap to add to contacts or block spam number.</string> + <!-- Text displayed in the expanded notification to the user after a non-spam call ends. [CHAR LIMIT=NONE] --> + <string name="spam_notification_non_spam_call_expanded_text">This is the first time this number called you. If this call was spam, you can block this number and report it.</string> + <!-- Text displayed in the collapsed notification to the user after a spam call ends. [CHAR LIMIT=100] --> + <string name="spam_notification_spam_call_collapsed_text">Tap to report as NOT SPAM, or block it.</string> + <!-- Text displayed in the expanded notification to the user after a spam call ends. [CHAR LIMIT=NONE] --> + <string name="spam_notification_spam_call_expanded_text">We suspected this to be a spammer. If this call wasn\'t spam, tap "NOT SPAM" to report our mistake.</string> + <!-- Text for the reporting spam action in the after call prompt. [CHAR LIMIT=20] --> + <string name="spam_notification_report_spam_action_text">Block & report</string> + <!-- Text for the adding to contacts action in the after call prompt. [CHAR LIMIT=20] --> + <string name="spam_notification_add_contact_action_text">Add contact</string> + <!-- Text for the reporting as not spam action in the after call prompt. [CHAR LIMIT=20] --> + <string name="spam_notification_not_spam_action_text">Not spam</string> + <!-- Text for the blocking spam action in the after call prompt. [CHAR LIMIT=20] --> + <string name="spam_notification_block_spam_action_text">Block number</string> + <!-- Text for the adding to contacts action in the after call dialog. [CHAR LIMIT=40] --> + <string name="spam_notification_dialog_add_contact_action_text">Add to contacts</string> + <!-- Text for the blocking and reporting spam action in the after call dialog. [CHAR LIMIT=40] --> + <string name="spam_notification_dialog_block_report_spam_action_text">Block & report spam</string> + <!-- Text for the marking a call as not spam in the after call dialog. [CHAR LIMIT=40] --> + <string name="spam_notification_dialog_was_not_spam_action_text">Not spam</string> + + <string name="callFailed_simError">No SIM or SIM error</string> + + <string name="conference_caller_disconnect_content_description">End call</string> + + <!-- Name for a conference call. Shown in the in call UI and in notifications. --> + <string name="conference_call_name">Conference call</string> + + <!-- Name for a generic conference call. Shown in the in call UI. This is used in CDMA where we + don't know the precise state of participants in the conference. --> + <string name="generic_conference_call_name">In call</string> + + <!-- Displayed when handover from WiFi to Lte occurs during a video call --> + <string name="video_call_wifi_to_lte_handover_toast">Continuing call using cellular data…</string> + + <!-- Displayed when WiFi handover from LTE fails during a video call. --> + <string name="video_call_lte_to_wifi_failed_title">Couldn\'t switch to Wi-Fi network</string> + <string name="video_call_lte_to_wifi_failed_message">Video call will remain on cellular network. Standard + data charges may apply. + </string> + <string name="video_call_lte_to_wifi_failed_do_not_show">Do not show this again</string> + +</resources> diff --git a/java/com/android/incallui/res/values/styles.xml b/java/com/android/incallui/res/values/styles.xml new file mode 100644 index 000000000..96e3d4d59 --- /dev/null +++ b/java/com/android/incallui/res/values/styles.xml @@ -0,0 +1,80 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2013 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> + <drawable name="grayBg">#FF333333</drawable> + + <!-- Theme for the InCallActivity activity. Should have a transparent background for the + circular reveal animation for a new outgoing call to work correctly. We don't just use + Theme.Black.NoTitleBar directly, since we want any popups or dialogs from the + InCallActivity to have the correct Material style. --> + <style name="Theme.InCallScreen" parent="@style/Theme.AppCompat.NoActionBar"> + <item name="android:textColorPrimary">#ffffff</item> + <item name="android:textColorSecondary">#DDFFFFFF</item> + <item name="android:colorPrimary">@color/dialer_theme_color</item> + <item name="android:colorPrimaryDark">@color/dialer_theme_color_dark</item> + + <item name="android:statusBarColor">@android:color/transparent</item> + <item name="android:navigationBarColor">@android:color/transparent</item> + <item name="android:windowDrawsSystemBarBackgrounds">true</item> + + <item name="dialpad_key_button_touch_tint">@color/incall_dialpad_touch_tint</item> + <item name="dialpad_style">@style/InCallDialpad</item> + <item name="android:windowAnimationStyle">@null</item> + <item name="android:alertDialogTheme">@style/AlertDialogTheme</item> + + <item name="android:windowBackground">@drawable/incall_background_gradient</item> + <item name="android:windowShowWallpaper">true</item> + </style> + + <style name="Theme.InCallScreen.ManageConference" parent="DialerThemeBase"> + </style> + + <style name="InCallDialpad" parent="Dialpad.Light"> + <item name="dialpad_key_number_margin_bottom"> + @dimen/incall_dialpad_key_number_margin_bottom + </item> + <item name="dialpad_zero_key_number_margin_bottom"> + @dimen/incall_dialpad_zero_key_number_margin_bottom + </item> + <item name="dialpad_digits_adjustable_text_size"> + @dimen/incall_dialpad_digits_adjustable_text_size + </item> + <item name="dialpad_digits_adjustable_height"> + @dimen/incall_dialpad_digits_adjustable_height + </item> + <item name="dialpad_key_numbers_size"> + @dimen/incall_dialpad_key_numbers_size + </item> + <item name="dialpad_end_key_spacing"> + @dimen/incall_end_call_spacing + </item> + </style> + + <style name="AfterCallNotificationTheme" parent="@style/Theme.AppCompat.Light.Dialog.MinWidth"> + <!-- This colorAccent is to style checkboxes in the dialogs --> + <item name="colorAccent">@color/dialer_theme_color</item> + <!-- This is needed to make any alert dialogs in this activity take up minimum space --> + <item name="android:alertDialogTheme">@style/AfterCallDialogStyle</item> + </style> + + <style name="AfterCallDialogStyle" parent="@style/Theme.AppCompat.Light.Dialog.MinWidth"> + <!-- This colorAccent is to style text in the dialogs --> + <item name="android:colorAccent">@color/dialer_theme_color</item> + </style> + +</resources> diff --git a/java/com/android/incallui/ringtone/DialerRingtoneManager.java b/java/com/android/incallui/ringtone/DialerRingtoneManager.java new file mode 100644 index 000000000..5ebd93378 --- /dev/null +++ b/java/com/android/incallui/ringtone/DialerRingtoneManager.java @@ -0,0 +1,134 @@ +/* + * 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.ringtone; + +import android.content.ContentResolver; +import android.net.Uri; +import android.os.Build.VERSION; +import android.os.Build.VERSION_CODES; +import android.provider.Settings; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import com.android.incallui.call.CallList; +import com.android.incallui.call.DialerCall.State; +import java.util.Objects; + +/** + * Class that determines when ringtones should be played and can play the call waiting tone when + * necessary. + */ +public class DialerRingtoneManager { + + /* + * Flag used to determine if the Dialer is responsible for playing ringtones for incoming calls. + * Once we're ready to enable Dialer Ringing, these flags should be removed. + */ + private static final boolean IS_DIALER_RINGING_ENABLED = false; + private final InCallTonePlayer mInCallTonePlayer; + private final CallList mCallList; + private Boolean mIsDialerRingingEnabledForTesting; + + /** + * Creates the DialerRingtoneManager with the given {@link InCallTonePlayer}. + * + * @param inCallTonePlayer the tone player used to play in-call tones. + * @param callList the CallList used to check for {@link State#CALL_WAITING} + * @throws NullPointerException if inCallTonePlayer or callList are null + */ + public DialerRingtoneManager( + @NonNull InCallTonePlayer inCallTonePlayer, @NonNull CallList callList) { + mInCallTonePlayer = Objects.requireNonNull(inCallTonePlayer); + mCallList = Objects.requireNonNull(callList); + } + + /** + * Determines if a ringtone should be played for the given call state (see {@link State}) and + * {@link Uri}. + * + * @param callState the call state for the call being checked. + * @param ringtoneUri the ringtone to potentially play. + * @return {@code true} if the ringtone should be played, {@code false} otherwise. + */ + public boolean shouldPlayRingtone(int callState, @Nullable Uri ringtoneUri) { + return isDialerRingingEnabled() + && translateCallStateForCallWaiting(callState) == State.INCOMING + && ringtoneUri != null; + } + + /** + * Determines if an incoming call should vibrate as well as ring. + * + * @param resolver {@link ContentResolver} used to look up the {@link + * Settings.System#VIBRATE_WHEN_RINGING} setting. + * @return {@code true} if the call should vibrate, {@code false} otherwise. + */ + public boolean shouldVibrate(ContentResolver resolver) { + return Settings.System.getInt(resolver, Settings.System.VIBRATE_WHEN_RINGING, 0) != 0; + } + + /** + * The incoming callState is never set as {@link State#CALL_WAITING} because {@link + * DialerCall#translateState(int)} doesn't account for that case, check for it here + */ + private int translateCallStateForCallWaiting(int callState) { + if (callState != State.INCOMING) { + return callState; + } + return mCallList.getActiveCall() == null ? State.INCOMING : State.CALL_WAITING; + } + + private boolean isDialerRingingEnabled() { + boolean enabledFlag = + mIsDialerRingingEnabledForTesting != null + ? mIsDialerRingingEnabledForTesting + : IS_DIALER_RINGING_ENABLED; + return VERSION.SDK_INT >= VERSION_CODES.N && enabledFlag; + } + + /** + * Determines if a call waiting tone should be played for the the given call state (see {@link + * State}). + * + * @param callState the call state for the call being checked. + * @return {@code true} if the call waiting tone should be played, {@code false} otherwise. + */ + public boolean shouldPlayCallWaitingTone(int callState) { + return isDialerRingingEnabled() + && translateCallStateForCallWaiting(callState) == State.CALL_WAITING + && !mInCallTonePlayer.isPlayingTone(); + } + + /** Plays the call waiting tone. */ + public void playCallWaitingTone() { + if (!isDialerRingingEnabled()) { + return; + } + mInCallTonePlayer.play(InCallTonePlayer.TONE_CALL_WAITING); + } + + /** Stops playing the call waiting tone. */ + public void stopCallWaitingTone() { + if (!isDialerRingingEnabled()) { + return; + } + mInCallTonePlayer.stop(); + } + + void setDialerRingingEnabledForTesting(boolean status) { + mIsDialerRingingEnabledForTesting = status; + } +} diff --git a/java/com/android/incallui/ringtone/InCallTonePlayer.java b/java/com/android/incallui/ringtone/InCallTonePlayer.java new file mode 100644 index 000000000..c76b41d72 --- /dev/null +++ b/java/com/android/incallui/ringtone/InCallTonePlayer.java @@ -0,0 +1,168 @@ +/* + * 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.ringtone; + +import android.media.AudioManager; +import android.media.ToneGenerator; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import com.android.incallui.Log; +import com.android.incallui.async.PausableExecutor; +import java.util.Objects; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +/** + * Class responsible for playing in-call related tones in a background thread. This class only + * allows one tone to be played at a time. + */ +public class InCallTonePlayer { + + public static final int TONE_CALL_WAITING = 4; + + public static final int VOLUME_RELATIVE_HIGH_PRIORITY = 80; + + @NonNull private final ToneGeneratorFactory mToneGeneratorFactory; + @NonNull private final PausableExecutor mExecutor; + private @Nullable CountDownLatch mNumPlayingTones; + + /** + * Creates a new InCallTonePlayer. + * + * @param toneGeneratorFactory the {@link ToneGeneratorFactory} used to create {@link + * ToneGenerator}s. + * @param executor the {@link PausableExecutor} used to play tones in a background thread. + * @throws NullPointerException if audioModeProvider, toneGeneratorFactory, or executor are {@code + * null}. + */ + public InCallTonePlayer( + @NonNull ToneGeneratorFactory toneGeneratorFactory, @NonNull PausableExecutor executor) { + mToneGeneratorFactory = Objects.requireNonNull(toneGeneratorFactory); + mExecutor = Objects.requireNonNull(executor); + } + + /** @return {@code true} if a tone is currently playing, {@code false} otherwise. */ + public boolean isPlayingTone() { + return mNumPlayingTones != null && mNumPlayingTones.getCount() > 0; + } + + /** + * Plays the given tone in a background thread. + * + * @param tone the tone to play. + * @throws IllegalStateException if a tone is already playing. + * @throws IllegalArgumentException if the tone is invalid. + */ + public void play(int tone) { + if (isPlayingTone()) { + throw new IllegalStateException("Tone already playing"); + } + final ToneGeneratorInfo info = getToneGeneratorInfo(tone); + mNumPlayingTones = new CountDownLatch(1); + mExecutor.execute( + new Runnable() { + @Override + public void run() { + playOnBackgroundThread(info); + } + }); + } + + private ToneGeneratorInfo getToneGeneratorInfo(int tone) { + switch (tone) { + case TONE_CALL_WAITING: + /* + * DialerCall waiting tones play until they're stopped either by the user accepting or + * declining the call so the tone length is set at what's effectively forever. The + * tone is played at a high priority volume and through STREAM_VOICE_CALL since it's + * call related and using that stream will route it through bluetooth devices + * appropriately. + */ + return new ToneGeneratorInfo( + ToneGenerator.TONE_SUP_CALL_WAITING, + VOLUME_RELATIVE_HIGH_PRIORITY, + Integer.MAX_VALUE, + AudioManager.STREAM_VOICE_CALL); + default: + throw new IllegalArgumentException("Bad tone: " + tone); + } + } + + private void playOnBackgroundThread(ToneGeneratorInfo info) { + ToneGenerator toneGenerator = null; + try { + Log.v(this, "Starting tone " + info); + toneGenerator = mToneGeneratorFactory.newInCallToneGenerator(info.stream, info.volume); + toneGenerator.startTone(info.tone); + /* + * During tests, this will block until the tests call mExecutor.ackMilestone. This call + * allows for synchronization to the point where the tone has started playing. + */ + mExecutor.milestone(); + if (mNumPlayingTones != null) { + mNumPlayingTones.await(info.toneLengthMillis, TimeUnit.MILLISECONDS); + // Allows for synchronization to the point where the tone has completed playing. + mExecutor.milestone(); + } + } catch (InterruptedException e) { + Log.w(this, "Interrupted while playing in-call tone."); + } finally { + if (toneGenerator != null) { + toneGenerator.release(); + } + if (mNumPlayingTones != null) { + mNumPlayingTones.countDown(); + } + // Allows for synchronization to the point where this background thread has cleaned up. + mExecutor.milestone(); + } + } + + /** Stops playback of the current tone. */ + public void stop() { + if (mNumPlayingTones != null) { + mNumPlayingTones.countDown(); + } + } + + private static class ToneGeneratorInfo { + + public final int tone; + public final int volume; + public final int toneLengthMillis; + public final int stream; + + public ToneGeneratorInfo(int toneGeneratorType, int volume, int toneLengthMillis, int stream) { + this.tone = toneGeneratorType; + this.volume = volume; + this.toneLengthMillis = toneLengthMillis; + this.stream = stream; + } + + @Override + public String toString() { + return "ToneGeneratorInfo{" + + "toneLengthMillis=" + + toneLengthMillis + + ", tone=" + + tone + + ", volume=" + + volume + + '}'; + } + } +} diff --git a/java/com/android/incallui/ringtone/ToneGeneratorFactory.java b/java/com/android/incallui/ringtone/ToneGeneratorFactory.java new file mode 100644 index 000000000..cd7b11aa9 --- /dev/null +++ b/java/com/android/incallui/ringtone/ToneGeneratorFactory.java @@ -0,0 +1,34 @@ +/* + * 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.ringtone; + +import android.media.ToneGenerator; + +/** Factory used to create {@link ToneGenerator}s. */ +public class ToneGeneratorFactory { + + /** + * Creates a new {@link ToneGenerator} to use while in a call. + * + * @param stream the stream through which to play tones. + * @param volume the volume at which to play tones. + * @return a new ToneGenerator. + */ + public ToneGenerator newInCallToneGenerator(int stream, int volume) { + return new ToneGenerator(stream, volume); + } +} diff --git a/java/com/android/incallui/sessiondata/AndroidManifest.xml b/java/com/android/incallui/sessiondata/AndroidManifest.xml new file mode 100644 index 000000000..11babd94d --- /dev/null +++ b/java/com/android/incallui/sessiondata/AndroidManifest.xml @@ -0,0 +1,18 @@ +<!-- + ~ 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 + --> +<manifest + package="com.android.incallui.sessiondata"> +</manifest> diff --git a/java/com/android/incallui/sessiondata/AvatarPresenter.java b/java/com/android/incallui/sessiondata/AvatarPresenter.java new file mode 100644 index 000000000..e7303b90a --- /dev/null +++ b/java/com/android/incallui/sessiondata/AvatarPresenter.java @@ -0,0 +1,31 @@ +/* + * 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.sessiondata; + +import android.support.annotation.Nullable; +import android.widget.ImageView; + +/** Interface for interacting with Fragments that can be put in the data container */ +public interface AvatarPresenter { + + @Nullable + ImageView getAvatarImageView(); + + int getAvatarSize(); + + boolean shouldShowAnonymousAvatar(); +} diff --git a/java/com/android/incallui/sessiondata/MultimediaFragment.java b/java/com/android/incallui/sessiondata/MultimediaFragment.java new file mode 100644 index 000000000..d6f671d58 --- /dev/null +++ b/java/com/android/incallui/sessiondata/MultimediaFragment.java @@ -0,0 +1,231 @@ +/* + * 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.sessiondata; + +import android.graphics.drawable.Drawable; +import android.location.Location; +import android.net.Uri; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.annotation.VisibleForTesting; +import android.support.v4.app.Fragment; +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.TextView; +import com.android.dialer.common.Assert; +import com.android.dialer.common.FragmentUtils; +import com.android.dialer.common.LogUtil; +import com.android.dialer.multimedia.MultimediaData; +import com.android.incallui.maps.StaticMapBinding; +import com.android.incallui.maps.StaticMapFactory; +import com.bumptech.glide.Glide; +import com.bumptech.glide.load.DataSource; +import com.bumptech.glide.load.engine.GlideException; +import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions; +import com.bumptech.glide.request.RequestListener; +import com.bumptech.glide.request.target.Target; + +/** + * Displays info from {@link MultimediaData MultimediaData}. + * + * <p>Currently displays image, location (as a map), and message that come bundled with + * MultimediaData when calling {@link #newInstance(MultimediaData, boolean, boolean)}. + */ +public class MultimediaFragment extends Fragment implements AvatarPresenter { + + private static final String ARG_SUBJECT = "subject"; + private static final String ARG_IMAGE = "image"; + private static final String ARG_LOCATION = "location"; + private static final String ARG_INTERACTIVE = "interactive"; + private static final String ARG_SHOW_AVATAR = "show_avatar"; + private ImageView avatarImageView; + // TODO: add click listeners + @SuppressWarnings("unused") + private boolean isInteractive; + + private boolean showAvatar; + private StaticMapFactory mapFactory; + + public static MultimediaFragment newInstance( + @NonNull MultimediaData multimediaData, boolean isInteractive, boolean showAvatar) { + return newInstance( + multimediaData.getSubject(), + multimediaData.getImageUri(), + multimediaData.getLocation(), + isInteractive, + showAvatar); + } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + public static MultimediaFragment newInstance( + @Nullable String subject, + @Nullable Uri imageUri, + @Nullable Location location, + boolean isInteractive, + boolean showAvatar) { + Bundle args = new Bundle(); + args.putString(ARG_SUBJECT, subject); + args.putParcelable(ARG_IMAGE, imageUri); + args.putParcelable(ARG_LOCATION, location); + args.putBoolean(ARG_INTERACTIVE, isInteractive); + args.putBoolean(ARG_SHOW_AVATAR, showAvatar); + MultimediaFragment fragment = new MultimediaFragment(); + fragment.setArguments(args); + return fragment; + } + + @Override + public void onCreate(@Nullable Bundle bundle) { + super.onCreate(bundle); + isInteractive = getArguments().getBoolean(ARG_INTERACTIVE); + showAvatar = getArguments().getBoolean(ARG_SHOW_AVATAR); + } + + @Nullable + @Override + public View onCreateView( + LayoutInflater layoutInflater, @Nullable ViewGroup viewGroup, @Nullable Bundle bundle) { + boolean hasImage = getImageUri() != null; + boolean hasSubject = !TextUtils.isEmpty(getSubject()); + boolean hasMap = getLocation() != null; + if (hasMap) { + mapFactory = StaticMapBinding.get(getActivity().getApplication()); + } + if (mapFactory != null) { + if (hasImage) { + if (hasSubject) { + return layoutInflater.inflate( + R.layout.fragment_composer_text_image_frag, viewGroup, false); + } else { + return layoutInflater.inflate(R.layout.fragment_composer_image_frag, viewGroup, false); + } + } else if (hasSubject) { + return layoutInflater.inflate(R.layout.fragment_composer_text_frag, viewGroup, false); + } else { + return layoutInflater.inflate(R.layout.fragment_composer_frag, viewGroup, false); + } + } else if (hasImage) { + if (hasSubject) { + return layoutInflater.inflate(R.layout.fragment_composer_text_image, viewGroup, false); + } else { + return layoutInflater.inflate(R.layout.fragment_composer_image, viewGroup, false); + } + } else { + return layoutInflater.inflate(R.layout.fragment_composer_text, viewGroup, false); + } + } + + @Override + public void onViewCreated(View view, @Nullable Bundle bundle) { + super.onViewCreated(view, bundle); + TextView messageText = (TextView) view.findViewById(R.id.answer_message_text); + if (messageText != null) { + messageText.setText(getSubject()); + } + ImageView mainImage = (ImageView) view.findViewById(R.id.answer_message_image); + if (mainImage != null) { + Glide.with(this) + .load(getImageUri()) + .transition(DrawableTransitionOptions.withCrossFade()) + .listener( + new RequestListener<Drawable>() { + @Override + public boolean onLoadFailed( + @Nullable GlideException e, + Object model, + Target<Drawable> target, + boolean isFirstResource) { + view.findViewById(R.id.loading_spinner).setVisibility(View.GONE); + LogUtil.e("MultimediaFragment.onLoadFailed", null, e); + // TODO(b/34720074) handle error cases nicely + return false; // Let Glide handle the rest + } + + @Override + public boolean onResourceReady( + Drawable drawable, + Object model, + Target<Drawable> target, + DataSource dataSource, + boolean isFirstResource) { + view.findViewById(R.id.loading_spinner).setVisibility(View.GONE); + return false; + } + }) + .into(mainImage); + mainImage.setClipToOutline(true); + } + FrameLayout fragmentHolder = (FrameLayout) view.findViewById(R.id.answer_message_frag); + if (fragmentHolder != null) { + fragmentHolder.setClipToOutline(true); + Fragment mapFragment = + Assert.isNotNull(mapFactory).getStaticMap(Assert.isNotNull(getLocation())); + getChildFragmentManager() + .beginTransaction() + .replace(R.id.answer_message_frag, mapFragment) + .commitNow(); + } + avatarImageView = ((ImageView) view.findViewById(R.id.answer_message_avatar)); + avatarImageView.setVisibility(showAvatar ? View.VISIBLE : View.GONE); + + Holder parent = FragmentUtils.getParent(this, Holder.class); + if (parent != null) { + parent.updateAvatar(this); + } + } + + @Nullable + @Override + public ImageView getAvatarImageView() { + return avatarImageView; + } + + @Override + public int getAvatarSize() { + return getResources().getDimensionPixelSize(R.dimen.answer_message_avatar_size); + } + + @Override + public boolean shouldShowAnonymousAvatar() { + return showAvatar; + } + + @Nullable + public String getSubject() { + return getArguments().getString(ARG_SUBJECT); + } + + @Nullable + public Uri getImageUri() { + return getArguments().getParcelable(ARG_IMAGE); + } + + @Nullable + public Location getLocation() { + return getArguments().getParcelable(ARG_LOCATION); + } + + /** Interface for notifying the fragment parent of changes. */ + public interface Holder { + void updateAvatar(AvatarPresenter sessionDataScreen); + } +} diff --git a/java/com/android/incallui/sessiondata/res/drawable/answer_data_background.xml b/java/com/android/incallui/sessiondata/res/drawable/answer_data_background.xml new file mode 100644 index 000000000..8826f904b --- /dev/null +++ b/java/com/android/incallui/sessiondata/res/drawable/answer_data_background.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 + --> + +<shape xmlns:android="http://schemas.android.com/apk/res/android" + android:shape="rectangle"> + <corners android:radius="16dp"/> + <solid android:color="@android:color/white"/> +</shape> diff --git a/java/com/android/incallui/sessiondata/res/layout/fragment_composer_frag.xml b/java/com/android/incallui/sessiondata/res/layout/fragment_composer_frag.xml new file mode 100644 index 000000000..ed2bee0d1 --- /dev/null +++ b/java/com/android/incallui/sessiondata/res/layout/fragment_composer_frag.xml @@ -0,0 +1,42 @@ +<?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="wrap_content" + android:paddingTop="16dp" + android:paddingStart="16dp" + android:paddingEnd="24dp" + android:orientation="horizontal"> + + <ImageView + android:id="@id/answer_message_avatar" + android:layout_width="@dimen/answer_message_avatar_size" + android:layout_height="@dimen/answer_message_avatar_size" + android:elevation="@dimen/answer_data_elevation"/> + + <FrameLayout + android:id="@id/answer_message_frag" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_weight="1" + android:layout_marginBottom="4dp" + android:layout_marginStart="8dp" + android:background="@drawable/answer_data_background" + android:elevation="@dimen/answer_data_elevation" + android:outlineProvider="background"/> +</LinearLayout> diff --git a/java/com/android/incallui/sessiondata/res/layout/fragment_composer_image.xml b/java/com/android/incallui/sessiondata/res/layout/fragment_composer_image.xml new file mode 100644 index 000000000..7000f83b5 --- /dev/null +++ b/java/com/android/incallui/sessiondata/res/layout/fragment_composer_image.xml @@ -0,0 +1,50 @@ +<?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 + --> + +<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:paddingTop="16dp" + android:paddingStart="16dp" + android:paddingEnd="24dp"> + + <ImageView + android:id="@id/answer_message_avatar" + android:layout_width="@dimen/answer_message_avatar_size" + android:layout_height="@dimen/answer_message_avatar_size" + android:elevation="@dimen/answer_data_elevation"/> + + <ImageView + android:id="@id/answer_message_image" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginBottom="4dp" + android:layout_marginStart="8dp" + android:layout_centerInParent="true" + android:layout_toEndOf="@+id/answer_message_avatar" + android:background="@drawable/answer_data_background" + android:elevation="@dimen/answer_data_elevation" + android:outlineProvider="background" + android:adjustViewBounds="true" + android:scaleType="fitXY"/> + + <ProgressBar + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:id="@+id/loading_spinner" + android:layout_centerInParent="true"/> +</RelativeLayout> diff --git a/java/com/android/incallui/sessiondata/res/layout/fragment_composer_image_frag.xml b/java/com/android/incallui/sessiondata/res/layout/fragment_composer_image_frag.xml new file mode 100644 index 000000000..9959f4dcc --- /dev/null +++ b/java/com/android/incallui/sessiondata/res/layout/fragment_composer_image_frag.xml @@ -0,0 +1,59 @@ +<?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 + --> + +<GridLayout xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:paddingTop="16dp" + android:paddingStart="16dp" + android:paddingEnd="24dp" + android:orientation="horizontal"> + + <ImageView + android:id="@id/answer_message_avatar" + android:layout_width="@dimen/answer_message_avatar_size" + android:layout_height="@dimen/answer_message_avatar_size" + android:layout_rowSpan="2" + android:elevation="@dimen/answer_data_elevation"/> + + <ImageView + android:id="@id/answer_message_image" + android:layout_width="0dp" + android:layout_height="0dp" + android:layout_marginStart="8dp" + android:layout_columnWeight="1" + android:layout_rowWeight="1" + android:background="@drawable/answer_data_background" + android:elevation="@dimen/answer_data_elevation" + android:outlineProvider="background" + android:scaleType="centerCrop"/> + + <FrameLayout + android:id="@id/answer_message_frag" + android:layout_width="0dp" + android:layout_height="0dp" + android:layout_marginTop="4dp" + android:layout_marginBottom="4dp" + android:layout_marginStart="8dp" + android:layout_column="1" + android:layout_columnWeight="1" + android:layout_row="1" + android:layout_rowWeight="1" + android:background="@drawable/answer_data_background" + android:elevation="@dimen/answer_data_elevation" + android:outlineProvider="background"/> +</GridLayout> diff --git a/java/com/android/incallui/sessiondata/res/layout/fragment_composer_text.xml b/java/com/android/incallui/sessiondata/res/layout/fragment_composer_text.xml new file mode 100644 index 000000000..c69973042 --- /dev/null +++ b/java/com/android/incallui/sessiondata/res/layout/fragment_composer_text.xml @@ -0,0 +1,43 @@ +<?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="wrap_content" + android:paddingTop="16dp" + android:paddingStart="16dp" + android:paddingEnd="24dp" + android:orientation="horizontal"> + + <ImageView + android:id="@id/answer_message_avatar" + android:layout_width="@dimen/answer_message_avatar_size" + android:layout_height="@dimen/answer_message_avatar_size" + android:elevation="@dimen/answer_data_elevation"/> + + <TextView + android:id="@id/answer_message_text" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_weight="1" + android:layout_marginBottom="4dp" + android:layout_marginStart="8dp" + android:padding="18dp" + android:background="@drawable/answer_data_background" + android:elevation="@dimen/answer_data_elevation" + android:textAppearance="@style/Dialer.Incall.TextAppearance.Message"/> +</LinearLayout> diff --git a/java/com/android/incallui/sessiondata/res/layout/fragment_composer_text_frag.xml b/java/com/android/incallui/sessiondata/res/layout/fragment_composer_text_frag.xml new file mode 100644 index 000000000..5a1cf728b --- /dev/null +++ b/java/com/android/incallui/sessiondata/res/layout/fragment_composer_text_frag.xml @@ -0,0 +1,61 @@ +<?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 + --> + +<GridLayout xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:paddingTop="16dp" + android:paddingStart="16dp" + android:paddingEnd="24dp" + android:orientation="horizontal"> + + <ImageView + android:id="@id/answer_message_avatar" + android:layout_width="@dimen/answer_message_avatar_size" + android:layout_height="@dimen/answer_message_avatar_size" + android:layout_rowSpan="2" + android:elevation="@dimen/answer_data_elevation"/> + + <TextView + android:id="@id/answer_message_text" + android:layout_width="0dp" + android:layout_height="0dp" + android:layout_marginStart="8dp" + android:layout_columnWeight="1" + android:layout_rowWeight="1" + android:padding="18dp" + android:background="@drawable/answer_data_background" + android:elevation="@dimen/answer_data_elevation" + android:gravity="center_vertical" + android:maxLines="2" + android:textAppearance="@style/Dialer.Incall.TextAppearance.Message"/> + + <FrameLayout + android:id="@id/answer_message_frag" + android:layout_width="0dp" + android:layout_height="0dp" + android:layout_marginTop="4dp" + android:layout_marginBottom="4dp" + android:layout_marginStart="8dp" + android:layout_column="1" + android:layout_columnWeight="1" + android:layout_row="1" + android:layout_rowWeight="1" + android:background="@drawable/answer_data_background" + android:elevation="@dimen/answer_data_elevation" + android:outlineProvider="background"/> +</GridLayout> diff --git a/java/com/android/incallui/sessiondata/res/layout/fragment_composer_text_image.xml b/java/com/android/incallui/sessiondata/res/layout/fragment_composer_text_image.xml new file mode 100644 index 000000000..995565455 --- /dev/null +++ b/java/com/android/incallui/sessiondata/res/layout/fragment_composer_text_image.xml @@ -0,0 +1,62 @@ +<?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 + --> + +<GridLayout xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:paddingTop="16dp" + android:paddingStart="16dp" + android:paddingEnd="24dp" + android:orientation="horizontal"> + + <ImageView + android:id="@id/answer_message_avatar" + android:layout_width="@dimen/answer_message_avatar_size" + android:layout_height="@dimen/answer_message_avatar_size" + android:layout_rowSpan="2" + android:elevation="@dimen/answer_data_elevation"/> + + <TextView + android:id="@id/answer_message_text" + android:layout_width="0dp" + android:layout_height="0dp" + android:layout_marginStart="8dp" + android:layout_columnWeight="1" + android:layout_rowWeight="1" + android:padding="18dp" + android:background="@drawable/answer_data_background" + android:elevation="@dimen/answer_data_elevation" + android:gravity="center_vertical" + android:maxLines="2" + android:textAppearance="@style/Dialer.Incall.TextAppearance.Message"/> + + <ImageView + android:id="@id/answer_message_image" + android:layout_width="0dp" + android:layout_height="0dp" + android:layout_marginTop="4dp" + android:layout_marginBottom="4dp" + android:layout_marginStart="8dp" + android:layout_column="1" + android:layout_columnWeight="1" + android:layout_row="1" + android:layout_rowWeight="1" + android:background="@drawable/answer_data_background" + android:elevation="@dimen/answer_data_elevation" + android:outlineProvider="background" + android:scaleType="centerCrop"/> +</GridLayout> diff --git a/java/com/android/incallui/sessiondata/res/layout/fragment_composer_text_image_frag.xml b/java/com/android/incallui/sessiondata/res/layout/fragment_composer_text_image_frag.xml new file mode 100644 index 000000000..387c5cf68 --- /dev/null +++ b/java/com/android/incallui/sessiondata/res/layout/fragment_composer_text_image_frag.xml @@ -0,0 +1,78 @@ +<?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 + --> + +<GridLayout xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:paddingTop="16dp" + android:paddingStart="16dp" + android:paddingEnd="24dp" + android:orientation="horizontal"> + + <ImageView + android:id="@id/answer_message_avatar" + android:layout_width="@dimen/answer_message_avatar_size" + android:layout_height="@dimen/answer_message_avatar_size" + android:layout_rowSpan="2" + android:elevation="@dimen/answer_data_elevation"/> + + <TextView + android:id="@id/answer_message_text" + android:layout_width="0dp" + android:layout_height="0dp" + android:layout_marginStart="8dp" + android:layout_columnWeight="2" + android:layout_columnSpan="2" + android:layout_rowWeight="1" + android:padding="18dp" + android:background="@drawable/answer_data_background" + android:elevation="@dimen/answer_data_elevation" + android:gravity="center_vertical" + android:maxLines="2" + android:textAppearance="@style/Dialer.Incall.TextAppearance.Message"/> + + <ImageView + android:id="@id/answer_message_image" + android:layout_width="0dp" + android:layout_height="0dp" + android:layout_marginTop="4dp" + android:layout_marginBottom="4dp" + android:layout_marginStart="8dp" + android:layout_column="1" + android:layout_columnWeight="1" + android:layout_row="1" + android:layout_rowWeight="1" + android:background="@drawable/answer_data_background" + android:elevation="@dimen/answer_data_elevation" + android:outlineProvider="background" + android:scaleType="centerCrop"/> + + <FrameLayout + android:id="@id/answer_message_frag" + android:layout_width="0dp" + android:layout_height="0dp" + android:layout_marginTop="4dp" + android:layout_marginBottom="4dp" + android:layout_marginStart="8dp" + android:layout_column="2" + android:layout_columnWeight="1" + android:layout_row="1" + android:layout_rowWeight="1" + android:background="@drawable/answer_data_background" + android:elevation="@dimen/answer_data_elevation" + android:outlineProvider="background"/> +</GridLayout> diff --git a/java/com/android/incallui/sessiondata/res/values/dimens.xml b/java/com/android/incallui/sessiondata/res/values/dimens.xml new file mode 100644 index 000000000..76c7edb1b --- /dev/null +++ b/java/com/android/incallui/sessiondata/res/values/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_message_avatar_size">40dp</dimen> + <dimen name="answer_data_elevation">2dp</dimen> +</resources> diff --git a/java/com/android/incallui/sessiondata/res/values/ids.xml b/java/com/android/incallui/sessiondata/res/values/ids.xml new file mode 100644 index 000000000..077474c81 --- /dev/null +++ b/java/com/android/incallui/sessiondata/res/values/ids.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> + <item name="answer_message_avatar" type="id"/> + <item name="answer_message_text" type="id"/> + <item name="answer_message_image" type="id"/> + <item name="answer_message_frag" type="id"/> +</resources> diff --git a/java/com/android/incallui/sessiondata/res/values/styles.xml b/java/com/android/incallui/sessiondata/res/values/styles.xml new file mode 100644 index 000000000..dd898a4e2 --- /dev/null +++ b/java/com/android/incallui/sessiondata/res/values/styles.xml @@ -0,0 +1,24 @@ +<?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> + <style name="Dialer.Incall.TextAppearance.Message" parent="Dialer.Incall.TextAppearance"> + <item name="android:fontFamily">sans-serif</item> + <item name="android:textColor">@android:color/black</item> + <item name="android:textSize">24sp</item> + </style> +</resources> diff --git a/java/com/android/incallui/spam/NumberInCallHistoryTask.java b/java/com/android/incallui/spam/NumberInCallHistoryTask.java new file mode 100644 index 000000000..a225606f6 --- /dev/null +++ b/java/com/android/incallui/spam/NumberInCallHistoryTask.java @@ -0,0 +1,107 @@ +/* + * 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.spam; + +import android.annotation.TargetApi; +import android.content.Context; +import android.database.Cursor; +import android.database.sqlite.SQLiteException; +import android.os.AsyncTask; +import android.os.Build.VERSION_CODES; +import android.provider.CallLog; +import android.provider.CallLog.Calls; +import android.support.annotation.NonNull; +import android.telephony.PhoneNumberUtils; +import android.text.TextUtils; +import com.android.dialer.common.AsyncTaskExecutor; +import com.android.dialer.common.AsyncTaskExecutors; +import com.android.dialer.common.LogUtil; +import com.android.dialer.telecom.TelecomUtil; +import com.android.dialer.util.PermissionsUtil; +import com.android.incallui.call.DialerCall; +import com.android.incallui.call.DialerCall.CallHistoryStatus; +import java.util.Objects; + +/** Checks if the number is in the call history. */ +@TargetApi(VERSION_CODES.M) +public class NumberInCallHistoryTask extends AsyncTask<Void, Void, Integer> { + + public static final String TASK_ID = "number_in_call_history_status"; + + private final Context context; + private final Listener listener; + private final String number; + private final String countryIso; + + public NumberInCallHistoryTask( + @NonNull Context context, @NonNull Listener listener, String number, String countryIso) { + this.context = Objects.requireNonNull(context); + this.listener = Objects.requireNonNull(listener); + this.number = number; + this.countryIso = countryIso; + } + + public void submitTask() { + if (!PermissionsUtil.hasPhonePermissions(context)) { + return; + } + AsyncTaskExecutor asyncTaskExecutor = AsyncTaskExecutors.createThreadPoolExecutor(); + asyncTaskExecutor.submit(TASK_ID, this); + } + + @Override + @CallHistoryStatus + public Integer doInBackground(Void... params) { + String numberToQuery = number; + String fieldToQuery = Calls.NUMBER; + String normalizedNumber = PhoneNumberUtils.formatNumberToE164(number, countryIso); + + // If we can normalize the number successfully, look in "normalized_number" + // field instead. Otherwise, look for number in "number" field. + if (!TextUtils.isEmpty(normalizedNumber)) { + numberToQuery = normalizedNumber; + fieldToQuery = Calls.CACHED_NORMALIZED_NUMBER; + } + try (Cursor cursor = + context + .getContentResolver() + .query( + TelecomUtil.getCallLogUri(context), + new String[] {CallLog.Calls._ID}, + fieldToQuery + " = ?", + new String[] {numberToQuery}, + null)) { + return cursor != null && cursor.getCount() > 0 + ? DialerCall.CALL_HISTORY_STATUS_PRESENT + : DialerCall.CALL_HISTORY_STATUS_NOT_PRESENT; + } catch (SQLiteException e) { + LogUtil.e("NumberInCallHistoryTask.doInBackground", "query call log error", e); + return DialerCall.CALL_HISTORY_STATUS_UNKNOWN; + } + } + + @Override + public void onPostExecute(@CallHistoryStatus Integer callHistoryStatus) { + listener.onComplete(callHistoryStatus); + } + + /** Callback for the async task. */ + public interface Listener { + + void onComplete(@CallHistoryStatus int callHistoryStatus); + } +} diff --git a/java/com/android/incallui/spam/SpamCallListListener.java b/java/com/android/incallui/spam/SpamCallListListener.java new file mode 100644 index 000000000..0897842de --- /dev/null +++ b/java/com/android/incallui/spam/SpamCallListListener.java @@ -0,0 +1,364 @@ +/* + * 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.spam; + +import android.app.Notification; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.graphics.drawable.Icon; +import android.telecom.DisconnectCause; +import android.telephony.PhoneNumberUtils; +import android.text.TextUtils; +import com.android.contacts.common.GeoUtil; +import com.android.contacts.common.compat.PhoneNumberUtilsCompat; +import com.android.dialer.blocking.FilteredNumberCompat; +import com.android.dialer.blocking.FilteredNumbersUtil; +import com.android.dialer.common.LogUtil; +import com.android.dialer.logging.Logger; +import com.android.dialer.logging.nano.ContactLookupResult; +import com.android.dialer.logging.nano.DialerImpression; +import com.android.dialer.spam.Spam; +import com.android.incallui.R; +import com.android.incallui.call.CallList; +import com.android.incallui.call.DialerCall; +import com.android.incallui.call.DialerCall.CallHistoryStatus; +import com.android.incallui.call.DialerCall.SessionModificationState; +import java.util.Random; + +/** + * Creates notifications after a call ends if the call matched the criteria (incoming, accepted, + * etc). + */ +public class SpamCallListListener implements CallList.Listener { + + static final int NOTIFICATION_ID = 1; + private static final String TAG = "SpamCallListListener"; + private final Context context; + private final Random random; + + public SpamCallListListener(Context context) { + this.context = context; + this.random = new Random(); + } + + public SpamCallListListener(Context context, Random rand) { + this.context = context; + this.random = rand; + } + + private static String pii(String pii) { + return com.android.incallui.Log.pii(pii); + } + + @Override + public void onIncomingCall(final DialerCall call) { + String number = call.getNumber(); + if (TextUtils.isEmpty(number)) { + return; + } + NumberInCallHistoryTask.Listener listener = + new NumberInCallHistoryTask.Listener() { + @Override + public void onComplete(@CallHistoryStatus int callHistoryStatus) { + call.setCallHistoryStatus(callHistoryStatus); + } + }; + new NumberInCallHistoryTask(context, listener, number, GeoUtil.getCurrentCountryIso(context)) + .submitTask(); + } + + @Override + public void onUpgradeToVideo(DialerCall call) {} + + @Override + public void onSessionModificationStateChange(@SessionModificationState int newState) {} + + @Override + public void onCallListChange(CallList callList) {} + + @Override + public void onWiFiToLteHandover(DialerCall call) {} + + @Override + public void onHandoverToWifiFailed(DialerCall call) {} + + @Override + public void onDisconnect(DialerCall call) { + if (!shouldShowAfterCallNotification(call)) { + return; + } + String e164Number = + PhoneNumberUtils.formatNumberToE164( + call.getNumber(), GeoUtil.getCurrentCountryIso(context)); + if (!FilteredNumbersUtil.canBlockNumber(context, e164Number, call.getNumber()) + || !FilteredNumberCompat.canAttemptBlockOperations(context)) { + return; + } + if (e164Number == null) { + return; + } + showNotification(call); + } + + /** Posts the intent for displaying the after call spam notification to the user. */ + private void showNotification(DialerCall call) { + if (call.isSpam()) { + maybeShowSpamCallNotification(call); + } else { + LogUtil.d(TAG, "Showing not spam notification for number=" + pii(call.getNumber())); + maybeShowNonSpamCallNotification(call); + } + } + + /** Determines if the after call notification should be shown for the specified call. */ + private boolean shouldShowAfterCallNotification(DialerCall call) { + if (!Spam.get(context).isSpamNotificationEnabled()) { + return false; + } + + String number = call.getNumber(); + if (TextUtils.isEmpty(number)) { + return false; + } + + DialerCall.LogState logState = call.getLogState(); + if (!logState.isIncoming) { + return false; + } + + if (logState.duration <= 0) { + return false; + } + + if (logState.contactLookupResult != ContactLookupResult.Type.NOT_FOUND + && logState.contactLookupResult != ContactLookupResult.Type.UNKNOWN_LOOKUP_RESULT_TYPE) { + return false; + } + + int callHistoryStatus = call.getCallHistoryStatus(); + if (callHistoryStatus == DialerCall.CALL_HISTORY_STATUS_PRESENT) { + return false; + } else if (callHistoryStatus == DialerCall.CALL_HISTORY_STATUS_UNKNOWN) { + LogUtil.i(TAG, "DialerCall history status is unknown, returning false"); + return false; + } + + // Check if call disconnected because of either user hanging up + int disconnectCause = call.getDisconnectCause().getCode(); + if (disconnectCause != DisconnectCause.LOCAL && disconnectCause != DisconnectCause.REMOTE) { + return false; + } + + LogUtil.i(TAG, "shouldShowAfterCallNotification, returning true"); + return true; + } + + /** + * Creates a notification builder with properties common among the two after call notifications. + */ + private Notification.Builder createAfterCallNotificationBuilder(DialerCall call) { + return new Notification.Builder(context) + .setContentIntent( + createActivityPendingIntent(call, SpamNotificationActivity.ACTION_SHOW_DIALOG)) + .setCategory(Notification.CATEGORY_STATUS) + .setPriority(Notification.PRIORITY_DEFAULT) + .setColor(context.getColor(R.color.dialer_theme_color)) + .setSmallIcon(R.drawable.ic_call_end_white_24dp); + } + + private CharSequence getDisplayNumber(DialerCall call) { + String formattedNumber = + PhoneNumberUtils.formatNumber(call.getNumber(), GeoUtil.getCurrentCountryIso(context)); + return PhoneNumberUtilsCompat.createTtsSpannable(formattedNumber); + } + + /** Display a notification with two actions: "add contact" and "report spam". */ + private void showNonSpamCallNotification(DialerCall call) { + Notification.Builder notificationBuilder = + createAfterCallNotificationBuilder(call) + .setLargeIcon(Icon.createWithResource(context, R.drawable.unknown_notification_icon)) + .setContentText( + context.getString(R.string.spam_notification_non_spam_call_collapsed_text)) + .setStyle( + new Notification.BigTextStyle() + .bigText( + context.getString(R.string.spam_notification_non_spam_call_expanded_text))) + // Add contact + .addAction( + new Notification.Action.Builder( + R.drawable.ic_person_add_grey600_24dp, + context.getString(R.string.spam_notification_add_contact_action_text), + createActivityPendingIntent( + call, SpamNotificationActivity.ACTION_ADD_TO_CONTACTS)) + .build()) + // Block/report spam + .addAction( + new Notification.Action.Builder( + R.drawable.ic_block_grey600_24dp, + context.getString(R.string.spam_notification_report_spam_action_text), + createBlockReportSpamPendingIntent(call)) + .build()) + .setContentTitle( + context.getString(R.string.non_spam_notification_title, getDisplayNumber(call))); + ((NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE)) + .notify(call.getNumber(), NOTIFICATION_ID, notificationBuilder.build()); + } + + private boolean shouldThrottleSpamNotification() { + int randomNumber = random.nextInt(100); + int thresholdForShowing = Spam.get(context).percentOfSpamNotificationsToShow(); + if (thresholdForShowing == 0) { + LogUtil.d( + TAG, + "shouldThrottleSpamNotification, not showing - percentOfSpamNotificationsToShow is 0"); + return true; + } else if (randomNumber < thresholdForShowing) { + LogUtil.d( + TAG, + "shouldThrottleSpamNotification, showing " + randomNumber + " < " + thresholdForShowing); + return false; + } else { + LogUtil.d( + TAG, + "shouldThrottleSpamNotification, not showing " + + randomNumber + + " >= " + + thresholdForShowing); + return true; + } + } + + private boolean shouldThrottleNonSpamNotification() { + int randomNumber = random.nextInt(100); + int thresholdForShowing = Spam.get(context).percentOfNonSpamNotificationsToShow(); + if (thresholdForShowing == 0) { + LogUtil.d(TAG, "Not showing non spam notification: percentOfNonSpamNotificationsToShow is 0"); + return true; + } else if (randomNumber < thresholdForShowing) { + LogUtil.d( + TAG, "Showing non spam notification: " + randomNumber + " < " + thresholdForShowing); + return false; + } else { + LogUtil.d( + TAG, "Not showing non spam notification:" + randomNumber + " >= " + thresholdForShowing); + return true; + } + } + + private void maybeShowSpamCallNotification(DialerCall call) { + if (shouldThrottleSpamNotification()) { + Logger.get(context) + .logCallImpression( + DialerImpression.Type.SPAM_NOTIFICATION_NOT_SHOWN_AFTER_THROTTLE, + call.getUniqueCallId(), + call.getTimeAddedMs()); + } else { + Logger.get(context) + .logCallImpression( + DialerImpression.Type.SPAM_NOTIFICATION_SHOWN_AFTER_THROTTLE, + call.getUniqueCallId(), + call.getTimeAddedMs()); + showSpamCallNotification(call); + } + } + + private void maybeShowNonSpamCallNotification(DialerCall call) { + if (shouldThrottleNonSpamNotification()) { + Logger.get(context) + .logCallImpression( + DialerImpression.Type.NON_SPAM_NOTIFICATION_NOT_SHOWN_AFTER_THROTTLE, + call.getUniqueCallId(), + call.getTimeAddedMs()); + } else { + Logger.get(context) + .logCallImpression( + DialerImpression.Type.NON_SPAM_NOTIFICATION_SHOWN_AFTER_THROTTLE, + call.getUniqueCallId(), + call.getTimeAddedMs()); + showNonSpamCallNotification(call); + } + } + + /** Display a notification with the action "not spam". */ + private void showSpamCallNotification(DialerCall call) { + Notification.Builder notificationBuilder = + createAfterCallNotificationBuilder(call) + .setLargeIcon(Icon.createWithResource(context, R.drawable.spam_notification_icon)) + .setContentText(context.getString(R.string.spam_notification_spam_call_collapsed_text)) + .setStyle( + new Notification.BigTextStyle() + .bigText(context.getString(R.string.spam_notification_spam_call_expanded_text))) + // Not spam + .addAction( + new Notification.Action.Builder( + R.drawable.ic_close_grey600_24dp, + context.getString(R.string.spam_notification_not_spam_action_text), + createNotSpamPendingIntent(call)) + .build()) + // Block/report spam + .addAction( + new Notification.Action.Builder( + R.drawable.ic_block_grey600_24dp, + context.getString(R.string.spam_notification_block_spam_action_text), + createBlockReportSpamPendingIntent(call)) + .build()) + .setContentTitle( + context.getString(R.string.spam_notification_title, getDisplayNumber(call))); + ((NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE)) + .notify(call.getNumber(), NOTIFICATION_ID, notificationBuilder.build()); + } + + /** + * Creates a pending intent for block/report spam action. If enabled, this intent is forwarded to + * the {@link SpamNotificationActivity}, otherwise to the {@link SpamNotificationService}. + */ + private PendingIntent createBlockReportSpamPendingIntent(DialerCall call) { + String action = SpamNotificationActivity.ACTION_MARK_NUMBER_AS_SPAM; + return Spam.get(context).isDialogEnabledForSpamNotification() + ? createActivityPendingIntent(call, action) + : createServicePendingIntent(call, action); + } + + /** + * Creates a pending intent for not spam action. If enabled, this intent is forwarded to the + * {@link SpamNotificationActivity}, otherwise to the {@link SpamNotificationService}. + */ + private PendingIntent createNotSpamPendingIntent(DialerCall call) { + String action = SpamNotificationActivity.ACTION_MARK_NUMBER_AS_NOT_SPAM; + return Spam.get(context).isDialogEnabledForSpamNotification() + ? createActivityPendingIntent(call, action) + : createServicePendingIntent(call, action); + } + + /** Creates a pending intent for {@link SpamNotificationService}. */ + private PendingIntent createServicePendingIntent(DialerCall call, String action) { + Intent intent = + SpamNotificationService.createServiceIntent(context, call, action, NOTIFICATION_ID); + return PendingIntent.getService( + context, (int) System.currentTimeMillis(), intent, PendingIntent.FLAG_ONE_SHOT); + } + + /** Creates a pending intent for {@link SpamNotificationActivity}. */ + private PendingIntent createActivityPendingIntent(DialerCall call, String action) { + Intent intent = + SpamNotificationActivity.createActivityIntent(context, call, action, NOTIFICATION_ID); + return PendingIntent.getActivity( + context, (int) System.currentTimeMillis(), intent, PendingIntent.FLAG_ONE_SHOT); + } +} diff --git a/java/com/android/incallui/spam/SpamNotificationActivity.java b/java/com/android/incallui/spam/SpamNotificationActivity.java new file mode 100644 index 000000000..88d6bdfda --- /dev/null +++ b/java/com/android/incallui/spam/SpamNotificationActivity.java @@ -0,0 +1,483 @@ +/* + * 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.spam; + +import android.app.AlertDialog; +import android.app.Dialog; +import android.app.NotificationManager; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.os.Bundle; +import android.provider.CallLog; +import android.provider.ContactsContract; +import android.support.v4.app.DialogFragment; +import android.support.v4.app.FragmentActivity; +import com.android.contacts.common.GeoUtil; +import com.android.contacts.common.compat.PhoneNumberUtilsCompat; +import com.android.dialer.blocking.BlockReportSpamDialogs; +import com.android.dialer.blocking.BlockedNumbersMigrator; +import com.android.dialer.blocking.FilteredNumberAsyncQueryHandler; +import com.android.dialer.blocking.FilteredNumberCompat; +import com.android.dialer.common.LogUtil; +import com.android.dialer.logging.Logger; +import com.android.dialer.logging.nano.DialerImpression; +import com.android.dialer.logging.nano.ReportingLocation; +import com.android.dialer.spam.Spam; +import com.android.incallui.R; +import com.android.incallui.call.DialerCall; + +/** Creates the after call notification dialogs. */ +public class SpamNotificationActivity extends FragmentActivity { + + /** Action to add number to contacts. */ + static final String ACTION_ADD_TO_CONTACTS = "com.android.incallui.spam.ACTION_ADD_TO_CONTACTS"; + /** Action to show dialog. */ + static final String ACTION_SHOW_DIALOG = "com.android.incallui.spam.ACTION_SHOW_DIALOG"; + /** Action to mark a number as spam. */ + static final String ACTION_MARK_NUMBER_AS_SPAM = + "com.android.incallui.spam.ACTION_MARK_NUMBER_AS_SPAM"; + /** Action to mark a number as not spam. */ + static final String ACTION_MARK_NUMBER_AS_NOT_SPAM = + "com.android.incallui.spam.ACTION_MARK_NUMBER_AS_NOT_SPAM"; + + private static final String TAG = "SpamNotifications"; + private static final String EXTRA_NOTIFICATION_ID = "notification_id"; + private static final String EXTRA_CALL_INFO = "call_info"; + + private static final String CALL_INFO_KEY_PHONE_NUMBER = "phone_number"; + private static final String CALL_INFO_KEY_IS_SPAM = "is_spam"; + private static final String CALL_INFO_KEY_CALL_ID = "call_id"; + private static final String CALL_INFO_KEY_START_TIME_MILLIS = "call_start_time_millis"; + private static final String CALL_INFO_CONTACT_LOOKUP_RESULT_TYPE = "contact_lookup_result_type"; + private final DialogInterface.OnDismissListener dismissListener = + new DialogInterface.OnDismissListener() { + @Override + public void onDismiss(DialogInterface dialog) { + if (!isFinishing()) { + finish(); + } + } + }; + private FilteredNumberAsyncQueryHandler filteredNumberAsyncQueryHandler; + + /** + * Creates an intent to start this activity. + * + * @return Intent intent that starts this activity. + */ + public static Intent createActivityIntent( + Context context, DialerCall call, String action, int notificationId) { + Intent intent = new Intent(context, SpamNotificationActivity.class); + intent.setAction(action); + // This ensures only one activity of this kind exists at a time. + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + intent.putExtra(EXTRA_NOTIFICATION_ID, notificationId); + intent.putExtra(EXTRA_CALL_INFO, newCallInfoBundle(call)); + return intent; + } + + /** Creates the intent to insert a contact. */ + private static Intent createInsertContactsIntent(String number) { + Intent intent = new Intent(ContactsContract.Intents.Insert.ACTION); + // This ensures that the edit contact number field gets updated if called more than once. + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); + intent.setType(ContactsContract.RawContacts.CONTENT_TYPE); + intent.putExtra(ContactsContract.Intents.Insert.PHONE, number); + return intent; + } + + /** Returns the formatted version of the given number. */ + private static String getFormattedNumber(String number) { + return PhoneNumberUtilsCompat.createTtsSpannable(number).toString(); + } + + private static void logCallImpression(Context context, Bundle bundle, int impression) { + Logger.get(context) + .logCallImpression( + impression, + bundle.getString(CALL_INFO_KEY_CALL_ID), + bundle.getLong(CALL_INFO_KEY_START_TIME_MILLIS, 0)); + } + + private static Bundle newCallInfoBundle(DialerCall call) { + Bundle bundle = new Bundle(); + bundle.putString(CALL_INFO_KEY_PHONE_NUMBER, call.getNumber()); + bundle.putBoolean(CALL_INFO_KEY_IS_SPAM, call.isSpam()); + bundle.putString(CALL_INFO_KEY_CALL_ID, call.getUniqueCallId()); + bundle.putLong(CALL_INFO_KEY_START_TIME_MILLIS, call.getTimeAddedMs()); + bundle.putInt(CALL_INFO_CONTACT_LOOKUP_RESULT_TYPE, call.getLogState().contactLookupResult); + return bundle; + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + LogUtil.i(TAG, "onCreate"); + super.onCreate(savedInstanceState); + setFinishOnTouchOutside(true); + filteredNumberAsyncQueryHandler = new FilteredNumberAsyncQueryHandler(this); + cancelNotification(); + } + + @Override + protected void onResume() { + LogUtil.i(TAG, "onResume"); + super.onResume(); + Intent intent = getIntent(); + String number = getCallInfo().getString(CALL_INFO_KEY_PHONE_NUMBER); + boolean isSpam = getCallInfo().getBoolean(CALL_INFO_KEY_IS_SPAM); + int contactLookupResultType = getCallInfo().getInt(CALL_INFO_CONTACT_LOOKUP_RESULT_TYPE, 0); + switch (intent.getAction()) { + case ACTION_ADD_TO_CONTACTS: + logCallImpression(DialerImpression.Type.SPAM_AFTER_CALL_NOTIFICATION_ADD_TO_CONTACTS); + startActivity(createInsertContactsIntent(number)); + finish(); + break; + case ACTION_MARK_NUMBER_AS_SPAM: + assertDialogsEnabled(); + maybeShowBlockReportSpamDialog(number, contactLookupResultType); + break; + case ACTION_MARK_NUMBER_AS_NOT_SPAM: + assertDialogsEnabled(); + maybeShowNotSpamDialog(number, contactLookupResultType); + break; + case ACTION_SHOW_DIALOG: + if (isSpam) { + showSpamFullDialog(); + } else { + showNonSpamDialog(); + } + break; + } + } + + @Override + protected void onPause() { + LogUtil.d(TAG, "onPause"); + // Finish activity on pause (e.g: orientation change or back button pressed) + filteredNumberAsyncQueryHandler = null; + if (!isFinishing()) { + finish(); + } + super.onPause(); + } + + /** Creates and displays the dialog for whitelisting a number. */ + private void maybeShowNotSpamDialog(final String number, final int contactLookupResultType) { + if (Spam.get(this).isDialogEnabledForSpamNotification()) { + BlockReportSpamDialogs.ReportNotSpamDialogFragment.newInstance( + getFormattedNumber(number), + new BlockReportSpamDialogs.OnConfirmListener() { + @Override + public void onClick() { + reportNotSpamAndFinish(number, contactLookupResultType); + } + }, + dismissListener) + .show(getFragmentManager(), BlockReportSpamDialogs.NOT_SPAM_DIALOG_TAG); + } else { + reportNotSpamAndFinish(number, contactLookupResultType); + } + } + + /** Creates and displays the dialog for blocking/reporting a number as spam. */ + private void maybeShowBlockReportSpamDialog( + final String number, final int contactLookupResultType) { + if (Spam.get(this).isDialogEnabledForSpamNotification()) { + maybeShowBlockNumberMigrationDialog( + new BlockedNumbersMigrator.Listener() { + @Override + public void onComplete() { + BlockReportSpamDialogs.BlockReportSpamDialogFragment.newInstance( + getFormattedNumber(number), + Spam.get(SpamNotificationActivity.this).isDialogReportSpamCheckedByDefault(), + new BlockReportSpamDialogs.OnSpamDialogClickListener() { + @Override + public void onClick(boolean isSpamChecked) { + blockReportNumberAndFinish( + number, isSpamChecked, contactLookupResultType); + } + }, + dismissListener) + .show(getFragmentManager(), BlockReportSpamDialogs.BLOCK_REPORT_SPAM_DIALOG_TAG); + } + }); + } else { + blockReportNumberAndFinish(number, true, contactLookupResultType); + } + } + + /** + * Displays the dialog for the first time unknown calls with actions "Add contact", "Block/report + * spam", and "Dismiss". + */ + private void showNonSpamDialog() { + logCallImpression(DialerImpression.Type.SPAM_AFTER_CALL_NOTIFICATION_SHOW_NON_SPAM_DIALOG); + FirstTimeNonSpamCallDialogFragment.newInstance(getCallInfo()) + .show(getSupportFragmentManager(), FirstTimeNonSpamCallDialogFragment.TAG); + } + + /** + * Displays the dialog for first time spam calls with actions "Not spam", "Block", and "Dismiss". + */ + private void showSpamFullDialog() { + logCallImpression(DialerImpression.Type.SPAM_AFTER_CALL_NOTIFICATION_SHOW_SPAM_DIALOG); + FirstTimeSpamCallDialogFragment.newInstance(getCallInfo()) + .show(getSupportFragmentManager(), FirstTimeSpamCallDialogFragment.TAG); + } + + /** Checks if the user has migrated to the new blocking and display a dialog if necessary. */ + private void maybeShowBlockNumberMigrationDialog(BlockedNumbersMigrator.Listener listener) { + if (!FilteredNumberCompat.maybeShowBlockNumberMigrationDialog( + this, getFragmentManager(), listener)) { + listener.onComplete(); + } + } + + /** Block and report the number as spam. */ + private void blockReportNumberAndFinish( + String number, boolean reportAsSpam, int contactLookupResultType) { + if (reportAsSpam) { + logCallImpression(DialerImpression.Type.SPAM_AFTER_CALL_NOTIFICATION_MARKED_NUMBER_AS_SPAM); + Spam.get(this) + .reportSpamFromAfterCallNotification( + number, + getCountryIso(), + CallLog.Calls.INCOMING_TYPE, + ReportingLocation.Type.FEEDBACK_PROMPT, + contactLookupResultType); + } + + logCallImpression(DialerImpression.Type.SPAM_AFTER_CALL_NOTIFICATION_BLOCK_NUMBER); + filteredNumberAsyncQueryHandler.blockNumber(null, number, getCountryIso()); + // TODO: DialerCall finish() after block/reporting async tasks complete (b/28441936) + finish(); + } + + /** Report the number as not spam. */ + private void reportNotSpamAndFinish(String number, int contactLookupResultType) { + logCallImpression(DialerImpression.Type.SPAM_AFTER_CALL_NOTIFICATION_REPORT_NUMBER_AS_NOT_SPAM); + Spam.get(this) + .reportNotSpamFromAfterCallNotification( + number, + getCountryIso(), + CallLog.Calls.INCOMING_TYPE, + ReportingLocation.Type.FEEDBACK_PROMPT, + contactLookupResultType); + // TODO: DialerCall finish() after async task completes (b/28441936) + finish(); + } + + /** Cancels the notification associated with the number. */ + private void cancelNotification() { + int notificationId = getIntent().getIntExtra(EXTRA_NOTIFICATION_ID, 1); + String number = getCallInfo().getString(CALL_INFO_KEY_PHONE_NUMBER); + ((NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE)) + .cancel(number, notificationId); + } + + private String getCountryIso() { + return GeoUtil.getCurrentCountryIso(this); + } + + private void assertDialogsEnabled() { + if (!Spam.get(this).isDialogEnabledForSpamNotification()) { + throw new IllegalStateException( + "Cannot start this activity with given action because dialogs are not enabled."); + } + } + + private Bundle getCallInfo() { + return getIntent().getBundleExtra(EXTRA_CALL_INFO); + } + + private void logCallImpression(int impression) { + logCallImpression(this, getCallInfo(), impression); + } + + /** Dialog that displays "Not spam", "Block/report spam" and "Dismiss". */ + public static class FirstTimeSpamCallDialogFragment extends DialogFragment { + + public static final String TAG = "FirstTimeSpamDialog"; + + private boolean dismissed; + private Context applicationContext; + + private static DialogFragment newInstance(Bundle bundle) { + FirstTimeSpamCallDialogFragment fragment = new FirstTimeSpamCallDialogFragment(); + fragment.setArguments(bundle); + return fragment; + } + + @Override + public void onPause() { + dismiss(); + super.onPause(); + } + + @Override + public void onDismiss(DialogInterface dialog) { + logCallImpression( + applicationContext, + getArguments(), + DialerImpression.Type.SPAM_AFTER_CALL_NOTIFICATION_ON_DISMISS_SPAM_DIALOG); + super.onDismiss(dialog); + // If dialog was not dismissed by user pressing one of the buttons, finish activity + if (!dismissed && getActivity() != null && !getActivity().isFinishing()) { + getActivity().finish(); + } + } + + @Override + public void onAttach(Context context) { + super.onAttach(context); + applicationContext = context.getApplicationContext(); + } + + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + super.onCreateDialog(savedInstanceState); + final SpamNotificationActivity spamNotificationActivity = + (SpamNotificationActivity) getActivity(); + final String number = getArguments().getString(CALL_INFO_KEY_PHONE_NUMBER); + final int contactLookupResultType = + getArguments().getInt(CALL_INFO_CONTACT_LOOKUP_RESULT_TYPE, 0); + + return new AlertDialog.Builder(getActivity()) + .setCancelable(false) + .setTitle(getString(R.string.spam_notification_title, getFormattedNumber(number))) + .setMessage(getString(R.string.spam_notification_spam_call_expanded_text)) + .setNeutralButton( + getString(R.string.notification_action_dismiss), + new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + dismiss(); + } + }) + .setPositiveButton( + getString(R.string.spam_notification_dialog_was_not_spam_action_text), + new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + dismissed = true; + dismiss(); + spamNotificationActivity.maybeShowNotSpamDialog(number, contactLookupResultType); + } + }) + .setNegativeButton( + getString(R.string.spam_notification_block_spam_action_text), + new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + dismissed = true; + dismiss(); + spamNotificationActivity.maybeShowBlockReportSpamDialog( + number, contactLookupResultType); + } + }) + .create(); + } + } + + /** Dialog that displays "Add contact", "Block/report spam" and "Dismiss". */ + public static class FirstTimeNonSpamCallDialogFragment extends DialogFragment { + + public static final String TAG = "FirstTimeNonSpamDialog"; + + private boolean dismissed; + private Context context; + + private static DialogFragment newInstance(Bundle bundle) { + FirstTimeNonSpamCallDialogFragment fragment = new FirstTimeNonSpamCallDialogFragment(); + fragment.setArguments(bundle); + return fragment; + } + + @Override + public void onPause() { + // Dismiss on pause e.g: orientation change + dismiss(); + super.onPause(); + } + + @Override + public void onDismiss(DialogInterface dialog) { + super.onDismiss(dialog); + logCallImpression( + context, + getArguments(), + DialerImpression.Type.SPAM_AFTER_CALL_NOTIFICATION_ON_DISMISS_NON_SPAM_DIALOG); + // If dialog was not dismissed by user pressing one of the buttons, finish activity + if (!dismissed && getActivity() != null && !getActivity().isFinishing()) { + getActivity().finish(); + } + } + + @Override + public void onAttach(Context context) { + super.onAttach(context); + this.context = context.getApplicationContext(); + } + + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + super.onCreateDialog(savedInstanceState); + final SpamNotificationActivity spamNotificationActivity = + (SpamNotificationActivity) getActivity(); + final String number = getArguments().getString(CALL_INFO_KEY_PHONE_NUMBER); + final int contactLookupResultType = + getArguments().getInt(CALL_INFO_CONTACT_LOOKUP_RESULT_TYPE, 0); + return new AlertDialog.Builder(getActivity()) + .setTitle(getString(R.string.non_spam_notification_title, getFormattedNumber(number))) + .setCancelable(false) + .setMessage(getString(R.string.spam_notification_non_spam_call_expanded_text)) + .setNeutralButton( + getString(R.string.notification_action_dismiss), + new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + dismiss(); + } + }) + .setPositiveButton( + getString(R.string.spam_notification_dialog_add_contact_action_text), + new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + dismissed = true; + dismiss(); + startActivity(createInsertContactsIntent(number)); + } + }) + .setNegativeButton( + getString(R.string.spam_notification_dialog_block_report_spam_action_text), + new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + dismissed = true; + dismiss(); + spamNotificationActivity.maybeShowBlockReportSpamDialog( + number, contactLookupResultType); + } + }) + .create(); + } + } +} diff --git a/java/com/android/incallui/spam/SpamNotificationService.java b/java/com/android/incallui/spam/SpamNotificationService.java new file mode 100644 index 000000000..bf107f789 --- /dev/null +++ b/java/com/android/incallui/spam/SpamNotificationService.java @@ -0,0 +1,132 @@ +/* + * 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.spam; + +import android.app.NotificationManager; +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.os.IBinder; +import android.provider.CallLog; +import android.support.annotation.Nullable; +import com.android.contacts.common.GeoUtil; +import com.android.dialer.blocking.FilteredNumberAsyncQueryHandler; +import com.android.dialer.common.LogUtil; +import com.android.dialer.logging.Logger; +import com.android.dialer.logging.nano.DialerImpression; +import com.android.dialer.logging.nano.ReportingLocation; +import com.android.dialer.spam.Spam; +import com.android.incallui.call.DialerCall; + +/** + * This service determines if the device is locked/unlocked and takes an action based on the state. + * A service is used to to determine this, as opposed to an activity, because the user must unlock + * the device before a notification can start an activity. This is not the case for a service, and + * intents can be sent to this service even from the lock screen. This allows users to quickly + * report a number as spam or not spam from their lock screen. + */ +public class SpamNotificationService extends Service { + + private static final String TAG = "SpamNotificationSvc"; + + private static final String EXTRA_PHONE_NUMBER = "service_phone_number"; + private static final String EXTRA_CALL_ID = "service_call_id"; + private static final String EXTRA_CALL_START_TIME_MILLIS = "service_call_start_time_millis"; + private static final String EXTRA_NOTIFICATION_ID = "service_notification_id"; + private static final String EXTRA_CONTACT_LOOKUP_RESULT_TYPE = + "service_contact_lookup_result_type"; + /** Creates an intent to start this service. */ + public static Intent createServiceIntent( + Context context, DialerCall call, String action, int notificationId) { + Intent intent = new Intent(context, SpamNotificationService.class); + intent.setAction(action); + intent.putExtra(EXTRA_PHONE_NUMBER, call.getNumber()); + intent.putExtra(EXTRA_CALL_ID, call.getUniqueCallId()); + intent.putExtra(EXTRA_CALL_START_TIME_MILLIS, call.getTimeAddedMs()); + intent.putExtra(EXTRA_NOTIFICATION_ID, notificationId); + intent.putExtra(EXTRA_CONTACT_LOOKUP_RESULT_TYPE, call.getLogState().contactLookupResult); + return intent; + } + + @Nullable + @Override + public IBinder onBind(Intent intent) { + // Return null because clients cannot bind to this service + return null; + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + LogUtil.d(TAG, "onStartCommand"); + if (intent == null) { + LogUtil.d(TAG, "Null intent"); + stopSelf(); + // Return {@link #START_NOT_STICKY} so service is not restarted. + return START_NOT_STICKY; + } + String number = intent.getStringExtra(EXTRA_PHONE_NUMBER); + int notificationId = intent.getIntExtra(EXTRA_NOTIFICATION_ID, 1); + String countryIso = GeoUtil.getCurrentCountryIso(this); + int contactLookupResultType = intent.getIntExtra(EXTRA_CONTACT_LOOKUP_RESULT_TYPE, 0); + + ((NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE)) + .cancel(number, notificationId); + + switch (intent.getAction()) { + case SpamNotificationActivity.ACTION_MARK_NUMBER_AS_SPAM: + logCallImpression( + intent, DialerImpression.Type.SPAM_NOTIFICATION_SERVICE_ACTION_MARK_NUMBER_AS_SPAM); + Spam.get(this) + .reportSpamFromAfterCallNotification( + number, + countryIso, + CallLog.Calls.INCOMING_TYPE, + ReportingLocation.Type.FEEDBACK_PROMPT, + contactLookupResultType); + new FilteredNumberAsyncQueryHandler(this).blockNumber(null, number, countryIso); + break; + case SpamNotificationActivity.ACTION_MARK_NUMBER_AS_NOT_SPAM: + logCallImpression( + intent, DialerImpression.Type.SPAM_NOTIFICATION_SERVICE_ACTION_MARK_NUMBER_AS_NOT_SPAM); + Spam.get(this) + .reportNotSpamFromAfterCallNotification( + number, + countryIso, + CallLog.Calls.INCOMING_TYPE, + ReportingLocation.Type.FEEDBACK_PROMPT, + contactLookupResultType); + break; + } + // TODO: call stopSelf() after async tasks complete (b/28441936) + stopSelf(); + return START_NOT_STICKY; + } + + @Override + public void onDestroy() { + super.onDestroy(); + LogUtil.d(TAG, "onDestroy"); + } + + private void logCallImpression(Intent intent, int impression) { + Logger.get(this) + .logCallImpression( + impression, + intent.getStringExtra(EXTRA_CALL_ID), + intent.getLongExtra(EXTRA_CALL_START_TIME_MILLIS, 0)); + } +} diff --git a/java/com/android/incallui/util/AccessibilityUtil.java b/java/com/android/incallui/util/AccessibilityUtil.java new file mode 100644 index 000000000..65753484a --- /dev/null +++ b/java/com/android/incallui/util/AccessibilityUtil.java @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2013 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.util; + +import android.content.Context; +import android.view.accessibility.AccessibilityManager; + +public class AccessibilityUtil { + + public static boolean isAccessibilityEnabled(Context context) { + AccessibilityManager accessibilityManager = + context.getSystemService(AccessibilityManager.class); + return accessibilityManager.isEnabled(); + } + + public static boolean isTouchExplorationEnabled(Context context) { + AccessibilityManager accessibilityManager = + context.getSystemService(AccessibilityManager.class); + return accessibilityManager.isTouchExplorationEnabled(); + } +} diff --git a/java/com/android/incallui/util/TelecomCallUtil.java b/java/com/android/incallui/util/TelecomCallUtil.java new file mode 100644 index 000000000..8855543b1 --- /dev/null +++ b/java/com/android/incallui/util/TelecomCallUtil.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.util; + +import android.net.Uri; +import android.telecom.Call; +import android.telephony.PhoneNumberUtils; + +/** + * Class to provide a standard interface for obtaining information from the underlying + * android.telecom.Call. Much of this should be obtained through the incall.Call, but on occasion we + * need to interact with the telecom.Call directly (eg. call blocking, before the incall.Call has + * been created). + */ +public class TelecomCallUtil { + + // Whether the call handle is an emergency number. + public static boolean isEmergencyCall(Call call) { + Uri handle = call.getDetails().getHandle(); + return PhoneNumberUtils.isEmergencyNumber(handle == null ? "" : handle.getSchemeSpecificPart()); + } + + public static String getNumber(Call call) { + if (call == null) { + return null; + } + if (call.getDetails().getGatewayInfo() != null) { + return call.getDetails().getGatewayInfo().getOriginalAddress().getSchemeSpecificPart(); + } + Uri handle = getHandle(call); + return handle == null ? null : handle.getSchemeSpecificPart(); + } + + public static Uri getHandle(Call call) { + return call == null ? null : call.getDetails().getHandle(); + } +} diff --git a/java/com/android/incallui/video/bindings/VideoBindings.java b/java/com/android/incallui/video/bindings/VideoBindings.java new file mode 100644 index 000000000..934ff078a --- /dev/null +++ b/java/com/android/incallui/video/bindings/VideoBindings.java @@ -0,0 +1,28 @@ +/* + * 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.video.bindings; + +import com.android.incallui.video.impl.VideoCallFragment; +import com.android.incallui.video.protocol.VideoCallScreen; + +/** Bindings for video module. */ +public class VideoBindings { + + public static VideoCallScreen createVideoCallScreen() { + return new VideoCallFragment(); + } +} diff --git a/java/com/android/incallui/video/impl/AndroidManifest.xml b/java/com/android/incallui/video/impl/AndroidManifest.xml new file mode 100644 index 000000000..a36828e29 --- /dev/null +++ b/java/com/android/incallui/video/impl/AndroidManifest.xml @@ -0,0 +1,3 @@ +<manifest + package="com.android.incallui.video.impl"> +</manifest> diff --git a/java/com/android/incallui/video/impl/CameraPermissionDialogFragment.java b/java/com/android/incallui/video/impl/CameraPermissionDialogFragment.java new file mode 100644 index 000000000..291fce4a0 --- /dev/null +++ b/java/com/android/incallui/video/impl/CameraPermissionDialogFragment.java @@ -0,0 +1,62 @@ +/* + * 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.video.impl; + +import android.app.AlertDialog; +import android.app.Dialog; +import android.content.DialogInterface; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.v4.app.DialogFragment; +import com.android.dialer.common.FragmentUtils; + +/** Dialog fragment to ask for camera permission from user. */ +public class CameraPermissionDialogFragment extends DialogFragment { + + static CameraPermissionDialogFragment newInstance() { + CameraPermissionDialogFragment fragment = new CameraPermissionDialogFragment(); + return fragment; + } + + @NonNull + @Override + public Dialog onCreateDialog(Bundle bundle) { + return new AlertDialog.Builder(getContext()) + .setTitle(R.string.camera_permission_dialog_title) + .setMessage(R.string.camera_permission_dialog_message) + .setPositiveButton( + R.string.camera_permission_dialog_positive_button, + new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + VideoCallFragment fragment = + FragmentUtils.getParentUnsafe( + CameraPermissionDialogFragment.this, VideoCallFragment.class); + fragment.onCameraPermissionGranted(); + } + }) + .setNegativeButton( + R.string.camera_permission_dialog_negative_button, + new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + dialog.dismiss(); + } + }) + .create(); + } +} diff --git a/java/com/android/incallui/video/impl/CheckableImageButton.java b/java/com/android/incallui/video/impl/CheckableImageButton.java new file mode 100644 index 000000000..320f0571a --- /dev/null +++ b/java/com/android/incallui/video/impl/CheckableImageButton.java @@ -0,0 +1,222 @@ +/* + * 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.video.impl; + +import android.content.Context; +import android.content.res.TypedArray; +import android.os.Parcel; +import android.os.Parcelable; +import android.util.AttributeSet; +import android.view.SoundEffectConstants; +import android.widget.Checkable; +import android.widget.ImageButton; + +/** Image button that maintains a checked state. */ +public class CheckableImageButton extends ImageButton implements Checkable { + + private static final int[] CHECKED_STATE_SET = {android.R.attr.state_checked}; + + /** Callback interface to notify when the button's checked state has changed */ + public interface OnCheckedChangeListener { + + void onCheckedChanged(CheckableImageButton button, boolean isChecked); + } + + private boolean broadcasting; + private boolean isChecked; + private OnCheckedChangeListener onCheckedChangeListener; + private CharSequence contentDescriptionChecked; + private CharSequence contentDescriptionUnchecked; + + public CheckableImageButton(Context context) { + this(context, null); + } + + public CheckableImageButton(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public CheckableImageButton(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(context, attrs); + } + + private void init(Context context, AttributeSet attrs) { + TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.CheckableImageButton); + setChecked(typedArray.getBoolean(R.styleable.CheckableImageButton_android_checked, false)); + contentDescriptionChecked = + typedArray.getText(R.styleable.CheckableImageButton_contentDescriptionChecked); + contentDescriptionUnchecked = + typedArray.getText(R.styleable.CheckableImageButton_contentDescriptionUnchecked); + typedArray.recycle(); + + updateContentDescription(); + setClickable(true); + setFocusable(true); + } + + @Override + public void setChecked(boolean checked) { + performSetChecked(checked); + } + + /** + * Called when the state of the button should be updated, this should not be the result of user + * interaction. + * + * @param checked {@code true} if the button should be in the checked state, {@code false} + * otherwise. + */ + private void performSetChecked(boolean checked) { + if (isChecked() == checked) { + return; + } + isChecked = checked; + CharSequence contentDescription = updateContentDescription(); + announceForAccessibility(contentDescription); + refreshDrawableState(); + } + + private CharSequence updateContentDescription() { + CharSequence contentDescription = + isChecked ? contentDescriptionChecked : contentDescriptionUnchecked; + setContentDescription(contentDescription); + return contentDescription; + } + + /** + * Called when the user interacts with a button. This should not result in the button updating + * state, rather the request should be propagated to the associated listener. + * + * @param checked {@code true} if the button should be in the checked state, {@code false} + * otherwise. + */ + private void userRequestedSetChecked(boolean checked) { + if (isChecked() == checked) { + return; + } + if (broadcasting) { + return; + } + broadcasting = true; + if (onCheckedChangeListener != null) { + onCheckedChangeListener.onCheckedChanged(this, checked); + } + broadcasting = false; + } + + @Override + public boolean isChecked() { + return isChecked; + } + + @Override + public void toggle() { + userRequestedSetChecked(!isChecked()); + } + + @Override + public int[] onCreateDrawableState(int extraSpace) { + final int[] drawableState = super.onCreateDrawableState(extraSpace + 1); + if (isChecked()) { + mergeDrawableStates(drawableState, CHECKED_STATE_SET); + } + return drawableState; + } + + @Override + protected void drawableStateChanged() { + super.drawableStateChanged(); + invalidate(); + } + + public void setOnCheckedChangeListener(OnCheckedChangeListener listener) { + this.onCheckedChangeListener = listener; + } + + @Override + public boolean performClick() { + if (!isCheckable()) { + return super.performClick(); + } + + toggle(); + final boolean handled = super.performClick(); + if (!handled) { + // View only makes a sound effect if the onClickListener was + // called, so we'll need to make one here instead. + playSoundEffect(SoundEffectConstants.CLICK); + } + return handled; + } + + private boolean isCheckable() { + return onCheckedChangeListener != null; + } + + @Override + protected void onRestoreInstanceState(Parcelable state) { + SavedState savedState = (SavedState) state; + super.onRestoreInstanceState(savedState.getSuperState()); + performSetChecked(savedState.isChecked); + requestLayout(); + } + + @Override + protected Parcelable onSaveInstanceState() { + return new SavedState(isChecked(), super.onSaveInstanceState()); + } + + private static class SavedState extends BaseSavedState { + + public final boolean isChecked; + + private SavedState(boolean isChecked, Parcelable superState) { + super(superState); + this.isChecked = isChecked; + } + + protected SavedState(Parcel in) { + super(in); + isChecked = in.readByte() != 0; + } + + public static final Creator<SavedState> CREATOR = + new Creator<SavedState>() { + @Override + public SavedState createFromParcel(Parcel in) { + return new SavedState(in); + } + + @Override + public SavedState[] newArray(int size) { + return new SavedState[size]; + } + }; + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + super.writeToParcel(dest, flags); + dest.writeByte((byte) (isChecked ? 1 : 0)); + } + } +} diff --git a/java/com/android/incallui/video/impl/SpeakerButtonController.java b/java/com/android/incallui/video/impl/SpeakerButtonController.java new file mode 100644 index 000000000..e12032abf --- /dev/null +++ b/java/com/android/incallui/video/impl/SpeakerButtonController.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.video.impl; + +import android.support.annotation.DrawableRes; +import android.support.annotation.NonNull; +import android.support.annotation.StringRes; +import android.telecom.CallAudioState; +import android.view.View; +import android.view.View.OnClickListener; +import com.android.dialer.common.Assert; +import com.android.dialer.common.LogUtil; +import com.android.incallui.incall.protocol.InCallButtonUiDelegate; +import com.android.incallui.video.impl.CheckableImageButton.OnCheckedChangeListener; +import com.android.incallui.video.protocol.VideoCallScreenDelegate; + +/** Manages a single button. */ +public class SpeakerButtonController implements OnCheckedChangeListener, OnClickListener { + + @NonNull private final InCallButtonUiDelegate inCallButtonUiDelegate; + @NonNull private final VideoCallScreenDelegate videoCallScreenDelegate; + + @NonNull private CheckableImageButton button; + + @DrawableRes private int icon = R.drawable.quantum_ic_volume_up_white_36; + + private boolean isChecked; + private boolean checkable; + private boolean isEnabled; + private CharSequence contentDescription; + + public SpeakerButtonController( + @NonNull CheckableImageButton button, + @NonNull InCallButtonUiDelegate inCallButtonUiDelegate, + @NonNull VideoCallScreenDelegate videoCallScreenDelegate) { + this.inCallButtonUiDelegate = Assert.isNotNull(inCallButtonUiDelegate); + this.videoCallScreenDelegate = Assert.isNotNull(videoCallScreenDelegate); + this.button = Assert.isNotNull(button); + } + + public void setEnabled(boolean isEnabled) { + this.isEnabled = isEnabled; + } + + public void updateButtonState() { + button.setVisibility(View.VISIBLE); + button.setEnabled(isEnabled); + button.setChecked(isChecked); + button.setOnClickListener(checkable ? null : this); + button.setOnCheckedChangeListener(checkable ? this : null); + button.setImageResource(icon); + button.setContentDescription(contentDescription); + } + + public void setAudioState(CallAudioState audioState) { + LogUtil.i("SpeakerButtonController.setSupportedAudio", "audioState: " + audioState); + + @StringRes int contentDescriptionResId; + if ((audioState.getSupportedRouteMask() & CallAudioState.ROUTE_BLUETOOTH) + == CallAudioState.ROUTE_BLUETOOTH) { + checkable = false; + isChecked = false; + + if ((audioState.getRoute() & CallAudioState.ROUTE_BLUETOOTH) + == CallAudioState.ROUTE_BLUETOOTH) { + icon = R.drawable.quantum_ic_bluetooth_audio_white_36; + contentDescriptionResId = R.string.incall_content_description_bluetooth; + } else if ((audioState.getRoute() & CallAudioState.ROUTE_SPEAKER) + == CallAudioState.ROUTE_SPEAKER) { + icon = R.drawable.quantum_ic_volume_up_white_36; + contentDescriptionResId = R.string.incall_content_description_speaker; + } else if ((audioState.getRoute() & CallAudioState.ROUTE_WIRED_HEADSET) + == CallAudioState.ROUTE_WIRED_HEADSET) { + icon = R.drawable.quantum_ic_headset_white_36; + contentDescriptionResId = R.string.incall_content_description_headset; + } else { + icon = R.drawable.ic_phone_audio_white_36dp; + contentDescriptionResId = R.string.incall_content_description_earpiece; + } + } else { + checkable = true; + isChecked = audioState.getRoute() == CallAudioState.ROUTE_SPEAKER; + icon = R.drawable.quantum_ic_volume_up_white_36; + contentDescriptionResId = R.string.incall_content_description_speaker; + } + + contentDescription = button.getContext().getText(contentDescriptionResId); + updateButtonState(); + } + + @Override + public void onCheckedChanged(CheckableImageButton button, boolean isChecked) { + LogUtil.i("SpeakerButtonController.onCheckedChanged", null); + inCallButtonUiDelegate.toggleSpeakerphone(); + videoCallScreenDelegate.resetAutoFullscreenTimer(); + } + + @Override + public void onClick(View view) { + LogUtil.i("SpeakerButtonController.onClick", null); + inCallButtonUiDelegate.showAudioRouteSelector(); + videoCallScreenDelegate.resetAutoFullscreenTimer(); + } +} diff --git a/java/com/android/incallui/video/impl/SwitchOnHoldCallController.java b/java/com/android/incallui/video/impl/SwitchOnHoldCallController.java new file mode 100644 index 000000000..372b56b4e --- /dev/null +++ b/java/com/android/incallui/video/impl/SwitchOnHoldCallController.java @@ -0,0 +1,91 @@ +/* + * 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.video.impl; + +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.view.View; +import android.view.View.OnClickListener; +import com.android.dialer.common.Assert; +import com.android.incallui.incall.protocol.InCallScreenDelegate; +import com.android.incallui.incall.protocol.SecondaryInfo; +import com.android.incallui.video.protocol.VideoCallScreenDelegate; + +/** Manages the swap button and on hold banner. */ +public class SwitchOnHoldCallController implements OnClickListener { + + @NonNull private InCallScreenDelegate inCallScreenDelegate; + @NonNull private VideoCallScreenDelegate videoCallScreenDelegate; + + @NonNull private View switchOnHoldButton; + + @NonNull private View onHoldBanner; + + private boolean isVisible; + + private boolean isEnabled; + + @Nullable private SecondaryInfo secondaryInfo; + + public SwitchOnHoldCallController( + @NonNull View switchOnHoldButton, + @NonNull View onHoldBanner, + @NonNull InCallScreenDelegate inCallScreenDelegate, + @NonNull VideoCallScreenDelegate videoCallScreenDelegate) { + this.switchOnHoldButton = Assert.isNotNull(switchOnHoldButton); + switchOnHoldButton.setOnClickListener(this); + this.onHoldBanner = Assert.isNotNull(onHoldBanner); + this.inCallScreenDelegate = Assert.isNotNull(inCallScreenDelegate); + this.videoCallScreenDelegate = Assert.isNotNull(videoCallScreenDelegate); + } + + public void setEnabled(boolean isEnabled) { + this.isEnabled = isEnabled; + updateButtonState(); + } + + public void setVisible(boolean isVisible) { + this.isVisible = isVisible; + updateButtonState(); + } + + public void setOnScreen() { + isVisible = hasSecondaryInfo(); + updateButtonState(); + } + + public void setSecondaryInfo(@Nullable SecondaryInfo secondaryInfo) { + this.secondaryInfo = secondaryInfo; + isVisible = hasSecondaryInfo(); + } + + private boolean hasSecondaryInfo() { + return secondaryInfo != null && secondaryInfo.shouldShow; + } + + public void updateButtonState() { + switchOnHoldButton.setEnabled(isEnabled); + switchOnHoldButton.setVisibility(isVisible ? View.VISIBLE : View.INVISIBLE); + onHoldBanner.setVisibility(isVisible ? View.VISIBLE : View.INVISIBLE); + } + + @Override + public void onClick(View view) { + inCallScreenDelegate.onSecondaryInfoClicked(); + videoCallScreenDelegate.resetAutoFullscreenTimer(); + } +} diff --git a/java/com/android/incallui/video/impl/VideoCallFragment.java b/java/com/android/incallui/video/impl/VideoCallFragment.java new file mode 100644 index 000000000..77a67d032 --- /dev/null +++ b/java/com/android/incallui/video/impl/VideoCallFragment.java @@ -0,0 +1,1215 @@ +/* + * 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.video.impl; + +import android.Manifest.permission; +import android.content.Context; +import android.content.pm.PackageManager; +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.Outline; +import android.graphics.Point; +import android.graphics.drawable.Animatable; +import android.os.Bundle; +import android.os.SystemClock; +import android.renderscript.Allocation; +import android.renderscript.Element; +import android.renderscript.RenderScript; +import android.renderscript.ScriptIntrinsicBlur; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v4.app.Fragment; +import android.support.v4.app.FragmentTransaction; +import android.support.v4.view.animation.FastOutLinearInInterpolator; +import android.support.v4.view.animation.LinearOutSlowInInterpolator; +import android.telecom.CallAudioState; +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.view.Surface; +import android.view.TextureView; +import android.view.View; +import android.view.View.OnClickListener; +import android.view.View.OnSystemUiVisibilityChangeListener; +import android.view.ViewGroup; +import android.view.ViewGroup.MarginLayoutParams; +import android.view.ViewOutlineProvider; +import android.view.ViewTreeObserver; +import android.view.accessibility.AccessibilityEvent; +import android.view.animation.AccelerateDecelerateInterpolator; +import android.view.animation.Interpolator; +import android.widget.ImageButton; +import android.widget.ImageView; +import android.widget.RelativeLayout; +import android.widget.TextView; +import com.android.dialer.common.Assert; +import com.android.dialer.common.FragmentUtils; +import com.android.dialer.common.LogUtil; +import com.android.dialer.compat.ActivityCompat; +import com.android.incallui.audioroute.AudioRouteSelectorDialogFragment; +import com.android.incallui.audioroute.AudioRouteSelectorDialogFragment.AudioRouteSelectorPresenter; +import com.android.incallui.call.VideoUtils; +import com.android.incallui.contactgrid.ContactGridManager; +import com.android.incallui.hold.OnHoldFragment; +import com.android.incallui.incall.protocol.InCallButtonIds; +import com.android.incallui.incall.protocol.InCallButtonIdsExtension; +import com.android.incallui.incall.protocol.InCallButtonUi; +import com.android.incallui.incall.protocol.InCallButtonUiDelegate; +import com.android.incallui.incall.protocol.InCallButtonUiDelegateFactory; +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.video.impl.CheckableImageButton.OnCheckedChangeListener; +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; +import com.android.incallui.videosurface.protocol.VideoSurfaceTexture; + +/** Contains UI elements for a video call. */ +public class VideoCallFragment extends Fragment + implements InCallScreen, + InCallButtonUi, + VideoCallScreen, + OnClickListener, + OnCheckedChangeListener, + AudioRouteSelectorPresenter, + OnSystemUiVisibilityChangeListener { + + private static final float BLUR_PREVIEW_RADIUS = 16.0f; + private static final float BLUR_PREVIEW_SCALE_FACTOR = 1.0f; + private static final float BLUR_REMOTE_RADIUS = 25.0f; + private static final float BLUR_REMOTE_SCALE_FACTOR = 0.25f; + private static final float ASPECT_RATIO_MATCH_THRESHOLD = 0.2f; + + private static final int CAMERA_PERMISSION_REQUEST_CODE = 1; + private static final String CAMERA_PERMISSION_DIALOG_FRAMENT_TAG = + "CameraPermissionDialogFragment"; + private static final long CAMERA_PERMISSION_DIALOG_DELAY_IN_MILLIS = 2000L; + private static final long VIDEO_OFF_VIEW_FADE_OUT_DELAY_IN_MILLIS = 2000L; + + private final ViewOutlineProvider circleOutlineProvider = + new ViewOutlineProvider() { + @Override + public void getOutline(View view, Outline outline) { + int x = view.getWidth() / 2; + int y = view.getHeight() / 2; + int radius = Math.min(x, y); + outline.setOval(x - radius, y - radius, x + radius, y + radius); + } + }; + private InCallScreenDelegate inCallScreenDelegate; + private VideoCallScreenDelegate videoCallScreenDelegate; + private InCallButtonUiDelegate inCallButtonUiDelegate; + private View endCallButton; + private CheckableImageButton speakerButton; + private SpeakerButtonController speakerButtonController; + private CheckableImageButton muteButton; + private CheckableImageButton cameraOffButton; + private ImageButton swapCameraButton; + private View switchOnHoldButton; + private View onHoldContainer; + private SwitchOnHoldCallController switchOnHoldCallController; + private TextView remoteVideoOff; + private ImageView remoteOffBlurredImageView; + private View mutePreviewOverlay; + private View previewOffOverlay; + private ImageView previewOffBlurredImageView; + private View controls; + private View controlsContainer; + private TextureView previewTextureView; + private TextureView remoteTextureView; + private View greenScreenBackgroundView; + private View fullscreenBackgroundView; + private boolean shouldShowRemote; + private boolean shouldShowPreview; + private boolean isInFullscreenMode; + private boolean isInGreenScreenMode; + private boolean hasInitializedScreenModes; + private boolean isRemotelyHeld; + private ContactGridManager contactGridManager; + private SecondaryInfo savedSecondaryInfo; + private final Runnable cameraPermissionDialogRunnable = + new Runnable() { + @Override + public void run() { + if (videoCallScreenDelegate.shouldShowCameraPermissionDialog()) { + LogUtil.i("VideoCallFragment.cameraPermissionDialogRunnable", "showing dialog"); + checkCameraPermission(); + } + } + }; + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + LogUtil.i("VideoCallFragment.onCreate", null); + + inCallButtonUiDelegate = + FragmentUtils.getParent(this, InCallButtonUiDelegateFactory.class) + .newInCallButtonUiDelegate(); + if (savedInstanceState != null) { + inCallButtonUiDelegate.onRestoreInstanceState(savedInstanceState); + } + } + + @Override + public void onRequestPermissionsResult( + int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + if (requestCode == CAMERA_PERMISSION_REQUEST_CODE) { + if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + LogUtil.i("VideoCallFragment.onRequestPermissionsResult", "Camera permission granted."); + videoCallScreenDelegate.onCameraPermissionGranted(); + } else { + LogUtil.i("VideoCallFragment.onRequestPermissionsResult", "Camera permission denied."); + } + } + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + } + + @Nullable + @Override + public View onCreateView( + LayoutInflater layoutInflater, @Nullable ViewGroup viewGroup, @Nullable Bundle bundle) { + LogUtil.i("VideoCallFragment.onCreateView", null); + + View view = + layoutInflater.inflate( + isLandscape() ? R.layout.frag_videocall_land : R.layout.frag_videocall, + viewGroup, + false); + contactGridManager = + new ContactGridManager(view, null /* no avatar */, 0, false /* showAnonymousAvatar */); + + controls = view.findViewById(R.id.videocall_video_controls); + controls.setVisibility( + ActivityCompat.isInMultiWindowMode(getActivity()) ? View.GONE : View.VISIBLE); + controlsContainer = view.findViewById(R.id.videocall_video_controls_container); + speakerButton = (CheckableImageButton) view.findViewById(R.id.videocall_speaker_button); + muteButton = (CheckableImageButton) view.findViewById(R.id.videocall_mute_button); + muteButton.setOnCheckedChangeListener(this); + mutePreviewOverlay = view.findViewById(R.id.videocall_video_preview_mute_overlay); + cameraOffButton = (CheckableImageButton) view.findViewById(R.id.videocall_mute_video); + cameraOffButton.setOnCheckedChangeListener(this); + previewOffOverlay = view.findViewById(R.id.videocall_video_preview_off_overlay); + previewOffBlurredImageView = + (ImageView) view.findViewById(R.id.videocall_preview_off_blurred_image_view); + swapCameraButton = (ImageButton) view.findViewById(R.id.videocall_switch_video); + swapCameraButton.setOnClickListener(this); + view.findViewById(R.id.videocall_switch_controls) + .setVisibility( + ActivityCompat.isInMultiWindowMode(getActivity()) ? View.GONE : View.VISIBLE); + switchOnHoldButton = view.findViewById(R.id.videocall_switch_on_hold); + onHoldContainer = view.findViewById(R.id.videocall_on_hold_banner); + remoteVideoOff = (TextView) view.findViewById(R.id.videocall_remote_video_off); + remoteVideoOff.setAccessibilityLiveRegion(View.ACCESSIBILITY_LIVE_REGION_POLITE); + remoteOffBlurredImageView = + (ImageView) view.findViewById(R.id.videocall_remote_off_blurred_image_view); + endCallButton = view.findViewById(R.id.videocall_end_call); + endCallButton.setOnClickListener(this); + previewTextureView = (TextureView) view.findViewById(R.id.videocall_video_preview); + previewTextureView.setClipToOutline(true); + previewOffOverlay.setOnClickListener( + new OnClickListener() { + @Override + public void onClick(View v) { + checkCameraPermission(); + } + }); + remoteTextureView = (TextureView) view.findViewById(R.id.videocall_video_remote); + greenScreenBackgroundView = view.findViewById(R.id.videocall_green_screen_background); + fullscreenBackgroundView = view.findViewById(R.id.videocall_fullscreen_background); + + // We need the texture view size to be able to scale the remote video. At this point the view + // layout won't be complete so add a layout listener. + ViewTreeObserver observer = remoteTextureView.getViewTreeObserver(); + observer.addOnGlobalLayoutListener( + new ViewTreeObserver.OnGlobalLayoutListener() { + @Override + public void onGlobalLayout() { + LogUtil.i("VideoCallFragment.onGlobalLayout", null); + updateRemoteVideoScaling(); + updatePreviewVideoScaling(); + updateVideoOffViews(); + // Remove the listener so we don't continually re-layout. + ViewTreeObserver observer = remoteTextureView.getViewTreeObserver(); + if (observer.isAlive()) { + observer.removeOnGlobalLayoutListener(this); + } + } + }); + + return view; + } + + @Override + public void onViewCreated(View view, @Nullable Bundle bundle) { + super.onViewCreated(view, bundle); + LogUtil.i("VideoCallFragment.onViewCreated", null); + + inCallScreenDelegate = + FragmentUtils.getParentUnsafe(this, InCallScreenDelegateFactory.class) + .newInCallScreenDelegate(); + videoCallScreenDelegate = + FragmentUtils.getParentUnsafe(this, VideoCallScreenDelegateFactory.class) + .newVideoCallScreenDelegate(); + + speakerButtonController = + new SpeakerButtonController(speakerButton, inCallButtonUiDelegate, videoCallScreenDelegate); + switchOnHoldCallController = + new SwitchOnHoldCallController( + switchOnHoldButton, onHoldContainer, inCallScreenDelegate, videoCallScreenDelegate); + + videoCallScreenDelegate.initVideoCallScreenDelegate(getContext(), this); + + inCallScreenDelegate.onInCallScreenDelegateInit(this); + inCallScreenDelegate.onInCallScreenReady(); + inCallButtonUiDelegate.onInCallButtonUiReady(this); + + view.setOnSystemUiVisibilityChangeListener(this); + } + + @Override + public void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + inCallButtonUiDelegate.onSaveInstanceState(outState); + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + LogUtil.i("VideoCallFragment.onDestroyView", null); + inCallButtonUiDelegate.onInCallButtonUiUnready(); + inCallScreenDelegate.onInCallScreenUnready(); + } + + @Override + public void onAttach(Context context) { + super.onAttach(context); + if (savedSecondaryInfo != null) { + setSecondary(savedSecondaryInfo); + } + } + + @Override + public void onResume() { + super.onResume(); + LogUtil.i("VideoCallFragment.onResume", null); + inCallScreenDelegate.onInCallScreenResumed(); + } + + @Override + public void onStart() { + super.onStart(); + LogUtil.i("VideoCallFragment.onStart", null); + inCallButtonUiDelegate.refreshMuteState(); + videoCallScreenDelegate.onVideoCallScreenUiReady(); + getView().postDelayed(cameraPermissionDialogRunnable, CAMERA_PERMISSION_DIALOG_DELAY_IN_MILLIS); + } + + @Override + public void onPause() { + super.onPause(); + LogUtil.i("VideoCallFragment.onPause", null); + } + + @Override + public void onStop() { + super.onStop(); + LogUtil.i("VideoCallFragment.onStop", null); + getView().removeCallbacks(cameraPermissionDialogRunnable); + videoCallScreenDelegate.onVideoCallScreenUiUnready(); + } + + private void exitFullscreenMode() { + LogUtil.i("VideoCallFragment.exitFullscreenMode", null); + + if (!getView().isAttachedToWindow()) { + LogUtil.i("VideoCallFragment.exitFullscreenMode", "not attached"); + return; + } + + showSystemUI(); + + LinearOutSlowInInterpolator linearOutSlowInInterpolator = new LinearOutSlowInInterpolator(); + + // Animate the controls to the shown state. + controls + .animate() + .translationX(0) + .translationY(0) + .setInterpolator(linearOutSlowInInterpolator) + .alpha(1) + .start(); + + // Animate onHold to the shown state. + switchOnHoldButton + .animate() + .translationX(0) + .translationY(0) + .setInterpolator(linearOutSlowInInterpolator) + .alpha(1) + .withStartAction( + new Runnable() { + @Override + public void run() { + switchOnHoldCallController.setOnScreen(); + } + }); + + View contactGridView = contactGridManager.getContainerView(); + // Animate contact grid to the shown state. + contactGridView + .animate() + .translationX(0) + .translationY(0) + .setInterpolator(linearOutSlowInInterpolator) + .alpha(1) + .withStartAction( + new Runnable() { + @Override + public void run() { + contactGridManager.show(); + } + }); + + endCallButton + .animate() + .translationX(0) + .translationY(0) + .setInterpolator(linearOutSlowInInterpolator) + .alpha(1) + .withStartAction( + new Runnable() { + @Override + public void run() { + endCallButton.setVisibility(View.VISIBLE); + } + }) + .start(); + + // Animate all the preview controls up to make room for the navigation bar. + // In green screen mode we don't need this because the preview takes up the whole screen and has + // a fixed position. + if (!isInGreenScreenMode) { + Point previewOffsetStartShown = getPreviewOffsetStartShown(); + for (View view : getAllPreviewRelatedViews()) { + // Animate up with the preview offset above the navigation bar. + view.animate() + .translationX(previewOffsetStartShown.x) + .translationY(previewOffsetStartShown.y) + .setInterpolator(new AccelerateDecelerateInterpolator()) + .start(); + } + } + + updateOverlayBackground(); + } + + private void showSystemUI() { + View view = getView(); + if (view != null) { + // Code is more expressive with all flags present, even though some may be combined + //noinspection PointlessBitwiseExpression + view.setSystemUiVisibility(View.SYSTEM_UI_FLAG_VISIBLE | View.SYSTEM_UI_FLAG_LAYOUT_STABLE); + } + } + + /** Set view flags to hide the system UI. System UI will return on any touch event */ + private void hideSystemUI() { + View view = getView(); + if (view != null) { + view.setSystemUiVisibility( + View.SYSTEM_UI_FLAG_FULLSCREEN + | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION + | View.SYSTEM_UI_FLAG_LAYOUT_STABLE); + } + } + + private Point getControlsOffsetEndHidden(View controls) { + if (isLandscape()) { + return new Point(0, getOffsetBottom(controls)); + } else { + return new Point(getOffsetStart(controls), 0); + } + } + + private Point getSwitchOnHoldOffsetEndHidden(View swapCallButton) { + if (isLandscape()) { + return new Point(0, getOffsetTop(swapCallButton)); + } else { + return new Point(getOffsetEnd(swapCallButton), 0); + } + } + + private Point getContactGridOffsetEndHidden(View view) { + return new Point(0, getOffsetTop(view)); + } + + private Point getEndCallOffsetEndHidden(View endCallButton) { + if (isLandscape()) { + return new Point(getOffsetEnd(endCallButton), 0); + } else { + return new Point(0, ((MarginLayoutParams) endCallButton.getLayoutParams()).bottomMargin); + } + } + + private Point getPreviewOffsetStartShown() { + // No insets in multiwindow mode, and rootWindowInsets will get the display's insets. + if (ActivityCompat.isInMultiWindowMode(getActivity())) { + return new Point(); + } + if (isLandscape()) { + int stableInsetEnd = + getView().getLayoutDirection() == View.LAYOUT_DIRECTION_RTL + ? getView().getRootWindowInsets().getStableInsetLeft() + : -getView().getRootWindowInsets().getStableInsetRight(); + return new Point(stableInsetEnd, 0); + } else { + return new Point(0, -getView().getRootWindowInsets().getStableInsetBottom()); + } + } + + private View[] getAllPreviewRelatedViews() { + return new View[] { + previewTextureView, previewOffOverlay, previewOffBlurredImageView, mutePreviewOverlay, + }; + } + + private int getOffsetTop(View view) { + return -(view.getHeight() + ((MarginLayoutParams) view.getLayoutParams()).topMargin); + } + + private int getOffsetBottom(View view) { + return view.getHeight() + ((MarginLayoutParams) view.getLayoutParams()).bottomMargin; + } + + private int getOffsetStart(View view) { + int offset = view.getWidth() + ((MarginLayoutParams) view.getLayoutParams()).getMarginStart(); + if (view.getLayoutDirection() == View.LAYOUT_DIRECTION_RTL) { + offset = -offset; + } + return -offset; + } + + private int getOffsetEnd(View view) { + int offset = view.getWidth() + ((MarginLayoutParams) view.getLayoutParams()).getMarginEnd(); + if (view.getLayoutDirection() == View.LAYOUT_DIRECTION_RTL) { + offset = -offset; + } + return offset; + } + + private void enterFullscreenMode() { + LogUtil.i("VideoCallFragment.enterFullscreenMode", null); + + hideSystemUI(); + + Interpolator fastOutLinearInInterpolator = new FastOutLinearInInterpolator(); + + // Animate controls to the hidden state. + Point offset = getControlsOffsetEndHidden(controls); + controls + .animate() + .translationX(offset.x) + .translationY(offset.y) + .setInterpolator(fastOutLinearInInterpolator) + .alpha(0) + .start(); + + // Animate onHold to the hidden state. + offset = getSwitchOnHoldOffsetEndHidden(switchOnHoldButton); + switchOnHoldButton + .animate() + .translationX(offset.x) + .translationY(offset.y) + .setInterpolator(fastOutLinearInInterpolator) + .alpha(0); + + View contactGridView = contactGridManager.getContainerView(); + // Animate contact grid to the hidden state. + offset = getContactGridOffsetEndHidden(contactGridView); + contactGridView + .animate() + .translationX(offset.x) + .translationY(offset.y) + .setInterpolator(fastOutLinearInInterpolator) + .alpha(0); + + offset = getEndCallOffsetEndHidden(endCallButton); + // Use a fast out interpolator to quickly fade out the button. This is important because the + // button can't draw under the navigation bar which means that it'll look weird if it just + // abruptly disappears when it reaches the edge of the naivgation bar. + endCallButton + .animate() + .translationX(offset.x) + .translationY(offset.y) + .setInterpolator(fastOutLinearInInterpolator) + .alpha(0) + .withEndAction( + new Runnable() { + @Override + public void run() { + endCallButton.setVisibility(View.INVISIBLE); + } + }) + .setInterpolator(new FastOutLinearInInterpolator()) + .start(); + + // Animate all the preview controls down now that the navigation bar is hidden. + // In green screen mode we don't need this because the preview takes up the whole screen and has + // a fixed position. + if (!isInGreenScreenMode) { + for (View view : getAllPreviewRelatedViews()) { + // Animate down with the navigation bar hidden. + view.animate() + .translationX(0) + .translationY(0) + .setInterpolator(new AccelerateDecelerateInterpolator()) + .start(); + } + } + updateOverlayBackground(); + } + + @Override + public void onClick(View v) { + if (v == endCallButton) { + LogUtil.i("VideoCallFragment.onClick", "end call button clicked"); + inCallButtonUiDelegate.onEndCallClicked(); + videoCallScreenDelegate.resetAutoFullscreenTimer(); + } else if (v == swapCameraButton) { + if (swapCameraButton.getDrawable() instanceof Animatable) { + ((Animatable) swapCameraButton.getDrawable()).start(); + } + inCallButtonUiDelegate.toggleCameraClicked(); + videoCallScreenDelegate.resetAutoFullscreenTimer(); + } + } + + @Override + public void onCheckedChanged(CheckableImageButton button, boolean isChecked) { + if (button == cameraOffButton) { + if (!isChecked && !VideoUtils.hasCameraPermissionAndAllowedByUser(getContext())) { + LogUtil.i("VideoCallFragment.onCheckedChanged", "show camera permission dialog"); + checkCameraPermission(); + } else { + inCallButtonUiDelegate.pauseVideoClicked(isChecked); + videoCallScreenDelegate.resetAutoFullscreenTimer(); + } + } else if (button == muteButton) { + inCallButtonUiDelegate.muteClicked(isChecked); + videoCallScreenDelegate.resetAutoFullscreenTimer(); + } + } + + @Override + public void showVideoViews( + boolean shouldShowPreview, boolean shouldShowRemote, boolean isRemotelyHeld) { + LogUtil.i( + "VideoCallFragment.showVideoViews", + "showPreview: %b, shouldShowRemote: %b", + shouldShowPreview, + shouldShowRemote); + this.shouldShowPreview = shouldShowPreview; + this.shouldShowRemote = shouldShowRemote; + this.isRemotelyHeld = isRemotelyHeld; + + videoCallScreenDelegate.getLocalVideoSurfaceTexture().attachToTextureView(previewTextureView); + videoCallScreenDelegate.getRemoteVideoSurfaceTexture().attachToTextureView(remoteTextureView); + + updateVideoOffViews(); + updateRemoteVideoScaling(); + } + + /** + * This method scales the video feed inside the texture view, it doesn't change the texture view's + * size. In the old UI we would change the view size to match the aspect ratio of the video. In + * the new UI the view is always square (with the circular clip) so we have to do additional work + * to make sure the non-square video doesn't look squished. + */ + @Override + public void onLocalVideoDimensionsChanged() { + LogUtil.i("VideoCallFragment.onLocalVideoDimensionsChanged", null); + updatePreviewVideoScaling(); + } + + @Override + public void onLocalVideoOrientationChanged() { + LogUtil.i("VideoCallFragment.onLocalVideoOrientationChanged", null); + updatePreviewVideoScaling(); + } + + /** Called when the remote video's dimensions change. */ + @Override + public void onRemoteVideoDimensionsChanged() { + LogUtil.i("VideoCallFragment.onRemoteVideoDimensionsChanged", null); + updateRemoteVideoScaling(); + } + + @Override + public void updateFullscreenAndGreenScreenMode( + boolean shouldShowFullscreen, boolean shouldShowGreenScreen) { + LogUtil.i( + "VideoCallFragment.updateFullscreenAndGreenScreenMode", + "shouldShowFullscreen: %b, shouldShowGreenScreen: %b", + shouldShowFullscreen, + shouldShowGreenScreen); + + if (getActivity() == null) { + LogUtil.i("VideoCallFragment.updateFullscreenAndGreenScreenMode", "not attached to activity"); + return; + } + + // Check if anything is actually going to change. The first time this function is called we + // force a change by checking the hasInitializedScreenModes flag. We also force both fullscreen + // and green screen modes to update even if only one has changed. That's because they both + // depend on each other. + if (hasInitializedScreenModes + && shouldShowGreenScreen == isInGreenScreenMode + && shouldShowFullscreen == isInFullscreenMode) { + LogUtil.i( + "VideoCallFragment.updateFullscreenAndGreenScreenMode", "no change to screen modes"); + return; + } + hasInitializedScreenModes = true; + isInGreenScreenMode = shouldShowGreenScreen; + isInFullscreenMode = shouldShowFullscreen; + + if (getView().isAttachedToWindow() && !ActivityCompat.isInMultiWindowMode(getActivity())) { + controlsContainer.onApplyWindowInsets(getView().getRootWindowInsets()); + } + if (shouldShowGreenScreen) { + enterGreenScreenMode(); + } else { + exitGreenScreenMode(); + } + if (shouldShowFullscreen) { + enterFullscreenMode(); + } else { + exitFullscreenMode(); + } + updateVideoOffViews(); + + OnHoldFragment onHoldFragment = + ((OnHoldFragment) + getChildFragmentManager().findFragmentById(R.id.videocall_on_hold_banner)); + if (onHoldFragment != null) { + onHoldFragment.setPadTopInset(!isInFullscreenMode); + } + } + + @Override + public Fragment getVideoCallScreenFragment() { + return this; + } + + @Override + public void showButton(@InCallButtonIds int buttonId, boolean show) { + LogUtil.v( + "VideoCallFragment.showButton", + "buttonId: %s, show: %b", + InCallButtonIdsExtension.toString(buttonId), + show); + if (buttonId == InCallButtonIds.BUTTON_AUDIO) { + speakerButtonController.setEnabled(show); + } else if (buttonId == InCallButtonIds.BUTTON_MUTE) { + muteButton.setEnabled(show); + } else if (buttonId == InCallButtonIds.BUTTON_PAUSE_VIDEO) { + cameraOffButton.setEnabled(show); + } else if (buttonId == InCallButtonIds.BUTTON_SWITCH_TO_SECONDARY) { + switchOnHoldCallController.setVisible(show); + } else if (buttonId == InCallButtonIds.BUTTON_SWITCH_CAMERA) { + swapCameraButton.setEnabled(show); + } + } + + @Override + public void enableButton(@InCallButtonIds int buttonId, boolean enable) { + LogUtil.v( + "VideoCallFragment.setEnabled", + "buttonId: %s, enable: %b", + InCallButtonIdsExtension.toString(buttonId), + enable); + if (buttonId == InCallButtonIds.BUTTON_AUDIO) { + speakerButtonController.setEnabled(enable); + } else if (buttonId == InCallButtonIds.BUTTON_MUTE) { + muteButton.setEnabled(enable); + } else if (buttonId == InCallButtonIds.BUTTON_PAUSE_VIDEO) { + cameraOffButton.setEnabled(enable); + } else if (buttonId == InCallButtonIds.BUTTON_SWITCH_TO_SECONDARY) { + switchOnHoldCallController.setEnabled(enable); + } + } + + @Override + public void setEnabled(boolean enabled) { + LogUtil.v("VideoCallFragment.setEnabled", "enabled: " + enabled); + speakerButtonController.setEnabled(enabled); + muteButton.setEnabled(enabled); + cameraOffButton.setEnabled(enabled); + switchOnHoldCallController.setEnabled(enabled); + } + + @Override + public void setHold(boolean value) { + LogUtil.i("VideoCallFragment.setHold", "value: " + value); + } + + @Override + public void setCameraSwitched(boolean isBackFacingCamera) { + LogUtil.i("VideoCallFragment.setCameraSwitched", "isBackFacingCamera: " + isBackFacingCamera); + } + + @Override + public void setVideoPaused(boolean isPaused) { + LogUtil.i("VideoCallFragment.setVideoPaused", "isPaused: " + isPaused); + cameraOffButton.setChecked(isPaused); + } + + @Override + public void setAudioState(CallAudioState audioState) { + LogUtil.i("VideoCallFragment.setAudioState", "audioState: " + audioState); + speakerButtonController.setAudioState(audioState); + muteButton.setChecked(audioState.isMuted()); + updateMutePreviewOverlayVisibility(); + } + + @Override + public void updateButtonStates() { + LogUtil.i("VideoCallFragment.updateButtonState", null); + speakerButtonController.updateButtonState(); + switchOnHoldCallController.updateButtonState(); + } + + @Override + public void updateInCallButtonUiColors() {} + + @Override + public Fragment getInCallButtonUiFragment() { + return this; + } + + @Override + public void showAudioRouteSelector() { + LogUtil.i("VideoCallFragment.showAudioRouteSelector", null); + AudioRouteSelectorDialogFragment.newInstance(inCallButtonUiDelegate.getCurrentAudioState()) + .show(getChildFragmentManager(), null); + } + + @Override + public void onAudioRouteSelected(int audioRoute) { + LogUtil.i("VideoCallFragment.onAudioRouteSelected", "audioRoute: " + audioRoute); + inCallButtonUiDelegate.setAudioRoute(audioRoute); + } + + @Override + public void setPrimary(@NonNull PrimaryInfo primaryInfo) { + LogUtil.i("VideoCallFragment.setPrimary", primaryInfo.toString()); + contactGridManager.setPrimary(primaryInfo); + } + + @Override + public void setSecondary(@NonNull SecondaryInfo secondaryInfo) { + LogUtil.i("VideoCallFragment.setSecondary", secondaryInfo.toString()); + if (!isAdded()) { + savedSecondaryInfo = secondaryInfo; + return; + } + savedSecondaryInfo = null; + switchOnHoldCallController.setSecondaryInfo(secondaryInfo); + updateButtonStates(); + FragmentTransaction transaction = getChildFragmentManager().beginTransaction(); + Fragment oldBanner = getChildFragmentManager().findFragmentById(R.id.videocall_on_hold_banner); + if (secondaryInfo.shouldShow) { + OnHoldFragment onHoldFragment = OnHoldFragment.newInstance(secondaryInfo); + onHoldFragment.setPadTopInset(!isInFullscreenMode); + transaction.replace(R.id.videocall_on_hold_banner, onHoldFragment); + } else { + if (oldBanner != null) { + transaction.remove(oldBanner); + } + } + transaction.setCustomAnimations(R.anim.abc_slide_in_top, R.anim.abc_slide_out_top); + transaction.commitAllowingStateLoss(); + } + + @Override + public void setCallState(@NonNull PrimaryCallState primaryCallState) { + LogUtil.i("VideoCallFragment.setCallState", primaryCallState.toString()); + contactGridManager.setCallState(primaryCallState); + } + + @Override + public void setEndCallButtonEnabled(boolean enabled, boolean animate) { + LogUtil.i("VideoCallFragment.setEndCallButtonEnabled", "enabled: " + enabled); + } + + @Override + public void showManageConferenceCallButton(boolean visible) { + LogUtil.i("VideoCallFragment.showManageConferenceCallButton", "visible: " + visible); + } + + @Override + public boolean isManageConferenceVisible() { + LogUtil.i("VideoCallFragment.isManageConferenceVisible", null); + return false; + } + + @Override + public void dispatchPopulateAccessibilityEvent(AccessibilityEvent event) { + contactGridManager.dispatchPopulateAccessibilityEvent(event); + } + + @Override + public void showNoteSentToast() { + LogUtil.i("VideoCallFragment.showNoteSentToast", null); + } + + @Override + public void updateInCallScreenColors() { + LogUtil.i("VideoCallFragment.updateColors", null); + } + + @Override + public void onInCallScreenDialpadVisibilityChange(boolean isShowing) { + LogUtil.i("VideoCallFragment.onInCallScreenDialpadVisibilityChange", null); + } + + @Override + public int getAnswerAndDialpadContainerResourceId() { + return 0; + } + + @Override + public Fragment getInCallScreenFragment() { + return this; + } + + @Override + public boolean isShowingLocationUi() { + return false; + } + + @Override + public void showLocationUi(Fragment locationUi) { + LogUtil.e("VideoCallFragment.showLocationUi", "Emergency video calling not supported"); + // Do nothing + } + + private void updatePreviewVideoScaling() { + if (previewTextureView.getWidth() == 0 || previewTextureView.getHeight() == 0) { + LogUtil.i("VideoCallFragment.updatePreviewVideoScaling", "view layout hasn't finished yet"); + return; + } + VideoSurfaceTexture localVideoSurfaceTexture = + videoCallScreenDelegate.getLocalVideoSurfaceTexture(); + Point cameraDimensions = localVideoSurfaceTexture.getSurfaceDimensions(); + if (cameraDimensions == null) { + LogUtil.i( + "VideoCallFragment.updatePreviewVideoScaling", "camera dimensions haven't been set"); + return; + } + if (isLandscape()) { + VideoSurfaceBindings.scaleVideoAndFillView( + previewTextureView, + cameraDimensions.x, + cameraDimensions.y, + videoCallScreenDelegate.getDeviceOrientation()); + } else { + VideoSurfaceBindings.scaleVideoAndFillView( + previewTextureView, + cameraDimensions.y, + cameraDimensions.x, + videoCallScreenDelegate.getDeviceOrientation()); + } + } + + private void updateRemoteVideoScaling() { + VideoSurfaceTexture remoteVideoSurfaceTexture = + videoCallScreenDelegate.getRemoteVideoSurfaceTexture(); + Point videoSize = remoteVideoSurfaceTexture.getSourceVideoDimensions(); + if (videoSize == null) { + LogUtil.i("VideoCallFragment.updateRemoteVideoScaling", "video size is null"); + return; + } + if (remoteTextureView.getWidth() == 0 || remoteTextureView.getHeight() == 0) { + LogUtil.i("VideoCallFragment.updateRemoteVideoScaling", "view layout hasn't finished yet"); + return; + } + + // If the video and display aspect ratio's are close then scale video to fill display + float videoAspectRatio = ((float) videoSize.x) / videoSize.y; + float displayAspectRatio = + ((float) remoteTextureView.getWidth()) / remoteTextureView.getHeight(); + float delta = Math.abs(videoAspectRatio - displayAspectRatio); + float sum = videoAspectRatio + displayAspectRatio; + if (delta / sum < ASPECT_RATIO_MATCH_THRESHOLD) { + VideoSurfaceBindings.scaleVideoAndFillView(remoteTextureView, videoSize.x, videoSize.y, 0); + } else { + VideoSurfaceBindings.scaleVideoMaintainingAspectRatio( + remoteTextureView, videoSize.x, videoSize.y); + } + } + + private boolean isLandscape() { + // Choose orientation based on display orientation, not window orientation + int rotation = getActivity().getWindowManager().getDefaultDisplay().getRotation(); + return rotation == Surface.ROTATION_90 || rotation == Surface.ROTATION_270; + } + + private void enterGreenScreenMode() { + LogUtil.i("VideoCallFragment.enterGreenScreenMode", null); + RelativeLayout.LayoutParams params = + new RelativeLayout.LayoutParams( + RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT); + params.addRule(RelativeLayout.ALIGN_PARENT_START); + params.addRule(RelativeLayout.ALIGN_PARENT_TOP); + previewTextureView.setLayoutParams(params); + previewTextureView.setOutlineProvider(null); + updatePreviewVideoScaling(); + updateOverlayBackground(); + contactGridManager.setIsMiddleRowVisible(true); + updateMutePreviewOverlayVisibility(); + + previewOffBlurredImageView.setLayoutParams(params); + previewOffBlurredImageView.setOutlineProvider(null); + previewOffBlurredImageView.setClipToOutline(false); + } + + private void exitGreenScreenMode() { + LogUtil.i("VideoCallFragment.exitGreenScreenMode", null); + Resources resources = getResources(); + RelativeLayout.LayoutParams params = + new RelativeLayout.LayoutParams( + (int) resources.getDimension(R.dimen.videocall_preview_width), + (int) resources.getDimension(R.dimen.videocall_preview_height)); + params.setMargins( + 0, 0, 0, (int) resources.getDimension(R.dimen.videocall_preview_margin_bottom)); + if (isLandscape()) { + params.addRule(RelativeLayout.ALIGN_PARENT_END); + params.setMarginEnd((int) resources.getDimension(R.dimen.videocall_preview_margin_end)); + } else { + params.addRule(RelativeLayout.ALIGN_PARENT_START); + params.setMarginStart((int) resources.getDimension(R.dimen.videocall_preview_margin_start)); + } + params.addRule(RelativeLayout.ALIGN_PARENT_BOTTOM); + previewTextureView.setLayoutParams(params); + previewTextureView.setOutlineProvider(circleOutlineProvider); + updatePreviewVideoScaling(); + updateOverlayBackground(); + contactGridManager.setIsMiddleRowVisible(false); + updateMutePreviewOverlayVisibility(); + + previewOffBlurredImageView.setLayoutParams(params); + previewOffBlurredImageView.setOutlineProvider(circleOutlineProvider); + previewOffBlurredImageView.setClipToOutline(true); + } + + private void updateVideoOffViews() { + // Always hide the preview off and remote off views in green screen mode. + boolean previewEnabled = isInGreenScreenMode || shouldShowPreview; + previewOffOverlay.setVisibility(previewEnabled ? View.GONE : View.VISIBLE); + updateBlurredImageView( + previewTextureView, + previewOffBlurredImageView, + shouldShowPreview, + BLUR_PREVIEW_RADIUS, + BLUR_PREVIEW_SCALE_FACTOR); + + boolean remoteEnabled = isInGreenScreenMode || shouldShowRemote; + boolean isResumed = remoteEnabled && !isRemotelyHeld; + if (isResumed) { + boolean wasRemoteVideoOff = + TextUtils.equals( + remoteVideoOff.getText(), + remoteVideoOff.getResources().getString(R.string.videocall_remote_video_off)); + // The text needs to be updated and hidden after enough delay in order to be announced by + // talkback. + remoteVideoOff.setText( + wasRemoteVideoOff + ? R.string.videocall_remote_video_on + : R.string.videocall_remotely_resumed); + remoteVideoOff.postDelayed( + new Runnable() { + @Override + public void run() { + remoteVideoOff.setVisibility(View.GONE); + } + }, + VIDEO_OFF_VIEW_FADE_OUT_DELAY_IN_MILLIS); + } else { + remoteVideoOff.setText( + isRemotelyHeld ? R.string.videocall_remotely_held : R.string.videocall_remote_video_off); + remoteVideoOff.setVisibility(View.VISIBLE); + } + LogUtil.i("VideoCallFragment.updateVideoOffViews", "calling updateBlurredImageView"); + updateBlurredImageView( + remoteTextureView, + remoteOffBlurredImageView, + shouldShowRemote, + BLUR_REMOTE_RADIUS, + BLUR_REMOTE_SCALE_FACTOR); + } + + private void updateBlurredImageView( + TextureView textureView, + ImageView blurredImageView, + boolean isVideoEnabled, + float blurRadius, + float scaleFactor) { + boolean didBlur = false; + long startTimeMillis = SystemClock.elapsedRealtime(); + if (!isVideoEnabled) { + int width = Math.round(textureView.getWidth() * scaleFactor); + int height = Math.round(textureView.getHeight() * scaleFactor); + // This call takes less than 10 milliseconds. + Bitmap bitmap = textureView.getBitmap(width, height); + if (bitmap != null) { + // TODO: When the view is first displayed after a rotation the bitmap is empty + // and thus this blur has no effect. + // This call can take 100 milliseconds. + blur(getContext(), bitmap, blurRadius); + + // TODO: Figure out why only have to apply the transform in landscape mode + if (width > height) { + bitmap = + Bitmap.createBitmap( + bitmap, + 0, + 0, + bitmap.getWidth(), + bitmap.getHeight(), + textureView.getTransform(null), + true); + } + + blurredImageView.setImageBitmap(bitmap); + blurredImageView.setVisibility(View.VISIBLE); + didBlur = true; + } + } + if (!didBlur) { + blurredImageView.setImageBitmap(null); + blurredImageView.setVisibility(View.GONE); + } + + LogUtil.i( + "VideoCallFragment.updateBlurredImageView", + "didBlur: %b, took %d millis", + didBlur, + (SystemClock.elapsedRealtime() - startTimeMillis)); + } + + private void updateOverlayBackground() { + if (isInGreenScreenMode) { + // We want to darken the preview view to make text and buttons readable. The fullscreen + // background is below the preview view so use the green screen background instead. + animateSetVisibility(greenScreenBackgroundView, View.VISIBLE); + animateSetVisibility(fullscreenBackgroundView, View.GONE); + } else if (!isInFullscreenMode) { + // We want to darken the remote view to make text and buttons readable. The green screen + // background is above the preview view so it would darken the preview too. Use the fullscreen + // background instead. + animateSetVisibility(greenScreenBackgroundView, View.GONE); + animateSetVisibility(fullscreenBackgroundView, View.VISIBLE); + } else { + animateSetVisibility(greenScreenBackgroundView, View.GONE); + animateSetVisibility(fullscreenBackgroundView, View.GONE); + } + } + + private void updateMutePreviewOverlayVisibility() { + // Normally the mute overlay shows on the bottom right of the preview bubble. In green screen + // mode the preview is fullscreen so there's no where to anchor it. + mutePreviewOverlay.setVisibility( + muteButton.isChecked() && !isInGreenScreenMode ? View.VISIBLE : View.GONE); + } + + private static void animateSetVisibility(final View view, final int visibility) { + if (view.getVisibility() == visibility) { + return; + } + + int startAlpha; + int endAlpha; + if (visibility == View.GONE) { + startAlpha = 1; + endAlpha = 0; + } else if (visibility == View.VISIBLE) { + startAlpha = 0; + endAlpha = 1; + } else { + Assert.fail(); + return; + } + + view.setAlpha(startAlpha); + view.setVisibility(View.VISIBLE); + view.animate() + .alpha(endAlpha) + .withEndAction( + new Runnable() { + @Override + public void run() { + view.setVisibility(visibility); + } + }) + .start(); + } + + private static void blur(Context context, Bitmap image, float blurRadius) { + RenderScript renderScript = RenderScript.create(context); + ScriptIntrinsicBlur blurScript = + ScriptIntrinsicBlur.create(renderScript, Element.U8_4(renderScript)); + Allocation allocationIn = Allocation.createFromBitmap(renderScript, image); + Allocation allocationOut = Allocation.createFromBitmap(renderScript, image); + blurScript.setRadius(blurRadius); + blurScript.setInput(allocationIn); + blurScript.forEach(allocationOut); + allocationOut.copyTo(image); + } + + @Override + public void onSystemUiVisibilityChange(int visibility) { + boolean navBarVisible = (visibility & View.SYSTEM_UI_FLAG_HIDE_NAVIGATION) == 0; + videoCallScreenDelegate.onSystemUiVisibilityChange(navBarVisible); + } + + protected void onCameraPermissionGranted() { + videoCallScreenDelegate.onCameraPermissionGranted(); + } + + private void checkCameraPermission() { + // Checks if user has consent of camera permission and the permission is granted. + // If camera permission is revoked, shows system permission dialog. + // If camera permission is granted but user doesn't have consent of camera permission + // (which means it's first time making video call), shows custom dialog instead. This + // will only be shown to user once. + if (!VideoUtils.hasCameraPermissionAndAllowedByUser(getContext())) { + videoCallScreenDelegate.onCameraPermissionDialogShown(); + if (!VideoUtils.hasCameraPermission(getContext())) { + requestPermissions(new String[] {permission.CAMERA}, CAMERA_PERMISSION_REQUEST_CODE); + } else { + CameraPermissionDialogFragment.newInstance() + .show(getChildFragmentManager(), CAMERA_PERMISSION_DIALOG_FRAMENT_TAG); + } + } + } +} diff --git a/java/com/android/incallui/video/impl/res/color/videocall_button_icon_tint.xml b/java/com/android/incallui/video/impl/res/color/videocall_button_icon_tint.xml new file mode 100644 index 000000000..b46607b1b --- /dev/null +++ b/java/com/android/incallui/video/impl/res/color/videocall_button_icon_tint.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<selector xmlns:android="http://schemas.android.com/apk/res/android"> + <item android:color="#ff000000" android:state_checked="true"/> + <item android:color="#ffffffff"/> +</selector> diff --git a/java/com/android/incallui/video/impl/res/drawable-hdpi/ic_switch_camera.png b/java/com/android/incallui/video/impl/res/drawable-hdpi/ic_switch_camera.png Binary files differnew file mode 100644 index 000000000..b5c6f0a87 --- /dev/null +++ b/java/com/android/incallui/video/impl/res/drawable-hdpi/ic_switch_camera.png diff --git a/java/com/android/incallui/video/impl/res/drawable-hdpi/video_button_bg_checked.png b/java/com/android/incallui/video/impl/res/drawable-hdpi/video_button_bg_checked.png Binary files differnew file mode 100644 index 000000000..2ab2f21a7 --- /dev/null +++ b/java/com/android/incallui/video/impl/res/drawable-hdpi/video_button_bg_checked.png diff --git a/java/com/android/incallui/video/impl/res/drawable-hdpi/video_button_bg_checked_disabled.png b/java/com/android/incallui/video/impl/res/drawable-hdpi/video_button_bg_checked_disabled.png Binary files differnew file mode 100644 index 000000000..2deaadd76 --- /dev/null +++ b/java/com/android/incallui/video/impl/res/drawable-hdpi/video_button_bg_checked_disabled.png diff --git a/java/com/android/incallui/video/impl/res/drawable-hdpi/video_button_bg_checked_pressed.png b/java/com/android/incallui/video/impl/res/drawable-hdpi/video_button_bg_checked_pressed.png Binary files differnew file mode 100644 index 000000000..c4147fa62 --- /dev/null +++ b/java/com/android/incallui/video/impl/res/drawable-hdpi/video_button_bg_checked_pressed.png diff --git a/java/com/android/incallui/video/impl/res/drawable-hdpi/video_button_bg_default.png b/java/com/android/incallui/video/impl/res/drawable-hdpi/video_button_bg_default.png Binary files differnew file mode 100644 index 000000000..c59e21504 --- /dev/null +++ b/java/com/android/incallui/video/impl/res/drawable-hdpi/video_button_bg_default.png diff --git a/java/com/android/incallui/video/impl/res/drawable-hdpi/video_button_bg_disabled.png b/java/com/android/incallui/video/impl/res/drawable-hdpi/video_button_bg_disabled.png Binary files differnew file mode 100644 index 000000000..95d6824f5 --- /dev/null +++ b/java/com/android/incallui/video/impl/res/drawable-hdpi/video_button_bg_disabled.png diff --git a/java/com/android/incallui/video/impl/res/drawable-hdpi/video_button_bg_pressed.png b/java/com/android/incallui/video/impl/res/drawable-hdpi/video_button_bg_pressed.png Binary files differnew file mode 100644 index 000000000..9a525a374 --- /dev/null +++ b/java/com/android/incallui/video/impl/res/drawable-hdpi/video_button_bg_pressed.png diff --git a/java/com/android/incallui/video/impl/res/drawable-mdpi/ic_switch_camera.png b/java/com/android/incallui/video/impl/res/drawable-mdpi/ic_switch_camera.png Binary files differnew file mode 100644 index 000000000..f3427a02e --- /dev/null +++ b/java/com/android/incallui/video/impl/res/drawable-mdpi/ic_switch_camera.png diff --git a/java/com/android/incallui/video/impl/res/drawable-mdpi/video_button_bg_checked.png b/java/com/android/incallui/video/impl/res/drawable-mdpi/video_button_bg_checked.png Binary files differnew file mode 100644 index 000000000..c3ff7b2bb --- /dev/null +++ b/java/com/android/incallui/video/impl/res/drawable-mdpi/video_button_bg_checked.png diff --git a/java/com/android/incallui/video/impl/res/drawable-mdpi/video_button_bg_checked_disabled.png b/java/com/android/incallui/video/impl/res/drawable-mdpi/video_button_bg_checked_disabled.png Binary files differnew file mode 100644 index 000000000..c75281332 --- /dev/null +++ b/java/com/android/incallui/video/impl/res/drawable-mdpi/video_button_bg_checked_disabled.png diff --git a/java/com/android/incallui/video/impl/res/drawable-mdpi/video_button_bg_checked_pressed.png b/java/com/android/incallui/video/impl/res/drawable-mdpi/video_button_bg_checked_pressed.png Binary files differnew file mode 100644 index 000000000..fd16baef7 --- /dev/null +++ b/java/com/android/incallui/video/impl/res/drawable-mdpi/video_button_bg_checked_pressed.png diff --git a/java/com/android/incallui/video/impl/res/drawable-mdpi/video_button_bg_default.png b/java/com/android/incallui/video/impl/res/drawable-mdpi/video_button_bg_default.png Binary files differnew file mode 100644 index 000000000..3fe2446e3 --- /dev/null +++ b/java/com/android/incallui/video/impl/res/drawable-mdpi/video_button_bg_default.png diff --git a/java/com/android/incallui/video/impl/res/drawable-mdpi/video_button_bg_disabled.png b/java/com/android/incallui/video/impl/res/drawable-mdpi/video_button_bg_disabled.png Binary files differnew file mode 100644 index 000000000..1ff3e7c25 --- /dev/null +++ b/java/com/android/incallui/video/impl/res/drawable-mdpi/video_button_bg_disabled.png diff --git a/java/com/android/incallui/video/impl/res/drawable-mdpi/video_button_bg_pressed.png b/java/com/android/incallui/video/impl/res/drawable-mdpi/video_button_bg_pressed.png Binary files differnew file mode 100644 index 000000000..aa7289af1 --- /dev/null +++ b/java/com/android/incallui/video/impl/res/drawable-mdpi/video_button_bg_pressed.png diff --git a/java/com/android/incallui/video/impl/res/drawable-xhdpi/ic_switch_camera.png b/java/com/android/incallui/video/impl/res/drawable-xhdpi/ic_switch_camera.png Binary files differnew file mode 100644 index 000000000..491547189 --- /dev/null +++ b/java/com/android/incallui/video/impl/res/drawable-xhdpi/ic_switch_camera.png diff --git a/java/com/android/incallui/video/impl/res/drawable-xhdpi/video_button_bg_checked.png b/java/com/android/incallui/video/impl/res/drawable-xhdpi/video_button_bg_checked.png Binary files differnew file mode 100644 index 000000000..799a78ebb --- /dev/null +++ b/java/com/android/incallui/video/impl/res/drawable-xhdpi/video_button_bg_checked.png diff --git a/java/com/android/incallui/video/impl/res/drawable-xhdpi/video_button_bg_checked_disabled.png b/java/com/android/incallui/video/impl/res/drawable-xhdpi/video_button_bg_checked_disabled.png Binary files differnew file mode 100644 index 000000000..4d5e03320 --- /dev/null +++ b/java/com/android/incallui/video/impl/res/drawable-xhdpi/video_button_bg_checked_disabled.png diff --git a/java/com/android/incallui/video/impl/res/drawable-xhdpi/video_button_bg_checked_pressed.png b/java/com/android/incallui/video/impl/res/drawable-xhdpi/video_button_bg_checked_pressed.png Binary files differnew file mode 100644 index 000000000..62cd1a477 --- /dev/null +++ b/java/com/android/incallui/video/impl/res/drawable-xhdpi/video_button_bg_checked_pressed.png diff --git a/java/com/android/incallui/video/impl/res/drawable-xhdpi/video_button_bg_default.png b/java/com/android/incallui/video/impl/res/drawable-xhdpi/video_button_bg_default.png Binary files differnew file mode 100644 index 000000000..c68ad909a --- /dev/null +++ b/java/com/android/incallui/video/impl/res/drawable-xhdpi/video_button_bg_default.png diff --git a/java/com/android/incallui/video/impl/res/drawable-xhdpi/video_button_bg_disabled.png b/java/com/android/incallui/video/impl/res/drawable-xhdpi/video_button_bg_disabled.png Binary files differnew file mode 100644 index 000000000..e5c3fc48d --- /dev/null +++ b/java/com/android/incallui/video/impl/res/drawable-xhdpi/video_button_bg_disabled.png diff --git a/java/com/android/incallui/video/impl/res/drawable-xhdpi/video_button_bg_pressed.png b/java/com/android/incallui/video/impl/res/drawable-xhdpi/video_button_bg_pressed.png Binary files differnew file mode 100644 index 000000000..583c3de82 --- /dev/null +++ b/java/com/android/incallui/video/impl/res/drawable-xhdpi/video_button_bg_pressed.png diff --git a/java/com/android/incallui/video/impl/res/drawable-xxhdpi/ic_switch_camera.png b/java/com/android/incallui/video/impl/res/drawable-xxhdpi/ic_switch_camera.png Binary files differnew file mode 100644 index 000000000..19a9344e9 --- /dev/null +++ b/java/com/android/incallui/video/impl/res/drawable-xxhdpi/ic_switch_camera.png diff --git a/java/com/android/incallui/video/impl/res/drawable-xxhdpi/video_button_bg_checked.png b/java/com/android/incallui/video/impl/res/drawable-xxhdpi/video_button_bg_checked.png Binary files differnew file mode 100644 index 000000000..5a7702bbc --- /dev/null +++ b/java/com/android/incallui/video/impl/res/drawable-xxhdpi/video_button_bg_checked.png diff --git a/java/com/android/incallui/video/impl/res/drawable-xxhdpi/video_button_bg_checked_disabled.png b/java/com/android/incallui/video/impl/res/drawable-xxhdpi/video_button_bg_checked_disabled.png Binary files differnew file mode 100644 index 000000000..a0be8d17d --- /dev/null +++ b/java/com/android/incallui/video/impl/res/drawable-xxhdpi/video_button_bg_checked_disabled.png diff --git a/java/com/android/incallui/video/impl/res/drawable-xxhdpi/video_button_bg_checked_pressed.png b/java/com/android/incallui/video/impl/res/drawable-xxhdpi/video_button_bg_checked_pressed.png Binary files differnew file mode 100644 index 000000000..5671bfa06 --- /dev/null +++ b/java/com/android/incallui/video/impl/res/drawable-xxhdpi/video_button_bg_checked_pressed.png diff --git a/java/com/android/incallui/video/impl/res/drawable-xxhdpi/video_button_bg_default.png b/java/com/android/incallui/video/impl/res/drawable-xxhdpi/video_button_bg_default.png Binary files differnew file mode 100644 index 000000000..527b3c47e --- /dev/null +++ b/java/com/android/incallui/video/impl/res/drawable-xxhdpi/video_button_bg_default.png diff --git a/java/com/android/incallui/video/impl/res/drawable-xxhdpi/video_button_bg_disabled.png b/java/com/android/incallui/video/impl/res/drawable-xxhdpi/video_button_bg_disabled.png Binary files differnew file mode 100644 index 000000000..996185890 --- /dev/null +++ b/java/com/android/incallui/video/impl/res/drawable-xxhdpi/video_button_bg_disabled.png diff --git a/java/com/android/incallui/video/impl/res/drawable-xxhdpi/video_button_bg_pressed.png b/java/com/android/incallui/video/impl/res/drawable-xxhdpi/video_button_bg_pressed.png Binary files differnew file mode 100644 index 000000000..56295b10f --- /dev/null +++ b/java/com/android/incallui/video/impl/res/drawable-xxhdpi/video_button_bg_pressed.png diff --git a/java/com/android/incallui/video/impl/res/drawable-xxxhdpi/ic_switch_camera.png b/java/com/android/incallui/video/impl/res/drawable-xxxhdpi/ic_switch_camera.png Binary files differnew file mode 100644 index 000000000..529c0a4d5 --- /dev/null +++ b/java/com/android/incallui/video/impl/res/drawable-xxxhdpi/ic_switch_camera.png diff --git a/java/com/android/incallui/video/impl/res/drawable/videocall_background_circle_white.xml b/java/com/android/incallui/video/impl/res/drawable/videocall_background_circle_white.xml new file mode 100644 index 000000000..ee514c776 --- /dev/null +++ b/java/com/android/incallui/video/impl/res/drawable/videocall_background_circle_white.xml @@ -0,0 +1,10 @@ +<?xml version="1.0" encoding="utf-8"?> +<ripple xmlns:android="http://schemas.android.com/apk/res/android" + android:color="#80888888"> + <item> + <shape + android:shape="oval"> + <solid android:color="@color/incall_button_white"/> + </shape> + </item> +</ripple> diff --git a/java/com/android/incallui/video/impl/res/drawable/videocall_video_button_background.xml b/java/com/android/incallui/video/impl/res/drawable/videocall_video_button_background.xml new file mode 100644 index 000000000..5e4841327 --- /dev/null +++ b/java/com/android/incallui/video/impl/res/drawable/videocall_video_button_background.xml @@ -0,0 +1,27 @@ +<?xml version="1.0" encoding="utf-8"?> +<ripple xmlns:android="http://schemas.android.com/apk/res/android" + android:color="@color/incall_button_ripple"> + <item android:id="@android:id/mask"> + <inset android:inset="5dp"> + <shape android:shape="oval"> + <solid android:color="@android:color/white"/> + </shape> + </inset> + </item> + <item> + <selector> + <item + android:drawable="@drawable/video_button_bg_checked_pressed" + android:state_checked="true" + android:state_pressed="true"/> + <item + android:drawable="@drawable/video_button_bg_checked" + android:state_checked="true"/> + <item + android:drawable="@drawable/video_button_bg_pressed" + android:state_pressed="true"/> + <item + android:drawable="@drawable/video_button_bg_default"/> + </selector> + </item> +</ripple> diff --git a/java/com/android/incallui/video/impl/res/layout-v21/switch_camera_button.xml b/java/com/android/incallui/video/impl/res/layout-v21/switch_camera_button.xml new file mode 100644 index 000000000..1fb1bb088 --- /dev/null +++ b/java/com/android/incallui/video/impl/res/layout-v21/switch_camera_button.xml @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="utf-8"?> +<ImageButton xmlns:android="http://schemas.android.com/apk/res/android" + android:id="@+id/videocall_switch_video" + style="@style/Incall.Button.VideoCall" + android:contentDescription="@string/incall_content_description_swap_video" + android:src="@drawable/front_back_switch_button_animation"/> diff --git a/java/com/android/incallui/video/impl/res/layout/frag_videocall.xml b/java/com/android/incallui/video/impl/res/layout/frag_videocall.xml new file mode 100644 index 000000000..dc663dda1 --- /dev/null +++ b/java/com/android/incallui/video/impl/res/layout/frag_videocall.xml @@ -0,0 +1,114 @@ +<?xml version="1.0" encoding="utf-8"?> +<RelativeLayout 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:background="@android:color/black" + android:orientation="vertical"> + + <TextureView + android:id="@+id/videocall_video_remote" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:layout_alignParentStart="true" + android:layout_alignParentTop="true" + android:importantForAccessibility="no"/> + + <ImageView + android:id="@+id/videocall_remote_off_blurred_image_view" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:layout_alignParentStart="true" + android:layout_alignParentTop="true" + android:scaleType="fitCenter"/> + + <TextView + android:gravity="center" + android:id="@+id/videocall_remote_video_off" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_centerInParent="true" + android:accessibilityTraversalBefore="@+id/videocall_speaker_button" + android:drawablePadding="8dp" + android:drawableTop="@drawable/quantum_ic_videocam_off_white_36" + android:padding="64dp" + android:text="@string/videocall_remote_video_off" + android:textAppearance="@style/Dialer.Incall.TextAppearance" + android:visibility="gone" + tools:visibility="visible"/> + + <View + android:id="@+id/videocall_fullscreen_background" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:layout_alignParentBottom="true" + android:layout_alignParentStart="true" + android:background="@color/videocall_overlay_background_color"/> + + <TextureView + android:id="@+id/videocall_video_preview" + android:layout_width="@dimen/videocall_preview_width" + android:layout_height="@dimen/videocall_preview_height" + android:layout_marginBottom="@dimen/videocall_preview_margin_bottom" + android:layout_marginStart="@dimen/videocall_preview_margin_start" + android:layout_alignParentBottom="true" + android:layout_alignParentStart="true" + android:importantForAccessibility="no"/> + + <ImageView + android:id="@+id/videocall_preview_off_blurred_image_view" + android:layout_width="@dimen/videocall_preview_width" + android:layout_height="@dimen/videocall_preview_height" + android:layout_marginBottom="@dimen/videocall_preview_margin_bottom" + android:layout_marginStart="@dimen/videocall_preview_margin_start" + android:layout_alignParentBottom="true" + android:layout_alignParentStart="true" + android:scaleType="center"/> + + <View + android:id="@+id/videocall_green_screen_background" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:layout_alignParentBottom="true" + android:layout_alignParentStart="true" + android:background="@color/videocall_overlay_background_color"/> + + <ImageView + android:id="@+id/videocall_video_preview_off_overlay" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_alignBottom="@+id/videocall_video_preview" + android:layout_alignLeft="@+id/videocall_video_preview" + android:layout_alignRight="@+id/videocall_video_preview" + android:layout_alignTop="@+id/videocall_video_preview" + android:scaleType="center" + android:src="@drawable/quantum_ic_videocam_off_white_36" + android:visibility="gone" + android:importantForAccessibility="no" + tools:visibility="visible"/> + + <ImageView + android:id="@+id/videocall_video_preview_mute_overlay" + android:layout_width="32dp" + android:layout_height="32dp" + android:layout_alignBottom="@+id/videocall_video_preview" + android:layout_alignRight="@+id/videocall_video_preview" + android:background="@drawable/videocall_background_circle_white" + android:contentDescription="@string/incall_content_description_muted" + android:scaleType="center" + android:src="@drawable/quantum_ic_mic_off_black_24" + android:visibility="gone" + tools:visibility="visible"/> + + <include + layout="@layout/videocall_controls" + android:layout_width="match_parent" + android:layout_height="match_parent"/> + + <FrameLayout + android:id="@+id/videocall_on_hold_banner" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_alignParentTop="true"/> + +</RelativeLayout> diff --git a/java/com/android/incallui/video/impl/res/layout/frag_videocall_land.xml b/java/com/android/incallui/video/impl/res/layout/frag_videocall_land.xml new file mode 100644 index 000000000..2353deea1 --- /dev/null +++ b/java/com/android/incallui/video/impl/res/layout/frag_videocall_land.xml @@ -0,0 +1,111 @@ +<?xml version="1.0" encoding="utf-8"?> +<RelativeLayout 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:background="@android:color/black"> + + <TextureView + android:id="@+id/videocall_video_remote" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:layout_alignParentStart="true" + android:layout_alignParentTop="true" + android:importantForAccessibility="no"/> + + <ImageView + android:id="@+id/videocall_remote_off_blurred_image_view" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:layout_alignParentStart="true" + android:layout_alignParentTop="true" + android:scaleType="fitCenter"/> + + <TextView + android:gravity="center" + android:id="@+id/videocall_remote_video_off" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_centerInParent="true" + android:accessibilityTraversalBefore="@+id/videocall_speaker_button" + android:drawablePadding="8dp" + android:drawableTop="@drawable/quantum_ic_videocam_off_white_36" + android:padding="64dp" + android:text="@string/videocall_remote_video_off" + android:textAppearance="@style/Dialer.Incall.TextAppearance" + android:visibility="gone" + tools:visibility="visible"/> + + <View + android:id="@+id/videocall_fullscreen_background" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:layout_alignParentBottom="true" + android:layout_alignParentStart="true" + android:background="@color/videocall_overlay_background_color"/> + + <TextureView + android:id="@+id/videocall_video_preview" + android:layout_width="@dimen/videocall_preview_width" + android:layout_height="@dimen/videocall_preview_height" + android:layout_marginEnd="@dimen/videocall_preview_margin_end" + android:layout_alignParentBottom="true" + android:layout_alignParentEnd="true" + android:importantForAccessibility="no"/> + + <ImageView + android:id="@+id/videocall_preview_off_blurred_image_view" + android:layout_width="@dimen/videocall_preview_width" + android:layout_height="@dimen/videocall_preview_height" + android:layout_marginEnd="@dimen/videocall_preview_margin_end" + android:layout_alignParentBottom="true" + android:layout_alignParentEnd="true" + android:scaleType="center"/> + + <View + android:id="@+id/videocall_green_screen_background" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:layout_alignParentBottom="true" + android:layout_alignParentStart="true" + android:background="@color/videocall_overlay_background_color"/> + + <ImageView + android:id="@+id/videocall_video_preview_off_overlay" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_alignBottom="@+id/videocall_video_preview" + android:layout_alignLeft="@+id/videocall_video_preview" + android:layout_alignRight="@+id/videocall_video_preview" + android:layout_alignTop="@+id/videocall_video_preview" + android:scaleType="center" + android:src="@drawable/quantum_ic_videocam_off_white_36" + android:visibility="gone" + android:importantForAccessibility="no" + tools:visibility="visible"/> + + <ImageView + android:id="@+id/videocall_video_preview_mute_overlay" + android:layout_width="32dp" + android:layout_height="32dp" + android:layout_alignBottom="@+id/videocall_video_preview" + android:layout_alignRight="@+id/videocall_video_preview" + android:background="@drawable/videocall_background_circle_white" + android:contentDescription="@string/incall_content_description_muted" + android:scaleType="center" + android:src="@drawable/quantum_ic_mic_off_black_24" + android:visibility="gone" + tools:visibility="visible"/> + + <include + layout="@layout/videocall_controls_land" + android:layout_width="match_parent" + android:layout_height="match_parent"/> + + <FrameLayout + android:id="@+id/videocall_on_hold_banner" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_alignParentTop="true"/> + +</RelativeLayout> diff --git a/java/com/android/incallui/video/impl/res/layout/switch_camera_button.xml b/java/com/android/incallui/video/impl/res/layout/switch_camera_button.xml new file mode 100644 index 000000000..87c2e1b6c --- /dev/null +++ b/java/com/android/incallui/video/impl/res/layout/switch_camera_button.xml @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="utf-8"?> +<ImageButton xmlns:android="http://schemas.android.com/apk/res/android" + android:id="@+id/videocall_switch_video" + style="@style/Incall.Button.VideoCall" + android:contentDescription="@string/incall_content_description_swap_video" + android:src="@drawable/ic_switch_camera"/> diff --git a/java/com/android/incallui/video/impl/res/layout/video_contact_grid.xml b/java/com/android/incallui/video/impl/res/layout/video_contact_grid.xml new file mode 100644 index 000000000..ad984f36e --- /dev/null +++ b/java/com/android/incallui/video/impl/res/layout/video_contact_grid.xml @@ -0,0 +1,33 @@ +<?xml version="1.0" encoding="utf-8"?> +<LinearLayout + 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:layout_width="match_parent" + android:layout_height="wrap_content" + android:gravity="center_horizontal" + android:paddingTop="16dp" + android:orientation="vertical"> + + <include + layout="@layout/incall_contactgrid_top_row" + android:layout_width="wrap_content" + android:layout_height="wrap_content"/> + + <!-- 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:singleLine="true" + android:textAppearance="@style/Dialer.Incall.TextAppearance.Large" + app:autoResizeText_minTextSize="28sp" + tools:text="Jake Peralta" + tools:ignore="Deprecated"/> + + <include + layout="@layout/incall_contactgrid_bottom_row" + android:layout_width="wrap_content" + android:layout_height="wrap_content"/> +</LinearLayout> diff --git a/java/com/android/incallui/video/impl/res/layout/videocall_controls.xml b/java/com/android/incallui/video/impl/res/layout/videocall_controls.xml new file mode 100644 index 000000000..b3141bdf3 --- /dev/null +++ b/java/com/android/incallui/video/impl/res/layout/videocall_controls.xml @@ -0,0 +1,113 @@ +<?xml version="1.0" encoding="utf-8"?> +<RelativeLayout + 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/videocall_video_controls_container" + android:fitsSystemWindows="true" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:orientation="vertical"> + + <include + android:id="@+id/incall_contact_grid" + layout="@layout/video_contact_grid" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="16dp" + android:layout_marginStart="24dp" + android:layout_marginEnd="24dp"/> + + <!-- This placeholder matches the position of the preview UI and is used to + anchor video buttons. This is needed in greenscreen mode when the + preview is fullscreen but we want the controls to be positioned as + normal. --> + <Space + android:id="@+id/videocall_video_preview_placeholder" + android:layout_width="@dimen/videocall_preview_width" + android:layout_height="@dimen/videocall_preview_height" + android:layout_marginBottom="@dimen/videocall_preview_margin_bottom" + android:layout_marginStart="@dimen/videocall_preview_margin_start" + android:layout_alignParentBottom="true" + android:layout_alignParentStart="true" + android:visibility="invisible"/> + + <LinearLayout + android:id="@+id/videocall_video_controls" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_above="@+id/videocall_video_preview_placeholder" + android:layout_alignEnd="@+id/videocall_video_preview_placeholder" + android:layout_alignStart="@+id/videocall_video_preview_placeholder" + android:gravity="center_horizontal" + android:orientation="vertical" + android:visibility="invisible" + tools:visibility="visible"> + <com.android.incallui.video.impl.CheckableImageButton + android:id="@+id/videocall_speaker_button" + style="@style/Incall.Button.VideoCall" + android:layout_width="@dimen/videocall_button_size" + android:layout_height="@dimen/videocall_button_size" + android:layout_marginBottom="@dimen/videocall_button_spacing" + android:checked="true" + android:src="@drawable/quantum_ic_volume_up_white_36" + app:contentDescriptionChecked="@string/incall_content_description_speaker" + app:contentDescriptionUnchecked="@string/incall_content_description_earpiece" + /> + <com.android.incallui.video.impl.CheckableImageButton + android:id="@+id/videocall_mute_button" + style="@style/Incall.Button.VideoCall" + android:layout_width="@dimen/videocall_button_size" + android:layout_height="@dimen/videocall_button_size" + android:layout_marginBottom="@dimen/videocall_button_spacing" + android:src="@drawable/quantum_ic_mic_off_white_36" + app:contentDescriptionChecked="@string/incall_content_description_muted" + app:contentDescriptionUnchecked="@string/incall_content_description_unmuted" + /> + <com.android.incallui.video.impl.CheckableImageButton + android:id="@+id/videocall_mute_video" + style="@style/Incall.Button.VideoCall" + android:layout_width="@dimen/videocall_button_size" + android:layout_height="@dimen/videocall_button_size" + android:layout_marginBottom="@dimen/videocall_button_spacing" + android:src="@drawable/quantum_ic_videocam_off_white_36" + app:contentDescriptionChecked="@string/incall_content_description_video_off" + app:contentDescriptionUnchecked="@string/incall_content_description_video_on" + /> + <include + layout="@layout/switch_camera_button" + android:layout_width="@dimen/videocall_button_size" + android:layout_height="@dimen/videocall_button_size" + android:layout_marginBottom="@dimen/videocall_button_spacing"/> + </LinearLayout> + + <FrameLayout + android:id="@+id/videocall_switch_controls" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginBottom="36dp" + android:layout_marginEnd="24dp" + android:layout_alignParentBottom="true" + android:layout_alignParentEnd="true"> + <ImageButton + android:id="@+id/videocall_switch_on_hold" + style="@style/Incall.Button.VideoCall" + android:layout_width="@dimen/videocall_button_size" + android:layout_height="@dimen/videocall_button_size" + android:contentDescription="@string/incall_content_description_swap_calls" + android:src="@drawable/quantum_ic_swap_calls_white_36" + android:visibility="gone" + tools:visibility="visible" + /> + </FrameLayout> + + <ImageButton + android:id="@+id/videocall_end_call" + style="@style/Incall.Button.End" + android:layout_marginBottom="36dp" + android:layout_alignParentBottom="true" + android:layout_centerHorizontal="true" + android:contentDescription="@string/incall_content_description_end_call" + android:visibility="visible"/> + +</RelativeLayout> diff --git a/java/com/android/incallui/video/impl/res/layout/videocall_controls_land.xml b/java/com/android/incallui/video/impl/res/layout/videocall_controls_land.xml new file mode 100644 index 000000000..d71b3c00e --- /dev/null +++ b/java/com/android/incallui/video/impl/res/layout/videocall_controls_land.xml @@ -0,0 +1,115 @@ +<?xml version="1.0" encoding="utf-8"?> +<RelativeLayout + 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/videocall_video_controls_container" + android:fitsSystemWindows="true" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:orientation="vertical"> + + <include + android:id="@+id/incall_contact_grid" + layout="@layout/video_contact_grid" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="16dp" + android:layout_marginStart="24dp" + android:layout_marginEnd="24dp"/> + + <!-- This placeholder matches the position of the preview UI and is used to + anchor video buttons. This is needed in greenscreen mode when the + preview is fullscreen but we want the controls to be positioned as + normal. --> + <Space + android:id="@+id/videocall_video_preview_placeholder" + android:layout_width="@dimen/videocall_preview_width" + android:layout_height="@dimen/videocall_preview_height" + android:layout_marginEnd="@dimen/videocall_preview_margin_end" + android:layout_alignParentBottom="true" + android:layout_alignParentEnd="true" + android:visibility="invisible"/> + + <LinearLayout + android:id="@+id/videocall_video_controls" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_alignBottom="@+id/videocall_video_preview_placeholder" + android:layout_alignTop="@+id/videocall_video_preview_placeholder" + android:layout_toStartOf="@+id/videocall_video_preview_placeholder" + android:gravity="center_horizontal" + android:orientation="horizontal" + android:visibility="invisible" + tools:visibility="visible"> + <com.android.incallui.video.impl.CheckableImageButton + android:id="@+id/videocall_speaker_button" + style="@style/Incall.Button.VideoCall" + android:layout_width="@dimen/videocall_button_size" + android:layout_height="@dimen/videocall_button_size" + android:layout_marginEnd="24dp" + android:checked="true" + android:src="@drawable/quantum_ic_volume_up_white_36" + app:contentDescriptionChecked="@string/incall_content_description_speaker" + app:contentDescriptionUnchecked="@string/incall_content_description_earpiece" + /> + <com.android.incallui.video.impl.CheckableImageButton + android:id="@+id/videocall_mute_button" + style="@style/Incall.Button.VideoCall" + android:layout_width="@dimen/videocall_button_size" + android:layout_height="@dimen/videocall_button_size" + android:layout_marginEnd="24dp" + android:scaleType="center" + android:src="@drawable/quantum_ic_mic_off_white_36" + app:contentDescriptionChecked="@string/incall_content_description_muted" + app:contentDescriptionUnchecked="@string/incall_content_description_unmuted" + /> + <com.android.incallui.video.impl.CheckableImageButton + android:id="@+id/videocall_mute_video" + style="@style/Incall.Button.VideoCall" + android:layout_width="@dimen/videocall_button_size" + android:layout_height="@dimen/videocall_button_size" + android:layout_marginEnd="24dp" + android:scaleType="center" + android:src="@drawable/quantum_ic_videocam_off_white_36" + app:contentDescriptionChecked="@string/incall_content_description_video_off" + app:contentDescriptionUnchecked="@string/incall_content_description_video_on" + /> + <include + layout="@layout/switch_camera_button" + android:layout_width="@dimen/videocall_button_size" + android:layout_height="@dimen/videocall_button_size" + android:layout_marginEnd="24dp"/> + </LinearLayout> + + <FrameLayout + android:id="@+id/videocall_switch_controls" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginTop="36dp" + android:layout_marginEnd="36dp" + android:layout_alignParentEnd="true" + android:layout_alignParentTop="true"> + <ImageButton + android:id="@+id/videocall_switch_on_hold" + style="@style/Incall.Button.VideoCall" + android:layout_width="@dimen/videocall_button_size" + android:layout_height="@dimen/videocall_button_size" + android:contentDescription="@string/incall_content_description_swap_calls" + android:src="@drawable/quantum_ic_swap_calls_white_36" + android:visibility="gone" + tools:visibility="visible" + /> + </FrameLayout> + + <ImageButton + android:id="@+id/videocall_end_call" + style="@style/Incall.Button.End" + android:layout_marginEnd="36dp" + android:layout_alignParentEnd="true" + android:layout_centerVertical="true" + android:contentDescription="@string/incall_content_description_end_call" + android:visibility="visible" + tools:visibility="visible"/> + +</RelativeLayout> diff --git a/java/com/android/incallui/video/impl/res/values-h580dp/dimens.xml b/java/com/android/incallui/video/impl/res/values-h580dp/dimens.xml new file mode 100644 index 000000000..b1a86a0fa --- /dev/null +++ b/java/com/android/incallui/video/impl/res/values-h580dp/dimens.xml @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <dimen name="videocall_button_spacing">16dp</dimen> + <dimen name="videocall_button_size">72dp</dimen> + <dimen name="videocall_preview_width">88dp</dimen> + <dimen name="videocall_preview_height">88dp</dimen> +</resources> diff --git a/java/com/android/incallui/video/impl/res/values-w460dp/dimens.xml b/java/com/android/incallui/video/impl/res/values-w460dp/dimens.xml new file mode 100644 index 000000000..b1a86a0fa --- /dev/null +++ b/java/com/android/incallui/video/impl/res/values-w460dp/dimens.xml @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <dimen name="videocall_button_spacing">16dp</dimen> + <dimen name="videocall_button_size">72dp</dimen> + <dimen name="videocall_preview_width">88dp</dimen> + <dimen name="videocall_preview_height">88dp</dimen> +</resources> diff --git a/java/com/android/incallui/video/impl/res/values/attrs.xml b/java/com/android/incallui/video/impl/res/values/attrs.xml new file mode 100644 index 000000000..e4cd8af89 --- /dev/null +++ b/java/com/android/incallui/video/impl/res/values/attrs.xml @@ -0,0 +1,8 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <declare-styleable name="CheckableImageButton"> + <attr name="android:checked"/> + <attr name="contentDescriptionChecked" format="reference|string"/> + <attr name="contentDescriptionUnchecked" format="reference|string"/> + </declare-styleable> +</resources> diff --git a/java/com/android/incallui/video/impl/res/values/dimens.xml b/java/com/android/incallui/video/impl/res/values/dimens.xml new file mode 100644 index 000000000..45860036f --- /dev/null +++ b/java/com/android/incallui/video/impl/res/values/dimens.xml @@ -0,0 +1,10 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <dimen name="videocall_preview_width">72dp</dimen> + <dimen name="videocall_preview_height">72dp</dimen> + <dimen name="videocall_preview_margin_bottom">24dp</dimen> + <dimen name="videocall_preview_margin_start">24dp</dimen> + <dimen name="videocall_preview_margin_end">24dp</dimen> + <dimen name="videocall_button_spacing">8dp</dimen> + <dimen name="videocall_button_size">60dp</dimen> +</resources> diff --git a/java/com/android/incallui/video/impl/res/values/strings.xml b/java/com/android/incallui/video/impl/res/values/strings.xml new file mode 100644 index 000000000..2b72b8004 --- /dev/null +++ b/java/com/android/incallui/video/impl/res/values/strings.xml @@ -0,0 +1,28 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + + <!-- Text indicates the video from remote party is off. [CHAR LIMIT=40] --> + <string name="videocall_remote_video_off">Their video is off</string> + + <!-- Text indicates the video from remote party is on. [CHAR LIMIT=40] --> + <string name="videocall_remote_video_on">Their video is on</string> + + <!-- Text indicates the call is held by remote party. [CHAR LIMIT=20] --> + <string name="videocall_remotely_held">Call on hold</string> + + <!-- Text indicates the call is resumed from held by remote party. [CHAR LIMIT=20] --> + <string name="videocall_remotely_resumed">Call resumed</string> + + <!-- Title of dialog to ask user for camera permission. [CHAR LIMIT=30] --> + <string name="camera_permission_dialog_title">Allow video?</string> + + <!-- Message of dialog to ask user for camera permission. [CHAR LIMIT=100] --> + <string name="camera_permission_dialog_message">The Phone app wants to use your camera for video calls.</string> + + <!-- Text of button to be confirmed for camera permission by user. [CHAR LIMIT=20] --> + <string name="camera_permission_dialog_positive_button">Allow</string> + + <!-- Text of button to be declined for camera permission by user. [CHAR LIMIT=20] --> + <string name="camera_permission_dialog_negative_button">Deny</string> + +</resources> diff --git a/java/com/android/incallui/video/impl/res/values/styles.xml b/java/com/android/incallui/video/impl/res/values/styles.xml new file mode 100644 index 000000000..b94400875 --- /dev/null +++ b/java/com/android/incallui/video/impl/res/values/styles.xml @@ -0,0 +1,11 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + + <style name="Incall.Button.VideoCall" parent="Widget.AppCompat.ImageButton"> + <item name="android:background">@drawable/videocall_video_button_background</item> + <item name="android:scaleType">center</item> + <item name="android:tint">@color/videocall_button_icon_tint</item> + <item name="android:tintMode">src_atop</item> + <item name="android:stateListAnimator">@animator/disabled_alpha</item> + </style> +</resources> diff --git a/java/com/android/incallui/video/protocol/VideoCallScreen.java b/java/com/android/incallui/video/protocol/VideoCallScreen.java new file mode 100644 index 000000000..0eaf692e2 --- /dev/null +++ b/java/com/android/incallui/video/protocol/VideoCallScreen.java @@ -0,0 +1,36 @@ +/* + * 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.video.protocol; + +import android.support.v4.app.Fragment; + +/** Interface for call video call module. */ +public interface VideoCallScreen { + + void showVideoViews(boolean shouldShowPreview, boolean shouldShowRemote, boolean isRemotelyHeld); + + void onLocalVideoDimensionsChanged(); + + void onLocalVideoOrientationChanged(); + + void onRemoteVideoDimensionsChanged(); + + void updateFullscreenAndGreenScreenMode( + boolean shouldShowFullscreen, boolean shouldShowGreenScreen); + + Fragment getVideoCallScreenFragment(); +} diff --git a/java/com/android/incallui/video/protocol/VideoCallScreenDelegate.java b/java/com/android/incallui/video/protocol/VideoCallScreenDelegate.java new file mode 100644 index 000000000..bbd86ee6a --- /dev/null +++ b/java/com/android/incallui/video/protocol/VideoCallScreenDelegate.java @@ -0,0 +1,48 @@ +/* + * 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.video.protocol; + +import android.content.Context; +import com.android.incallui.videosurface.protocol.VideoSurfaceTexture; + +/** Callbacks from the module out to the container. */ +public interface VideoCallScreenDelegate { + + void initVideoCallScreenDelegate(Context context, VideoCallScreen videoCallScreen); + + void onVideoCallScreenUiReady(); + + void onVideoCallScreenUiUnready(); + + void cancelAutoFullScreen(); + + void resetAutoFullscreenTimer(); + + void onSystemUiVisibilityChange(boolean visible); + + void onCameraPermissionGranted(); + + boolean shouldShowCameraPermissionDialog(); + + void onCameraPermissionDialogShown(); + + VideoSurfaceTexture getLocalVideoSurfaceTexture(); + + VideoSurfaceTexture getRemoteVideoSurfaceTexture(); + + int getDeviceOrientation(); +} diff --git a/java/com/android/incallui/video/protocol/VideoCallScreenDelegateFactory.java b/java/com/android/incallui/video/protocol/VideoCallScreenDelegateFactory.java new file mode 100644 index 000000000..285857a23 --- /dev/null +++ b/java/com/android/incallui/video/protocol/VideoCallScreenDelegateFactory.java @@ -0,0 +1,23 @@ +/* + * 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.video.protocol; + +/** Callbacks from the module out to the container. */ +public interface VideoCallScreenDelegateFactory { + + VideoCallScreenDelegate newVideoCallScreenDelegate(); +} diff --git a/java/com/android/incallui/videosurface/bindings/VideoSurfaceBindings.java b/java/com/android/incallui/videosurface/bindings/VideoSurfaceBindings.java new file mode 100644 index 000000000..96fccb451 --- /dev/null +++ b/java/com/android/incallui/videosurface/bindings/VideoSurfaceBindings.java @@ -0,0 +1,44 @@ +/* + * 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.videosurface.bindings; + +import android.view.TextureView; +import com.android.incallui.videosurface.impl.VideoScale; +import com.android.incallui.videosurface.impl.VideoSurfaceTextureImpl; +import com.android.incallui.videosurface.protocol.VideoSurfaceTexture; + +/** Bindings for video surface module. */ +public class VideoSurfaceBindings { + + public static VideoSurfaceTexture createLocalVideoSurfaceTexture() { + return new VideoSurfaceTextureImpl(VideoSurfaceTexture.SURFACE_TYPE_LOCAL); + } + + public static VideoSurfaceTexture createRemoteVideoSurfaceTexture() { + return new VideoSurfaceTextureImpl(VideoSurfaceTexture.SURFACE_TYPE_REMOTE); + } + + public static void scaleVideoAndFillView( + TextureView textureView, float videoWidth, float videoHeight, float rotationDegrees) { + VideoScale.scaleVideoAndFillView(textureView, videoWidth, videoHeight, rotationDegrees); + } + + public static void scaleVideoMaintainingAspectRatio( + TextureView textureView, int videoWidth, int videoHeight) { + VideoScale.scaleVideoMaintainingAspectRatio(textureView, videoWidth, videoHeight); + } +} diff --git a/java/com/android/incallui/videosurface/impl/VideoScale.java b/java/com/android/incallui/videosurface/impl/VideoScale.java new file mode 100644 index 000000000..1444f5900 --- /dev/null +++ b/java/com/android/incallui/videosurface/impl/VideoScale.java @@ -0,0 +1,147 @@ +/* + * 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.videosurface.impl; + +import android.graphics.Matrix; +import android.view.TextureView; +import com.android.dialer.common.LogUtil; + +/** Utilities to scale the preview and remote video. */ +public class VideoScale { + /** + * Scales the video in the given view such that the video takes up the entire view. To maintain + * aspect ratio the video will be scaled to be larger than the view. + */ + public static void scaleVideoAndFillView( + TextureView textureView, float videoWidth, float videoHeight, float rotationDegrees) { + float viewWidth = textureView.getWidth(); + float viewHeight = textureView.getHeight(); + float viewAspectRatio = viewWidth / viewHeight; + float videoAspectRatio = videoWidth / videoHeight; + float scaleWidth = 1.0f; + float scaleHeight = 1.0f; + + if (viewAspectRatio > videoAspectRatio) { + // Scale to exactly fit the width of the video. The top and bottom will be cropped. + float scaleFactor = viewWidth / videoWidth; + float desiredScaledHeight = videoHeight * scaleFactor; + scaleHeight = desiredScaledHeight / viewHeight; + } else { + // Scale to exactly fit the height of the video. The sides will be cropped. + float scaleFactor = viewHeight / videoHeight; + float desiredScaledWidth = videoWidth * scaleFactor; + scaleWidth = desiredScaledWidth / viewWidth; + } + + if (rotationDegrees == 90.0f || rotationDegrees == 270.0f) { + // We're in landscape mode but the camera feed is still drawing in portrait mode. Normally, + // scale of 1.0 means that the video feed stretches to fit the view. In this case the X axis + // is scaled to fit the height and the Y axis is scaled to fit the width. + float scaleX = scaleWidth; + float scaleY = scaleHeight; + scaleWidth = viewHeight / viewWidth * scaleY; + scaleHeight = viewWidth / viewHeight * scaleX; + + // This flips the view vertically. Without this the camera feed would be upside down. + scaleWidth = scaleWidth * -1.0f; + // This flips the view horizontally. Without this the camera feed would be mirrored (left + // side would appear on right). + scaleHeight = scaleHeight * -1.0f; + } + + LogUtil.i( + "VideoScale.scaleVideoAndFillView", + "view: %f x %f, video: %f x %f scale: %f x %f, rotation: %f", + viewWidth, + viewHeight, + videoWidth, + videoHeight, + scaleWidth, + scaleHeight, + rotationDegrees); + + Matrix transform = new Matrix(); + transform.setScale( + scaleWidth, + scaleHeight, + // This performs the scaling from the horizontal middle of the view. + viewWidth / 2.0f, + // This perform the scaling from vertical middle of the view. + viewHeight / 2.0f); + if (rotationDegrees != 0) { + transform.postRotate(rotationDegrees, viewWidth / 2.0f, viewHeight / 2.0f); + } + textureView.setTransform(transform); + } + + /** + * Scales the video in the given view such that all of the video is visible. This will result in + * black bars on the top and bottom or the sides of the video. + */ + public static void scaleVideoMaintainingAspectRatio( + TextureView textureView, int videoWidth, int videoHeight) { + int viewWidth = textureView.getWidth(); + int viewHeight = textureView.getHeight(); + float scaleWidth = 1.0f; + float scaleHeight = 1.0f; + + if (viewWidth > viewHeight) { + // Landscape layout. + if (viewHeight * videoWidth > viewWidth * videoHeight) { + // Current display height is too much. Correct it. + int desiredHeight = viewWidth * videoHeight / videoWidth; + scaleWidth = (float) desiredHeight / (float) viewHeight; + } else if (viewHeight * videoWidth < viewWidth * videoHeight) { + // Current display width is too much. Correct it. + int desiredWidth = viewHeight * videoWidth / videoHeight; + scaleWidth = (float) desiredWidth / (float) viewWidth; + } + } else { + // Portrait layout. + if (viewHeight * videoWidth > viewWidth * videoHeight) { + // Current display height is too much. Correct it. + int desiredHeight = viewWidth * videoHeight / videoWidth; + scaleHeight = (float) desiredHeight / (float) viewHeight; + } else if (viewHeight * videoWidth < viewWidth * videoHeight) { + // Current display width is too much. Correct it. + int desiredWidth = viewHeight * videoWidth / videoHeight; + scaleHeight = (float) desiredWidth / (float) viewWidth; + } + } + + LogUtil.i( + "VideoScale.scaleVideoMaintainingAspectRatio", + "view: %d x %d, video: %d x %d scale: %f x %f", + viewWidth, + viewHeight, + videoWidth, + videoHeight, + scaleWidth, + scaleHeight); + Matrix transform = new Matrix(); + transform.setScale( + scaleWidth, + scaleHeight, + // This performs the scaling from the horizontal middle of the view. + viewWidth / 2.0f, + // This perform the scaling from vertical middle of the view. + viewHeight / 2.0f); + textureView.setTransform(transform); + } + + private VideoScale() {} +} diff --git a/java/com/android/incallui/videosurface/impl/VideoSurfaceTextureImpl.java b/java/com/android/incallui/videosurface/impl/VideoSurfaceTextureImpl.java new file mode 100644 index 000000000..21160cadb --- /dev/null +++ b/java/com/android/incallui/videosurface/impl/VideoSurfaceTextureImpl.java @@ -0,0 +1,249 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.android.incallui.videosurface.impl; + +import android.graphics.Point; +import android.graphics.SurfaceTexture; +import android.view.Surface; +import android.view.TextureView; +import android.view.View; +import com.android.dialer.common.LogUtil; +import com.android.incallui.videosurface.protocol.VideoSurfaceDelegate; +import com.android.incallui.videosurface.protocol.VideoSurfaceTexture; +import java.util.Locale; +import java.util.Objects; + +/** + * Represents a {@link TextureView} and its associated {@link SurfaceTexture} and {@link Surface}. + * Used to manage the lifecycle of these objects across device orientation changes. + */ +public class VideoSurfaceTextureImpl implements VideoSurfaceTexture { + @SurfaceType private final int surfaceType; + private VideoSurfaceDelegate delegate; + private TextureView textureView; + private Surface savedSurface; + private SurfaceTexture savedSurfaceTexture; + private Point surfaceDimensions; + private Point sourceVideoDimensions; + private boolean isDoneWithSurface; + + public VideoSurfaceTextureImpl(@SurfaceType int surfaceType) { + this.surfaceType = surfaceType; + } + + @Override + public void setDelegate(VideoSurfaceDelegate delegate) { + LogUtil.i("VideoSurfaceTextureImpl.setDelegate", "delegate: " + delegate + " " + toString()); + this.delegate = delegate; + } + + @Override + public int getSurfaceType() { + return surfaceType; + } + + @Override + public Surface getSavedSurface() { + return savedSurface; + } + + @Override + public void setSurfaceDimensions(Point surfaceDimensions) { + LogUtil.i( + "VideoSurfaceTextureImpl.setSurfaceDimensions", + "surfaceDimensions: " + surfaceDimensions + " " + toString()); + this.surfaceDimensions = surfaceDimensions; + if (surfaceDimensions != null && savedSurfaceTexture != null) { + savedSurfaceTexture.setDefaultBufferSize(surfaceDimensions.x, surfaceDimensions.y); + } + } + + @Override + public Point getSurfaceDimensions() { + return surfaceDimensions; + } + + @Override + public void setSourceVideoDimensions(Point sourceVideoDimensions) { + this.sourceVideoDimensions = sourceVideoDimensions; + } + + @Override + public Point getSourceVideoDimensions() { + return sourceVideoDimensions; + } + + @Override + public void attachToTextureView(TextureView textureView) { + if (this.textureView == textureView) { + return; + } + LogUtil.i("VideoSurfaceTextureImpl.attachToTextureView", toString()); + + if (this.textureView != null) { + this.textureView.setOnClickListener(null); + // Don't clear the surface texture listener. This is important because our listener prevents + // the surface from being released so that it can be reused later. + } + + this.textureView = textureView; + textureView.setSurfaceTextureListener(new SurfaceTextureListener()); + textureView.setOnClickListener(new OnClickListener()); + + boolean areSameSurfaces = Objects.equals(savedSurfaceTexture, textureView.getSurfaceTexture()); + LogUtil.i("VideoSurfaceTextureImpl.attachToTextureView", "areSameSurfaces: " + areSameSurfaces); + if (savedSurfaceTexture != null && !areSameSurfaces) { + textureView.setSurfaceTexture(savedSurfaceTexture); + if (surfaceDimensions != null && createSurface(surfaceDimensions.x, surfaceDimensions.y)) { + onSurfaceCreated(); + } + } + isDoneWithSurface = false; + } + + @Override + public void setDoneWithSurface() { + LogUtil.i("VideoSurfaceTextureImpl.setDoneWithSurface", toString()); + isDoneWithSurface = true; + if (textureView != null && textureView.isAvailable()) { + return; + } + if (savedSurface != null) { + onSurfaceReleased(); + savedSurface.release(); + savedSurface = null; + } + if (savedSurfaceTexture != null) { + savedSurfaceTexture.release(); + savedSurfaceTexture = null; + } + } + + private boolean createSurface(int width, int height) { + LogUtil.i( + "VideoSurfaceTextureImpl.createSurface", + "width: " + width + ", height: " + height + " " + toString()); + if (savedSurfaceTexture != null) { + savedSurfaceTexture.setDefaultBufferSize(width, height); + savedSurface = new Surface(savedSurfaceTexture); + return true; + } + return false; + } + + private void onSurfaceCreated() { + if (delegate != null) { + delegate.onSurfaceCreated(this); + } else { + LogUtil.e("VideoSurfaceTextureImpl.onSurfaceCreated", "delegate is null. " + toString()); + } + } + + private void onSurfaceReleased() { + if (delegate != null) { + delegate.onSurfaceReleased(this); + } else { + LogUtil.e("VideoSurfaceTextureImpl.onSurfaceReleased", "delegate is null. " + toString()); + } + } + + @Override + public String toString() { + return String.format( + Locale.US, + "VideoSurfaceTextureImpl<%s%s%s%s>", + (surfaceType == SURFACE_TYPE_LOCAL ? "local, " : "remote, "), + (savedSurface == null ? "no-surface, " : ""), + (savedSurfaceTexture == null ? "no-texture, " : ""), + (surfaceDimensions == null + ? "(-1 x -1)" + : (surfaceDimensions.x + " x " + surfaceDimensions.y))); + } + + private class SurfaceTextureListener implements TextureView.SurfaceTextureListener { + @Override + public void onSurfaceTextureAvailable(SurfaceTexture newSurfaceTexture, int width, int height) { + LogUtil.i( + "SurfaceTextureListener.onSurfaceTextureAvailable", + "newSurfaceTexture: " + + newSurfaceTexture + + " " + + VideoSurfaceTextureImpl.this.toString()); + + // Where there is no saved {@link SurfaceTexture} available, use the newly created one. + // If a saved {@link SurfaceTexture} is available, we are re-creating after an + // orientation change. + boolean surfaceCreated; + if (savedSurfaceTexture == null) { + savedSurfaceTexture = newSurfaceTexture; + surfaceCreated = createSurface(width, height); + } else { + // A saved SurfaceTexture was found. + LogUtil.i( + "SurfaceTextureListener.onSurfaceTextureAvailable", "replacing with cached surface..."); + textureView.setSurfaceTexture(savedSurfaceTexture); + surfaceCreated = true; + } + + // Inform the delegate that the surface is available. + if (surfaceCreated) { + onSurfaceCreated(); + } + } + + @Override + public boolean onSurfaceTextureDestroyed(SurfaceTexture destroyedSurfaceTexture) { + LogUtil.i( + "SurfaceTextureListener.onSurfaceTextureDestroyed", + "destroyedSurfaceTexture: " + + destroyedSurfaceTexture + + " " + + VideoSurfaceTextureImpl.this.toString()); + if (delegate != null) { + delegate.onSurfaceDestroyed(VideoSurfaceTextureImpl.this); + } else { + LogUtil.e("SurfaceTextureListener.onSurfaceTextureDestroyed", "delegate is null"); + } + + if (isDoneWithSurface) { + onSurfaceReleased(); + if (savedSurface != null) { + savedSurface.release(); + savedSurface = null; + } + } + return isDoneWithSurface; + } + + @Override + public void onSurfaceTextureSizeChanged(SurfaceTexture surface, int width, int height) {} + + @Override + public void onSurfaceTextureUpdated(SurfaceTexture surface) {} + } + + private class OnClickListener implements View.OnClickListener { + @Override + public void onClick(View view) { + if (delegate != null) { + delegate.onSurfaceClick(VideoSurfaceTextureImpl.this); + } else { + LogUtil.e("OnClickListener.onClick", "delegate is null"); + } + } + } +} diff --git a/java/com/android/incallui/videosurface/protocol/VideoSurfaceDelegate.java b/java/com/android/incallui/videosurface/protocol/VideoSurfaceDelegate.java new file mode 100644 index 000000000..8fa585a72 --- /dev/null +++ b/java/com/android/incallui/videosurface/protocol/VideoSurfaceDelegate.java @@ -0,0 +1,29 @@ +/* + * 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.videosurface.protocol; + +/** Callbacks from the video surface. */ +public interface VideoSurfaceDelegate { + + void onSurfaceCreated(VideoSurfaceTexture videoCallSurface); + + void onSurfaceReleased(VideoSurfaceTexture videoCallSurface); + + void onSurfaceDestroyed(VideoSurfaceTexture videoCallSurface); + + void onSurfaceClick(VideoSurfaceTexture videoCallSurface); +} diff --git a/java/com/android/incallui/videosurface/protocol/VideoSurfaceTexture.java b/java/com/android/incallui/videosurface/protocol/VideoSurfaceTexture.java new file mode 100644 index 000000000..411b45f56 --- /dev/null +++ b/java/com/android/incallui/videosurface/protocol/VideoSurfaceTexture.java @@ -0,0 +1,57 @@ +/* + * 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.videosurface.protocol; + +import android.graphics.Point; +import android.support.annotation.IntDef; +import android.view.Surface; +import android.view.TextureView; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** Represents a surface texture for a video feed. */ +public interface VideoSurfaceTexture { + + /** Whether this represents the preview or remote display. */ + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + SURFACE_TYPE_LOCAL, + SURFACE_TYPE_REMOTE, + }) + @interface SurfaceType {} + + int SURFACE_TYPE_LOCAL = 1; + int SURFACE_TYPE_REMOTE = 2; + + void setDelegate(VideoSurfaceDelegate delegate); + + int getSurfaceType(); + + Surface getSavedSurface(); + + void setSurfaceDimensions(Point surfaceDimensions); + + Point getSurfaceDimensions(); + + void setSourceVideoDimensions(Point sourceVideoDimensions); + + Point getSourceVideoDimensions(); + + void attachToTextureView(TextureView textureView); + + void setDoneWithSurface(); +} diff --git a/java/com/android/incallui/wifi/AndroidManifest.xml b/java/com/android/incallui/wifi/AndroidManifest.xml new file mode 100644 index 000000000..843f8f3e6 --- /dev/null +++ b/java/com/android/incallui/wifi/AndroidManifest.xml @@ -0,0 +1,3 @@ +<manifest + package="com.android.incallui.wifi"> +</manifest> diff --git a/java/com/android/incallui/wifi/EnableWifiCallingPrompt.java b/java/com/android/incallui/wifi/EnableWifiCallingPrompt.java new file mode 100644 index 000000000..85603bfb1 --- /dev/null +++ b/java/com/android/incallui/wifi/EnableWifiCallingPrompt.java @@ -0,0 +1,82 @@ +/* + * 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.wifi; + +import android.app.AlertDialog; +import android.app.Dialog; +import android.content.Context; +import android.content.DialogInterface; +import android.content.DialogInterface.OnClickListener; +import android.content.Intent; +import android.support.annotation.NonNull; +import android.telecom.DisconnectCause; +import android.util.Pair; +import com.android.dialer.common.Assert; +import com.android.dialer.common.LogUtil; + +/** Prompts the user to enable Wi-Fi calling. */ +public class EnableWifiCallingPrompt { + // This is a hidden constant in android.telecom.DisconnectCause. Telecom sets this as a disconnect + // reason if it wants us to prompt the user to enable Wi-Fi calling. In Android-O we might + // consider using a more explicit way to signal this. + private static final String REASON_WIFI_ON_BUT_WFC_OFF = "REASON_WIFI_ON_BUT_WFC_OFF"; + private static final String ACTION_WIFI_CALLING_SETTINGS = + "android.settings.WIFI_CALLING_SETTINGS"; + private static final String ANDROID_SETTINGS_PACKAGE = "com.android.settings"; + + public static boolean shouldShowPrompt(@NonNull DisconnectCause cause) { + Assert.isNotNull(cause); + if (cause.getReason() != null && cause.getReason().startsWith(REASON_WIFI_ON_BUT_WFC_OFF)) { + LogUtil.i( + "EnableWifiCallingPrompt.shouldShowPrompt", + "showing prompt for disconnect cause: %s", + cause); + return true; + } + return false; + } + + @NonNull + public static Pair<Dialog, CharSequence> createDialog( + final @NonNull Context context, @NonNull DisconnectCause cause) { + Assert.isNotNull(context); + Assert.isNotNull(cause); + CharSequence message = cause.getDescription(); + Dialog dialog = + new AlertDialog.Builder(context) + .setMessage(message) + .setPositiveButton( + R.string.incall_enable_wifi_calling_button, + new OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + openWifiCallingSettings(context); + } + }) + .setNegativeButton(android.R.string.cancel, null) + .create(); + return new Pair<Dialog, CharSequence>(dialog, message); + } + + private static void openWifiCallingSettings(@NonNull Context context) { + LogUtil.i("EnableWifiCallingPrompt.openWifiCallingSettings", "opening settings"); + context.startActivity( + new Intent(ACTION_WIFI_CALLING_SETTINGS).setPackage(ANDROID_SETTINGS_PACKAGE)); + } + + private EnableWifiCallingPrompt() {} +} diff --git a/java/com/android/incallui/wifi/res/values/strings.xml b/java/com/android/incallui/wifi/res/values/strings.xml new file mode 100644 index 000000000..1b52b9fdc --- /dev/null +++ b/java/com/android/incallui/wifi/res/values/strings.xml @@ -0,0 +1,9 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + + <!-- Button to enable Wi-Fi calling. This is displayed in a dialog after a phone call disconnects + because there is no cellular service. + [CHAR LIMIT=20] --> + <string name="incall_enable_wifi_calling_button">Enable</string> + +</resources> |