1 /*
2  * Copyright (C) 2015 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 package com.android.messaging.ui.conversation;
17 
18 import android.app.FragmentManager;
19 import android.content.Context;
20 import android.os.Bundle;
21 import androidx.appcompat.app.ActionBar;
22 import android.widget.EditText;
23 
24 import com.android.messaging.R;
25 import com.android.messaging.datamodel.binding.BindingBase;
26 import com.android.messaging.datamodel.binding.ImmutableBindingRef;
27 import com.android.messaging.datamodel.data.ConversationData;
28 import com.android.messaging.datamodel.data.ConversationData.ConversationDataListener;
29 import com.android.messaging.datamodel.data.ConversationData.SimpleConversationDataListener;
30 import com.android.messaging.datamodel.data.DraftMessageData;
31 import com.android.messaging.datamodel.data.DraftMessageData.DraftMessageSubscriptionDataProvider;
32 import com.android.messaging.datamodel.data.MessagePartData;
33 import com.android.messaging.datamodel.data.PendingAttachmentData;
34 import com.android.messaging.datamodel.data.SubscriptionListData.SubscriptionListEntry;
35 import com.android.messaging.ui.ConversationDrawables;
36 import com.android.messaging.ui.mediapicker.MediaPicker;
37 import com.android.messaging.ui.mediapicker.MediaPicker.MediaPickerListener;
38 import com.android.messaging.util.Assert;
39 import com.android.messaging.util.ImeUtil;
40 import com.android.messaging.util.ImeUtil.ImeStateHost;
41 import com.google.common.annotations.VisibleForTesting;
42 
43 import java.util.Collection;
44 
45 /**
46  * Manages showing/hiding/persisting different mutually exclusive UI components nested in
47  * ConversationFragment that take user inputs, i.e. media picker, SIM selector and
48  * IME keyboard (the IME keyboard is not owned by Bugle, but we try to model it the same way
49  * as the other components).
50  */
51 public class ConversationInputManager implements ConversationInput.ConversationInputBase {
52     /**
53      * The host component where all input components are contained. This is typically the
54      * conversation fragment but may be mocked in test code.
55      */
56     public interface ConversationInputHost extends DraftMessageSubscriptionDataProvider {
invalidateActionBar()57         void invalidateActionBar();
setOptionsMenuVisibility(boolean visible)58         void setOptionsMenuVisibility(boolean visible);
dismissActionMode()59         void dismissActionMode();
selectSim(SubscriptionListEntry subscriptionData)60         void selectSim(SubscriptionListEntry subscriptionData);
onStartComposeMessage()61         void onStartComposeMessage();
getSimSelectorView()62         SimSelectorView getSimSelectorView();
createMediaPicker()63         MediaPicker createMediaPicker();
showHideSimSelector(boolean show)64         void showHideSimSelector(boolean show);
getSimSelectorItemLayoutId()65         int getSimSelectorItemLayoutId();
66     }
67 
68     /**
69      * The "sink" component where all inputs components will direct the user inputs to. This is
70      * typically the ComposeMessageView but may be mocked in test code.
71      */
72     public interface ConversationInputSink {
onMediaItemsSelected(Collection<MessagePartData> items)73         void onMediaItemsSelected(Collection<MessagePartData> items);
onMediaItemsUnselected(MessagePartData item)74         void onMediaItemsUnselected(MessagePartData item);
onPendingAttachmentAdded(PendingAttachmentData pendingItem)75         void onPendingAttachmentAdded(PendingAttachmentData pendingItem);
resumeComposeMessage()76         void resumeComposeMessage();
getComposeEditText()77         EditText getComposeEditText();
setAccessibility(boolean enabled)78         void setAccessibility(boolean enabled);
79     }
80 
81     private final ConversationInputHost mHost;
82     private final ConversationInputSink mSink;
83 
84     /** Dependencies injected from the host during construction */
85     private final FragmentManager mFragmentManager;
86     private final Context mContext;
87     private final ImeStateHost mImeStateHost;
88     private final ImmutableBindingRef<ConversationData> mConversationDataModel;
89     private final ImmutableBindingRef<DraftMessageData> mDraftDataModel;
90 
91     private final ConversationInput[] mInputs;
92     private final ConversationMediaPicker mMediaInput;
93     private final ConversationSimSelector mSimInput;
94     private final ConversationImeKeyboard mImeInput;
95     private int mUpdateCount;
96 
97     private final ImeUtil.ImeStateObserver mImeStateObserver = new ImeUtil.ImeStateObserver() {
98         @Override
99         public void onImeStateChanged(final boolean imeOpen) {
100             mImeInput.onVisibilityChanged(imeOpen);
101         }
102     };
103 
104     private final ConversationDataListener mDataListener = new SimpleConversationDataListener() {
105         @Override
106         public void onConversationParticipantDataLoaded(ConversationData data) {
107             mConversationDataModel.ensureBound(data);
108         }
109 
110         @Override
111         public void onSubscriptionListDataLoaded(ConversationData data) {
112             mConversationDataModel.ensureBound(data);
113             mSimInput.onSubscriptionListDataLoaded(data.getSubscriptionListData());
114         }
115     };
116 
ConversationInputManager( final Context context, final ConversationInputHost host, final ConversationInputSink sink, final ImeStateHost imeStateHost, final FragmentManager fm, final BindingBase<ConversationData> conversationDataModel, final BindingBase<DraftMessageData> draftDataModel, final Bundle savedState)117     public ConversationInputManager(
118             final Context context,
119             final ConversationInputHost host,
120             final ConversationInputSink sink,
121             final ImeStateHost imeStateHost,
122             final FragmentManager fm,
123             final BindingBase<ConversationData> conversationDataModel,
124             final BindingBase<DraftMessageData> draftDataModel,
125             final Bundle savedState) {
126         mHost = host;
127         mSink = sink;
128         mFragmentManager = fm;
129         mContext = context;
130         mImeStateHost = imeStateHost;
131         mConversationDataModel = BindingBase.createBindingReference(conversationDataModel);
132         mDraftDataModel = BindingBase.createBindingReference(draftDataModel);
133 
134         // Register listeners on dependencies.
135         mImeStateHost.registerImeStateObserver(mImeStateObserver);
136         mConversationDataModel.getData().addConversationDataListener(mDataListener);
137 
138         // Initialize the inputs
139         mMediaInput = new ConversationMediaPicker(this);
140         mSimInput = new SimSelector(this);
141         mImeInput = new ConversationImeKeyboard(this, mImeStateHost.isImeOpen());
142         mInputs = new ConversationInput[] { mMediaInput, mSimInput, mImeInput };
143 
144         if (savedState != null) {
145             for (int i = 0; i < mInputs.length; i++) {
146                 mInputs[i].restoreState(savedState);
147             }
148         }
149         updateHostOptionsMenu();
150     }
151 
onDetach()152     public void onDetach() {
153         mImeStateHost.unregisterImeStateObserver(mImeStateObserver);
154         // Don't need to explicitly unregister for data model events. It will unregister all
155         // listeners automagically on unbind.
156     }
157 
onSaveInputState(final Bundle savedState)158     public void onSaveInputState(final Bundle savedState) {
159         for (int i = 0; i < mInputs.length; i++) {
160             mInputs[i].saveState(savedState);
161         }
162     }
163 
164     @Override
getInputStateKey(final ConversationInput input)165     public String getInputStateKey(final ConversationInput input) {
166         return input.getClass().getCanonicalName() + "_savedstate_";
167     }
168 
onBackPressed()169     public boolean onBackPressed() {
170         for (int i = 0; i < mInputs.length; i++) {
171             if (mInputs[i].onBackPressed()) {
172                 return true;
173             }
174         }
175         return false;
176     }
177 
onNavigationUpPressed()178     public boolean onNavigationUpPressed() {
179         for (int i = 0; i < mInputs.length; i++) {
180             if (mInputs[i].onNavigationUpPressed()) {
181                 return true;
182             }
183         }
184         return false;
185     }
186 
resetMediaPickerState()187     public void resetMediaPickerState() {
188         mMediaInput.resetViewHolderState();
189     }
190 
showHideMediaPicker(final boolean show, final boolean animate)191     public void showHideMediaPicker(final boolean show, final boolean animate) {
192         showHideInternal(mMediaInput, show, animate);
193     }
194 
195     /**
196      * Show or hide the sim selector
197      * @param show visibility
198      * @param animate whether to animate the change in visibility
199      * @return true if the state of the visibility was changed
200      */
showHideSimSelector(final boolean show, final boolean animate)201     public boolean showHideSimSelector(final boolean show, final boolean animate) {
202         return showHideInternal(mSimInput, show, animate);
203     }
204 
showHideImeKeyboard(final boolean show, final boolean animate)205     public void showHideImeKeyboard(final boolean show, final boolean animate) {
206         showHideInternal(mImeInput, show, animate);
207     }
208 
hideAllInputs(final boolean animate)209     public void hideAllInputs(final boolean animate) {
210         beginUpdate();
211         for (int i = 0; i < mInputs.length; i++) {
212             showHideInternal(mInputs[i], false, animate);
213         }
214         endUpdate();
215     }
216 
217     /**
218      * Toggle the visibility of the sim selector.
219      * @param animate
220      * @param subEntry
221      * @return true if the view is now shown, false if it now hidden
222      */
toggleSimSelector(final boolean animate, final SubscriptionListEntry subEntry)223     public boolean toggleSimSelector(final boolean animate, final SubscriptionListEntry subEntry) {
224         mSimInput.setSelected(subEntry);
225         return mSimInput.toggle(animate);
226     }
227 
updateActionBar(final ActionBar actionBar)228     public boolean updateActionBar(final ActionBar actionBar) {
229         for (int i = 0; i < mInputs.length; i++) {
230             if (mInputs[i].mShowing) {
231                 return mInputs[i].updateActionBar(actionBar);
232             }
233         }
234         return false;
235     }
236 
237     @VisibleForTesting
isMediaPickerVisible()238     boolean isMediaPickerVisible() {
239         return mMediaInput.mShowing;
240     }
241 
242     @VisibleForTesting
isSimSelectorVisible()243     boolean isSimSelectorVisible() {
244         return mSimInput.mShowing;
245     }
246 
247     @VisibleForTesting
isImeKeyboardVisible()248     boolean isImeKeyboardVisible() {
249         return mImeInput.mShowing;
250     }
251 
252     @VisibleForTesting
testNotifyImeStateChanged(final boolean imeOpen)253     void testNotifyImeStateChanged(final boolean imeOpen) {
254         mImeStateObserver.onImeStateChanged(imeOpen);
255     }
256 
257     /**
258      * returns true if the state of the visibility was actually changed
259      */
260     @Override
showHideInternal(final ConversationInput target, final boolean show, final boolean animate)261     public boolean showHideInternal(final ConversationInput target, final boolean show,
262             final boolean animate) {
263         if (!mConversationDataModel.isBound()) {
264             return false;
265         }
266 
267         if (target.mShowing == show) {
268             return false;
269         }
270         beginUpdate();
271         boolean success;
272         if (!show) {
273             success = target.hide(animate);
274         } else {
275             success = target.show(animate);
276         }
277 
278         if (success) {
279             target.onVisibilityChanged(show);
280         }
281         endUpdate();
282         return true;
283     }
284 
285     @Override
handleOnShow(final ConversationInput target)286     public void handleOnShow(final ConversationInput target) {
287         if (!mConversationDataModel.isBound()) {
288             return;
289         }
290         beginUpdate();
291 
292         // All inputs are mutually exclusive. Showing one will hide everything else.
293         // The one exception, is that the keyboard and location media chooser can be open at the
294         // time to enable searching within that chooser
295         for (int i = 0; i < mInputs.length; i++) {
296             final ConversationInput currInput = mInputs[i];
297             if (currInput != target) {
298                 // TODO : If there's more exceptions we will want to make this more
299                 // generic
300                 if (currInput instanceof ConversationMediaPicker &&
301                         target instanceof ConversationImeKeyboard &&
302                         mMediaInput.getExistingOrCreateMediaPicker() != null &&
303                         mMediaInput.getExistingOrCreateMediaPicker().canShowIme()) {
304                     // Allow the keyboard and location mediaPicker to be open at the same time,
305                     // but ensure the media picker is full screen to allow enough room
306                     mMediaInput.getExistingOrCreateMediaPicker().setFullScreen(true);
307                     continue;
308                 }
309                 showHideInternal(currInput, false /* show */, false /* animate */);
310             }
311         }
312         // Always dismiss action mode on show.
313         mHost.dismissActionMode();
314         // Invoking any non-keyboard input UI is treated as starting message compose.
315         if (target != mImeInput) {
316             mHost.onStartComposeMessage();
317         }
318         endUpdate();
319     }
320 
321     @Override
beginUpdate()322     public void beginUpdate() {
323         mUpdateCount++;
324     }
325 
326     @Override
endUpdate()327     public void endUpdate() {
328         Assert.isTrue(mUpdateCount > 0);
329         if (--mUpdateCount == 0) {
330             // Always try to update the host action bar after every update cycle.
331             mHost.invalidateActionBar();
332         }
333     }
334 
updateHostOptionsMenu()335     private void updateHostOptionsMenu() {
336         mHost.setOptionsMenuVisibility(!mMediaInput.isOpen());
337     }
338 
339     /**
340      * Manages showing/hiding the media picker in conversation.
341      */
342     private class ConversationMediaPicker extends ConversationInput {
ConversationMediaPicker(ConversationInputBase baseHost)343         public ConversationMediaPicker(ConversationInputBase baseHost) {
344             super(baseHost, false);
345         }
346 
347         private MediaPicker mMediaPicker;
348 
349         @Override
show(boolean animate)350         public boolean show(boolean animate) {
351             if (mMediaPicker == null) {
352                 mMediaPicker = getExistingOrCreateMediaPicker();
353                 setConversationThemeColor(ConversationDrawables.get().getConversationThemeColor());
354                 mMediaPicker.setSubscriptionDataProvider(mHost);
355                 mMediaPicker.setDraftMessageDataModel(mDraftDataModel);
356                 mMediaPicker.setListener(new MediaPickerListener() {
357                     @Override
358                     public void onOpened() {
359                         handleStateChange();
360                     }
361 
362                     @Override
363                     public void onFullScreenChanged(boolean fullScreen) {
364                         // When we're full screen, we want to disable accessibility on the
365                         // ComposeMessageView controls (attach button, message input, sim chooser)
366                         // that are hiding underneath the action bar.
367                         mSink.setAccessibility(!fullScreen /*enabled*/);
368                         handleStateChange();
369                     }
370 
371                     @Override
372                     public void onDismissed() {
373                         // Re-enable accessibility on all controls now that the media picker is
374                         // going away.
375                         mSink.setAccessibility(true /*enabled*/);
376                         handleStateChange();
377                     }
378 
379                     private void handleStateChange() {
380                         onVisibilityChanged(isOpen());
381                         mHost.invalidateActionBar();
382                         updateHostOptionsMenu();
383                     }
384 
385                     @Override
386                     public void onItemsSelected(final Collection<MessagePartData> items,
387                             final boolean resumeCompose) {
388                         mSink.onMediaItemsSelected(items);
389                         mHost.invalidateActionBar();
390                         if (resumeCompose) {
391                             mSink.resumeComposeMessage();
392                         }
393                     }
394 
395                     @Override
396                     public void onItemUnselected(final MessagePartData item) {
397                         mSink.onMediaItemsUnselected(item);
398                         mHost.invalidateActionBar();
399                     }
400 
401                     @Override
402                     public void onConfirmItemSelection() {
403                         mSink.resumeComposeMessage();
404                     }
405 
406                     @Override
407                     public void onPendingItemAdded(final PendingAttachmentData pendingItem) {
408                         mSink.onPendingAttachmentAdded(pendingItem);
409                     }
410 
411                     @Override
412                     public void onChooserSelected(final int chooserIndex) {
413                         mHost.invalidateActionBar();
414                         mHost.dismissActionMode();
415                     }
416                 });
417             }
418 
419             mMediaPicker.open(MediaPicker.MEDIA_TYPE_DEFAULT, animate);
420 
421             return isOpen();
422         }
423 
424         @Override
hide(boolean animate)425         public boolean hide(boolean animate) {
426             if (mMediaPicker != null) {
427                 mMediaPicker.dismiss(animate);
428             }
429             return !isOpen();
430         }
431 
resetViewHolderState()432         public void resetViewHolderState() {
433             if (mMediaPicker != null) {
434                 mMediaPicker.resetViewHolderState();
435             }
436         }
437 
setConversationThemeColor(final int themeColor)438         public void setConversationThemeColor(final int themeColor) {
439             if (mMediaPicker != null) {
440                 mMediaPicker.setConversationThemeColor(themeColor);
441             }
442         }
443 
isOpen()444         private boolean isOpen() {
445             return (mMediaPicker != null && mMediaPicker.isOpen());
446         }
447 
getExistingOrCreateMediaPicker()448         private MediaPicker getExistingOrCreateMediaPicker() {
449             if (mMediaPicker != null) {
450                 return mMediaPicker;
451             }
452             MediaPicker mediaPicker = (MediaPicker)
453                     mFragmentManager.findFragmentByTag(MediaPicker.FRAGMENT_TAG);
454             if (mediaPicker == null) {
455                 mediaPicker = mHost.createMediaPicker();
456                 if (mediaPicker == null) {
457                     return null;    // this use of ComposeMessageView doesn't support media picking
458                 }
459                 mFragmentManager.beginTransaction().replace(
460                         R.id.mediapicker_container,
461                         mediaPicker,
462                         MediaPicker.FRAGMENT_TAG).commit();
463             }
464             return mediaPicker;
465         }
466 
467         @Override
updateActionBar(ActionBar actionBar)468         public boolean updateActionBar(ActionBar actionBar) {
469             if (isOpen()) {
470                 mMediaPicker.updateActionBar(actionBar);
471                 return true;
472             }
473             return false;
474         }
475 
476         @Override
onNavigationUpPressed()477         public boolean onNavigationUpPressed() {
478             if (isOpen() && mMediaPicker.isFullScreen()) {
479                 return onBackPressed();
480             }
481             return super.onNavigationUpPressed();
482         }
483 
onBackPressed()484         public boolean onBackPressed() {
485             if (mMediaPicker != null && mMediaPicker.onBackPressed()) {
486                 return true;
487             }
488             return super.onBackPressed();
489         }
490     }
491 
492     /**
493      * Manages showing/hiding the SIM selector in conversation.
494      */
495     private class SimSelector extends ConversationSimSelector {
SimSelector(ConversationInputBase baseHost)496         public SimSelector(ConversationInputBase baseHost) {
497             super(baseHost);
498         }
499 
500         @Override
getSimSelectorView()501         protected SimSelectorView getSimSelectorView() {
502             return mHost.getSimSelectorView();
503         }
504 
505         @Override
getSimSelectorItemLayoutId()506         public int getSimSelectorItemLayoutId() {
507             return mHost.getSimSelectorItemLayoutId();
508         }
509 
510         @Override
selectSim(SubscriptionListEntry item)511         protected void selectSim(SubscriptionListEntry item) {
512             mHost.selectSim(item);
513         }
514 
515         @Override
show(boolean animate)516         public boolean show(boolean animate) {
517             final boolean result = super.show(animate);
518             mHost.showHideSimSelector(true /*show*/);
519             return result;
520         }
521 
522         @Override
hide(boolean animate)523         public boolean hide(boolean animate) {
524             final boolean result = super.hide(animate);
525             mHost.showHideSimSelector(false /*show*/);
526             return result;
527         }
528     }
529 
530     /**
531      * Manages showing/hiding the IME keyboard in conversation.
532      */
533     private class ConversationImeKeyboard extends ConversationInput {
ConversationImeKeyboard(ConversationInputBase baseHost, final boolean isShowing)534         public ConversationImeKeyboard(ConversationInputBase baseHost, final boolean isShowing) {
535             super(baseHost, isShowing);
536         }
537 
538         @Override
show(boolean animate)539         public boolean show(boolean animate) {
540             ImeUtil.get().showImeKeyboard(mContext, mSink.getComposeEditText());
541             return true;
542         }
543 
544         @Override
hide(boolean animate)545         public boolean hide(boolean animate) {
546             ImeUtil.get().hideImeKeyboard(mContext, mSink.getComposeEditText());
547             return true;
548         }
549     }
550 }
551