1 /**
<lambda>null2  * Copyright (C) 2024 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 android.ext.services.notification
18 
19 import android.app.ActivityManager
20 import android.app.Notification
21 import android.app.Notification.CATEGORY_MESSAGE
22 import android.app.NotificationChannel
23 import android.app.NotificationManager.IMPORTANCE_DEFAULT
24 import android.app.PendingIntent
25 import android.content.Intent
26 import android.content.pm.PackageManager
27 import android.content.pm.PackageManager.FEATURE_WATCH
28 import android.icu.util.ULocale
29 import android.os.Process
30 import android.platform.test.flag.junit.SetFlagsRule
31 import android.service.notification.Adjustment.KEY_SENSITIVE_CONTENT
32 import android.service.notification.Adjustment.KEY_TEXT_REPLIES
33 import android.service.notification.Flags
34 import android.service.notification.StatusBarNotification
35 import android.view.textclassifier.TextClassificationManager
36 import android.view.textclassifier.TextClassifier
37 import android.view.textclassifier.TextLanguage
38 import android.view.textclassifier.TextLinks
39 import androidx.test.platform.app.InstrumentationRegistry
40 import com.android.modules.utils.build.SdkLevel
41 import com.android.textclassifier.notification.SmartSuggestions
42 import com.android.textclassifier.notification.SmartSuggestionsHelper
43 import com.google.common.truth.Truth.assertThat
44 import com.google.common.truth.Truth.assertWithMessage
45 import org.junit.Assume.assumeTrue
46 import org.junit.Before
47 import org.junit.Rule
48 import org.junit.Test
49 import org.junit.rules.TestRule
50 import org.junit.runner.RunWith
51 import org.junit.runners.JUnit4
52 import org.mockito.ArgumentMatchers.any
53 import org.mockito.ArgumentMatchers.eq
54 import org.mockito.ArgumentMatchers.isNull
55 import org.mockito.Mockito.atLeast
56 import org.mockito.Mockito.doAnswer
57 import org.mockito.Mockito.doReturn
58 import org.mockito.Mockito.mock
59 import org.mockito.Mockito.never
60 import org.mockito.Mockito.spy
61 import org.mockito.Mockito.times
62 import org.mockito.Mockito.verify
63 import org.mockito.invocation.InvocationOnMock
64 import org.mockito.stubbing.Stubber
65 
66 @RunWith(JUnit4::class)
67 class AssistantTest {
68     val context = InstrumentationRegistry.getInstrumentation().targetContext!!
69     lateinit var mockSuggestions: SmartSuggestionsHelper
70     lateinit var mockTc: TextClassifier
71     lateinit var assistant: Assistant
72     lateinit var mockPm: PackageManager
73     lateinit var mockAm: ActivityManager
74     val EXECUTOR_AWAIT_TIME = 200L
75 
76     private fun <T> Stubber.whenKt(mock: T): T = `when`(mock)
77 
78     @get:Rule
79     val setFlagsRule = if (SdkLevel.isAtLeastV()) {
80         SetFlagsRule()
81     } else {
82         // On < V, have a test rule that does nothing
83         TestRule { statement, _ -> statement}
84     }
85 
86     @Before
87     fun setUpMocks() {
88         assumeTrue(SdkLevel.isAtLeastV())
89         assistant = spy(Assistant())
90         mockSuggestions = mock(SmartSuggestionsHelper::class.java)
91         mockTc = mock(TextClassifier::class.java)
92         mockAm = mock(ActivityManager::class.java)
93         mockPm = mock(PackageManager::class.java)
94         assistant.mAm = mockAm
95         assistant.mPm = mockPm
96         assistant.mSmartSuggestionsHelper = mockSuggestions
97         doReturn(SmartSuggestions(emptyList(), emptyList()))
98                 .whenKt(mockSuggestions).onNotificationEnqueued(any())
99         assistant.mTcm = context.getSystemService(TextClassificationManager::class.java)!!
100         assistant.mTcm.setTextClassifier(mockTc)
101         doReturn(TextLinks.Builder("").build()).whenKt(mockTc).generateLinks(any())
102         if (SdkLevel.isAtLeastV()) {
103             (setFlagsRule as SetFlagsRule).enableFlags(
104                 Flags.FLAG_REDACT_SENSITIVE_NOTIFICATIONS_FROM_UNTRUSTED_LISTENERS
105             )
106         }
107     }
108 
109     @Test
110     fun onNotificationEnqueued_callsTextClassifierForOtpAndSuggestions() {
111         val sbn = createSbn(TEXT_WITH_OTP)
112         doReturn(TextLanguage.Builder().putLocale(ULocale.ROOT, 0.9f).build())
113             .whenKt(mockTc).detectLanguage(any())
114         assistant.onNotificationEnqueued(sbn, NotificationChannel("0", "", IMPORTANCE_DEFAULT))
115         Thread.sleep(EXECUTOR_AWAIT_TIME)
116         verify(mockTc).detectLanguage(any())
117         verify(assistant.mSmartSuggestionsHelper, times(1)).onNotificationEnqueued(eq(sbn))
118         // A false result shouldn't result in an adjustment call for the otp
119         verify(assistant).createNotificationAdjustment(any(), isNull(), isNull(), eq(true))
120         // One adjustment for the suggestions and OTP together
121         verify(assistant).createNotificationAdjustment(any(),
122             eq(ArrayList<Notification.Action>()), eq(ArrayList<CharSequence>()), eq(true))
123     }
124 
125     @Test
126     fun onNotificationEnqueued_usesBothRegexAndTc() {
127         val sbn = createSbn(TEXT_WITH_OTP)
128         doReturn(TextLanguage.Builder().putLocale(ULocale.ROOT, 0.9f).build())
129             .whenKt(mockTc).detectLanguage(any())
130         val directReturn =
131             assistant.onNotificationEnqueued(sbn, NotificationChannel("0", "", IMPORTANCE_DEFAULT))
132         // Expect an adjustment to be returned, due to regex
133         assertThat(directReturn).isNotNull()
134         assertThat(directReturn!!.signals.getBoolean(KEY_SENSITIVE_CONTENT)).isTrue()
135         assertThat(directReturn.signals.getCharSequenceArrayList(KEY_TEXT_REPLIES)).isNull()
136         Thread.sleep(EXECUTOR_AWAIT_TIME)
137         // Expect a call to the TC, and a call to adjust the notification
138         verify(mockTc).detectLanguage(any())
139         verify(assistant).createNotificationAdjustment(any(), isNull(), isNull(), eq(true))
140         // Expect adjustment for the suggestions and OTP together, with a true value
141         verify(assistant).createNotificationAdjustment(any(),
142             eq(ArrayList<Notification.Action>()), eq(ArrayList<CharSequence>()), eq(true))
143     }
144 
145     @Test
146     fun onNotificationEnqueued_returnsNullIfRegexDoesntMatch() {
147         val sbn = createSbn(text = "")
148         val directReturn =
149             assistant.onNotificationEnqueued(sbn, NotificationChannel("0", "", IMPORTANCE_DEFAULT))
150         // Expect an adjustment to be returned, due to regex
151         assertThat(directReturn).isNull()
152     }
153 
154     @Test
155     fun onNotificationEnqueued_doesntUseTcIfWatch() {
156         val sbn = createSbn(TEXT_WITH_OTP)
157         doReturn(true).whenKt(mockPm).hasSystemFeature(eq(FEATURE_WATCH))
158         assistant.setUseTextClassifier()
159         // Empty list of detected languages means that the notification language didn't match
160         doReturn(TextLanguage.Builder().build())
161             .whenKt(mockTc).detectLanguage(any())
162         assistant.onNotificationEnqueued(sbn, NotificationChannel("0", "", IMPORTANCE_DEFAULT))
163         Thread.sleep(EXECUTOR_AWAIT_TIME)
164         verify(mockTc, never()).generateLinks(any())
165         // Never calls generateLinks, but still gets an adjustment, due to regex
166         verify(assistant, atLeast(1))
167             .createNotificationAdjustment(any(), any(), any(), eq(true))
168         verify(assistant.mSmartSuggestionsHelper, times(1)).onNotificationEnqueued(eq(sbn))
169     }
170 
171     @Test
172     fun onNotificationEnqueued_doesntUseTcIfLowRamDevice() {
173         val sbn = createSbn(TEXT_WITH_OTP)
174         doReturn(true).whenKt(mockAm).isLowRamDevice
175         assistant.setUseTextClassifier()
176         // Empty list of detected languages means that the notification language didn't match
177         doReturn(TextLanguage.Builder().build())
178             .whenKt(mockTc).detectLanguage(any())
179         assistant.onNotificationEnqueued(sbn, NotificationChannel("0", "", IMPORTANCE_DEFAULT))
180         Thread.sleep(EXECUTOR_AWAIT_TIME)
181         verify(mockTc, never()).generateLinks(any())
182         verify(assistant, atLeast(1))
183             .createNotificationAdjustment(any(), any(), any(), eq(true))
184         verify(assistant.mSmartSuggestionsHelper, times(1)).onNotificationEnqueued(eq(sbn))
185     }
186 
187     @Test
188     fun onNotificationEnqueued_usesHelperToGetText() {
189         var sensitiveString: String? = null
190         doAnswer { invocation: InvocationOnMock ->
191             val request = invocation.getArgument<TextLanguage.Request>(0)
192             sensitiveString = request.text.toString()
193             return@doAnswer TextLanguage.Builder().putLocale(ULocale.ROOT, 0.9f).build()
194 
195         }.whenKt(mockTc).detectLanguage(any())
196         val sbn = createSbn(text = TEXT_WITH_OTP, title = "title", subtext = "subtext")
197         assistant.onNotificationEnqueued(sbn, NotificationChannel("0", "", IMPORTANCE_DEFAULT))
198         Thread.sleep(EXECUTOR_AWAIT_TIME)
199         val expectedText = NotificationOtpDetectionHelper.getTextForDetection(sbn.notification)
200         assertWithMessage("Expected sensitive text to be $expectedText, but was $sensitiveString")
201             .that(sensitiveString).isEqualTo(expectedText)
202     }
203 
204     @Test
205     fun onNotificationEnqueued_checksHelperBeforeClassifying() {
206         // Category, Style, Regex all don't match
207         var sbn = createSbn(text = "text", title = "title", subtext = "subtext", category = "")
208         assistant.onNotificationEnqueued(sbn, NotificationChannel("0", "", IMPORTANCE_DEFAULT))
209         Thread.sleep(EXECUTOR_AWAIT_TIME)
210         verify(mockTc, never()).detectLanguage(any())
211         // Category matching is checked implicitly in other tests
212         // Style matches
213         sbn = createSbn(text = TEXT_WITH_OTP, title = "title", subtext = "subtext", category = "",
214             style = Notification.InboxStyle())
215         assistant.onNotificationEnqueued(sbn, NotificationChannel("0", "", IMPORTANCE_DEFAULT))
216         Thread.sleep(EXECUTOR_AWAIT_TIME)
217         verify(mockTc).detectLanguage(any())
218     }
219 
220     @Test
221     fun createEnqueuedNotificationAdjustment_hasAdjustmentIfCheckedForOtpCode() {
222         val adjustment = assistant.createNotificationAdjustment(
223             createSbn(),
224             arrayListOf<Notification.Action>(),
225             arrayListOf<CharSequence>(),
226             true)
227         assertThat(adjustment.signals.getBoolean(KEY_SENSITIVE_CONTENT)).isTrue()
228         val adjustment2 = assistant.createNotificationAdjustment(
229             createSbn(),
230             arrayListOf<Notification.Action>(),
231             arrayListOf<CharSequence>(),
232             false)
233         assertThat(adjustment2.signals.getBoolean(KEY_SENSITIVE_CONTENT)).isFalse()
234         val adjustment3 = assistant.createNotificationAdjustment(
235             createSbn(),
236             arrayListOf<Notification.Action>(),
237             arrayListOf<CharSequence>(),
238             null)
239         assertThat(adjustment3.signals.containsKey(KEY_SENSITIVE_CONTENT)).isFalse()
240     }
241 
242     private fun createSbn(
243         text: String = "",
244         title: String = "",
245         subtext: String = "",
246         category: String = CATEGORY_MESSAGE,
247         style: Notification.Style? = null
248     ): StatusBarNotification {
249         val intent = Intent(Intent.ACTION_MAIN)
250         intent.setFlags(
251             Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP
252                     or Intent.FLAG_ACTIVITY_CLEAR_TOP
253         )
254         intent.setAction(Intent.ACTION_MAIN)
255         intent.setPackage(context.packageName)
256 
257         val nb = Notification.Builder(context, "")
258         nb.setContentText(text)
259         nb.setContentTitle(title)
260         nb.setSubText(subtext)
261         nb.setCategory(category)
262         nb.setContentIntent(createTestPendingIntent())
263         if (style != null) {
264             nb.setStyle(style)
265         }
266         return StatusBarNotification(context.packageName, context.packageName, 0, "",
267             Process.myUid(), 0, 0, nb.build(), Process.myUserHandle(), System.currentTimeMillis())
268     }
269 
270     private fun createTestPendingIntent(): PendingIntent {
271         val intent = Intent(Intent.ACTION_MAIN)
272         intent.setFlags(
273             Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP
274                     or Intent.FLAG_ACTIVITY_CLEAR_TOP
275         )
276         intent.setAction(Intent.ACTION_MAIN)
277         intent.setPackage(context.packageName)
278 
279         return PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_MUTABLE)
280     }
281 
282     companion object {
283         const val TEXT_WITH_OTP = "Your login code is 345454"
284     }
285 
286 }
287