1 /*
2  * Copyright (C) 2018 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License
15  */
16 
17 package com.android.incallui;
18 
19 import android.app.PendingIntent;
20 import android.content.Context;
21 import android.content.Intent;
22 import android.graphics.drawable.Drawable;
23 import android.graphics.drawable.Icon;
24 import android.provider.Settings;
25 import android.support.annotation.NonNull;
26 import android.support.annotation.VisibleForTesting;
27 import android.telecom.CallAudioState;
28 import android.text.TextUtils;
29 import com.android.bubble.Bubble;
30 import com.android.bubble.BubbleComponent;
31 import com.android.bubble.BubbleInfo;
32 import com.android.bubble.BubbleInfo.Action;
33 import com.android.dialer.common.LogUtil;
34 import com.android.dialer.configprovider.ConfigProviderComponent;
35 import com.android.dialer.contacts.ContactsComponent;
36 import com.android.dialer.lettertile.LetterTileDrawable;
37 import com.android.dialer.telecom.TelecomUtil;
38 import com.android.dialer.theme.base.ThemeComponent;
39 import com.android.incallui.ContactInfoCache.ContactCacheEntry;
40 import com.android.incallui.ContactInfoCache.ContactInfoCacheCallback;
41 import com.android.incallui.InCallPresenter.InCallState;
42 import com.android.incallui.InCallPresenter.InCallUiListener;
43 import com.android.incallui.audiomode.AudioModeProvider;
44 import com.android.incallui.audiomode.AudioModeProvider.AudioModeListener;
45 import com.android.incallui.call.CallList;
46 import com.android.incallui.call.CallList.Listener;
47 import com.android.incallui.call.DialerCall;
48 import com.android.incallui.speakerbuttonlogic.SpeakerButtonInfo;
49 import java.lang.ref.WeakReference;
50 import java.util.ArrayList;
51 import java.util.List;
52 
53 /**
54  * Listens for events relevant to the return-to-call bubble and updates the bubble's state as
55  * necessary.
56  *
57  * <p>Bubble shows when one of following happens: 1. a new outgoing/ongoing call appears 2. leave
58  * in-call UI with an outgoing/ongoing call
59  *
60  * <p>Bubble hides when one of following happens: 1. a call disconnect and there is no more
61  * outgoing/ongoing call 2. show in-call UI
62  */
63 public class ReturnToCallController implements InCallUiListener, Listener, AudioModeListener {
64 
65   private final Context context;
66 
67   @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
68   Bubble bubble;
69 
70   private static Boolean canShowBubblesForTesting = null;
71 
72   private CallAudioState audioState;
73 
74   private final PendingIntent toggleSpeaker;
75   private final PendingIntent showSpeakerSelect;
76   private final PendingIntent toggleMute;
77   private final PendingIntent endCall;
78   private final PendingIntent fullScreen;
79 
80   private final ContactInfoCache contactInfoCache;
81 
82   private InCallState inCallState;
83 
isEnabled(Context context)84   public static boolean isEnabled(Context context) {
85     return ConfigProviderComponent.get(context)
86         .getConfigProvider()
87         .getBoolean("enable_return_to_call_bubble_v2", false);
88   }
89 
ReturnToCallController(Context context, ContactInfoCache contactInfoCache)90   public ReturnToCallController(Context context, ContactInfoCache contactInfoCache) {
91     this.context = context;
92     this.contactInfoCache = contactInfoCache;
93 
94     toggleSpeaker = createActionIntent(ReturnToCallActionReceiver.ACTION_TOGGLE_SPEAKER);
95     showSpeakerSelect =
96         createActionIntent(ReturnToCallActionReceiver.ACTION_SHOW_AUDIO_ROUTE_SELECTOR);
97     toggleMute = createActionIntent(ReturnToCallActionReceiver.ACTION_TOGGLE_MUTE);
98     endCall = createActionIntent(ReturnToCallActionReceiver.ACTION_END_CALL);
99     fullScreen = createActionIntent(ReturnToCallActionReceiver.ACTION_RETURN_TO_CALL);
100 
101     AudioModeProvider.getInstance().addListener(this);
102     audioState = AudioModeProvider.getInstance().getAudioState();
103     InCallPresenter.getInstance().addInCallUiListener(this);
104     CallList.getInstance().addListener(this);
105   }
106 
tearDown()107   public void tearDown() {
108     hide();
109     InCallPresenter.getInstance().removeInCallUiListener(this);
110     CallList.getInstance().removeListener(this);
111     AudioModeProvider.getInstance().removeListener(this);
112   }
113 
114   @Override
onUiShowing(boolean showing)115   public void onUiShowing(boolean showing) {
116     if (!isEnabled(context)) {
117       hide();
118       return;
119     }
120 
121     LogUtil.i("ReturnToCallController.onUiShowing", "showing: " + showing);
122     if (showing) {
123       LogUtil.i("ReturnToCallController.onUiShowing", "going to hide");
124       hide();
125     } else {
126       if (getCall() != null) {
127         LogUtil.i("ReturnToCallController.onUiShowing", "going to show");
128         show();
129       }
130     }
131   }
132 
hide()133   private void hide() {
134     if (bubble != null) {
135       bubble.hide();
136     } else {
137       LogUtil.i("ReturnToCallController.hide", "hide() called without calling show()");
138     }
139   }
140 
show()141   private void show() {
142     if (bubble == null) {
143       bubble = startBubble();
144     } else {
145       bubble.show();
146     }
147     startContactInfoSearch();
148   }
149 
150   /**
151    * Determines whether bubbles can be shown based on permissions obtained. This should be checked
152    * before attempting to create a Bubble.
153    *
154    * @return true iff bubbles are able to be shown.
155    * @see Settings#canDrawOverlays(Context)
156    */
canShowBubbles(@onNull Context context)157   private static boolean canShowBubbles(@NonNull Context context) {
158     return canShowBubblesForTesting != null
159         ? canShowBubblesForTesting
160         : Settings.canDrawOverlays(context);
161   }
162 
163   @VisibleForTesting(otherwise = VisibleForTesting.NONE)
setCanShowBubblesForTesting(boolean canShowBubbles)164   static void setCanShowBubblesForTesting(boolean canShowBubbles) {
165     canShowBubblesForTesting = canShowBubbles;
166   }
167 
startBubble()168   private Bubble startBubble() {
169     if (!canShowBubbles(context)) {
170       LogUtil.i("ReturnToCallController.startBubble", "can't show bubble, no permission");
171       return null;
172     }
173     Bubble returnToCallBubble = BubbleComponent.get(context).getBubble();
174     returnToCallBubble.setBubbleInfo(generateBubbleInfo());
175     returnToCallBubble.show();
176     return returnToCallBubble;
177   }
178 
179   @Override
onIncomingCall(DialerCall call)180   public void onIncomingCall(DialerCall call) {}
181 
182   @Override
onUpgradeToVideo(DialerCall call)183   public void onUpgradeToVideo(DialerCall call) {}
184 
185   @Override
onSessionModificationStateChange(DialerCall call)186   public void onSessionModificationStateChange(DialerCall call) {}
187 
188   @Override
onCallListChange(CallList callList)189   public void onCallListChange(CallList callList) {
190     if (!isEnabled(context)) {
191       hide();
192       return;
193     }
194 
195     boolean shouldStartInBubbleMode = InCallPresenter.getInstance().shouldStartInBubbleMode();
196     InCallState newInCallState =
197         InCallPresenter.getInstance().getPotentialStateFromCallList(callList);
198     boolean isNewBackgroundCall =
199         newInCallState != inCallState
200             && newInCallState == InCallState.OUTGOING
201             && shouldStartInBubbleMode;
202     boolean bubbleNeverVisible = (bubble == null || !(bubble.isVisible() || bubble.isDismissed()));
203     if (bubble != null && isNewBackgroundCall) {
204       // If new outgoing call is in bubble mode, update bubble info.
205       // We don't update if new call is not in bubble mode even if the existing call is.
206       bubble.setBubbleInfo(generateBubbleInfoForBackgroundCalling());
207     }
208     if (((bubbleNeverVisible && newInCallState != InCallState.OUTGOING) || isNewBackgroundCall)
209         && getCall() != null
210         && !InCallPresenter.getInstance().isShowingInCallUi()) {
211       LogUtil.i("ReturnToCallController.onCallListChange", "going to show bubble");
212       show();
213     } else {
214       // The call to display might be different for the existing bubble
215       startContactInfoSearch();
216     }
217     inCallState = newInCallState;
218   }
219 
220   @Override
onDisconnect(DialerCall call)221   public void onDisconnect(DialerCall call) {
222     if (!isEnabled(context)) {
223       hide();
224       return;
225     }
226 
227     LogUtil.enterBlock("ReturnToCallController.onDisconnect");
228     if (bubble != null && bubble.isVisible() && (getCall() == null)) {
229       // Show "Call ended" and hide bubble when there is no outgoing, active or background call
230       LogUtil.i("ReturnToCallController.onDisconnect", "show call ended and hide bubble");
231       // Don't show text if it's Duo upgrade
232       // It doesn't work for Duo fallback upgrade since we're not considered in call
233       if (!TelecomUtil.isInCall(context) || CallList.getInstance().getIncomingCall() != null) {
234         bubble.showText(context.getText(R.string.incall_call_ended));
235       }
236       hide();
237     } else {
238       startContactInfoSearch();
239     }
240   }
241 
242   @Override
onWiFiToLteHandover(DialerCall call)243   public void onWiFiToLteHandover(DialerCall call) {}
244 
245   @Override
onHandoverToWifiFailed(DialerCall call)246   public void onHandoverToWifiFailed(DialerCall call) {}
247 
248   @Override
onInternationalCallOnWifi(@onNull DialerCall call)249   public void onInternationalCallOnWifi(@NonNull DialerCall call) {}
250 
251   @Override
onAudioStateChanged(CallAudioState audioState)252   public void onAudioStateChanged(CallAudioState audioState) {
253     if (!isEnabled(context)) {
254       hide();
255       return;
256     }
257 
258     this.audioState = audioState;
259     if (bubble != null) {
260       bubble.updateActions(generateActions());
261     }
262   }
263 
startContactInfoSearch()264   private void startContactInfoSearch() {
265     DialerCall dialerCall = getCall();
266     if (dialerCall != null) {
267       contactInfoCache.findInfo(
268           dialerCall, false /* isIncoming */, new ReturnToCallContactInfoCacheCallback(this));
269     }
270   }
271 
getCall()272   private DialerCall getCall() {
273     DialerCall dialerCall = CallList.getInstance().getOutgoingCall();
274     if (dialerCall == null) {
275       dialerCall = CallList.getInstance().getActiveOrBackgroundCall();
276     }
277     return dialerCall;
278   }
279 
onPhotoAvatarReceived(@onNull Drawable photo)280   private void onPhotoAvatarReceived(@NonNull Drawable photo) {
281     if (bubble != null) {
282       bubble.updatePhotoAvatar(photo);
283     }
284   }
285 
onLetterTileAvatarReceived(@onNull Drawable photo)286   private void onLetterTileAvatarReceived(@NonNull Drawable photo) {
287     if (bubble != null) {
288       bubble.updateAvatar(photo);
289     }
290   }
291 
generateBubbleInfo()292   private BubbleInfo generateBubbleInfo() {
293     return BubbleInfo.builder()
294         .setPrimaryColor(ThemeComponent.get(context).theme().getColorPrimary())
295         .setPrimaryIcon(Icon.createWithResource(context, R.drawable.on_going_call))
296         .setStartingYPosition(
297             InCallPresenter.getInstance().shouldStartInBubbleMode()
298                 ? context.getResources().getDisplayMetrics().heightPixels / 2
299                 : context
300                     .getResources()
301                     .getDimensionPixelOffset(R.dimen.return_to_call_initial_offset_y))
302         .setActions(generateActions())
303         .build();
304   }
305 
generateBubbleInfoForBackgroundCalling()306   private BubbleInfo generateBubbleInfoForBackgroundCalling() {
307     return BubbleInfo.builder()
308         .setPrimaryColor(ThemeComponent.get(context).theme().getColorPrimary())
309         .setPrimaryIcon(Icon.createWithResource(context, R.drawable.on_going_call))
310         .setStartingYPosition(context.getResources().getDisplayMetrics().heightPixels / 2)
311         .setActions(generateActions())
312         .build();
313   }
314 
315   @NonNull
generateActions()316   private List<Action> generateActions() {
317     List<Action> actions = new ArrayList<>();
318     SpeakerButtonInfo speakerButtonInfo = new SpeakerButtonInfo(audioState);
319 
320     // Return to call
321     actions.add(
322         Action.builder()
323             .setIconDrawable(
324                 context.getDrawable(R.drawable.quantum_ic_exit_to_app_flip_vd_theme_24))
325             .setIntent(fullScreen)
326             .setName(context.getText(R.string.bubble_return_to_call))
327             .setCheckable(false)
328             .build());
329     // Mute/unmute
330     actions.add(
331         Action.builder()
332             .setIconDrawable(context.getDrawable(R.drawable.quantum_ic_mic_off_vd_theme_24))
333             .setChecked(audioState.isMuted())
334             .setIntent(toggleMute)
335             .setName(context.getText(R.string.incall_label_mute))
336             .build());
337     // Speaker/audio selector
338     actions.add(
339         Action.builder()
340             .setIconDrawable(context.getDrawable(speakerButtonInfo.icon))
341             .setSecondaryIconDrawable(
342                 speakerButtonInfo.nonBluetoothMode
343                     ? null
344                     : context.getDrawable(R.drawable.quantum_ic_arrow_drop_down_vd_theme_24))
345             .setName(context.getText(speakerButtonInfo.label))
346             .setCheckable(speakerButtonInfo.nonBluetoothMode)
347             .setChecked(speakerButtonInfo.isChecked)
348             .setIntent(speakerButtonInfo.nonBluetoothMode ? toggleSpeaker : showSpeakerSelect)
349             .build());
350     // End call
351     actions.add(
352         Action.builder()
353             .setIconDrawable(context.getDrawable(R.drawable.quantum_ic_call_end_vd_theme_24))
354             .setIntent(endCall)
355             .setName(context.getText(R.string.incall_label_end_call))
356             .setCheckable(false)
357             .build());
358     return actions;
359   }
360 
361   @NonNull
createActionIntent(String action)362   private PendingIntent createActionIntent(String action) {
363     Intent intent = new Intent(context, ReturnToCallActionReceiver.class);
364     intent.setAction(action);
365     return PendingIntent.getBroadcast(context, 0, intent, 0);
366   }
367 
368   @NonNull
createLettleTileDrawable( DialerCall dialerCall, ContactCacheEntry entry)369   private LetterTileDrawable createLettleTileDrawable(
370       DialerCall dialerCall, ContactCacheEntry entry) {
371     String preferredName =
372         ContactsComponent.get(context)
373             .contactDisplayPreferences()
374             .getDisplayName(entry.namePrimary, entry.nameAlternative);
375     if (TextUtils.isEmpty(preferredName)) {
376       preferredName = entry.number;
377     }
378 
379     LetterTileDrawable letterTile = new LetterTileDrawable(context.getResources());
380     letterTile.setCanonicalDialerLetterTileDetails(
381         dialerCall.updateNameIfRestricted(preferredName),
382         entry.lookupKey,
383         LetterTileDrawable.SHAPE_CIRCLE,
384         LetterTileDrawable.getContactTypeFromPrimitives(
385             dialerCall.isVoiceMailNumber(),
386             dialerCall.isSpam(),
387             entry.isBusiness,
388             dialerCall.getNumberPresentation(),
389             dialerCall.isConferenceCall()));
390     return letterTile;
391   }
392 
393   private static class ReturnToCallContactInfoCacheCallback implements ContactInfoCacheCallback {
394 
395     private final WeakReference<ReturnToCallController> returnToCallControllerWeakReference;
396 
ReturnToCallContactInfoCacheCallback(ReturnToCallController returnToCallController)397     private ReturnToCallContactInfoCacheCallback(ReturnToCallController returnToCallController) {
398       returnToCallControllerWeakReference = new WeakReference<>(returnToCallController);
399     }
400 
401     @Override
onContactInfoComplete(String callId, ContactCacheEntry entry)402     public void onContactInfoComplete(String callId, ContactCacheEntry entry) {
403       ReturnToCallController returnToCallController = returnToCallControllerWeakReference.get();
404       if (returnToCallController == null) {
405         return;
406       }
407       if (entry.photo != null) {
408         returnToCallController.onPhotoAvatarReceived(entry.photo);
409       } else {
410         DialerCall dialerCall = CallList.getInstance().getCallById(callId);
411         if (dialerCall != null) {
412           returnToCallController.onLetterTileAvatarReceived(
413               returnToCallController.createLettleTileDrawable(dialerCall, entry));
414         }
415       }
416     }
417 
418     @Override
onImageLoadComplete(String callId, ContactCacheEntry entry)419     public void onImageLoadComplete(String callId, ContactCacheEntry entry) {
420       ReturnToCallController returnToCallController = returnToCallControllerWeakReference.get();
421       if (returnToCallController == null) {
422         return;
423       }
424       if (entry.photo != null) {
425         returnToCallController.onPhotoAvatarReceived(entry.photo);
426       } else {
427         DialerCall dialerCall = CallList.getInstance().getCallById(callId);
428         if (dialerCall != null) {
429           returnToCallController.onLetterTileAvatarReceived(
430               returnToCallController.createLettleTileDrawable(dialerCall, entry));
431         }
432       }
433     }
434   }
435 }
436