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