/* * 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.phone; import android.animation.Animator; import android.animation.AnimatorListenerAdapter; import android.content.Context; import android.graphics.drawable.LayerDrawable; import android.os.Handler; import android.os.Message; import android.os.SystemClock; import android.text.TextUtils; import android.util.AttributeSet; import android.util.Log; import android.view.Gravity; import android.view.Menu; import android.view.MenuItem; import android.view.MotionEvent; import android.view.View; import android.view.ViewGroup; import android.view.ViewPropertyAnimator; import android.view.ViewStub; import android.view.animation.AlphaAnimation; import android.view.animation.Animation; import android.view.animation.Animation.AnimationListener; import android.widget.CompoundButton; import android.widget.FrameLayout; import android.widget.ImageButton; import android.widget.PopupMenu; import android.widget.TextView; import android.widget.Toast; import com.android.internal.telephony.Call; import com.android.internal.telephony.CallManager; import com.android.internal.telephony.Phone; import com.android.internal.telephony.PhoneConstants; import com.android.internal.widget.multiwaveview.GlowPadView; import com.android.internal.widget.multiwaveview.GlowPadView.OnTriggerListener; import com.android.phone.InCallUiState.InCallScreenMode; /** * In-call onscreen touch UI elements, used on some platforms. * * This widget is a fullscreen overlay, drawn on top of the * non-touch-sensitive parts of the in-call UI (i.e. the call card). */ public class InCallTouchUi extends FrameLayout implements View.OnClickListener, View.OnLongClickListener, OnTriggerListener, PopupMenu.OnMenuItemClickListener, PopupMenu.OnDismissListener { private static final String LOG_TAG = "InCallTouchUi"; private static final boolean DBG = (PhoneGlobals.DBG_LEVEL >= 2); // Incoming call widget targets private static final int ANSWER_CALL_ID = 0; // drag right private static final int SEND_SMS_ID = 1; // drag up private static final int DECLINE_CALL_ID = 2; // drag left /** * Reference to the InCallScreen activity that owns us. This may be * null if we haven't been initialized yet *or* after the InCallScreen * activity has been destroyed. */ private InCallScreen mInCallScreen; // Phone app instance private PhoneGlobals mApp; // UI containers / elements private GlowPadView mIncomingCallWidget; // UI used for an incoming call private boolean mIncomingCallWidgetIsFadingOut; private boolean mIncomingCallWidgetShouldBeReset = true; /** UI elements while on a regular call (bottom buttons, DTMF dialpad) */ private View mInCallControls; private boolean mShowInCallControlsDuringHidingAnimation; // private ImageButton mAddButton; private ImageButton mMergeButton; private ImageButton mEndButton; private CompoundButton mDialpadButton; private CompoundButton mMuteButton; private CompoundButton mAudioButton; private CompoundButton mHoldButton; private ImageButton mSwapButton; private View mHoldSwapSpacer; // "Extra button row" private ViewStub mExtraButtonRow; private ViewGroup mCdmaMergeButton; private ViewGroup mManageConferenceButton; private ImageButton mManageConferenceButtonImage; // "Audio mode" PopupMenu private PopupMenu mAudioModePopup; private boolean mAudioModePopupVisible = false; // Time of the most recent "answer" or "reject" action (see updateState()) private long mLastIncomingCallActionTime; // in SystemClock.uptimeMillis() time base // Parameters for the GlowPadView "ping" animation; see triggerPing(). private static final boolean ENABLE_PING_ON_RING_EVENTS = false; private static final boolean ENABLE_PING_AUTO_REPEAT = true; private static final long PING_AUTO_REPEAT_DELAY_MSEC = 1200; private static final int INCOMING_CALL_WIDGET_PING = 101; private Handler mHandler = new Handler() { @Override public void handleMessage(Message msg) { // If the InCallScreen activity isn't around any more, // there's no point doing anything here. if (mInCallScreen == null) return; switch (msg.what) { case INCOMING_CALL_WIDGET_PING: if (DBG) log("INCOMING_CALL_WIDGET_PING..."); triggerPing(); break; default: Log.wtf(LOG_TAG, "mHandler: unexpected message: " + msg); break; } } }; public InCallTouchUi(Context context, AttributeSet attrs) { super(context, attrs); if (DBG) log("InCallTouchUi constructor..."); if (DBG) log("- this = " + this); if (DBG) log("- context " + context + ", attrs " + attrs); mApp = PhoneGlobals.getInstance(); } void setInCallScreenInstance(InCallScreen inCallScreen) { mInCallScreen = inCallScreen; } @Override protected void onFinishInflate() { super.onFinishInflate(); if (DBG) log("InCallTouchUi onFinishInflate(this = " + this + ")..."); // Look up the various UI elements. // "Drag-to-answer" widget for incoming calls. mIncomingCallWidget = (GlowPadView) findViewById(R.id.incomingCallWidget); mIncomingCallWidget.setOnTriggerListener(this); // Container for the UI elements shown while on a regular call. mInCallControls = findViewById(R.id.inCallControls); // Regular (single-tap) buttons, where we listen for click events: // Main cluster of buttons: mAddButton = (ImageButton) mInCallControls.findViewById(R.id.addButton); mAddButton.setOnClickListener(this); mAddButton.setOnLongClickListener(this); mMergeButton = (ImageButton) mInCallControls.findViewById(R.id.mergeButton); mMergeButton.setOnClickListener(this); mMergeButton.setOnLongClickListener(this); mEndButton = (ImageButton) mInCallControls.findViewById(R.id.endButton); mEndButton.setOnClickListener(this); mDialpadButton = (CompoundButton) mInCallControls.findViewById(R.id.dialpadButton); mDialpadButton.setOnClickListener(this); mDialpadButton.setOnLongClickListener(this); mMuteButton = (CompoundButton) mInCallControls.findViewById(R.id.muteButton); mMuteButton.setOnClickListener(this); mMuteButton.setOnLongClickListener(this); mAudioButton = (CompoundButton) mInCallControls.findViewById(R.id.audioButton); mAudioButton.setOnClickListener(this); mAudioButton.setOnLongClickListener(this); mHoldButton = (CompoundButton) mInCallControls.findViewById(R.id.holdButton); mHoldButton.setOnClickListener(this); mHoldButton.setOnLongClickListener(this); mSwapButton = (ImageButton) mInCallControls.findViewById(R.id.swapButton); mSwapButton.setOnClickListener(this); mSwapButton.setOnLongClickListener(this); mHoldSwapSpacer = mInCallControls.findViewById(R.id.holdSwapSpacer); // TODO: Back when these buttons had text labels, we changed // the label of mSwapButton for CDMA as follows: // // if (PhoneApp.getPhone().getPhoneType() == PhoneConstants.PHONE_TYPE_CDMA) { // // In CDMA we use a generalized text - "Manage call", as behavior on selecting // // this option depends entirely on what the current call state is. // mSwapButtonLabel.setText(R.string.onscreenManageCallsText); // } else { // mSwapButtonLabel.setText(R.string.onscreenSwapCallsText); // } // // If this is still needed, consider having a special icon for this // button in CDMA. // Buttons shown on the "extra button row", only visible in certain (rare) states. mExtraButtonRow = (ViewStub) mInCallControls.findViewById(R.id.extraButtonRow); // If in PORTRAIT, add a custom OnTouchListener to shrink the "hit target". if (!PhoneUtils.isLandscape(this.getContext())) { mEndButton.setOnTouchListener(new SmallerHitTargetTouchListener()); } } /** * Updates the visibility and/or state of our UI elements, based on * the current state of the phone. * * TODO: This function should be relying on a state defined by InCallScreen, * and not generic call states. The incoming call screen handles more states * than Call.State or PhoneConstant.State know about. */ /* package */ void updateState(CallManager cm) { if (mInCallScreen == null) { log("- updateState: mInCallScreen has been destroyed; bailing out..."); return; } PhoneConstants.State state = cm.getState(); // IDLE, RINGING, or OFFHOOK if (DBG) log("updateState: current state = " + state); boolean showIncomingCallControls = false; boolean showInCallControls = false; final Call ringingCall = cm.getFirstActiveRingingCall(); final Call.State fgCallState = cm.getActiveFgCallState(); // If the FG call is dialing/alerting, we should display for that call // and ignore the ringing call. This case happens when the telephony // layer rejects the ringing call while the FG call is dialing/alerting, // but the incoming call *does* briefly exist in the DISCONNECTING or // DISCONNECTED state. if ((ringingCall.getState() != Call.State.IDLE) && !fgCallState.isDialing()) { // A phone call is ringing *or* call waiting. // Watch out: even if the phone state is RINGING, it's // possible for the ringing call to be in the DISCONNECTING // state. (This typically happens immediately after the user // rejects an incoming call, and in that case we *don't* show // the incoming call controls.) if (ringingCall.getState().isAlive()) { if (DBG) log("- updateState: RINGING! Showing incoming call controls..."); showIncomingCallControls = true; } // Ugly hack to cover up slow response from the radio: // if we get an updateState() call immediately after answering/rejecting a call // (via onTrigger()), *don't* show the incoming call // UI even if the phone is still in the RINGING state. // This covers up a slow response from the radio for some actions. // To detect that situation, we are using "500 msec" heuristics. // // Watch out: we should *not* rely on this behavior when "instant text response" action // has been chosen. See also onTrigger() for why. long now = SystemClock.uptimeMillis(); if (now < mLastIncomingCallActionTime + 500) { log("updateState: Too soon after last action; not drawing!"); showIncomingCallControls = false; } // b/6765896 // If the glowview triggers two hits of the respond-via-sms gadget in // quick succession, it can cause the incoming call widget to show and hide // twice in a row. However, the second hide doesn't get triggered because // we are already attemping to hide. This causes an additional glowview to // stay up above all other screens. // In reality, we shouldn't even be showing incoming-call UI while we are // showing the respond-via-sms popup, so we check for that here. // // TODO: In the future, this entire state machine // should be reworked. Respond-via-sms was stapled onto the current // design (and so were other states) and should be made a first-class // citizen in a new state machine. if (mInCallScreen.isQuickResponseDialogShowing()) { log("updateState: quickResponse visible. Cancel showing incoming call controls."); showIncomingCallControls = false; } } else { // Ok, show the regular in-call touch UI (with some exceptions): if (okToShowInCallControls()) { showInCallControls = true; } else { if (DBG) log("- updateState: NOT OK to show touch UI; disabling..."); } } // In usual cases we don't allow showing both incoming call controls and in-call controls. // // There's one exception: if this call is during fading-out animation for the incoming // call controls, we need to show both for smoother transition. if (showIncomingCallControls && showInCallControls) { throw new IllegalStateException( "'Incoming' and 'in-call' touch controls visible at the same time!"); } if (mShowInCallControlsDuringHidingAnimation) { if (DBG) { log("- updateState: FORCE showing in-call controls during incoming call widget" + " being hidden with animation"); } showInCallControls = true; } // Update visibility and state of the incoming call controls or // the normal in-call controls. if (showInCallControls) { if (DBG) log("- updateState: showing in-call controls..."); updateInCallControls(cm); mInCallControls.setVisibility(View.VISIBLE); } else { if (DBG) log("- updateState: HIDING in-call controls..."); mInCallControls.setVisibility(View.GONE); } if (showIncomingCallControls) { if (DBG) log("- updateState: showing incoming call widget..."); showIncomingCallWidget(ringingCall); // On devices with a system bar (soft buttons at the bottom of // the screen), disable navigation while the incoming-call UI // is up. // This prevents false touches (e.g. on the "Recents" button) // from interfering with the incoming call UI, like if you // accidentally touch the system bar while pulling the phone // out of your pocket. mApp.notificationMgr.statusBarHelper.enableSystemBarNavigation(false); } else { if (DBG) log("- updateState: HIDING incoming call widget..."); hideIncomingCallWidget(); // The system bar is allowed to work normally in regular // in-call states. mApp.notificationMgr.statusBarHelper.enableSystemBarNavigation(true); } // Dismiss the "Audio mode" PopupMenu if necessary. // // The "Audio mode" popup is only relevant in call states that support // in-call audio, namely when the phone is OFFHOOK (not RINGING), *and* // the foreground call is either ALERTING (where you can hear the other // end ringing) or ACTIVE (when the call is actually connected.) In any // state *other* than these, the popup should not be visible. if ((state == PhoneConstants.State.OFFHOOK) && (fgCallState == Call.State.ALERTING || fgCallState == Call.State.ACTIVE)) { // The audio mode popup is allowed to be visible in this state. // So if it's up, leave it alone. } else { // The Audio mode popup isn't relevant in this state, so make sure // it's not visible. dismissAudioModePopup(); // safe even if not active } } private boolean okToShowInCallControls() { // Note that this method is concerned only with the internal state // of the InCallScreen. (The InCallTouchUi widget has separate // logic to make sure it's OK to display the touch UI given the // current telephony state, and that it's allowed on the current // device in the first place.) // The touch UI is available in the following InCallScreenModes: // - NORMAL (obviously) // - CALL_ENDED (which is intended to look mostly the same as // a normal in-call state, even though the in-call // buttons are mostly disabled) // and is hidden in any of the other modes, like MANAGE_CONFERENCE // or one of the OTA modes (which use totally different UIs.) return ((mApp.inCallUiState.inCallScreenMode == InCallScreenMode.NORMAL) || (mApp.inCallUiState.inCallScreenMode == InCallScreenMode.CALL_ENDED)); } @Override public void onClick(View view) { int id = view.getId(); if (DBG) log("onClick(View " + view + ", id " + id + ")..."); switch (id) { case R.id.addButton: case R.id.mergeButton: case R.id.endButton: case R.id.dialpadButton: case R.id.muteButton: case R.id.holdButton: case R.id.swapButton: case R.id.cdmaMergeButton: case R.id.manageConferenceButton: // Clicks on the regular onscreen buttons get forwarded // straight to the InCallScreen. mInCallScreen.handleOnscreenButtonClick(id); break; case R.id.audioButton: handleAudioButtonClick(); break; default: Log.w(LOG_TAG, "onClick: unexpected click: View " + view + ", id " + id); break; } } @Override public boolean onLongClick(View view) { final int id = view.getId(); if (DBG) log("onLongClick(View " + view + ", id " + id + ")..."); switch (id) { case R.id.addButton: case R.id.mergeButton: case R.id.dialpadButton: case R.id.muteButton: case R.id.holdButton: case R.id.swapButton: case R.id.audioButton: { final CharSequence description = view.getContentDescription(); if (!TextUtils.isEmpty(description)) { // Show description as ActionBar's menu buttons do. // See also ActionMenuItemView#onLongClick() for the original implementation. final Toast cheatSheet = Toast.makeText(view.getContext(), description, Toast.LENGTH_SHORT); cheatSheet.setGravity( Gravity.BOTTOM | Gravity.CENTER_HORIZONTAL, 0, view.getHeight()); cheatSheet.show(); } return true; } default: Log.w(LOG_TAG, "onLongClick() with unexpected View " + view + ". Ignoring it."); break; } return false; } /** * Updates the enabledness and "checked" state of the buttons on the * "inCallControls" panel, based on the current telephony state. */ private void updateInCallControls(CallManager cm) { int phoneType = cm.getActiveFgCall().getPhone().getPhoneType(); // Note we do NOT need to worry here about cases where the entire // in-call touch UI is disabled, like during an OTA call or if the // dtmf dialpad is up. (That's handled by updateState(), which // calls okToShowInCallControls().) // // If we get here, it *is* OK to show the in-call touch UI, so we // now need to update the enabledness and/or "checked" state of // each individual button. // // The InCallControlState object tells us the enabledness and/or // state of the various onscreen buttons: InCallControlState inCallControlState = mInCallScreen.getUpdatedInCallControlState(); if (DBG) { log("updateInCallControls()..."); inCallControlState.dumpState(); } // "Add" / "Merge": // These two buttons occupy the same space onscreen, so at any // given point exactly one of them must be VISIBLE and the other // must be GONE. if (inCallControlState.canAddCall) { mAddButton.setVisibility(View.VISIBLE); mAddButton.setEnabled(true); mMergeButton.setVisibility(View.GONE); } else if (inCallControlState.canMerge) { if (phoneType == PhoneConstants.PHONE_TYPE_CDMA) { // In CDMA "Add" option is always given to the user and the // "Merge" option is provided as a button on the top left corner of the screen, // we always set the mMergeButton to GONE mMergeButton.setVisibility(View.GONE); } else if ((phoneType == PhoneConstants.PHONE_TYPE_GSM) || (phoneType == PhoneConstants.PHONE_TYPE_SIP)) { mMergeButton.setVisibility(View.VISIBLE); mMergeButton.setEnabled(true); mAddButton.setVisibility(View.GONE); } else { throw new IllegalStateException("Unexpected phone type: " + phoneType); } } else { // Neither "Add" nor "Merge" is available. (This happens in // some transient states, like while dialing an outgoing call, // and in other rare cases like if you have both lines in use // *and* there are already 5 people on the conference call.) // Since the common case here is "while dialing", we show the // "Add" button in a disabled state so that there won't be any // jarring change in the UI when the call finally connects. mAddButton.setVisibility(View.VISIBLE); mAddButton.setEnabled(false); mMergeButton.setVisibility(View.GONE); } if (inCallControlState.canAddCall && inCallControlState.canMerge) { if ((phoneType == PhoneConstants.PHONE_TYPE_GSM) || (phoneType == PhoneConstants.PHONE_TYPE_SIP)) { // Uh oh, the InCallControlState thinks that "Add" *and* "Merge" // should both be available right now. This *should* never // happen with GSM, but if it's possible on any // future devices we may need to re-layout Add and Merge so // they can both be visible at the same time... Log.w(LOG_TAG, "updateInCallControls: Add *and* Merge enabled," + " but can't show both!"); } else if (phoneType == PhoneConstants.PHONE_TYPE_CDMA) { // In CDMA "Add" option is always given to the user and the hence // in this case both "Add" and "Merge" options would be available to user if (DBG) log("updateInCallControls: CDMA: Add and Merge both enabled"); } else { throw new IllegalStateException("Unexpected phone type: " + phoneType); } } // "End call" mEndButton.setEnabled(inCallControlState.canEndCall); // "Dialpad": Enabled only when it's OK to use the dialpad in the // first place. mDialpadButton.setEnabled(inCallControlState.dialpadEnabled); mDialpadButton.setChecked(inCallControlState.dialpadVisible); // "Mute" mMuteButton.setEnabled(inCallControlState.canMute); mMuteButton.setChecked(inCallControlState.muteIndicatorOn); // "Audio" updateAudioButton(inCallControlState); // "Hold" / "Swap": // These two buttons occupy the same space onscreen, so at any // given point exactly one of them must be VISIBLE and the other // must be GONE. if (inCallControlState.canHold) { mHoldButton.setVisibility(View.VISIBLE); mHoldButton.setEnabled(true); mHoldButton.setChecked(inCallControlState.onHold); mSwapButton.setVisibility(View.GONE); mHoldSwapSpacer.setVisibility(View.VISIBLE); } else if (inCallControlState.canSwap) { mSwapButton.setVisibility(View.VISIBLE); mSwapButton.setEnabled(true); mHoldButton.setVisibility(View.GONE); mHoldSwapSpacer.setVisibility(View.VISIBLE); } else { // Neither "Hold" nor "Swap" is available. This can happen for two // reasons: // (1) this is a transient state on a device that *can* // normally hold or swap, or // (2) this device just doesn't have the concept of hold/swap. // // In case (1), show the "Hold" button in a disabled state. In case // (2), remove the button entirely. (This means that the button row // will only have 4 buttons on some devices.) if (inCallControlState.supportsHold) { mHoldButton.setVisibility(View.VISIBLE); mHoldButton.setEnabled(false); mHoldButton.setChecked(false); mSwapButton.setVisibility(View.GONE); mHoldSwapSpacer.setVisibility(View.VISIBLE); } else { mHoldButton.setVisibility(View.GONE); mSwapButton.setVisibility(View.GONE); mHoldSwapSpacer.setVisibility(View.GONE); } } mInCallScreen.updateButtonStateOutsideInCallTouchUi(); if (inCallControlState.canSwap && inCallControlState.canHold) { // Uh oh, the InCallControlState thinks that Swap *and* Hold // should both be available. This *should* never happen with // either GSM or CDMA, but if it's possible on any future // devices we may need to re-layout Hold and Swap so they can // both be visible at the same time... Log.w(LOG_TAG, "updateInCallControls: Hold *and* Swap enabled, but can't show both!"); } if (phoneType == PhoneConstants.PHONE_TYPE_CDMA) { if (inCallControlState.canSwap && inCallControlState.canMerge) { // Uh oh, the InCallControlState thinks that Swap *and* Merge // should both be available. This *should* never happen with // CDMA, but if it's possible on any future // devices we may need to re-layout Merge and Swap so they can // both be visible at the same time... Log.w(LOG_TAG, "updateInCallControls: Merge *and* Swap" + "enabled, but can't show both!"); } } // Finally, update the "extra button row": It's displayed above the // "End" button, but only if necessary. Also, it's never displayed // while the dialpad is visible (since it would overlap.) // // The row contains two buttons: // // - "Manage conference" (used only on GSM devices) // - "Merge" button (used only on CDMA devices) // // Note that mExtraButtonRow is ViewStub, which will be inflated for the first time when // any of its buttons becomes visible. final boolean showCdmaMerge = (phoneType == PhoneConstants.PHONE_TYPE_CDMA) && inCallControlState.canMerge; final boolean showExtraButtonRow = showCdmaMerge || inCallControlState.manageConferenceVisible; if (showExtraButtonRow && !inCallControlState.dialpadVisible) { // This will require the ViewStub inflate itself. mExtraButtonRow.setVisibility(View.VISIBLE); // Need to set up mCdmaMergeButton and mManageConferenceButton if this is the first // time they're visible. if (mCdmaMergeButton == null) { setupExtraButtons(); } mCdmaMergeButton.setVisibility(showCdmaMerge ? View.VISIBLE : View.GONE); if (inCallControlState.manageConferenceVisible) { mManageConferenceButton.setVisibility(View.VISIBLE); mManageConferenceButtonImage.setEnabled(inCallControlState.manageConferenceEnabled); } else { mManageConferenceButton.setVisibility(View.GONE); } } else { mExtraButtonRow.setVisibility(View.GONE); } if (DBG) { log("At the end of updateInCallControls()."); dumpBottomButtonState(); } } /** * Set up the buttons that are part of the "extra button row" */ private void setupExtraButtons() { // The two "buttons" here (mCdmaMergeButton and mManageConferenceButton) // are actually layouts containing an icon and a text label side-by-side. mCdmaMergeButton = (ViewGroup) mInCallControls.findViewById(R.id.cdmaMergeButton); if (mCdmaMergeButton == null) { Log.wtf(LOG_TAG, "CDMA Merge button is null even after ViewStub being inflated."); return; } mCdmaMergeButton.setOnClickListener(this); mManageConferenceButton = (ViewGroup) mInCallControls.findViewById(R.id.manageConferenceButton); mManageConferenceButton.setOnClickListener(this); mManageConferenceButtonImage = (ImageButton) mInCallControls.findViewById(R.id.manageConferenceButtonImage); } private void dumpBottomButtonState() { log(" - dialpad: " + getButtonState(mDialpadButton)); log(" - speaker: " + getButtonState(mAudioButton)); log(" - mute: " + getButtonState(mMuteButton)); log(" - hold: " + getButtonState(mHoldButton)); log(" - swap: " + getButtonState(mSwapButton)); log(" - add: " + getButtonState(mAddButton)); log(" - merge: " + getButtonState(mMergeButton)); log(" - cdmaMerge: " + getButtonState(mCdmaMergeButton)); log(" - swap: " + getButtonState(mSwapButton)); log(" - manageConferenceButton: " + getButtonState(mManageConferenceButton)); } private static String getButtonState(View view) { if (view == null) { return "(null)"; } StringBuilder builder = new StringBuilder(); builder.append("visibility: " + (view.getVisibility() == View.VISIBLE ? "VISIBLE" : view.getVisibility() == View.INVISIBLE ? "INVISIBLE" : "GONE")); if (view instanceof ImageButton) { builder.append(", enabled: " + ((ImageButton) view).isEnabled()); } else if (view instanceof CompoundButton) { builder.append(", enabled: " + ((CompoundButton) view).isEnabled()); builder.append(", checked: " + ((CompoundButton) view).isChecked()); } return builder.toString(); } /** * Updates the onscreen "Audio mode" button based on the current state. * * - If bluetooth is available, this button's function is to bring up the * "Audio mode" popup (which provides a 3-way choice between earpiece / * speaker / bluetooth). So it should look like a regular action button, * but should also have the small "more_indicator" triangle that indicates * that a menu will pop up. * * - If speaker (but not bluetooth) is available, this button should look like * a regular toggle button (and indicate the current speaker state.) * * - If even speaker isn't available, disable the button entirely. */ private void updateAudioButton(InCallControlState inCallControlState) { if (DBG) log("updateAudioButton()..."); // The various layers of artwork for this button come from // btn_compound_audio.xml. Keep track of which layers we want to be // visible: // // - This selector shows the blue bar below the button icon when // this button is a toggle *and* it's currently "checked". boolean showToggleStateIndication = false; // // - This is visible if the popup menu is enabled: boolean showMoreIndicator = false; // // - Foreground icons for the button. Exactly one of these is enabled: boolean showSpeakerOnIcon = false; boolean showSpeakerOffIcon = false; boolean showHandsetIcon = false; boolean showBluetoothIcon = false; if (inCallControlState.bluetoothEnabled) { if (DBG) log("- updateAudioButton: 'popup menu action button' mode..."); mAudioButton.setEnabled(true); // The audio button is NOT a toggle in this state. (And its // setChecked() state is irrelevant since we completely hide the // btn_compound_background layer anyway.) // Update desired layers: showMoreIndicator = true; if (inCallControlState.bluetoothIndicatorOn) { showBluetoothIcon = true; } else if (inCallControlState.speakerOn) { showSpeakerOnIcon = true; } else { showHandsetIcon = true; // TODO: if a wired headset is plugged in, that takes precedence // over the handset earpiece. If so, maybe we should show some // sort of "wired headset" icon here instead of the "handset // earpiece" icon. (Still need an asset for that, though.) } } else if (inCallControlState.speakerEnabled) { if (DBG) log("- updateAudioButton: 'speaker toggle' mode..."); mAudioButton.setEnabled(true); // The audio button *is* a toggle in this state, and indicates the // current state of the speakerphone. mAudioButton.setChecked(inCallControlState.speakerOn); // Update desired layers: showToggleStateIndication = true; showSpeakerOnIcon = inCallControlState.speakerOn; showSpeakerOffIcon = !inCallControlState.speakerOn; } else { if (DBG) log("- updateAudioButton: disabled..."); // The audio button is a toggle in this state, but that's mostly // irrelevant since it's always disabled and unchecked. mAudioButton.setEnabled(false); mAudioButton.setChecked(false); // Update desired layers: showToggleStateIndication = true; showSpeakerOffIcon = true; } // Finally, update the drawable layers (see btn_compound_audio.xml). // Constants used below with Drawable.setAlpha(): final int HIDDEN = 0; final int VISIBLE = 255; LayerDrawable layers = (LayerDrawable) mAudioButton.getBackground(); if (DBG) log("- 'layers' drawable: " + layers); layers.findDrawableByLayerId(R.id.compoundBackgroundItem) .setAlpha(showToggleStateIndication ? VISIBLE : HIDDEN); layers.findDrawableByLayerId(R.id.moreIndicatorItem) .setAlpha(showMoreIndicator ? VISIBLE : HIDDEN); layers.findDrawableByLayerId(R.id.bluetoothItem) .setAlpha(showBluetoothIcon ? VISIBLE : HIDDEN); layers.findDrawableByLayerId(R.id.handsetItem) .setAlpha(showHandsetIcon ? VISIBLE : HIDDEN); layers.findDrawableByLayerId(R.id.speakerphoneOnItem) .setAlpha(showSpeakerOnIcon ? VISIBLE : HIDDEN); layers.findDrawableByLayerId(R.id.speakerphoneOffItem) .setAlpha(showSpeakerOffIcon ? VISIBLE : HIDDEN); } /** * Handles a click on the "Audio mode" button. * - If bluetooth is available, bring up the "Audio mode" popup * (which provides a 3-way choice between earpiece / speaker / bluetooth). * - If bluetooth is *not* available, just toggle between earpiece and * speaker, with no popup at all. */ private void handleAudioButtonClick() { InCallControlState inCallControlState = mInCallScreen.getUpdatedInCallControlState(); if (inCallControlState.bluetoothEnabled) { if (DBG) log("- handleAudioButtonClick: 'popup menu' mode..."); showAudioModePopup(); } else { if (DBG) log("- handleAudioButtonClick: 'speaker toggle' mode..."); mInCallScreen.toggleSpeaker(); } } /** * Brings up the "Audio mode" popup. */ private void showAudioModePopup() { if (DBG) log("showAudioModePopup()..."); mAudioModePopup = new PopupMenu(mInCallScreen /* context */, mAudioButton /* anchorView */); mAudioModePopup.getMenuInflater().inflate(R.menu.incall_audio_mode_menu, mAudioModePopup.getMenu()); mAudioModePopup.setOnMenuItemClickListener(this); mAudioModePopup.setOnDismissListener(this); // Update the enabled/disabledness of menu items based on the // current call state. InCallControlState inCallControlState = mInCallScreen.getUpdatedInCallControlState(); Menu menu = mAudioModePopup.getMenu(); // TODO: Still need to have the "currently active" audio mode come // up pre-selected (or focused?) with a blue highlight. Still // need exact visual design, and possibly framework support for this. // See comments below for the exact logic. MenuItem speakerItem = menu.findItem(R.id.audio_mode_speaker); speakerItem.setEnabled(inCallControlState.speakerEnabled); // TODO: Show speakerItem as initially "selected" if // inCallControlState.speakerOn is true. // We display *either* "earpiece" or "wired headset", never both, // depending on whether a wired headset is physically plugged in. MenuItem earpieceItem = menu.findItem(R.id.audio_mode_earpiece); MenuItem wiredHeadsetItem = menu.findItem(R.id.audio_mode_wired_headset); final boolean usingHeadset = mApp.isHeadsetPlugged(); earpieceItem.setVisible(!usingHeadset); earpieceItem.setEnabled(!usingHeadset); wiredHeadsetItem.setVisible(usingHeadset); wiredHeadsetItem.setEnabled(usingHeadset); // TODO: Show the above item (either earpieceItem or wiredHeadsetItem) // as initially "selected" if inCallControlState.speakerOn and // inCallControlState.bluetoothIndicatorOn are both false. MenuItem bluetoothItem = menu.findItem(R.id.audio_mode_bluetooth); bluetoothItem.setEnabled(inCallControlState.bluetoothEnabled); // TODO: Show bluetoothItem as initially "selected" if // inCallControlState.bluetoothIndicatorOn is true. mAudioModePopup.show(); // Unfortunately we need to manually keep track of the popup menu's // visiblity, since PopupMenu doesn't have an isShowing() method like // Dialogs do. mAudioModePopupVisible = true; } /** * Dismisses the "Audio mode" popup if it's visible. * * This is safe to call even if the popup is already dismissed, or even if * you never called showAudioModePopup() in the first place. */ public void dismissAudioModePopup() { if (mAudioModePopup != null) { mAudioModePopup.dismiss(); // safe even if already dismissed mAudioModePopup = null; mAudioModePopupVisible = false; } } /** * Refreshes the "Audio mode" popup if it's visible. This is useful * (for example) when a wired headset is plugged or unplugged, * since we need to switch back and forth between the "earpiece" * and "wired headset" items. * * This is safe to call even if the popup is already dismissed, or even if * you never called showAudioModePopup() in the first place. */ public void refreshAudioModePopup() { if (mAudioModePopup != null && mAudioModePopupVisible) { // Dismiss the previous one mAudioModePopup.dismiss(); // safe even if already dismissed // And bring up a fresh PopupMenu showAudioModePopup(); } } // PopupMenu.OnMenuItemClickListener implementation; see showAudioModePopup() @Override public boolean onMenuItemClick(MenuItem item) { if (DBG) log("- onMenuItemClick: " + item); if (DBG) log(" id: " + item.getItemId()); if (DBG) log(" title: '" + item.getTitle() + "'"); if (mInCallScreen == null) { Log.w(LOG_TAG, "onMenuItemClick(" + item + "), but null mInCallScreen!"); return true; } switch (item.getItemId()) { case R.id.audio_mode_speaker: mInCallScreen.switchInCallAudio(InCallScreen.InCallAudioMode.SPEAKER); break; case R.id.audio_mode_earpiece: case R.id.audio_mode_wired_headset: // InCallAudioMode.EARPIECE means either the handset earpiece, // or the wired headset (if connected.) mInCallScreen.switchInCallAudio(InCallScreen.InCallAudioMode.EARPIECE); break; case R.id.audio_mode_bluetooth: mInCallScreen.switchInCallAudio(InCallScreen.InCallAudioMode.BLUETOOTH); break; default: Log.wtf(LOG_TAG, "onMenuItemClick: unexpected View ID " + item.getItemId() + " (MenuItem = '" + item + "')"); break; } return true; } // PopupMenu.OnDismissListener implementation; see showAudioModePopup(). // This gets called when the PopupMenu gets dismissed for *any* reason, like // the user tapping outside its bounds, or pressing Back, or selecting one // of the menu items. @Override public void onDismiss(PopupMenu menu) { if (DBG) log("- onDismiss: " + menu); mAudioModePopupVisible = false; } /** * @return the amount of vertical space (in pixels) that needs to be * reserved for the button cluster at the bottom of the screen. * (The CallCard uses this measurement to determine how big * the main "contact photo" area can be.) * * NOTE that this returns the "canonical height" of the main in-call * button cluster, which may not match the amount of vertical space * actually used. Specifically: * * - If an incoming call is ringing, the button cluster isn't * visible at all. (And the GlowPadView widget is actually * much taller than the button cluster.) * * - If the InCallTouchUi widget's "extra button row" is visible * (in some rare phone states) the button cluster will actually * be slightly taller than the "canonical height". * * In either of these cases, we allow the bottom edge of the contact * photo to be covered up by whatever UI is actually onscreen. */ public int getTouchUiHeight() { // Add up the vertical space consumed by the various rows of buttons. int height = 0; // - The main row of buttons: height += (int) getResources().getDimension(R.dimen.in_call_button_height); // - The End button: height += (int) getResources().getDimension(R.dimen.in_call_end_button_height); // - Note we *don't* consider the InCallTouchUi widget's "extra // button row" here. //- And an extra bit of margin: height += (int) getResources().getDimension(R.dimen.in_call_touch_ui_upper_margin); return height; } // // GlowPadView.OnTriggerListener implementation // @Override public void onGrabbed(View v, int handle) { } @Override public void onReleased(View v, int handle) { } /** * Handles "Answer" and "Reject" actions for an incoming call. * We get this callback from the incoming call widget * when the user triggers an action. */ @Override public void onTrigger(View view, int whichHandle) { if (DBG) log("onTrigger(whichHandle = " + whichHandle + ")..."); if (mInCallScreen == null) { Log.wtf(LOG_TAG, "onTrigger(" + whichHandle + ") from incoming-call widget, but null mInCallScreen!"); return; } // The InCallScreen actually implements all of these actions. // Each possible action from the incoming call widget corresponds // to an R.id value; we pass those to the InCallScreen's "button // click" handler (even though the UI elements aren't actually // buttons; see InCallScreen.handleOnscreenButtonClick().) mShowInCallControlsDuringHidingAnimation = false; switch (whichHandle) { case ANSWER_CALL_ID: if (DBG) log("ANSWER_CALL_ID: answer!"); mInCallScreen.handleOnscreenButtonClick(R.id.incomingCallAnswer); mShowInCallControlsDuringHidingAnimation = true; // ...and also prevent it from reappearing right away. // (This covers up a slow response from the radio for some // actions; see updateState().) mLastIncomingCallActionTime = SystemClock.uptimeMillis(); break; case SEND_SMS_ID: if (DBG) log("SEND_SMS_ID!"); mInCallScreen.handleOnscreenButtonClick(R.id.incomingCallRespondViaSms); // Watch out: mLastIncomingCallActionTime should not be updated for this case. // // The variable is originally for avoiding a problem caused by delayed phone state // update; RINGING state may remain just after answering/declining an incoming // call, so we need to wait a bit (500ms) until we get the effective phone state. // For this case, we shouldn't rely on that hack. // // When the user selects this case, there are two possibilities, neither of which // should rely on the hack. // // 1. The first possibility is that, the device eventually sends one of canned // responses per the user's "send" request, and reject the call after sending it. // At that moment the code introducing the canned responses should handle the // case separately. // // 2. The second possibility is that, the device will show incoming call widget // again per the user's "cancel" request, where the incoming call will still // remain. At that moment the incoming call will keep its RINGING state. // The remaining phone state should never be ignored by the hack for // answering/declining calls because the RINGING state is legitimate. If we // use the hack for answer/decline cases, the user loses the incoming call // widget, until further screen update occurs afterward, which often results in // missed calls. break; case DECLINE_CALL_ID: if (DBG) log("DECLINE_CALL_ID: reject!"); mInCallScreen.handleOnscreenButtonClick(R.id.incomingCallReject); // Same as "answer" case. mLastIncomingCallActionTime = SystemClock.uptimeMillis(); break; default: Log.wtf(LOG_TAG, "onDialTrigger: unexpected whichHandle value: " + whichHandle); break; } // On any action by the user, hide the widget. // // If requested above (i.e. if mShowInCallControlsDuringHidingAnimation is set to true), // in-call controls will start being shown too. // // TODO: The decision to hide this should be made by the controller // (InCallScreen), and not this view. hideIncomingCallWidget(); // Regardless of what action the user did, be sure to clear out // the hint text we were displaying while the user was dragging. mInCallScreen.updateIncomingCallWidgetHint(0, 0); } public void onFinishFinalAnimation() { // Not used } /** * Apply an animation to hide the incoming call widget. */ private void hideIncomingCallWidget() { if (DBG) log("hideIncomingCallWidget()..."); if (mIncomingCallWidget.getVisibility() != View.VISIBLE || mIncomingCallWidgetIsFadingOut) { if (DBG) log("Skipping hideIncomingCallWidget action"); // Widget is already hidden or in the process of being hidden return; } // Hide the incoming call screen with a transition mIncomingCallWidgetIsFadingOut = true; ViewPropertyAnimator animator = mIncomingCallWidget.animate(); animator.cancel(); animator.setDuration(AnimationUtils.ANIMATION_DURATION); animator.setListener(new AnimatorListenerAdapter() { @Override public void onAnimationStart(Animator animation) { if (mShowInCallControlsDuringHidingAnimation) { if (DBG) log("IncomingCallWidget's hiding animation started"); updateInCallControls(mApp.mCM); mInCallControls.setVisibility(View.VISIBLE); } } @Override public void onAnimationEnd(Animator animation) { if (DBG) log("IncomingCallWidget's hiding animation ended"); mIncomingCallWidget.setAlpha(1); mIncomingCallWidget.setVisibility(View.GONE); mIncomingCallWidget.animate().setListener(null); mShowInCallControlsDuringHidingAnimation = false; mIncomingCallWidgetIsFadingOut = false; mIncomingCallWidgetShouldBeReset = true; } @Override public void onAnimationCancel(Animator animation) { mIncomingCallWidget.animate().setListener(null); mShowInCallControlsDuringHidingAnimation = false; mIncomingCallWidgetIsFadingOut = false; mIncomingCallWidgetShouldBeReset = true; // Note: the code which reset this animation should be responsible for // alpha and visibility. } }); animator.alpha(0f); } /** * Shows the incoming call widget and cancels any animation that may be fading it out. */ private void showIncomingCallWidget(Call ringingCall) { if (DBG) log("showIncomingCallWidget()..."); // TODO: wouldn't be ok to suppress this whole request if the widget is already VISIBLE // and we don't need to reset it? // log("showIncomingCallWidget(). widget visibility: " + mIncomingCallWidget.getVisibility()); ViewPropertyAnimator animator = mIncomingCallWidget.animate(); if (animator != null) { animator.cancel(); } mIncomingCallWidget.setAlpha(1.0f); // Update the GlowPadView widget's targets based on the state of // the ringing call. (Specifically, we need to disable the // "respond via SMS" option for certain types of calls, like SIP // addresses or numbers with blocked caller-id.) final boolean allowRespondViaSms = RespondViaSmsManager.allowRespondViaSmsForCall(mInCallScreen, ringingCall); final int targetResourceId = allowRespondViaSms ? R.array.incoming_call_widget_3way_targets : R.array.incoming_call_widget_2way_targets; // The widget should be updated only when appropriate; if the previous choice can be reused // for this incoming call, we'll just keep using it. Otherwise we'll see UI glitch // everytime when this method is called during a single incoming call. if (targetResourceId != mIncomingCallWidget.getTargetResourceId()) { if (allowRespondViaSms) { // The GlowPadView widget is allowed to have all 3 choices: // Answer, Decline, and Respond via SMS. mIncomingCallWidget.setTargetResources(targetResourceId); mIncomingCallWidget.setTargetDescriptionsResourceId( R.array.incoming_call_widget_3way_target_descriptions); mIncomingCallWidget.setDirectionDescriptionsResourceId( R.array.incoming_call_widget_3way_direction_descriptions); } else { // You only get two choices: Answer or Decline. mIncomingCallWidget.setTargetResources(targetResourceId); mIncomingCallWidget.setTargetDescriptionsResourceId( R.array.incoming_call_widget_2way_target_descriptions); mIncomingCallWidget.setDirectionDescriptionsResourceId( R.array.incoming_call_widget_2way_direction_descriptions); } // This will be used right after this block. mIncomingCallWidgetShouldBeReset = true; } if (mIncomingCallWidgetShouldBeReset) { // Watch out: be sure to call reset() and setVisibility() *after* // updating the target resources, since otherwise the GlowPadView // widget will make the targets visible initially (even before you // touch the widget.) mIncomingCallWidget.reset(false); mIncomingCallWidgetShouldBeReset = false; } // On an incoming call, if the layout is landscape, then align the "incoming call" text // to the left, because the incomingCallWidget (black background with glowing ring) // is aligned to the right and would cover the "incoming call" text. // Note that callStateLabel is within CallCard, outside of the context of InCallTouchUi if (PhoneUtils.isLandscape(this.getContext())) { TextView callStateLabel = (TextView) mIncomingCallWidget .getRootView().findViewById(R.id.callStateLabel); if (callStateLabel != null) callStateLabel.setGravity(Gravity.LEFT); } mIncomingCallWidget.setVisibility(View.VISIBLE); // Finally, manually trigger a "ping" animation. // // Normally, the ping animation is triggered by RING events from // the telephony layer (see onIncomingRing().) But that *doesn't* // happen for the very first RING event of an incoming call, since // the incoming-call UI hasn't been set up yet at that point! // // So trigger an explicit ping() here, to force the animation to // run when the widget first appears. // mHandler.removeMessages(INCOMING_CALL_WIDGET_PING); mHandler.sendEmptyMessageDelayed( INCOMING_CALL_WIDGET_PING, // Visual polish: add a small delay here, to make the // GlowPadView widget visible for a brief moment // *before* starting the ping animation. // This value doesn't need to be very precise. 250 /* msec */); } /** * Handles state changes of the incoming-call widget. * * In previous releases (where we used a SlidingTab widget) we would * display an onscreen hint depending on which "handle" the user was * dragging. But we now use a GlowPadView widget, which has only * one handle, so for now we don't display a hint at all (see the TODO * comment below.) */ @Override public void onGrabbedStateChange(View v, int grabbedState) { if (mInCallScreen != null) { // Look up the hint based on which handle is currently grabbed. // (Note we don't simply pass grabbedState thru to the InCallScreen, // since *this* class is the only place that knows that the left // handle means "Answer" and the right handle means "Decline".) int hintTextResId, hintColorResId; switch (grabbedState) { case GlowPadView.OnTriggerListener.NO_HANDLE: case GlowPadView.OnTriggerListener.CENTER_HANDLE: hintTextResId = 0; hintColorResId = 0; break; default: Log.e(LOG_TAG, "onGrabbedStateChange: unexpected grabbedState: " + grabbedState); hintTextResId = 0; hintColorResId = 0; break; } // Tell the InCallScreen to update the CallCard and force the // screen to redraw. mInCallScreen.updateIncomingCallWidgetHint(hintTextResId, hintColorResId); } } /** * Handles an incoming RING event from the telephony layer. */ public void onIncomingRing() { if (ENABLE_PING_ON_RING_EVENTS) { // Each RING from the telephony layer triggers a "ping" animation // of the GlowPadView widget. (The intent here is to make the // pinging appear to be synchronized with the ringtone, although // that only works for non-looping ringtones.) triggerPing(); } } /** * Runs a single "ping" animation of the GlowPadView widget, * or do nothing if the GlowPadView widget is no longer visible. * * Also, if ENABLE_PING_AUTO_REPEAT is true, schedule the next ping as * well (but again, only if the GlowPadView widget is still visible.) */ public void triggerPing() { if (DBG) log("triggerPing: mIncomingCallWidget = " + mIncomingCallWidget); if (!mInCallScreen.isForegroundActivity()) { // InCallScreen has been dismissed; no need to run a ping *or* // schedule another one. log("- triggerPing: InCallScreen no longer in foreground; ignoring..."); return; } if (mIncomingCallWidget == null) { // This shouldn't happen; the GlowPadView widget should // always be present in our layout file. Log.w(LOG_TAG, "- triggerPing: null mIncomingCallWidget!"); return; } if (DBG) log("- triggerPing: mIncomingCallWidget visibility = " + mIncomingCallWidget.getVisibility()); if (mIncomingCallWidget.getVisibility() != View.VISIBLE) { if (DBG) log("- triggerPing: mIncomingCallWidget no longer visible; ignoring..."); return; } // Ok, run a ping (and schedule the next one too, if desired...) mIncomingCallWidget.ping(); if (ENABLE_PING_AUTO_REPEAT) { // Schedule the next ping. (ENABLE_PING_AUTO_REPEAT mode // allows the ping animation to repeat much faster than in // the ENABLE_PING_ON_RING_EVENTS case, since telephony RING // events come fairly slowly (about 3 seconds apart.)) // No need to check here if the call is still ringing, by // the way, since we hide mIncomingCallWidget as soon as the // ringing stops, or if the user answers. (And at that // point, any future triggerPing() call will be a no-op.) // TODO: Rather than having a separate timer here, maybe try // having these pings synchronized with the vibrator (see // VibratorThread in Ringer.java; we'd just need to get // events routed from there to here, probably via the // PhoneApp instance.) (But watch out: make sure pings // still work even if the Vibrate setting is turned off!) mHandler.sendEmptyMessageDelayed(INCOMING_CALL_WIDGET_PING, PING_AUTO_REPEAT_DELAY_MSEC); } } // Debugging / testing code private void log(String msg) { Log.d(LOG_TAG, msg); } }