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