blob: 37677bc05abf3e7171e5bf88387c4071f7bc6380 [file] [edit]
/*
* Copyright (C) 2024 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.cts.verifier.notifications
import android.app.Activity
import android.app.ActivityManager
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Person
import android.app.RemoteInput
import android.app.Service
import android.app.role.RoleManager
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.content.pm.ShortcutInfo
import android.content.pm.ShortcutManager
import android.content.res.Resources
import android.content.res.Resources.ID_NULL
import android.graphics.Bitmap
import android.graphics.Color
import android.graphics.PixelFormat
import android.graphics.Rect
import android.graphics.drawable.Icon
import android.hardware.display.VirtualDisplay
import android.media.Image
import android.media.ImageReader
import android.media.projection.MediaProjection
import android.media.projection.MediaProjectionManager
import android.os.Binder
import android.os.Bundle
import android.os.Environment
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.os.SystemClock
import android.util.DisplayMetrics
import android.util.Log
import android.view.View
import android.view.WindowManager
import android.widget.Button
import android.widget.TextView
import android.widget.Toast
import com.android.compatibility.common.util.CddTest
import com.android.cts.verifier.PassFailButtons
import com.android.cts.verifier.R
import java.io.File
import java.io.FileOutputStream
import java.nio.ByteBuffer
@CddTest(requirements = ["9.8.2"])
class NotificationHidingVerifierActivity : PassFailButtons.Activity() {
private lateinit var mediaProjectionServiceIntent: Intent
private lateinit var notificationManager: NotificationManager
private lateinit var mediaProjectionManager: MediaProjectionManager
private lateinit var shortcutManager: ShortcutManager
private lateinit var title: TextView
private lateinit var instructions: TextView
private lateinit var warning: TextView
private lateinit var screenCapturePath: TextView
private lateinit var buttonView: View
private var currentTestIdx = 0
private var numFailures = 0
private var mediaProjection: MediaProjection? = null
private val mediaProjectionCallback = object : MediaProjection.Callback() {}
private var imageReader: ImageReader? = null
private var virtualDisplay: VirtualDisplay? = null
private var serviceConnection: ServiceConnection? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
mediaProjectionServiceIntent = Intent(this, MediaProjectionService::class.java)
notificationManager = getSystemService(NotificationManager::class.java)!!
mediaProjectionManager = getSystemService(MediaProjectionManager::class.java)!!
shortcutManager = getSystemService(ShortcutManager::class.java)!!
createChannel()
setContentView(R.layout.notif_hiding_main)
title = requireViewById(R.id.test_title)
instructions = requireViewById(R.id.test_instructions)
warning = requireViewById(R.id.test_warning)
screenCapturePath = requireViewById(R.id.test_screen_capture_path)
buttonView = requireViewById(R.id.action_button_layout)
requireViewById<Button>(R.id.test_step_passed).setOnClickListener { _ ->
showNextTestOrSummary()
}
requireViewById<Button>(R.id.test_step_failed).setOnClickListener { _ ->
numFailures += 1
showNextTestOrSummary()
}
requireViewById<Button>(R.id.send_notification_button).setOnClickListener { _ ->
tests[currentTestIdx].sendNotification()
}
requireViewById<Button>(R.id.start_screenshare_button).setOnClickListener { _ ->
startScreenRecording()
}
requireViewById<Button>(R.id.save_screen_capture_button).setOnClickListener { _ ->
saveScreenCapture()
}
requireViewById<Button>(R.id.request_sms_button).setOnClickListener { _ ->
requestSmsRole()
}
val am = getSystemService(ActivityManager::class.java)!!
var supportsBubble = false
try {
supportsBubble = resources.getBoolean(resources.getIdentifier(
"config_supportsBubble", "bool", "android"))
} catch (e: Resources.NotFoundException) {
// Assume device does not support bubble, no need to do anything.
}
if (!am.isLowRamDevice && supportsBubble) {
// Only test bubbles if the device isn't a low ram device, and supports bubbles
tests.add(notificationContentHiddenInBubblesTest)
val shortcut =
ShortcutInfo.Builder(this, SHORTCUT_ID)
.setCategories(setOf(Notification.CATEGORY_MESSAGE))
.setIntent(Intent(Intent.ACTION_MAIN))
.setLongLived(true)
.setShortLabel(PERSON)
.build()
shortcutManager.addDynamicShortcuts(listOf(shortcut))
}
setPassFailButtonClickListeners()
passButton.isEnabled = false
if (savedInstanceState != null) {
restoreState(savedInstanceState)
}
showNextTestOrSummary(incrementCounter = false)
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putInt("currentTestIdx", currentTestIdx)
outState.putInt("numFailures", numFailures)
}
private fun restoreState(savedState: Bundle) {
currentTestIdx = savedState.getInt("currentTestIdx")
numFailures = savedState.getInt("numFailures")
}
private fun createChannel() {
val channel =
NotificationChannel(
NOTIFICATION_CHANNEL_ID,
NOTIFICATION_CHANNEL_ID,
NotificationManager.IMPORTANCE_HIGH
)
notificationManager.createNotificationChannel(channel)
}
private fun showNextTestOrSummary(incrementCounter: Boolean = true) {
stopScreenRecording()
notificationManager.cancelAll()
if (incrementCounter) {
currentTestIdx += 1
}
if (currentTestIdx >= tests.size) {
showCompletionSummary()
} else {
title.setText(tests[currentTestIdx].getTestTitle())
instructions.setText(tests[currentTestIdx].getTestInstructions())
val testWarning = tests[currentTestIdx].getTestWarning()
if (testWarning == ID_NULL) {
warning.visibility = View.GONE
} else {
warning.visibility = View.VISIBLE
warning.setText(testWarning)
}
screenCapturePath.setText("")
}
}
private fun requestSmsRole() {
startActivityForResult(
getSystemService(RoleManager::class.java)
.createRequestRoleIntent(RoleManager.ROLE_SMS),
0
)
}
private fun showCompletionSummary() {
shortcutManager.removeAllDynamicShortcuts()
title.setText(R.string.notif_hiding_test)
buttonView.visibility = View.GONE
if (numFailures == 0) {
instructions.setText(R.string.notif_hiding_success)
passButton.isEnabled = true
} else {
instructions.text = (getString(R.string.notif_hiding_failure, numFailures, tests.size))
}
warning.visibility = View.GONE
}
private fun sendNotification(createBubble: Boolean) {
val builder: Notification.Builder = getConversationNotif(SENSITIVE_TEXT)
if (createBubble) {
val metadata: Notification.BubbleMetadata = getBubbleMetadata()
builder.setBubbleMetadata(metadata)
builder.setShortcutId(SHORTCUT_ID)
}
notificationManager.notify(NOTIFICATION_ID, builder.build())
}
/** Creates a [Notification.Builder] that is a conversation. */
private fun getConversationNotif(content: String): Notification.Builder {
val timeContent = "$content at ${System.currentTimeMillis()}"
val context = applicationContext
val intent = Intent(context, BubbleActivity::class.java)
val pendingIntent = PendingIntent.getActivity(
context,
0,
intent,
PendingIntent.FLAG_MUTABLE
)
val person = Person.Builder()
.setName(PERSON)
.build()
val remoteInput = RemoteInput.Builder("reply_key").setLabel("reply").build()
val inputIntent = PendingIntent.getActivity(
applicationContext,
0,
Intent().setPackage(applicationContext.packageName),
PendingIntent.FLAG_MUTABLE
)
val icon = Icon.createWithResource(
applicationContext,
R.drawable.ic_android
)
val replyAction = Notification.Action.Builder(
icon,
"Reply",
inputIntent
).addRemoteInput(remoteInput)
.build()
return Notification.Builder(context, NOTIFICATION_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_android)
.setContentTitle("Test Notification")
.setContentText(timeContent)
.setColor(Color.GREEN)
.setContentIntent(pendingIntent)
.setActions(replyAction)
.setStyle(
Notification.MessagingStyle(person)
.setConversationTitle("Chat")
.addMessage(
timeContent,
SystemClock.currentThreadTimeMillis(),
person
)
)
}
/** Creates a minimally filled out [android.app.Notification.BubbleMetadata.Builder] */
private fun getBubbleMetadata(): Notification.BubbleMetadata {
val context = applicationContext
val intent = Intent(context, BubbleActivity::class.java)
val pendingIntent = PendingIntent.getActivity(
context,
0,
intent,
PendingIntent.FLAG_MUTABLE
)
val builder = Notification.BubbleMetadata.Builder(
pendingIntent,
Icon.createWithResource(
applicationContext,
R.drawable.ic_android
)
)
builder.setDesiredHeight(Short.MAX_VALUE.toInt())
return builder.build()
}
private fun startScreenRecording() {
startActivityForResult(
mediaProjectionManager.createScreenCaptureIntent(),
REQUEST_PROJECTION_CODE
)
}
private fun setupVirtualDisplay() {
val maxWindowMetrics = getSystemService(WindowManager::class.java).maximumWindowMetrics
val windowBounds: Rect = maxWindowMetrics.bounds
if (imageReader == null) {
imageReader = ImageReader.newInstance(
windowBounds.width(), windowBounds.height(),
PixelFormat.RGBA_8888,
/* maxImages= */
1
).also {
mediaProjection?.run {
registerCallback(mediaProjectionCallback, Handler(Looper.getMainLooper()))
val testTitle: String = getString(tests[currentTestIdx].getTestTitle())
virtualDisplay = createVirtualDisplay(
VIRTUAL_DISPLAY + "_" + testTitle,
windowBounds.width(), windowBounds.height(),
DisplayMetrics.DENSITY_HIGH,
/* flags= */
0,
it.surface,
/* callback= */
null,
Handler(Looper.getMainLooper())
)
}
}
}
}
private fun stopScreenRecording() {
imageReader = null
virtualDisplay?.apply {
surface.release()
release()
}
virtualDisplay = null
mediaProjection?.apply {
unregisterCallback(mediaProjectionCallback)
stop()
}
mediaProjection = null
serviceConnection?.let { applicationContext.unbindService(it) }
serviceConnection = null
applicationContext.stopService(mediaProjectionServiceIntent)
}
private fun saveScreenCapture() {
val testTitle: String = getString(tests[currentTestIdx].getTestTitle())
if (mediaProjection == null || imageReader == null || virtualDisplay == null) {
Log.w(
TAG,
"mediaProjection is null (${mediaProjection == null})" +
" or imageReader is null (${imageReader == null})" +
" or virtualDisplay is null (${virtualDisplay == null})" +
" which will cause screengrab failure for $testTitle"
)
Toast.makeText(
applicationContext,
"Screen capture failed. Is screen recording active?",
Toast.LENGTH_LONG
).show()
return
}
imageReader?.let {
// All screen captures will be available in pictures directory on device.
val externalStoragePublicDirectory =
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES)
val ctsScreenshotStorageDir = File(externalStoragePublicDirectory, "cts.$TAG")
if (ctsScreenshotStorageDir == null) {
Log.w(TAG, "Failed to retrieve external files directory.")
Toast.makeText(
applicationContext,
"Failed to save screenshot",
Toast.LENGTH_SHORT
).show()
return
}
val screenshotPath = saveScreenCapture(it, ctsScreenshotStorageDir, testTitle)
if (screenshotPath == null) {
Log.w(TAG, "Failed to save screenshot for $testTitle")
Toast.makeText(
applicationContext,
"Failed to save screenshot",
Toast.LENGTH_SHORT
).show()
} else {
Log.i(TAG, "Screen shot of recording saved to $screenshotPath")
Toast.makeText(
applicationContext,
"Screen shot of recording saved to " +
"$screenshotPath",
Toast.LENGTH_LONG
).show()
screenCapturePath.text =
getString(R.string.notif_hiding_save_screen_capture_path, screenshotPath)
}
}
}
private fun saveScreenCapture(
reader: ImageReader,
screenshotStorageDir: File,
testName: String
): String? {
reader.acquireLatestImage().use { image: Image? ->
if (image == null) {
Log.w(TAG, "failed to save screenshot for $testName, image null")
return null
}
val plane: Image.Plane? = image.getPlanes()[0]
if (plane == null) {
Log.w(TAG, "failed to save screenshot for $testName, plane null")
return null
}
val rowPadding: Int =
plane.getRowStride() - plane.getPixelStride() * image.getWidth()
val bitmap = Bitmap.createBitmap(
/* width= */
image.getWidth() + rowPadding / plane.getPixelStride(),
/* height= */
image.getHeight(),
Bitmap.Config.ARGB_8888
)
if (bitmap == null) {
Log.w(TAG, "failed to save screenshot for $testName, bitmap null")
return null
}
val buffer: ByteBuffer = plane.getBuffer()
if (buffer == null) {
Log.w(TAG, "failed to save screenshot for $testName, buffer null")
return null
}
bitmap.copyPixelsFromBuffer(plane.getBuffer())
try {
if (!screenshotStorageDir.exists()) {
if (!screenshotStorageDir.mkdirs()) {
Log.d(TAG, "Failed to create media storage directory.")
return null
}
}
val fileName: String =
testName + "_screenshot_" + System.currentTimeMillis() + ".jpg"
val screenshot: File = File(screenshotStorageDir, fileName)
FileOutputStream(screenshot).use { stream: FileOutputStream? ->
if (stream == null) {
Log.w(TAG, "failed to save screenshot for $testName, stream null")
return null
}
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, stream)
return screenshot.absolutePath
}
} catch (e: Exception) {
Log.e(TAG, "Unable to write out screenshot", e)
}
}
return null
}
override fun onActivityResult(
requestCode: Int,
resultCode: Int,
data: Intent?
) {
if (requestCode == REQUEST_PROJECTION_CODE) {
if (resultCode != Activity.RESULT_OK) {
Toast.makeText(this, "Please approve screen recording", Toast.LENGTH_SHORT).show()
return
}
applicationContext.startForegroundService(mediaProjectionServiceIntent)
serviceConnection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
mediaProjection = mediaProjectionManager.getMediaProjection(resultCode, data!!)
setupVirtualDisplay()
}
override fun onServiceDisconnected(name: ComponentName?) {}
}
applicationContext.bindService(
mediaProjectionServiceIntent,
serviceConnection!!,
Context.BIND_AUTO_CREATE
)
}
}
override fun onDestroy() {
super.onDestroy()
stopScreenRecording()
notificationManager.cancelAll()
shortcutManager.removeAllDynamicShortcuts()
}
private val notificationContentHiddenInShadeTest = object : NotificationHidingTestCase() {
override fun getTestTitle(): Int {
return R.string.notif_hiding_shade_test
}
override fun getTestInstructions(): Int {
return R.string.notif_hiding_shade_test_instructions
}
}
private val notificationContentHiddenInAppTest = object : NotificationHidingTestCase() {
override fun getTestTitle(): Int {
return R.string.notif_hiding_app_test
}
override fun getTestInstructions(): Int {
return R.string.notif_hiding_app_test_instructions
}
}
private val notificationContentHiddenInShadePartialTest =
object : NotificationHidingTestCase() {
override fun getTestTitle(): Int {
return R.string.notif_hiding_shade_partial_test
}
override fun getTestInstructions(): Int {
return R.string.notif_hiding_shade_partial_test_instructions
}
}
private val notificationContentHiddenInLauncherTest = object : NotificationHidingTestCase() {
override fun getTestTitle(): Int {
return R.string.notif_hiding_launcher_test
}
override fun getTestInstructions(): Int {
return R.string.notif_hiding_launcher_test_instructions
}
}
private val notificationContentHiddenInBubblesTest = object : NotificationHidingTestCase() {
override fun getTestTitle(): Int {
return R.string.notif_hiding_bubble_test
}
override fun getTestInstructions(): Int {
return R.string.notif_hiding_bubble_test_instructions
}
override fun sendNotification() = sendNotification(createBubble = true)
}
private val notificationContentHiddenInShadeLocalScreenRecorderTest =
object : NotificationHidingTestCase() {
override fun getTestTitle(): Int {
return R.string.notif_hiding_shade_local_screen_recorder_test
}
override fun getTestInstructions(): Int {
return R.string.notif_hiding_shade_local_screen_recorder_test_instructions
}
override fun getTestWarning(): Int {
return R.string.notif_hiding_no_local_screen_recorder_warning
}
}
private val notificationContentHiddenInAppLocalScreenRecorderTest =
object : NotificationHidingTestCase() {
override fun getTestTitle(): Int {
return R.string.notif_hiding_app_local_screen_recorder_test
}
override fun getTestInstructions(): Int {
return R.string.notif_hiding_app_local_screen_recorder_test_instructions
}
override fun getTestWarning(): Int {
return R.string.notif_hiding_no_local_screen_recorder_warning
}
}
private val notificationContentHiddenInShadeDisableProtectionsTest =
object : NotificationHidingTestCase() {
override fun getTestTitle(): Int {
return R.string.notif_hiding_shade_disable_protections_test
}
override fun getTestInstructions(): Int {
return R.string.notif_hiding_shade_disable_protections_test_instructions
}
}
private val notificationContentHiddenInAppDisableProtectionsTest =
object : NotificationHidingTestCase() {
override fun getTestTitle(): Int {
return R.string.notif_hiding_app_disable_protections_test
}
override fun getTestInstructions(): Int {
return R.string.notif_hiding_app_disable_protections_test_instructions
}
}
private val tests = mutableListOf(
notificationContentHiddenInShadeTest,
notificationContentHiddenInAppTest,
notificationContentHiddenInShadePartialTest,
notificationContentHiddenInLauncherTest,
notificationContentHiddenInShadeLocalScreenRecorderTest,
notificationContentHiddenInAppLocalScreenRecorderTest,
notificationContentHiddenInShadeDisableProtectionsTest,
notificationContentHiddenInAppDisableProtectionsTest
)
companion object {
private const val TAG: String = "NotifHidingVerifier"
private const val VIRTUAL_DISPLAY: String = "NotifHidingVerifierVD"
private const val NOTIFICATION_CHANNEL_ID = TAG
private const val NOTIFICATION_ID = 1
private const val FGS_NOTIFICATION_ID = 2
private const val SHORTCUT_ID = "shortcut"
private const val REQUEST_PROJECTION_CODE = 1
private const val PERSON = "Person"
private const val FGS = "Media Projection FGS"
private const val SENSITIVE_TEXT = "Sensitive Text login code is 397964"
private const val FGS_MESSAGE = "FGS Running"
}
private abstract inner class NotificationHidingTestCase {
/** The title of the test step. */
abstract fun getTestTitle(): Int
/** What the tester should do & look for to verify this step was successful. */
abstract fun getTestInstructions(): Int
/** Returns string resid for warnings associated with the test not passing prerequisites */
open fun getTestWarning(): Int = ID_NULL
open fun sendNotification() = sendNotification(createBubble = false)
}
class MediaProjectionService : Service() {
val binder: IBinder = Binder()
override fun onBind(intent: Intent?): IBinder? {
return binder
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val icon = Icon.createWithResource(
applicationContext,
R.drawable.ic_android
)
val notif = Notification.Builder(this, "NotifHidingVerifier")
.setSmallIcon(icon)
.setContentTitle(FGS)
.setCategory(Notification.CATEGORY_SERVICE)
.setContentText(FGS_MESSAGE)
.build()
startForeground(FGS_NOTIFICATION_ID, notif)
return super.onStartCommand(intent, flags, startId)
}
}
}