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