1 /*
2  * Copyright (C) 2018 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file
5  * except in compliance with the License. You may obtain a copy of the License at
6  *
7  *      http://www.apache.org/licenses/LICENSE-2.0
8  *
9  * Unless required by applicable law or agreed to in writing, software distributed under the
10  * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
11  * KIND, either express or implied. See the License for the specific language governing
12  * permissions and limitations under the License.
13  */
14 
15 package com.android.systemui.statusbar.policy;
16 
17 import static android.view.ContentInfo.SOURCE_CLIPBOARD;
18 
19 import static com.android.systemui.statusbar.notification.stack.StackStateAnimator.ANIMATION_DURATION_STANDARD;
20 
21 import static com.google.common.truth.Truth.assertThat;
22 
23 import static junit.framework.Assert.assertEquals;
24 import static junit.framework.Assert.assertFalse;
25 import static junit.framework.Assert.assertNotNull;
26 import static junit.framework.Assert.assertTrue;
27 
28 import static org.mockito.ArgumentMatchers.any;
29 import static org.mockito.ArgumentMatchers.anyInt;
30 import static org.mockito.ArgumentMatchers.eq;
31 import static org.mockito.Mockito.doReturn;
32 import static org.mockito.Mockito.mock;
33 import static org.mockito.Mockito.spy;
34 import static org.mockito.Mockito.verify;
35 import static org.mockito.Mockito.when;
36 
37 import android.app.ActivityManager;
38 import android.app.PendingIntent;
39 import android.app.RemoteInput;
40 import android.content.ClipData;
41 import android.content.ClipDescription;
42 import android.content.Context;
43 import android.content.Intent;
44 import android.content.IntentFilter;
45 import android.content.pm.ShortcutManager;
46 import android.net.Uri;
47 import android.os.Handler;
48 import android.os.Process;
49 import android.os.UserHandle;
50 import android.testing.AndroidTestingRunner;
51 import android.testing.TestableLooper;
52 import android.view.ContentInfo;
53 import android.view.View;
54 import android.view.ViewRootImpl;
55 import android.view.inputmethod.EditorInfo;
56 import android.view.inputmethod.InputConnection;
57 import android.widget.EditText;
58 import android.widget.FrameLayout;
59 import android.widget.ImageButton;
60 import android.window.OnBackInvokedCallback;
61 import android.window.OnBackInvokedDispatcher;
62 import android.window.WindowOnBackInvokedDispatcher;
63 
64 import androidx.annotation.NonNull;
65 import androidx.test.filters.SmallTest;
66 
67 import com.android.internal.logging.UiEventLogger;
68 import com.android.internal.logging.testing.UiEventLoggerFake;
69 import com.android.systemui.Dependency;
70 import com.android.systemui.SysuiTestCase;
71 import com.android.systemui.animation.AnimatorTestRule;
72 import com.android.systemui.flags.FakeFeatureFlags;
73 import com.android.systemui.res.R;
74 import com.android.systemui.statusbar.NotificationRemoteInputManager;
75 import com.android.systemui.statusbar.RemoteInputController;
76 import com.android.systemui.statusbar.notification.collection.NotificationEntry;
77 import com.android.systemui.statusbar.notification.row.ExpandableNotificationRow;
78 import com.android.systemui.statusbar.notification.row.NotificationTestHelper;
79 import com.android.systemui.statusbar.notification.stack.StackStateAnimator;
80 import com.android.systemui.statusbar.phone.LightBarController;
81 
82 import org.junit.After;
83 import org.junit.Before;
84 import org.junit.Rule;
85 import org.junit.Test;
86 import org.junit.runner.RunWith;
87 import org.mockito.ArgumentCaptor;
88 import org.mockito.Mock;
89 import org.mockito.MockitoAnnotations;
90 
91 @RunWith(AndroidTestingRunner.class)
92 @TestableLooper.RunWithLooper
93 @SmallTest
94 public class RemoteInputViewTest extends SysuiTestCase {
95 
96     private static final String TEST_RESULT_KEY = "test_result_key";
97     private static final String TEST_REPLY = "hello";
98     private static final String TEST_ACTION = "com.android.REMOTE_INPUT_VIEW_ACTION";
99 
100     private static final String DUMMY_MESSAGE_APP_PKG =
101             "com.android.sysuitest.dummynotificationsender";
102     private static final int DUMMY_MESSAGE_APP_ID = Process.LAST_APPLICATION_UID - 1;
103 
104     private final FakeFeatureFlags mFeatureFlags = new FakeFeatureFlags();
105 
106     @Mock private RemoteInputController mController;
107     @Mock private ShortcutManager mShortcutManager;
108     @Mock private RemoteInputQuickSettingsDisabler mRemoteInputQuickSettingsDisabler;
109     @Mock private LightBarController mLightBarController;
110     private BlockingQueueIntentReceiver mReceiver;
111     private final UiEventLoggerFake mUiEventLoggerFake = new UiEventLoggerFake();
112 
113     @Rule
114     public final AnimatorTestRule mAnimatorTestRule = new AnimatorTestRule(this);
115 
116     @Before
setUp()117     public void setUp() throws Exception {
118         allowTestableLooperAsMainThread();
119         MockitoAnnotations.initMocks(this);
120 
121         mDependency.injectTestDependency(RemoteInputQuickSettingsDisabler.class,
122                 mRemoteInputQuickSettingsDisabler);
123         mDependency.injectTestDependency(LightBarController.class,
124                 mLightBarController);
125         mDependency.injectTestDependency(UiEventLogger.class, mUiEventLoggerFake);
126         mDependency.injectMockDependency(NotificationRemoteInputManager.class);
127 
128         mReceiver = new BlockingQueueIntentReceiver();
129         mContext.registerReceiver(mReceiver, new IntentFilter(TEST_ACTION), null,
130                 Handler.createAsync(Dependency.get(Dependency.BG_LOOPER)),
131                 Context.RECEIVER_EXPORTED_UNAUDITED);
132 
133         // Avoid SecurityException RemoteInputView#sendRemoteInput().
134         mContext.addMockSystemService(ShortcutManager.class, mShortcutManager);
135     }
136 
137     @After
tearDown()138     public void tearDown() {
139         mContext.unregisterReceiver(mReceiver);
140     }
141 
setTestPendingIntent(RemoteInputViewController controller)142     private void setTestPendingIntent(RemoteInputViewController controller) {
143         PendingIntent pendingIntent = PendingIntent.getBroadcast(mContext, 0,
144                 new Intent(TEST_ACTION).setPackage(mContext.getPackageName()),
145                 PendingIntent.FLAG_MUTABLE);
146         RemoteInput input = new RemoteInput.Builder(TEST_RESULT_KEY).build();
147         RemoteInput[] inputs = {input};
148 
149         controller.setPendingIntent(pendingIntent);
150         controller.setRemoteInput(input);
151         controller.setRemoteInputs(inputs);
152     }
153 
154     @Test
testSendRemoteInput_intentContainsResultsAndSource()155     public void testSendRemoteInput_intentContainsResultsAndSource() throws Exception {
156         NotificationTestHelper helper = new NotificationTestHelper(
157                 mContext,
158                 mDependency,
159                 TestableLooper.get(this));
160         ExpandableNotificationRow row = helper.createRow();
161         RemoteInputView view = RemoteInputView.inflate(mContext, null, row.getEntry(), mController);
162         RemoteInputViewController controller = bindController(view, row.getEntry());
163 
164         setTestPendingIntent(controller);
165 
166         view.focus();
167 
168         EditText editText = view.findViewById(R.id.remote_input_text);
169         editText.setText(TEST_REPLY);
170         ImageButton sendButton = view.findViewById(R.id.remote_input_send);
171         sendButton.performClick();
172 
173         Intent resultIntent = mReceiver.waitForIntent();
174         assertNotNull(resultIntent);
175         assertEquals(TEST_REPLY,
176                 RemoteInput.getResultsFromIntent(resultIntent).get(TEST_RESULT_KEY));
177         assertEquals(RemoteInput.SOURCE_FREE_FORM_INPUT,
178                 RemoteInput.getResultsSource(resultIntent));
179     }
180 
getTargetInputMethodUser(UserHandle fromUser, UserHandle toUser)181     private UserHandle getTargetInputMethodUser(UserHandle fromUser, UserHandle toUser)
182             throws Exception {
183         /**
184          * RemoteInputView, Icon, and Bubble have the situation need to handle the other user.
185          * SystemUI cross multiple user but this test(com.android.systemui.tests) doesn't cross
186          * multiple user. It needs some of mocking multiple user environment to ensure the
187          * createContextAsUser without throwing IllegalStateException.
188          */
189         Context contextSpy = spy(mContext);
190         doReturn(contextSpy).when(contextSpy).createContextAsUser(any(), anyInt());
191         doReturn(toUser.getIdentifier()).when(contextSpy).getUserId();
192 
193         NotificationTestHelper helper = new NotificationTestHelper(
194                 contextSpy,
195                 mDependency,
196                 TestableLooper.get(this));
197         ExpandableNotificationRow row = helper.createRow(
198                 DUMMY_MESSAGE_APP_PKG,
199                 UserHandle.getUid(fromUser.getIdentifier(), DUMMY_MESSAGE_APP_ID),
200                 toUser);
201         RemoteInputView view = RemoteInputView.inflate(mContext, null, row.getEntry(), mController);
202         RemoteInputViewController controller = bindController(view, row.getEntry());
203         EditText editText = view.findViewById(R.id.remote_input_text);
204 
205         setTestPendingIntent(controller);
206         assertThat(editText.isEnabled()).isFalse();
207         view.onVisibilityAggregated(true);
208         assertThat(editText.isEnabled()).isTrue();
209 
210         view.focus();
211 
212         EditorInfo editorInfo = new EditorInfo();
213         editorInfo.packageName = DUMMY_MESSAGE_APP_PKG;
214         editorInfo.fieldId = editText.getId();
215         InputConnection ic = editText.onCreateInputConnection(editorInfo);
216         assertNotNull(ic);
217         return editorInfo.targetInputMethodUser;
218     }
219 
220     @Test
testEditorInfoTargetInputMethodUserForCallingUser()221     public void testEditorInfoTargetInputMethodUserForCallingUser() throws Exception {
222         UserHandle callingUser = Process.myUserHandle();
223         assertEquals(callingUser, getTargetInputMethodUser(callingUser, callingUser));
224     }
225 
226     @Test
testEditorInfoTargetInputMethodUserForDifferentUser()227     public void testEditorInfoTargetInputMethodUserForDifferentUser() throws Exception {
228         UserHandle differentUser = UserHandle.of(UserHandle.getCallingUserId() + 1);
229         assertEquals(differentUser, getTargetInputMethodUser(differentUser, differentUser));
230     }
231 
232     @Test
testEditorInfoTargetInputMethodUserForAllUser()233     public void testEditorInfoTargetInputMethodUserForAllUser() throws Exception {
234         // For the special pseudo user UserHandle.ALL, EditorInfo#targetInputMethodUser must be
235         // resolved as the current user.
236         UserHandle callingUser = Process.myUserHandle();
237         assertEquals(UserHandle.of(ActivityManager.getCurrentUser()),
238                 getTargetInputMethodUser(callingUser, UserHandle.ALL));
239     }
240 
241     @Test
testNoCrashWithoutVisibilityListener()242     public void testNoCrashWithoutVisibilityListener() throws Exception {
243         NotificationTestHelper helper = new NotificationTestHelper(
244                 mContext,
245                 mDependency,
246                 TestableLooper.get(this));
247         ExpandableNotificationRow row = helper.createRow();
248         RemoteInputView view = RemoteInputView.inflate(mContext, null, row.getEntry(), mController);
249 
250         view.addOnVisibilityChangedListener(null);
251         view.setVisibility(View.INVISIBLE);
252         view.setVisibility(View.VISIBLE);
253     }
254 
255     @Test
testPredictiveBack_registerAndUnregister()256     public void testPredictiveBack_registerAndUnregister() throws Exception {
257         NotificationTestHelper helper = new NotificationTestHelper(
258                 mContext,
259                 mDependency,
260                 TestableLooper.get(this));
261         ExpandableNotificationRow row = helper.createRow();
262         RemoteInputView view = RemoteInputView.inflate(mContext, null, row.getEntry(), mController);
263 
264         ViewRootImpl viewRoot = mock(ViewRootImpl.class);
265         WindowOnBackInvokedDispatcher backInvokedDispatcher = mock(
266                 WindowOnBackInvokedDispatcher.class);
267         ArgumentCaptor<OnBackInvokedCallback> onBackInvokedCallbackCaptor = ArgumentCaptor.forClass(
268                 OnBackInvokedCallback.class);
269         when(viewRoot.getOnBackInvokedDispatcher()).thenReturn(backInvokedDispatcher);
270         view.setViewRootImpl(viewRoot);
271 
272         /* verify that predictive back callback registered when RemoteInputView becomes visible */
273         view.onVisibilityAggregated(true);
274         verify(backInvokedDispatcher).registerOnBackInvokedCallback(
275                 eq(OnBackInvokedDispatcher.PRIORITY_OVERLAY),
276                 onBackInvokedCallbackCaptor.capture());
277 
278         /* verify that same callback unregistered when RemoteInputView becomes invisible */
279         view.onVisibilityAggregated(false);
280         verify(backInvokedDispatcher).unregisterOnBackInvokedCallback(
281                 eq(onBackInvokedCallbackCaptor.getValue()));
282     }
283 
284     @Test
testUiPredictiveBack_openAndDispatchCallback()285     public void testUiPredictiveBack_openAndDispatchCallback() throws Exception {
286         NotificationTestHelper helper = new NotificationTestHelper(
287                 mContext,
288                 mDependency,
289                 TestableLooper.get(this));
290         ExpandableNotificationRow row = helper.createRow();
291         RemoteInputView view = RemoteInputView.inflate(mContext, null, row.getEntry(), mController);
292         ViewRootImpl viewRoot = mock(ViewRootImpl.class);
293         WindowOnBackInvokedDispatcher backInvokedDispatcher = mock(
294                 WindowOnBackInvokedDispatcher.class);
295         ArgumentCaptor<OnBackInvokedCallback> onBackInvokedCallbackCaptor = ArgumentCaptor.forClass(
296                 OnBackInvokedCallback.class);
297         when(viewRoot.getOnBackInvokedDispatcher()).thenReturn(backInvokedDispatcher);
298         view.setViewRootImpl(viewRoot);
299         view.onVisibilityAggregated(true);
300         view.setEditTextReferenceToSelf();
301 
302         /* capture the callback during registration */
303         verify(backInvokedDispatcher).registerOnBackInvokedCallback(
304                 eq(OnBackInvokedDispatcher.PRIORITY_OVERLAY),
305                 onBackInvokedCallbackCaptor.capture());
306 
307         view.focus();
308 
309         /* invoke the captured callback */
310         onBackInvokedCallbackCaptor.getValue().onBackInvoked();
311 
312         /* wait for RemoteInputView disappear animation to finish */
313         mAnimatorTestRule.advanceTimeBy(StackStateAnimator.ANIMATION_DURATION_STANDARD);
314 
315         /* verify that the RemoteInputView goes away */
316         assertEquals(view.getVisibility(), View.GONE);
317     }
318 
319     @Test
testUiEventLogging_openAndSend()320     public void testUiEventLogging_openAndSend() throws Exception {
321         NotificationTestHelper helper = new NotificationTestHelper(
322                 mContext,
323                 mDependency,
324                 TestableLooper.get(this));
325         ExpandableNotificationRow row = helper.createRow();
326         RemoteInputView view = RemoteInputView.inflate(mContext, null, row.getEntry(), mController);
327         RemoteInputViewController controller = bindController(view, row.getEntry());
328 
329         setTestPendingIntent(controller);
330 
331         // Open view, send a reply
332         view.focus();
333         EditText editText = view.findViewById(R.id.remote_input_text);
334         editText.setText(TEST_REPLY);
335         ImageButton sendButton = view.findViewById(R.id.remote_input_send);
336         sendButton.performClick();
337 
338         mReceiver.waitForIntent();
339 
340         assertEquals(2, mUiEventLoggerFake.numLogs());
341         assertEquals(
342                 RemoteInputView.NotificationRemoteInputEvent.NOTIFICATION_REMOTE_INPUT_OPEN.getId(),
343                 mUiEventLoggerFake.eventId(0));
344         assertEquals(
345                 RemoteInputView.NotificationRemoteInputEvent.NOTIFICATION_REMOTE_INPUT_SEND.getId(),
346                 mUiEventLoggerFake.eventId(1));
347     }
348 
349     @Test
testUiEventLogging_openAndAttach()350     public void testUiEventLogging_openAndAttach() throws Exception {
351         NotificationTestHelper helper = new NotificationTestHelper(
352                 mContext,
353                 mDependency,
354                 TestableLooper.get(this));
355         ExpandableNotificationRow row = helper.createRow();
356         RemoteInputView view = RemoteInputView.inflate(mContext, null, row.getEntry(), mController);
357         RemoteInputViewController controller = bindController(view, row.getEntry());
358 
359         setTestPendingIntent(controller);
360 
361         // Open view, attach an image
362         view.focus();
363         EditText editText = view.findViewById(R.id.remote_input_text);
364         editText.setText(TEST_REPLY);
365         ClipDescription description = new ClipDescription("", new String[] {"image/png"});
366         // We need to use an (arbitrary) real resource here so that an actual image gets attached
367         ClipData clip = new ClipData(description, new ClipData.Item(
368                 Uri.parse("android.resource://android/" + android.R.drawable.btn_default)));
369         ContentInfo payload =
370                 new ContentInfo.Builder(clip, SOURCE_CLIPBOARD).build();
371         view.setAttachment(payload);
372         mReceiver.waitForIntent();
373 
374         assertEquals(2, mUiEventLoggerFake.numLogs());
375         assertEquals(
376                 RemoteInputView.NotificationRemoteInputEvent.NOTIFICATION_REMOTE_INPUT_OPEN.getId(),
377                 mUiEventLoggerFake.eventId(0));
378         assertEquals(
379                 RemoteInputView.NotificationRemoteInputEvent
380                         .NOTIFICATION_REMOTE_INPUT_ATTACH_IMAGE.getId(),
381                 mUiEventLoggerFake.eventId(1));
382     }
383 
384     @Test
testFocusAnimation()385     public void testFocusAnimation() throws Exception {
386         NotificationTestHelper helper = new NotificationTestHelper(
387                 mContext,
388                 mDependency,
389                 TestableLooper.get(this));
390         ExpandableNotificationRow row = helper.createRow();
391         RemoteInputView view = RemoteInputView.inflate(mContext, null, row.getEntry(), mController);
392         bindController(view, row.getEntry());
393         view.setVisibility(View.GONE);
394 
395         View fadeOutView = new View(mContext);
396         fadeOutView.setId(com.android.internal.R.id.actions_container_layout);
397 
398         FrameLayout parent = new FrameLayout(mContext);
399         parent.addView(view);
400         parent.addView(fadeOutView);
401 
402         // Start focus animation
403         view.focusAnimated();
404         assertTrue(view.isAnimatingAppearance());
405 
406         // fast forward to 1 ms before end of animation and verify fadeOutView has alpha set to 0f
407         mAnimatorTestRule.advanceTimeBy(ANIMATION_DURATION_STANDARD - 1);
408         assertEquals(0f, fadeOutView.getAlpha());
409 
410         // fast forward to end of animation
411         mAnimatorTestRule.advanceTimeBy(1);
412 
413         // assert that fadeOutView's alpha is reset to 1f after the animation (hidden behind
414         // RemoteInputView)
415         assertEquals(1f, fadeOutView.getAlpha());
416         assertFalse(view.isAnimatingAppearance());
417         assertEquals(View.VISIBLE, view.getVisibility());
418         assertEquals(1f, view.getAlpha());
419     }
420 
421     @Test
testDefocusAnimation()422     public void testDefocusAnimation() throws Exception {
423         NotificationTestHelper helper = new NotificationTestHelper(
424                 mContext,
425                 mDependency,
426                 TestableLooper.get(this));
427         ExpandableNotificationRow row = helper.createRow();
428         RemoteInputView view = RemoteInputView.inflate(mContext, null, row.getEntry(), mController);
429         bindController(view, row.getEntry());
430 
431         View fadeInView = new View(mContext);
432         fadeInView.setId(com.android.internal.R.id.actions_container_layout);
433 
434         FrameLayout parent = new FrameLayout(mContext);
435         parent.addView(view);
436         parent.addView(fadeInView);
437 
438         // Start defocus animation
439         view.onDefocus(true /* animate */, false /* logClose */, null /* doAfterDefocus */);
440         assertEquals(View.VISIBLE, view.getVisibility());
441         assertEquals(0f, fadeInView.getAlpha());
442 
443         // fast forward to end of animation
444         mAnimatorTestRule.advanceTimeBy(ANIMATION_DURATION_STANDARD);
445 
446         // assert that RemoteInputView is no longer visible
447         assertEquals(View.GONE, view.getVisibility());
448         assertEquals(1f, fadeInView.getAlpha());
449     }
450 
451     @Test
testUnanimatedFocusAfterDefocusAnimation()452     public void testUnanimatedFocusAfterDefocusAnimation() throws Exception {
453         NotificationTestHelper helper = new NotificationTestHelper(
454                 mContext,
455                 mDependency,
456                 TestableLooper.get(this));
457         ExpandableNotificationRow row = helper.createRow();
458         RemoteInputView view = RemoteInputView.inflate(mContext, null, row.getEntry(), mController);
459         bindController(view, row.getEntry());
460 
461         FrameLayout parent = new FrameLayout(mContext);
462         parent.addView(view);
463 
464         // Play defocus animation
465         view.onDefocus(true /* animate */, false /* logClose */, null /* doAfterDefocus */);
466         mAnimatorTestRule.advanceTimeBy(ANIMATION_DURATION_STANDARD);
467 
468         // assert that RemoteInputView is no longer visible, but alpha is reset to 1f
469         assertEquals(View.GONE, view.getVisibility());
470         assertEquals(1f, view.getAlpha());
471 
472         // focus RemoteInputView without an animation
473         view.focus();
474         // assert that RemoteInputView is visible, and alpha is 1f
475         assertEquals(View.VISIBLE, view.getVisibility());
476         assertEquals(1f, view.getAlpha());
477     }
478 
479     // NOTE: because we're refactoring the RemoteInputView and moving logic into the
480     // RemoteInputViewController, it's easiest to just test the system of the two classes together.
481     @NonNull
bindController( RemoteInputView view, NotificationEntry entry)482     private RemoteInputViewController bindController(
483             RemoteInputView view,
484             NotificationEntry entry) {
485         RemoteInputViewControllerImpl viewController = new RemoteInputViewControllerImpl(
486                 view,
487                 entry,
488                 mRemoteInputQuickSettingsDisabler,
489                 mController,
490                 mShortcutManager,
491                 mUiEventLoggerFake,
492                 mFeatureFlags
493                 );
494         viewController.bind();
495         return viewController;
496     }
497 }
498