diff options
Diffstat (limited to 'InCallUI')
19 files changed, 1207 insertions, 53 deletions
diff --git a/InCallUI/res/layout/call_button_fragment.xml b/InCallUI/res/layout/call_button_fragment.xml index 6dbfbf73a..802e3de62 100644 --- a/InCallUI/res/layout/call_button_fragment.xml +++ b/InCallUI/res/layout/call_button_fragment.xml @@ -126,7 +126,7 @@ <ToggleButton android:id="@+id/pauseVideoButton" style="@style/InCallCompoundButton" android:background="@drawable/btn_compound_video_off" - android:contentDescription="@string/onscreenPauseVideoText" + android:contentDescription="@string/onscreenTurnOffCameraText" android:visibility="gone" /> <!-- "Change to audio call" for video calls. --> diff --git a/InCallUI/res/values/strings.xml b/InCallUI/res/values/strings.xml index 57a1b5389..84eb14c0a 100644 --- a/InCallUI/res/values/strings.xml +++ b/InCallUI/res/values/strings.xml @@ -218,6 +218,17 @@ The user will be able to send text messages using the phone number. [CHAR LIMIT=12] --> <string name="notification_missedCall_message">Message</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> + <!-- 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 "transfer call" action initiates the process of transferring an + external call to the current device. + [CHAR LIMIT=30] --> + <string name="notification_transfer_call">Transfer 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> @@ -313,8 +324,10 @@ <!-- Text for the onscreen "Switch camera" button. When clicked, this switches the user's camera for video calling between the front-facing camera and the back-facing camera. --> <string name="onscreenSwitchCameraText">Switch camera</string> - <!-- Text for the onscreen "Pause video" button. --> - <string name="onscreenPauseVideoText">Pause video</string> + <!-- Text for the onscreen "turn on camera" button. --> + <string name="onscreenTurnOnCameraText">Turn on camera</string> + <!-- Text for the onscreen "turn off camera" button. --> + <string name="onscreenTurnOffCameraText">Turn off camera</string> <!-- Text for the onscreen overflow button, to see additional actions which can be done. --> <string name="onscreenOverflowText">More options</string> diff --git a/InCallUI/src/com/android/incallui/AnswerPresenter.java b/InCallUI/src/com/android/incallui/AnswerPresenter.java index 6757268f3..883b54fed 100644 --- a/InCallUI/src/com/android/incallui/AnswerPresenter.java +++ b/InCallUI/src/com/android/incallui/AnswerPresenter.java @@ -128,10 +128,7 @@ public class AnswerPresenter extends Presenter<AnswerPresenter.AnswerUi> @Override public void onUpgradeToVideo(Call call) { Log.d(this, "onUpgradeToVideo: " + this + " call=" + call); - if (getUi() == null) { - Log.d(this, "onUpgradeToVideo ui is null"); - return; - } + showAnswerUi(true); boolean isUpgradePending = isVideoUpgradePending(call); InCallPresenter inCallPresenter = InCallPresenter.getInstance(); if (isUpgradePending diff --git a/InCallUI/src/com/android/incallui/Call.java b/InCallUI/src/com/android/incallui/Call.java index 447c34c88..d552ecfe5 100644 --- a/InCallUI/src/com/android/incallui/Call.java +++ b/InCallUI/src/com/android/incallui/Call.java @@ -33,6 +33,8 @@ import android.telecom.VideoProfile; import android.text.TextUtils; import com.android.contacts.common.CallUtil; +import com.android.contacts.common.compat.CallSdkCompat; +import com.android.contacts.common.compat.CompatUtils; import com.android.contacts.common.compat.SdkVersionOverride; import com.android.contacts.common.compat.telecom.TelecomManagerCompat; import com.android.contacts.common.testing.NeededForTesting; @@ -400,13 +402,30 @@ public class Call { setState(state); } + /** + * Creates a new instance of a {@link Call}. Registers a callback for + * {@link android.telecom.Call} events. + */ public Call(android.telecom.Call telecomCall) { + this(telecomCall, true /* registerCallback */); + } + + /** + * Creates a new instance of a {@link Call}. Optionally registers a callback for + * {@link android.telecom.Call} events. + * + * Intended for use when creating a {@link Call} instance for use with the + * {@link ContactInfoCache}, where we do not want to register callbacks for the new call. + */ + public Call(android.telecom.Call telecomCall, boolean registerCallback) { mTelecomCall = telecomCall; mId = ID_PREFIX + Integer.toString(sIdCounter++); - updateFromTelecomCall(); + updateFromTelecomCall(registerCallback); - mTelecomCall.registerCallback(mTelecomCallCallback); + if (registerCallback) { + mTelecomCall.registerCallback(mTelecomCallCallback); + } mTimeAddedMs = System.currentTimeMillis(); } @@ -426,7 +445,8 @@ public class Call { private void update() { Trace.beginSection("Update"); int oldState = getState(); - updateFromTelecomCall(); + // We want to potentially register a video call callback here. + updateFromTelecomCall(true /* registerCallback */); if (oldState != getState() && getState() == Call.State.DISCONNECTED) { CallList.getInstance().onDisconnect(this); } else { @@ -435,7 +455,7 @@ public class Call { Trace.endSection(); } - private void updateFromTelecomCall() { + private void updateFromTelecomCall(boolean registerCallback) { Log.d(this, "updateFromTelecomCall: " + mTelecomCall.toString()); final int translatedState = translateState(mTelecomCall.getState()); if (mState != State.BLOCKED) { @@ -444,7 +464,7 @@ public class Call { maybeCancelVideoUpgrade(mTelecomCall.getDetails().getVideoState()); } - if (mTelecomCall.getVideoCall() != null) { + if (registerCallback && mTelecomCall.getVideoCall() != null) { if (mVideoCallCallback == null) { mVideoCallCallback = new InCallVideoCallCallback(this); } @@ -883,9 +903,46 @@ public class Call { } /** + * Determines if the call is an external call. + * + * An external call is one which does not exist locally for the + * {@link android.telecom.ConnectionService} it is associated with. + * + * 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 CompatUtils.isNCompatible() && + hasProperty(CallSdkCompat.Details.PROPERTY_IS_EXTERNAL_CALL); + } + + /** + * Determines if the external call is pullable. + * + * 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. + * + * 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 CompatUtils.isNCompatible() && + (mTelecomCall.getDetails().getCallCapabilities() + & CallSdkCompat.Details.CAPABILITY_CAN_PULL_CALL) + == CallSdkCompat.Details.CAPABILITY_CAN_PULL_CALL; + } + + /** * Logging utility methods */ public void logCallInitiationType() { + if (isExternalCall()) { + return; + } + if (getState() == State.INCOMING) { getLogState().callInitiationMethod = LogState.INITIATION_INCOMING; } else if (getIntentExtras() != null) { @@ -903,11 +960,12 @@ public class Call { return String.valueOf(mId); } - return String.format(Locale.US, "[%s, %s, %s, children:%s, parent:%s, conferenceable:%s, " + - "videoState:%s, mSessionModificationState:%d, VideoSettings:%s]", + 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(), diff --git a/InCallUI/src/com/android/incallui/CallButtonFragment.java b/InCallUI/src/com/android/incallui/CallButtonFragment.java index 5a25b6a7b..6b633eaf3 100644 --- a/InCallUI/src/com/android/incallui/CallButtonFragment.java +++ b/InCallUI/src/com/android/incallui/CallButtonFragment.java @@ -65,7 +65,6 @@ public class CallButtonFragment implements CallButtonPresenter.CallButtonUi, OnMenuItemClickListener, OnDismissListener, View.OnClickListener { - private static final int INVALID_INDEX = -1; private int mButtonMaxVisible; // The button is currently visible in the UI private static final int BUTTON_VISIBLE = 1; @@ -182,7 +181,7 @@ public class CallButtonFragment super.onActivityCreated(savedInstanceState); // set the buttons - updateAudioButtons(getPresenter().getSupportedAudio()); + updateAudioButtons(); } @Override @@ -425,8 +424,14 @@ public class CallButtonFragment } @Override - public void setVideoPaused(boolean isPaused) { - mPauseVideoButton.setSelected(isPaused); + public void setVideoPaused(boolean isVideoPaused) { + mPauseVideoButton.setSelected(isVideoPaused); + + if (isVideoPaused) { + mPauseVideoButton.setContentDescription(getText(R.string.onscreenTurnOnCameraText)); + } else { + mPauseVideoButton.setContentDescription(getText(R.string.onscreenTurnOffCameraText)); + } } @Override @@ -505,7 +510,7 @@ public class CallButtonFragment @Override public void setAudio(int mode) { - updateAudioButtons(getPresenter().getSupportedAudio()); + updateAudioButtons(); refreshAudioModePopup(); if (mPrevAudioMode != mode) { @@ -516,7 +521,7 @@ public class CallButtonFragment @Override public void setSupportedAudio(int modeMask) { - updateAudioButtons(modeMask); + updateAudioButtons(); refreshAudioModePopup(); } @@ -555,7 +560,7 @@ public class CallButtonFragment public void onDismiss(PopupMenu menu) { Log.d(this, "- onDismiss: " + menu); mAudioModePopupVisible = false; - updateAudioButtons(getPresenter().getSupportedAudio()); + updateAudioButtons(); } /** @@ -600,7 +605,7 @@ public class CallButtonFragment * Updates the audio button so that the appriopriate visual layers * are visible based on the supported audio formats. */ - private void updateAudioButtons(int supportedModes) { + private void updateAudioButtons() { final boolean bluetoothSupported = isSupported(CallAudioState.ROUTE_BLUETOOTH); final boolean speakerSupported = isSupported(CallAudioState.ROUTE_SPEAKER); diff --git a/InCallUI/src/com/android/incallui/CallButtonPresenter.java b/InCallUI/src/com/android/incallui/CallButtonPresenter.java index e8c2d4b13..df1cd6645 100644 --- a/InCallUI/src/com/android/incallui/CallButtonPresenter.java +++ b/InCallUI/src/com/android/incallui/CallButtonPresenter.java @@ -366,7 +366,6 @@ public class CallButtonPresenter extends Presenter<CallButtonPresenter.CallButto private void updateButtonsState(Call call) { Log.v(this, "updateButtonsState"); final CallButtonUi ui = getUi(); - final boolean isVideo = VideoUtils.isVideoCall(call); // Common functionality (audio, hold, etc). @@ -398,6 +397,9 @@ public class CallButtonPresenter extends Presenter<CallButtonPresenter.CallButto ui.showButton(BUTTON_DOWNGRADE_TO_AUDIO, showDowngradeToAudio); ui.showButton(BUTTON_SWITCH_CAMERA, isVideo); ui.showButton(BUTTON_PAUSE_VIDEO, isVideo); + if (isVideo) { + getUi().setVideoPaused(!VideoUtils.isTransmissionEnabled(call)); + } ui.showButton(BUTTON_DIALPAD, true); ui.showButton(BUTTON_MERGE, showMerge); diff --git a/InCallUI/src/com/android/incallui/ExternalCallList.java b/InCallUI/src/com/android/incallui/ExternalCallList.java new file mode 100644 index 000000000..06e0bb975 --- /dev/null +++ b/InCallUI/src/com/android/incallui/ExternalCallList.java @@ -0,0 +1,105 @@ +package com.android.incallui; + +import com.google.common.base.Preconditions; + +import com.android.contacts.common.compat.CallSdkCompat; + +import android.os.Handler; +import android.os.Looper; +import android.telecom.Call; +import android.util.ArraySet; + +import java.util.Collections; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Tracks the external calls known to the InCall UI. + * + * External calls are those with {@link android.telecom.Call.Details#PROPERTY_IS_EXTERNAL_CALL}. + */ +public class ExternalCallList { + + public interface ExternalCallListener { + void onExternalCallAdded(Call call); + void onExternalCallRemoved(Call call); + void onExternalCallUpdated(Call call); + } + + /** + * 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); + } + }; + + private final Set<Call> mExternalCalls = new ArraySet<>(); + private final Set<ExternalCallListener> mExternalCallListeners = Collections.newSetFromMap( + new ConcurrentHashMap<ExternalCallListener, Boolean>(8, 0.9f, 1)); + + /** + * Begins tracking an external call and notifies listeners of the new call. + */ + public void onCallAdded(Call telecomCall) { + Preconditions.checkArgument(telecomCall.getDetails() + .hasProperty(CallSdkCompat.Details.PROPERTY_IS_EXTERNAL_CALL)); + 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) { + Preconditions.checkArgument(mExternalCalls.contains(telecomCall)); + mExternalCalls.remove(telecomCall); + telecomCall.unregisterCallback(mTelecomCallCallback); + notifyExternalCallRemoved(telecomCall); + } + + /** + * Adds a new listener to external call events. + */ + public void addExternalCallListener(ExternalCallListener listener) { + mExternalCallListeners.add(Preconditions.checkNotNull(listener)); + } + + /** + * Removes a listener to external call events. + */ + public void removeExternalCallListener(ExternalCallListener listener) { + Preconditions.checkArgument(mExternalCallListeners.contains(listener)); + mExternalCallListeners.remove(Preconditions.checkNotNull(listener)); + } + + /** + * 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) { + for (ExternalCallListener listener : mExternalCallListeners) { + listener.onExternalCallUpdated(call); + } + } +} diff --git a/InCallUI/src/com/android/incallui/ExternalCallNotifier.java b/InCallUI/src/com/android/incallui/ExternalCallNotifier.java new file mode 100644 index 000000000..40a2e02bf --- /dev/null +++ b/InCallUI/src/com/android/incallui/ExternalCallNotifier.java @@ -0,0 +1,406 @@ +/* + * 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 com.google.common.base.Preconditions; + +import com.android.contacts.common.ContactsUtils; +import com.android.contacts.common.compat.CallSdkCompat; +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.R; +import com.android.incallui.util.TelecomCallUtil; + +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.support.annotation.Nullable; +import android.telecom.Call; +import android.telecom.PhoneAccount; +import android.text.BidiFormatter; +import android.text.TextDirectionHeuristics; +import android.text.TextUtils; +import android.util.ArrayMap; + +import java.util.Map; + +/** + * Handles the display of notifications for "external calls". + * + * 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"; + + /** + * Represents a call and associated cached notification data. + */ + private static class NotificationInfo { + private final Call mCall; + private final int mNotificationId; + @Nullable private String mContentTitle; + @Nullable private Bitmap mLargeIcon; + @Nullable private String mPersonReference; + + public NotificationInfo(Call call, int notificationId) { + Preconditions.checkNotNull(call); + mCall = call; + mNotificationId = notificationId; + } + + public Call getCall() { + return mCall; + } + + public int getNotificationId() { + return mNotificationId; + } + + public @Nullable String getContentTitle() { + return mContentTitle; + } + + public @Nullable Bitmap getLargeIcon() { + return mLargeIcon; + } + + public @Nullable String getPersonReference() { + return mPersonReference; + } + + public void setContentTitle(@Nullable String contentTitle) { + mContentTitle = contentTitle; + } + + public void setLargeIcon(@Nullable Bitmap largeIcon) { + mLargeIcon = largeIcon; + } + + public void setPersonReference(@Nullable String personReference) { + mPersonReference = personReference; + } + } + + private final Context mContext; + private final ContactInfoCache mContactInfoCache; + private Map<Call, NotificationInfo> mNotifications = new ArrayMap<>(); + private int mNextUniqueNotificationId; + private ContactsPreferences mContactsPreferences; + + /** + * Initializes a new instance of the external call notifier. + */ + public ExternalCallNotifier(Context context, ContactInfoCache contactInfoCache) { + mContext = Preconditions.checkNotNull(context); + mContactsPreferences = ContactsPreferencesFactory.newContactsPreferences(mContext); + mContactInfoCache = Preconditions.checkNotNull(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); + Preconditions.checkArgument(!mNotifications.containsKey(call)); + 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) { + Preconditions.checkArgument(mNotifications.containsKey(call)); + postNotification(mNotifications.get(call)); + } + + /** + * Initiates a call pull given a notification ID. + * + * @param notificationId The notification ID associated with the external call which is to be + * pulled. + */ + public void pullExternalCall(int notificationId) { + for (NotificationInfo info : mNotifications.values()) { + if (info.getNotificationId() == notificationId) { + CallSdkCompat.pullExternalCall(info.getCall()); + 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. + com.android.incallui.Call incallCall = new com.android.incallui.Call(info.getCall(), + false /* registerCallback */); + + mContactInfoCache.findInfo(incallCall, 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); + } + } + + @Override + public void onContactInteractionsInfoComplete(String callId, + ContactInfoCache.ContactCacheEntry entry) { + } + }); + } + + /** + * Dismisses a notification for an external call. + */ + private void dismissNotification(Call call) { + Preconditions.checkArgument(mNotifications.containsKey(call)); + + NotificationManager notificationManager = + (NotificationManager) mContext.getSystemService(Context.NOTIFICATION_SERVICE); + notificationManager.cancel(NOTIFICATION_TAG, mNotifications.get(call).getNotificationId()); + + mNotifications.remove(call); + } + + /** + * 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) { + Log.i(this, "postNotification : " + info.getContentTitle()); + 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); + // Set the content ("Ongoing call on another device") + builder.setContentText(mContext.getString(R.string.notification_external_call)); + builder.setSmallIcon(R.drawable.ic_call_white_24dp); + 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 ((info.getCall().getDetails().getCallCapabilities() + & CallSdkCompat.Details.CAPABILITY_CAN_PULL_CALL) + == CallSdkCompat.Details.CAPABILITY_CAN_PULL_CALL) { + + 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.ic_call_white_24dp, + mContext.getText(R.string.notification_transfer_call), + PendingIntent.getBroadcast(mContext, 0, 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.ic_call_white_24dp); + 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); + } + + /** + * 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.card_title_conf_call); + } + + 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 ""; + } +} diff --git a/InCallUI/src/com/android/incallui/InCallPresenter.java b/InCallUI/src/com/android/incallui/InCallPresenter.java index 0109d7ee6..38507eef9 100644 --- a/InCallUI/src/com/android/incallui/InCallPresenter.java +++ b/InCallUI/src/com/android/incallui/InCallPresenter.java @@ -41,6 +41,7 @@ import android.view.Window; import android.view.WindowManager; import com.android.contacts.common.GeoUtil; +import com.android.contacts.common.compat.CallSdkCompat; import com.android.contacts.common.compat.CompatUtils; import com.android.contacts.common.compat.telecom.TelecomManagerCompat; import com.android.contacts.common.interactions.TouchPointManager; @@ -109,9 +110,11 @@ public class InCallPresenter implements CallList.Listener, private AudioModeProvider mAudioModeProvider; private StatusBarNotifier mStatusBarNotifier; + private ExternalCallNotifier mExternalCallNotifier; private ContactInfoCache mContactInfoCache; private Context mContext; private CallList mCallList; + private ExternalCallList mExternalCallList; private InCallActivity mInCallActivity; private InCallState mInCallState = InCallState.NO_CALLS; private ProximitySensor mProximitySensor; @@ -299,8 +302,10 @@ public class InCallPresenter implements CallList.Listener, public void setUp(Context context, CallList callList, + ExternalCallList externalCallList, AudioModeProvider audioModeProvider, StatusBarNotifier statusBarNotifier, + ExternalCallNotifier externalCallNotifier, ContactInfoCache contactInfoCache, ProximitySensor proximitySensor) { if (mServiceConnected) { @@ -318,6 +323,7 @@ public class InCallPresenter implements CallList.Listener, mContactInfoCache = contactInfoCache; mStatusBarNotifier = statusBarNotifier; + mExternalCallNotifier = externalCallNotifier; addListener(mStatusBarNotifier); mAudioModeProvider = audioModeProvider; @@ -329,6 +335,8 @@ public class InCallPresenter implements CallList.Listener, addInCallUiListener(mAnswerPresenter); mCallList = callList; + mExternalCallList = externalCallList; + externalCallList.addExternalCallListener(mExternalCallNotifier); // This only gets called by the service so this is okay. mServiceConnected = true; @@ -501,7 +509,12 @@ public class InCallPresenter implements CallList.Listener, if (shouldAttemptBlocking(call)) { maybeBlockCall(call); } else { - mCallList.onCallAdded(call); + if (call.getDetails() + .hasProperty(CallSdkCompat.Details.PROPERTY_IS_EXTERNAL_CALL)) { + mExternalCallList.onCallAdded(call); + } else { + mCallList.onCallAdded(call); + } } // Since a call has been added we are no longer waiting for Telecom to send us a call. @@ -590,8 +603,13 @@ public class InCallPresenter implements CallList.Listener, } public void onCallRemoved(android.telecom.Call call) { - mCallList.onCallRemoved(call); - call.unregisterCallback(mCallCallback); + if (call.getDetails() + .hasProperty(CallSdkCompat.Details.PROPERTY_IS_EXTERNAL_CALL)) { + mExternalCallList.onCallRemoved(call); + } else { + mCallList.onCallRemoved(call); + call.unregisterCallback(mCallCallback); + } } public void onCanAddCallChanged(boolean canAddCall) { @@ -1506,6 +1524,9 @@ public class InCallPresenter implements CallList.Listener, if (mStatusBarNotifier != null) { removeListener(mStatusBarNotifier); } + if (mExternalCallNotifier != null && mExternalCallList != null) { + mExternalCallList.removeExternalCallListener(mExternalCallNotifier); + } mStatusBarNotifier = null; if (mCallList != null) { @@ -1808,6 +1829,10 @@ public class InCallPresenter implements CallList.Listener, return mAnswerPresenter; } + ExternalCallNotifier getExternalCallNotifier() { + return mExternalCallNotifier; + } + /** * Private constructor. Must use getInstance() to get this singleton. */ diff --git a/InCallUI/src/com/android/incallui/InCallServiceImpl.java b/InCallUI/src/com/android/incallui/InCallServiceImpl.java index 86936973e..1414bc51d 100644 --- a/InCallUI/src/com/android/incallui/InCallServiceImpl.java +++ b/InCallUI/src/com/android/incallui/InCallServiceImpl.java @@ -64,8 +64,10 @@ public class InCallServiceImpl extends InCallService { InCallPresenter.getInstance().setUp( getApplicationContext(), CallList.getInstance(), + new ExternalCallList(), AudioModeProvider.getInstance(), new StatusBarNotifier(context, contactInfoCache), + new ExternalCallNotifier(context, contactInfoCache), contactInfoCache, new ProximitySensor( context, diff --git a/InCallUI/src/com/android/incallui/NotificationBroadcastReceiver.java b/InCallUI/src/com/android/incallui/NotificationBroadcastReceiver.java index 2543b783d..27f71159d 100644 --- a/InCallUI/src/com/android/incallui/NotificationBroadcastReceiver.java +++ b/InCallUI/src/com/android/incallui/NotificationBroadcastReceiver.java @@ -45,6 +45,10 @@ public class NotificationBroadcastReceiver extends BroadcastReceiver { "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"; + 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) { @@ -68,6 +72,10 @@ public class NotificationBroadcastReceiver extends BroadcastReceiver { VideoProfile.STATE_BIDIRECTIONAL, context); } else if (action.equals(ACTION_DECLINE_VIDEO_UPGRADE_REQUEST)) { InCallPresenter.getInstance().declineUpgradeRequest(context); + } else if (action.equals(ACTION_PULL_EXTERNAL_CALL)) { + int notificationId = intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1); + InCallPresenter.getInstance().getExternalCallNotifier() + .pullExternalCall(notificationId); } } diff --git a/InCallUI/src/com/android/incallui/StatusBarNotifier.java b/InCallUI/src/com/android/incallui/StatusBarNotifier.java index 7d212aa8e..a61620317 100644 --- a/InCallUI/src/com/android/incallui/StatusBarNotifier.java +++ b/InCallUI/src/com/android/incallui/StatusBarNotifier.java @@ -247,8 +247,11 @@ public class StatusBarNotifier implements InCallPresenter.InCallStateListener, getContentString(call, contactInfo.userType); final String contentTitle = getContentTitle(contactInfo, call); + final boolean isVideoUpgradeRequest = call.getSessionModificationState() + == Call.SessionModificationState.RECEIVED_UPGRADE_TO_VIDEO_REQUEST; final int notificationType; - if (callState == Call.State.INCOMING || callState == Call.State.CALL_WAITING) { + if (callState == Call.State.INCOMING || callState == Call.State.CALL_WAITING + || isVideoUpgradeRequest) { notificationType = NOTIFICATION_INCOMING_CALL; } else { notificationType = NOTIFICATION_IN_CALL; @@ -301,8 +304,6 @@ public class StatusBarNotifier implements InCallPresenter.InCallStateListener, builder.setLargeIcon(largeIcon); builder.setColor(mContext.getResources().getColor(R.color.dialer_theme_color)); - final boolean isVideoUpgradeRequest = call.getSessionModificationState() - == Call.SessionModificationState.RECEIVED_UPGRADE_TO_VIDEO_REQUEST; if (isVideoUpgradeRequest) { builder.setUsesChronometer(false); addDismissUpgradeRequestAction(builder); diff --git a/InCallUI/src/com/android/incallui/VideoCallFragment.java b/InCallUI/src/com/android/incallui/VideoCallFragment.java index cb8c6449b..2e65f81b4 100644 --- a/InCallUI/src/com/android/incallui/VideoCallFragment.java +++ b/InCallUI/src/com/android/incallui/VideoCallFragment.java @@ -499,6 +499,7 @@ public class VideoCallFragment extends BaseFragment<VideoCallPresenter, public void onPause() { super.onPause(); Log.d(this, "onPause:"); + getPresenter().cancelAutoFullScreen(); } @Override @@ -549,6 +550,7 @@ public class VideoCallFragment extends BaseFragment<VideoCallPresenter, * Hides and shows the incoming video view and changes the outgoing video view's state based on * whether outgoing view is enabled or not. */ + @Override public void showVideoViews(boolean previewPaused, boolean showIncoming) { inflateVideoUi(true); @@ -567,6 +569,7 @@ public class VideoCallFragment extends BaseFragment<VideoCallPresenter, /** * Hide all video views. */ + @Override public void hideVideoUi() { inflateVideoUi(false); } diff --git a/InCallUI/src/com/android/incallui/VideoCallPresenter.java b/InCallUI/src/com/android/incallui/VideoCallPresenter.java index 9a33d80eb..7617608d1 100644 --- a/InCallUI/src/com/android/incallui/VideoCallPresenter.java +++ b/InCallUI/src/com/android/incallui/VideoCallPresenter.java @@ -81,7 +81,9 @@ public class VideoCallPresenter extends Presenter<VideoCallPresenter.VideoCallUi private Runnable mAutoFullscreenRunnable = new Runnable() { @Override public void run() { - if (mAutoFullScreenPending && !InCallPresenter.getInstance().isDialpadVisible()) { + if (mAutoFullScreenPending && !InCallPresenter.getInstance().isDialpadVisible() + && mIsVideoMode) { + Log.v(this, "Automatically entering fullscreen mode."); InCallPresenter.getInstance().setFullScreen(true); mAutoFullScreenPending = false; @@ -258,6 +260,8 @@ public class VideoCallPresenter extends Presenter<VideoCallPresenter.VideoCallUi return; } + cancelAutoFullScreen(); + InCallPresenter.getInstance().removeListener(this); InCallPresenter.getInstance().removeDetailsListener(this); InCallPresenter.getInstance().removeIncomingCallListener(this); @@ -495,7 +499,7 @@ public class VideoCallPresenter extends Presenter<VideoCallPresenter.VideoCallUi updateCameraSelection(call); if (isVideoCall) { - enterVideoMode(call); + adjustVideoMode(call); } else if (isVideoMode()) { exitVideoMode(); } @@ -555,7 +559,7 @@ public class VideoCallPresenter extends Presenter<VideoCallPresenter.VideoCallUi Log.d(this, "onPrimaryCallChanged: Entering video mode..."); updateCameraSelection(newPrimaryCall); - enterVideoMode(newPrimaryCall); + adjustVideoMode(newPrimaryCall); } } @@ -630,7 +634,7 @@ public class VideoCallPresenter extends Presenter<VideoCallPresenter.VideoCallUi * 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 videoCall The new video call. + * @param call The new video call. */ private void changeVideoCall(Call call) { final VideoCall videoCall = call.getTelecomCall().getVideoCall(); @@ -651,13 +655,13 @@ public class VideoCallPresenter extends Presenter<VideoCallPresenter.VideoCallUi } if (VideoUtils.isVideoCall(call) && hasChanged) { - enterVideoMode(call); + adjustVideoMode(call); } } private static boolean isCameraRequired(int videoState) { - return VideoProfile.isBidirectional(videoState) || - VideoProfile.isTransmissionEnabled(videoState); + return VideoProfile.isBidirectional(videoState) + || VideoProfile.isTransmissionEnabled(videoState); } private boolean isCameraRequired() { @@ -665,14 +669,16 @@ public class VideoCallPresenter extends Presenter<VideoCallPresenter.VideoCallUi } /** - * Enters video mode by showing the video surfaces and making other adjustments (eg. audio). + * 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(vt): Need to adjust size and orientation of preview surface here. */ - private void enterVideoMode(Call call) { + private void adjustVideoMode(Call call) { VideoCall videoCall = call.getVideoCall(); int newVideoState = call.getVideoState(); - Log.d(this, "enterVideoMode videoCall= " + videoCall + " videoState: " + newVideoState); + Log.d(this, "adjustVideoMode videoCall= " + videoCall + " videoState: " + newVideoState); VideoCallUi ui = getUi(); if (ui == null) { Log.e(this, "Error VideoCallUi is null so returning"); @@ -692,16 +698,15 @@ public class VideoCallPresenter extends Presenter<VideoCallPresenter.VideoCallUi videoCall.setDeviceOrientation(mDeviceOrientation); enableCamera(videoCall, isCameraRequired(newVideoState)); } + int previousVideoState = mCurrentVideoState; mCurrentVideoState = newVideoState; - mIsVideoMode = true; - maybeAutoEnterFullscreen(call); - } - - private static boolean isSpeakerEnabledForVideoCalls() { - // TODO: Make this a carrier configurable setting. For now this is always true. b/20090407 - return 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 void enableCamera(VideoCall videoCall, boolean isCameraRequired) { @@ -1068,6 +1073,8 @@ public class VideoCallPresenter extends Presenter<VideoCallPresenter.VideoCallUi * 2. Call is not active * 3. Call is not video call * 4. Already in fullscreen mode + * 5. The current video state is not bi-directional (if the remote party stops transmitting, + * the user's contact photo would dominate in fullscreen mode). * * @param call The current call. */ @@ -1079,7 +1086,8 @@ public class VideoCallPresenter extends Presenter<VideoCallPresenter.VideoCallUi if (call == null || ( call != null && (call.getState() != Call.State.ACTIVE || !VideoUtils.isVideoCall(call)) || - InCallPresenter.getInstance().isFullscreen())) { + InCallPresenter.getInstance().isFullscreen()) || + !VideoUtils.isBidirectionalVideoCall(call)) { // Ensure any previously scheduled attempt to enter fullscreen is cancelled. cancelAutoFullScreen(); return; @@ -1106,10 +1114,6 @@ public class VideoCallPresenter extends Presenter<VideoCallPresenter.VideoCallUi mAutoFullScreenPending = false; } - private static boolean isAudioRouteEnabled(int audioRoute, int audioRouteMask) { - return ((audioRoute & audioRouteMask) != 0); - } - private static void updateCameraSelection(Call call) { Log.d(TAG, "updateCameraSelection: call=" + call); Log.d(TAG, "updateCameraSelection: call=" + toSimpleString(call)); diff --git a/InCallUI/src/com/android/incallui/VideoUtils.java b/InCallUI/src/com/android/incallui/VideoUtils.java index 8641d60ec..a2eb8bcf2 100644 --- a/InCallUI/src/com/android/incallui/VideoUtils.java +++ b/InCallUI/src/com/android/incallui/VideoUtils.java @@ -45,6 +45,14 @@ public class VideoUtils { return VideoProfile.isBidirectional(call.getVideoState()); } + public static boolean isTransmissionEnabled(Call call) { + if (!CompatUtils.isVideoCompatible()) { + return false; + } + + return VideoProfile.isTransmissionEnabled(call.getVideoState()); + } + public static boolean isIncomingVideoCall(Call call) { if (!VideoUtils.isVideoCall(call)) { return false; diff --git a/InCallUI/tests/src/com/android/incallui/ExternalCallListTest.java b/InCallUI/tests/src/com/android/incallui/ExternalCallListTest.java new file mode 100644 index 000000000..070bdf522 --- /dev/null +++ b/InCallUI/tests/src/com/android/incallui/ExternalCallListTest.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.ComponentName; +import android.content.Context; +import android.net.Uri; +import android.os.Bundle; +import android.telecom.*; +import android.telecom.Call; +import android.test.AndroidTestCase; + +import java.lang.reflect.Constructor; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +public class ExternalCallListTest extends AndroidTestCase { + + private static class Listener implements ExternalCallList.ExternalCallListener { + private CountDownLatch mCallAddedLatch = new CountDownLatch(1); + private CountDownLatch mCallRemovedLatch = new CountDownLatch(1); + private CountDownLatch mCallUpdatedLatch = new CountDownLatch(1); + + @Override + public void onExternalCallAdded(Call call) { + mCallAddedLatch.countDown(); + } + + @Override + public void onExternalCallRemoved(Call call) { + mCallRemovedLatch.countDown(); + } + + @Override + public void onExternalCallUpdated(Call call) { + mCallUpdatedLatch.countDown(); + } + + public boolean awaitCallAdded() { + try { + return mCallAddedLatch.await(WAIT_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + return false; + } + } + + public boolean awaitCallRemoved() { + try { + return mCallRemovedLatch.await(WAIT_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + return false; + } + } + + public boolean awaitCallUpdated() { + try { + return mCallUpdatedLatch.await(WAIT_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + return false; + } + } + } + + private static final int WAIT_TIMEOUT_MILLIS = 5000; + + private ExternalCallList mExternalCallList = new ExternalCallList(); + private Listener mExternalCallListener = new Listener(); + + @Override + public void setUp() throws Exception { + super.setUp(); + mExternalCallList.addExternalCallListener(mExternalCallListener); + } + + public void testAddCallSuccess() { + TestTelecomCall call = getTestCall(Call.Details.PROPERTY_IS_EXTERNAL_CALL); + mExternalCallList.onCallAdded(call.getCall()); + assertTrue(mExternalCallListener.awaitCallAdded()); + } + + public void testAddCallFail() { + TestTelecomCall call = getTestCall(0 /* no properties */); + try { + mExternalCallList.onCallAdded(call.getCall()); + fail(); + } catch (IllegalArgumentException e) { + } + } + + public void testUpdateCall() { + TestTelecomCall call = getTestCall(Call.Details.PROPERTY_IS_EXTERNAL_CALL); + mExternalCallList.onCallAdded(call.getCall()); + assertTrue(mExternalCallListener.awaitCallAdded()); + + call.forceDetailsUpdate(); + assertTrue(mExternalCallListener.awaitCallUpdated()); + } + + public void testRemoveCall() { + TestTelecomCall call = getTestCall(Call.Details.PROPERTY_IS_EXTERNAL_CALL); + mExternalCallList.onCallAdded(call.getCall()); + assertTrue(mExternalCallListener.awaitCallAdded()); + + mExternalCallList.onCallRemoved(call.getCall()); + assertTrue(mExternalCallListener.awaitCallRemoved()); + } + + private TestTelecomCall getTestCall(int properties) { + TestTelecomCall testCall = TestTelecomCall.createInstance( + "1", + Uri.parse("tel:650-555-1212"), /* handle */ + TelecomManager.PRESENTATION_ALLOWED, /* handlePresentation */ + "Joe", /* callerDisplayName */ + TelecomManager.PRESENTATION_ALLOWED, /* callerDisplayNamePresentation */ + new PhoneAccountHandle(new ComponentName("test", "class"), + "handle"), /* accountHandle */ + Call.Details.CAPABILITY_CAN_PULL_CALL, /* capabilities */ + properties, /* properties */ + null, /* disconnectCause */ + 0, /* connectTimeMillis */ + null, /* GatewayInfo */ + VideoProfile.STATE_AUDIO_ONLY, /* videoState */ + null, /* statusHints */ + null, /* extras */ + null /* intentExtras */); + return testCall; + } +} diff --git a/InCallUI/tests/src/com/android/incallui/ExternalCallNotifierTest.java b/InCallUI/tests/src/com/android/incallui/ExternalCallNotifierTest.java new file mode 100644 index 000000000..e57efef67 --- /dev/null +++ b/InCallUI/tests/src/com/android/incallui/ExternalCallNotifierTest.java @@ -0,0 +1,212 @@ +/* + * 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 static org.mockito.Matchers.anyInt; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.anyBoolean; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.eq; +import static org.mockito.Mockito.timeout; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.android.contacts.common.preference.ContactsPreferences; + +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.mockito.Spy; +import org.mockito.invocation.InvocationOnMock; +import org.mockito.stubbing.Answer; + +import android.app.Notification; +import android.app.NotificationManager; +import android.content.ComponentName; +import android.content.Context; +import android.content.res.Resources; +import android.net.Uri; +import android.telecom.*; +import android.telecom.Call; +import android.telephony.TelephonyManager; +import android.test.AndroidTestCase; +import android.test.mock.MockContext; + +/** + * Unit tests for {@link ExternalCallNotifier}. + */ +public class ExternalCallNotifierTest extends AndroidTestCase { + private static final int TIMEOUT_MILLIS = 5000; + private static final String NAME_PRIMARY = "Full Name"; + private static final String NAME_ALTERNATIVE = "Name, Full"; + private static final String LOCATION = "US"; + private static final String NUMBER = "6505551212"; + + @Mock private ContactsPreferences mContactsPreferences; + @Mock private NotificationManager mNotificationManager; + @Mock private MockContext mMockContext; + @Mock private Resources mResources; + @Mock private StatusBarNotifier mStatusBarNotifier; + @Mock private ContactInfoCache mContactInfoCache; + @Mock private TelecomManager mTelecomManager; + @Mock private TelephonyManager mTelephonyManager; + @Mock private ProximitySensor mProximitySensor; + @Mock private CallList mCallList; + private InCallPresenter mInCallPresenter; + private ExternalCallNotifier mExternalCallNotifier; + private ContactInfoCache.ContactCacheEntry mContactInfo; + + @Override + public void setUp() throws Exception { + super.setUp(); + MockitoAnnotations.initMocks(this); + + when(mContactsPreferences.getDisplayOrder()) + .thenReturn(ContactsPreferences.DISPLAY_ORDER_PRIMARY); + + // Setup the mock context to return mocks for some of the needed services; the notification + // service is especially important as we want to be able to intercept calls into it and + // validate the notifcations. + when(mMockContext.getSystemService(eq(Context.NOTIFICATION_SERVICE))) + .thenReturn(mNotificationManager); + when(mMockContext.getSystemService(eq(Context.TELECOM_SERVICE))) + .thenReturn(mTelecomManager); + when(mMockContext.getSystemService(eq(Context.TELEPHONY_SERVICE))) + .thenReturn(mTelephonyManager); + + // These aspects of the context are used by the notification builder to build the actual + // notification; we will rely on the actual implementations of these. + when(mMockContext.getPackageManager()).thenReturn(mContext.getPackageManager()); + when(mMockContext.getResources()).thenReturn(mContext.getResources()); + when(mMockContext.getApplicationInfo()).thenReturn(mContext.getApplicationInfo()); + when(mMockContext.getContentResolver()).thenReturn(mContext.getContentResolver()); + when(mMockContext.getPackageName()).thenReturn(mContext.getPackageName()); + + ContactsPreferencesFactory.setTestInstance(null); + mExternalCallNotifier = new ExternalCallNotifier(mMockContext, mContactInfoCache); + + // We don't directly use the InCallPresenter in the test, or even in ExternalCallNotifier + // itself. However, ExternalCallNotifier needs to make instances of + // com.android.incallui.Call for the purpose of performing contact cache lookups. The + // Call class depends on the static InCallPresenter for a number of things, so we need to + // set it up here to prevent crashes. + mInCallPresenter = InCallPresenter.getInstance(); + mInCallPresenter.setUp(mMockContext, mCallList, new ExternalCallList(), + null, mStatusBarNotifier, mExternalCallNotifier, mContactInfoCache, + mProximitySensor); + + // Unlocked all contact info is available + mContactInfo = new ContactInfoCache.ContactCacheEntry(); + mContactInfo.namePrimary = NAME_PRIMARY; + mContactInfo.nameAlternative = NAME_ALTERNATIVE; + mContactInfo.location = LOCATION; + mContactInfo.number = NUMBER; + + // Given the mock ContactInfoCache cache, we need to mock out what happens when the + // ExternalCallNotifier calls into the contact info cache to do a lookup. We will always + // return mock info stored in mContactInfo. + doAnswer(new Answer() { + @Override + public Object answer(InvocationOnMock invocation) throws Throwable { + Object[] args = invocation.getArguments(); + com.android.incallui.Call call = (com.android.incallui.Call) args[0]; + ContactInfoCache.ContactInfoCacheCallback callback + = (ContactInfoCache.ContactInfoCacheCallback) args[2]; + callback.onContactInfoComplete(call.getId(), mContactInfo); + return null; + } + }).when(mContactInfoCache).findInfo(any(com.android.incallui.Call.class), anyBoolean(), + any(ContactInfoCache.ContactInfoCacheCallback.class)); + } + + @Override + public void tearDown() throws Exception { + super.tearDown(); + ContactsPreferencesFactory.setTestInstance(null); + mInCallPresenter.tearDown(); + } + + public void testPostNonPullable() { + TestTelecomCall call = getTestCall(false); + mExternalCallNotifier.onExternalCallAdded(call.getCall()); + Notification notification = verifyNotificationPosted(); + assertNull(notification.actions); + } + + public void testPostPullable() { + TestTelecomCall call = getTestCall(true); + mExternalCallNotifier.onExternalCallAdded(call.getCall()); + Notification notification = verifyNotificationPosted(); + assertEquals(1, notification.actions.length); + } + + public void testNotificationDismissed() { + TestTelecomCall call = getTestCall(false); + mExternalCallNotifier.onExternalCallAdded(call.getCall()); + verifyNotificationPosted(); + + mExternalCallNotifier.onExternalCallRemoved(call.getCall()); + verify(mNotificationManager, timeout(TIMEOUT_MILLIS)).cancel(eq("EXTERNAL_CALL"), eq(0)); + } + + public void testNotificationUpdated() { + TestTelecomCall call = getTestCall(false); + mExternalCallNotifier.onExternalCallAdded(call.getCall()); + verifyNotificationPosted(); + + call.setCapabilities(android.telecom.Call.Details.CAPABILITY_CAN_PULL_CALL); + mExternalCallNotifier.onExternalCallUpdated(call.getCall()); + + ArgumentCaptor<Notification> notificationCaptor = + ArgumentCaptor.forClass(Notification.class); + verify(mNotificationManager, timeout(TIMEOUT_MILLIS).times(2)) + .notify(eq("EXTERNAL_CALL"), eq(0), notificationCaptor.capture()); + Notification notification1 = notificationCaptor.getAllValues().get(0); + assertNull(notification1.actions); + Notification notification2 = notificationCaptor.getAllValues().get(1); + assertEquals(1, notification2.actions.length); + } + + private Notification verifyNotificationPosted() { + ArgumentCaptor<Notification> notificationCaptor = + ArgumentCaptor.forClass(Notification.class); + verify(mNotificationManager, timeout(TIMEOUT_MILLIS)) + .notify(eq("EXTERNAL_CALL"), eq(0), notificationCaptor.capture()); + return notificationCaptor.getValue(); + } + + private TestTelecomCall getTestCall(boolean canPull) { + TestTelecomCall testCall = TestTelecomCall.createInstance( + "1", + Uri.parse("tel:650-555-1212"), /* handle */ + TelecomManager.PRESENTATION_ALLOWED, /* handlePresentation */ + "Joe", /* callerDisplayName */ + TelecomManager.PRESENTATION_ALLOWED, /* callerDisplayNamePresentation */ + new PhoneAccountHandle(new ComponentName("test", "class"), + "handle"), /* accountHandle */ + canPull ? android.telecom.Call.Details.CAPABILITY_CAN_PULL_CALL : 0, /* capabilities */ + Call.Details.PROPERTY_IS_EXTERNAL_CALL, /* properties */ + null, /* disconnectCause */ + 0, /* connectTimeMillis */ + null, /* GatewayInfo */ + VideoProfile.STATE_AUDIO_ONLY, /* videoState */ + null, /* statusHints */ + null, /* extras */ + null /* intentExtras */); + return testCall; + } +} diff --git a/InCallUI/tests/src/com/android/incallui/InCallPresenterTest.java b/InCallUI/tests/src/com/android/incallui/InCallPresenterTest.java index ed8d6223c..f0f08ab68 100644 --- a/InCallUI/tests/src/com/android/incallui/InCallPresenterTest.java +++ b/InCallUI/tests/src/com/android/incallui/InCallPresenterTest.java @@ -39,6 +39,7 @@ public class InCallPresenterTest extends InstrumentationTestCase { @Mock private InCallActivity mInCallActivity; @Mock private AudioModeProvider mAudioModeProvider; @Mock private StatusBarNotifier mStatusBarNotifier; + @Mock private ExternalCallNotifier mExternalCallNotifier; @Mock private ContactInfoCache mContactInfoCache; @Mock private ProximitySensor mProximitySensor; @@ -57,8 +58,9 @@ public class InCallPresenterTest extends InstrumentationTestCase { when(mContext.getSystemService(Context.TELEPHONY_SERVICE)).thenReturn(mTelephonyManager); mInCallPresenter = InCallPresenter.getInstance(); - mInCallPresenter.setUp(mContext, mCallList.getCallList(), mAudioModeProvider, - mStatusBarNotifier, mContactInfoCache, mProximitySensor); + mInCallPresenter.setUp(mContext, mCallList.getCallList(), new ExternalCallList(), + mAudioModeProvider, mStatusBarNotifier, mExternalCallNotifier, mContactInfoCache, + mProximitySensor); } @Override diff --git a/InCallUI/tests/src/com/android/incallui/TestTelecomCall.java b/InCallUI/tests/src/com/android/incallui/TestTelecomCall.java new file mode 100644 index 000000000..48ac6e18f --- /dev/null +++ b/InCallUI/tests/src/com/android/incallui/TestTelecomCall.java @@ -0,0 +1,161 @@ +/* + * 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 com.google.common.base.Preconditions; + +import android.content.Context; +import android.net.Uri; +import android.os.Bundle; +import android.support.annotation.Nullable; +import android.telecom.DisconnectCause; +import android.telecom.GatewayInfo; +import android.telecom.PhoneAccountHandle; +import android.telecom.StatusHints; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; + +/** + * Wrapper class which uses reflection to create instances of {@link android.telecom.Call} for use + * with unit testing. Since {@link android.telecom.Call} is final, it cannot be mocked using + * mockito, and since all setter methods are hidden, it is necessary to use reflection. In the + * future, it would be desirable to replace this if a different mocking solution is used. + */ +public class TestTelecomCall { + + private android.telecom.Call mCall; + + public static @Nullable TestTelecomCall createInstance(String callId, + Uri handle, + int handlePresentation, + String callerDisplayName, + int callerDisplayNamePresentation, + PhoneAccountHandle accountHandle, + int capabilities, + int properties, + DisconnectCause disconnectCause, + long connectTimeMillis, + GatewayInfo gatewayInfo, + int videoState, + StatusHints statusHints, + Bundle extras, + Bundle intentExtras) { + + try { + // Phone and InCall adapter are @hide, so we cannot refer to them directly. + Class<?> phoneClass = Class.forName("android.telecom.Phone"); + Class<?> incallAdapterClass = Class.forName("android.telecom.InCallAdapter"); + Class<?> callClass = android.telecom.Call.class; + Constructor<?> cons = callClass + .getDeclaredConstructor(phoneClass, String.class, incallAdapterClass); + cons.setAccessible(true); + + android.telecom.Call call = (android.telecom.Call) cons.newInstance(null, callId, null); + + // Create an instance of the call details. + Class<?> callDetailsClass = android.telecom.Call.Details.class; + Constructor<?> detailsCons = callDetailsClass.getDeclaredConstructor( + String.class, /* telecomCallId */ + Uri.class, /* handle */ + int.class, /* handlePresentation */ + String.class, /* callerDisplayName */ + int.class, /* callerDisplayNamePresentation */ + PhoneAccountHandle.class, /* accountHandle */ + int.class, /* capabilities */ + int.class, /* properties */ + DisconnectCause.class, /* disconnectCause */ + long.class, /* connectTimeMillis */ + GatewayInfo.class, /* gatewayInfo */ + int.class, /* videoState */ + StatusHints.class, /* statusHints */ + Bundle.class, /* extras */ + Bundle.class /* intentExtras */); + detailsCons.setAccessible(true); + + android.telecom.Call.Details details = (android.telecom.Call.Details) + detailsCons.newInstance(callId, handle, handlePresentation, callerDisplayName, + callerDisplayNamePresentation, accountHandle, capabilities, properties, + disconnectCause, connectTimeMillis, gatewayInfo, videoState, + statusHints, + extras, intentExtras); + + // Finally, set this as the details of the call. + Field detailsField = call.getClass().getDeclaredField("mDetails"); + detailsField.setAccessible(true); + detailsField.set(call, details); + + return new TestTelecomCall(call); + } catch (NoSuchMethodException nsm) { + return null; + } catch (ClassNotFoundException cnf) { + return null; + } catch (IllegalAccessException e) { + return null; + } catch (InstantiationException e) { + return null; + } catch (InvocationTargetException e) { + return null; + } catch (NoSuchFieldException e) { + return null; + } + } + + private TestTelecomCall(android.telecom.Call call) { + mCall = call; + } + + public android.telecom.Call getCall() { + return mCall; + } + + public void forceDetailsUpdate() { + Preconditions.checkNotNull(mCall); + + try { + Method method = mCall.getClass().getDeclaredMethod("fireDetailsChanged", + android.telecom.Call.Details.class); + method.setAccessible(true); + method.invoke(mCall, mCall.getDetails()); + } catch (NoSuchMethodException e) { + Log.e(this, "forceDetailsUpdate", e); + } catch (InvocationTargetException e) { + Log.e(this, "forceDetailsUpdate", e); + } catch (IllegalAccessException e) { + Log.e(this, "forceDetailsUpdate", e); + } + } + + public void setCapabilities(int capabilities) { + Preconditions.checkNotNull(mCall); + try { + Field field = mCall.getDetails().getClass().getDeclaredField("mCallCapabilities"); + field.setAccessible(true); + field.set(mCall.getDetails(), capabilities); + } catch (IllegalAccessException e) { + Log.e(this, "setProperties", e); + } catch (NoSuchFieldException e) { + Log.e(this, "setProperties", e); + } + } + + public void setCall(android.telecom.Call call) { + mCall = call; + } +} |