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