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 package com.android.systemui.screenshot 17 18 import android.animation.Animator 19 import android.content.BroadcastReceiver 20 import android.content.Context 21 import android.content.Intent 22 import android.content.IntentFilter 23 import android.content.pm.ActivityInfo 24 import android.content.res.Configuration 25 import android.graphics.Bitmap 26 import android.graphics.Insets 27 import android.graphics.Rect 28 import android.net.Uri 29 import android.os.Process 30 import android.os.UserHandle 31 import android.os.UserManager 32 import android.provider.Settings 33 import android.util.DisplayMetrics 34 import android.util.Log 35 import android.view.Display 36 import android.view.ScrollCaptureResponse 37 import android.view.ViewRootImpl.ActivityConfigCallback 38 import android.view.WindowManager.TAKE_SCREENSHOT_PROVIDED_IMAGE 39 import android.widget.Toast 40 import android.window.WindowContext 41 import androidx.core.animation.doOnEnd 42 import com.android.internal.logging.UiEventLogger 43 import com.android.settingslib.applications.InterestingConfigChanges 44 import com.android.systemui.broadcast.BroadcastDispatcher 45 import com.android.systemui.broadcast.BroadcastSender 46 import com.android.systemui.clipboardoverlay.ClipboardOverlayController 47 import com.android.systemui.dagger.qualifiers.Main 48 import com.android.systemui.res.R 49 import com.android.systemui.screenshot.ScreenshotShelfViewProxy.ScreenshotViewCallback 50 import com.android.systemui.screenshot.scroll.ScrollCaptureController.LongScreenshot 51 import com.android.systemui.screenshot.scroll.ScrollCaptureExecutor 52 import com.android.systemui.util.Assert 53 import dagger.assisted.Assisted 54 import dagger.assisted.AssistedFactory 55 import dagger.assisted.AssistedInject 56 import java.util.UUID 57 import java.util.concurrent.Executor 58 import java.util.concurrent.ExecutorService 59 import java.util.concurrent.Executors 60 import java.util.function.Consumer 61 import kotlin.math.abs 62 63 /** Controls the state and flow for screenshots. */ 64 class ScreenshotController 65 @AssistedInject 66 internal constructor( 67 appContext: Context, 68 screenshotWindowFactory: ScreenshotWindow.Factory, 69 viewProxyFactory: ScreenshotShelfViewProxy.Factory, 70 screenshotNotificationsControllerFactory: ScreenshotNotificationsController.Factory, 71 screenshotActionsControllerFactory: ScreenshotActionsController.Factory, 72 actionExecutorFactory: ActionExecutor.Factory, 73 private val screenshotSoundController: ScreenshotSoundController, 74 private val uiEventLogger: UiEventLogger, 75 private val imageExporter: ImageExporter, 76 private val imageCapture: ImageCapture, 77 private val scrollCaptureExecutor: ScrollCaptureExecutor, 78 private val screenshotHandler: TimeoutHandler, 79 private val broadcastSender: BroadcastSender, 80 private val broadcastDispatcher: BroadcastDispatcher, 81 private val userManager: UserManager, 82 private val assistContentRequester: AssistContentRequester, 83 private val messageContainerController: MessageContainerController, 84 private val announcementResolver: AnnouncementResolver, 85 @Main private val mainExecutor: Executor, 86 private val actionIntentCreator: ActionIntentCreator, 87 @Assisted private val display: Display, 88 ) : InteractiveScreenshotHandler { 89 private val context: WindowContext 90 private val viewProxy: ScreenshotShelfViewProxy 91 private val notificationController = 92 screenshotNotificationsControllerFactory.create(display.displayId) 93 private val bgExecutor: ExecutorService = Executors.newSingleThreadExecutor() 94 private val actionsController: ScreenshotActionsController 95 private val window: ScreenshotWindow 96 private val actionExecutor: ActionExecutor 97 private val copyBroadcastReceiver: BroadcastReceiver 98 private val currentRequestCallbacks: MutableList<TakeScreenshotService.RequestCallback> = 99 mutableListOf() 100 101 private var screenBitmap: Bitmap? = null 102 private var screenshotTakenInPortrait = false 103 private var screenshotAnimation: Animator? = null 104 private var packageName = "" 105 106 /** Tracks config changes that require re-creating UI */ 107 private val configChanges = 108 InterestingConfigChanges( 109 ActivityInfo.CONFIG_ORIENTATION or 110 ActivityInfo.CONFIG_LAYOUT_DIRECTION or 111 ActivityInfo.CONFIG_LOCALE or 112 ActivityInfo.CONFIG_UI_MODE or 113 ActivityInfo.CONFIG_SCREEN_LAYOUT or 114 ActivityInfo.CONFIG_ASSETS_PATHS 115 ) 116 117 init { 118 screenshotHandler.defaultTimeoutMillis = SCREENSHOT_CORNER_DEFAULT_TIMEOUT_MILLIS 119 120 window = screenshotWindowFactory.create(display) 121 context = window.getContext() 122 123 viewProxy = viewProxyFactory.getProxy(context, display.displayId) 124 125 screenshotHandler.setOnTimeoutRunnable { 126 if (LogConfig.DEBUG_UI) { 127 Log.d(TAG, "Corner timeout hit") 128 } 129 viewProxy.requestDismissal(ScreenshotEvent.SCREENSHOT_INTERACTION_TIMEOUT) 130 } 131 132 configChanges.applyNewConfig(appContext.resources) 133 reloadAssets() 134 135 actionExecutor = actionExecutorFactory.create(window.window, viewProxy) { finishDismiss() } 136 actionsController = screenshotActionsControllerFactory.getController(actionExecutor) 137 138 copyBroadcastReceiver = 139 object : BroadcastReceiver() { 140 override fun onReceive(context: Context, intent: Intent) { 141 if (ClipboardOverlayController.COPY_OVERLAY_ACTION == intent.action) { 142 viewProxy.requestDismissal(ScreenshotEvent.SCREENSHOT_DISMISSED_OTHER) 143 } 144 } 145 } 146 broadcastDispatcher.registerReceiver( 147 copyBroadcastReceiver, 148 IntentFilter(ClipboardOverlayController.COPY_OVERLAY_ACTION), 149 null, 150 null, 151 Context.RECEIVER_NOT_EXPORTED, 152 ClipboardOverlayController.SELF_PERMISSION, 153 ) 154 } 155 156 override fun handleScreenshot( 157 screenshot: ScreenshotData, 158 finisher: Consumer<Uri?>, 159 requestCallback: TakeScreenshotService.RequestCallback, 160 ) { 161 Assert.isMainThread() 162 screenshotHandler.resetTimeout() 163 164 val currentBitmap = screenshot.bitmap 165 if (currentBitmap == null) { 166 Log.e(TAG, "handleScreenshot: Screenshot bitmap was null") 167 notificationController.notifyScreenshotError(R.string.screenshot_failed_to_capture_text) 168 requestCallback.reportError() 169 return 170 } 171 172 screenBitmap = currentBitmap 173 val oldPackageName = packageName 174 packageName = screenshot.packageNameString 175 176 if (!isUserSetupComplete(Process.myUserHandle())) { 177 Log.w(TAG, "User setup not complete, displaying toast only") 178 // User setup isn't complete, so we don't want to show any UI beyond a toast, as editing 179 // and sharing shouldn't be exposed to the user. 180 saveScreenshotAndToast(screenshot, finisher) 181 requestCallback.onFinish() 182 return 183 } 184 currentRequestCallbacks.add(requestCallback) 185 186 broadcastSender.sendBroadcast( 187 Intent(ClipboardOverlayController.SCREENSHOT_ACTION), 188 ClipboardOverlayController.SELF_PERMISSION, 189 ) 190 191 screenshotTakenInPortrait = 192 context.resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT 193 194 // Optimizations 195 currentBitmap.setHasAlpha(false) 196 currentBitmap.prepareToDraw() 197 198 prepareViewForNewScreenshot(screenshot, oldPackageName) 199 val requestId = actionsController.setCurrentScreenshot(screenshot) 200 saveScreenshotInBackground(screenshot, requestId, finisher) { result -> 201 if (result.uri != null) { 202 val savedScreenshot = 203 ScreenshotSavedResult(result.uri, screenshot.userHandle, result.timestamp) 204 actionsController.setCompletedScreenshot(requestId, savedScreenshot) 205 } 206 } 207 208 if (screenshot.taskId >= 0) { 209 assistContentRequester.requestAssistContent(screenshot.taskId) { assistContent -> 210 actionsController.onAssistContent(requestId, assistContent) 211 } 212 } else { 213 actionsController.onAssistContent(requestId, null) 214 } 215 216 // The window is focusable by default 217 window.setFocusable(true) 218 viewProxy.requestFocus() 219 220 enqueueScrollCaptureRequest(requestId, screenshot.userHandle) 221 222 window.attachWindow() 223 224 var bounds = 225 screenshot.originalScreenBounds ?: Rect(0, 0, currentBitmap.width, currentBitmap.height) 226 227 val showFlash: Boolean 228 if (screenshot.type == TAKE_SCREENSHOT_PROVIDED_IMAGE) { 229 if ( 230 aspectRatiosMatch( 231 currentBitmap, 232 screenshot.originalInsets, 233 screenshot.originalScreenBounds, 234 ) 235 ) { 236 showFlash = false 237 } else { 238 showFlash = true 239 bounds = Rect(0, 0, currentBitmap.width, currentBitmap.height) 240 } 241 } else { 242 showFlash = true 243 } 244 245 viewProxy.prepareEntranceAnimation { 246 startAnimation(bounds, showFlash) { 247 messageContainerController.onScreenshotTaken(screenshot) 248 } 249 } 250 251 viewProxy.screenshot = screenshot 252 } 253 254 private fun prepareViewForNewScreenshot(screenshot: ScreenshotData, oldPackageName: String?) { 255 window.whenWindowAttached { 256 announcementResolver.getScreenshotAnnouncement(screenshot.userHandle.identifier) { 257 viewProxy.announceForAccessibility(it) 258 } 259 } 260 261 viewProxy.reset() 262 263 if (viewProxy.isAttachedToWindow) { 264 // if we didn't already dismiss for another reason 265 if (!viewProxy.isDismissing) { 266 uiEventLogger.log(ScreenshotEvent.SCREENSHOT_REENTERED, 0, oldPackageName) 267 } 268 if (LogConfig.DEBUG_WINDOW) { 269 Log.d( 270 TAG, 271 "saveScreenshot: screenshotView is already attached, resetting. " + 272 "(dismissing=${viewProxy.isDismissing})", 273 ) 274 } 275 } 276 277 viewProxy.packageName = packageName 278 } 279 280 /** 281 * Requests the view to dismiss the current screenshot (may be ignored, if screenshot is already 282 * being dismissed) 283 */ 284 override fun requestDismissal(event: ScreenshotEvent) { 285 viewProxy.requestDismissal(event) 286 } 287 288 override fun isPendingSharedTransition(): Boolean { 289 return actionExecutor.isPendingSharedTransition 290 } 291 292 // Any cleanup needed when the service is being destroyed. 293 override fun onDestroy() { 294 removeWindow() 295 screenshotSoundController.releaseScreenshotSoundAsync() 296 releaseContext() 297 bgExecutor.shutdown() 298 } 299 300 /** Release the constructed window context. */ 301 private fun releaseContext() { 302 broadcastDispatcher.unregisterReceiver(copyBroadcastReceiver) 303 context.release() 304 } 305 306 /** Update resources on configuration change. Reinflate for theme/color changes. */ 307 private fun reloadAssets() { 308 if (LogConfig.DEBUG_UI) { 309 Log.d(TAG, "reloadAssets()") 310 } 311 312 messageContainerController.setView(viewProxy.view) 313 viewProxy.callbacks = 314 object : ScreenshotViewCallback { 315 override fun onUserInteraction() { 316 if (LogConfig.DEBUG_INPUT) { 317 Log.d(TAG, "onUserInteraction") 318 } 319 screenshotHandler.resetTimeout() 320 } 321 322 override fun onDismiss() { 323 finishDismiss() 324 } 325 326 override fun onTouchOutside() { 327 // TODO(159460485): Remove this when focus is handled properly in the system 328 window.setFocusable(false) 329 } 330 } 331 332 if (LogConfig.DEBUG_WINDOW) { 333 Log.d(TAG, "setContentView: " + viewProxy.view) 334 } 335 window.setContentView(viewProxy.view) 336 } 337 338 private fun enqueueScrollCaptureRequest(requestId: UUID, owner: UserHandle) { 339 // Wait until this window is attached to request because it is 340 // the reference used to locate the target window (below). 341 window.whenWindowAttached { 342 requestScrollCapture(requestId, owner) 343 window.setActivityConfigCallback( 344 object : ActivityConfigCallback { 345 override fun onConfigurationChanged( 346 overrideConfig: Configuration, 347 newDisplayId: Int, 348 ) { 349 if (configChanges.applyNewConfig(context.resources)) { 350 // Hide the scroll chip until we know it's available in this 351 // orientation 352 actionsController.onScrollChipInvalidated() 353 // Delay scroll capture eval a bit to allow the underlying activity 354 // to set up in the new orientation. 355 screenshotHandler.postDelayed( 356 { requestScrollCapture(requestId, owner) }, 357 150, 358 ) 359 viewProxy.updateInsets(window.getWindowInsets()) 360 // Screenshot animation calculations won't be valid anymore, so just end 361 screenshotAnimation?.let { currentAnimation -> 362 if (currentAnimation.isRunning) { 363 currentAnimation.end() 364 } 365 } 366 } 367 } 368 } 369 ) 370 } 371 } 372 373 private fun requestScrollCapture(requestId: UUID, owner: UserHandle) { 374 scrollCaptureExecutor.requestScrollCapture(display.displayId, window.getWindowToken()) { 375 response: ScrollCaptureResponse -> 376 uiEventLogger.log( 377 ScreenshotEvent.SCREENSHOT_LONG_SCREENSHOT_IMPRESSION, 378 0, 379 response.packageName, 380 ) 381 actionsController.onScrollChipReady(requestId) { 382 onScrollButtonClicked(owner, response) 383 } 384 } 385 } 386 387 private fun onScrollButtonClicked(owner: UserHandle, response: ScrollCaptureResponse) { 388 if (LogConfig.DEBUG_INPUT) { 389 Log.d(TAG, "scroll chip tapped") 390 } 391 uiEventLogger.log( 392 ScreenshotEvent.SCREENSHOT_LONG_SCREENSHOT_REQUESTED, 393 0, 394 response.packageName, 395 ) 396 val newScreenshot = imageCapture.captureDisplay(display.displayId, null) 397 if (newScreenshot == null) { 398 Log.e(TAG, "Failed to capture current screenshot for scroll transition!") 399 return 400 } 401 // delay starting scroll capture to make sure scrim is up before the app moves 402 viewProxy.prepareScrollingTransition(response, newScreenshot, screenshotTakenInPortrait) { 403 executeBatchScrollCapture(response, owner) 404 } 405 } 406 407 private fun executeBatchScrollCapture(response: ScrollCaptureResponse, owner: UserHandle) { 408 scrollCaptureExecutor.executeBatchScrollCapture( 409 response, 410 { 411 val intent = actionIntentCreator.createLongScreenshotIntent(owner) 412 context.startActivity(intent) 413 }, 414 { viewProxy.restoreNonScrollingUi() }, 415 { transitionDestination: Rect, onTransitionEnd: Runnable, longScreenshot: LongScreenshot 416 -> 417 viewProxy.startLongScreenshotTransition( 418 transitionDestination, 419 onTransitionEnd, 420 longScreenshot, 421 ) 422 }, 423 ) 424 } 425 426 override fun removeWindow() { 427 window.removeWindow() 428 viewProxy.stopInputListening() 429 } 430 431 /** 432 * Save the bitmap but don't show the normal screenshot UI.. just a toast (or notification on 433 * failure). 434 */ 435 private fun saveScreenshotAndToast(screenshot: ScreenshotData, finisher: Consumer<Uri?>) { 436 // Play the shutter sound to notify that we've taken a screenshot 437 screenshotSoundController.playScreenshotSoundAsync() 438 439 saveScreenshotInBackground(screenshot, UUID.randomUUID(), finisher) { 440 result: ImageExporter.Result -> 441 if (result.uri != null) { 442 screenshotHandler.post { 443 Toast.makeText(context, R.string.screenshot_saved_title, Toast.LENGTH_SHORT) 444 .show() 445 } 446 } 447 } 448 } 449 450 /** Starts the animation after taking the screenshot */ 451 private fun startAnimation( 452 screenRect: Rect, 453 showFlash: Boolean, 454 onAnimationComplete: Runnable?, 455 ) { 456 screenshotAnimation?.let { currentAnimation -> 457 if (currentAnimation.isRunning) { 458 currentAnimation.cancel() 459 } 460 } 461 462 screenshotAnimation = 463 viewProxy.createScreenshotDropInAnimation(screenRect, showFlash).apply { 464 doOnEnd { onAnimationComplete?.run() } 465 // Play the shutter sound to notify that we've taken a screenshot 466 screenshotSoundController.playScreenshotSoundAsync() 467 if (LogConfig.DEBUG_ANIM) { 468 Log.d(TAG, "starting post-screenshot animation") 469 } 470 start() 471 } 472 } 473 474 /** Reset screenshot view and then call onCompleteRunnable */ 475 private fun finishDismiss() { 476 Log.d(TAG, "finishDismiss") 477 actionsController.endScreenshotSession() 478 scrollCaptureExecutor.close() 479 currentRequestCallbacks.forEach { it.onFinish() } 480 currentRequestCallbacks.clear() 481 viewProxy.reset() 482 removeWindow() 483 screenshotHandler.cancelTimeout() 484 } 485 486 private fun saveScreenshotInBackground( 487 screenshot: ScreenshotData, 488 requestId: UUID, 489 finisher: Consumer<Uri?>, 490 onResult: Consumer<ImageExporter.Result>, 491 ) { 492 val future = 493 imageExporter.export( 494 bgExecutor, 495 requestId, 496 screenshot.bitmap, 497 screenshot.userHandle, 498 display.displayId, 499 ) 500 future.addListener( 501 { 502 try { 503 val result = future.get() 504 Log.d(TAG, "Saved screenshot: $result") 505 logScreenshotResultStatus(result.uri, screenshot.userHandle) 506 onResult.accept(result) 507 if (LogConfig.DEBUG_CALLBACK) { 508 Log.d(TAG, "finished bg processing, calling back with uri: ${result.uri}") 509 } 510 finisher.accept(result.uri) 511 } catch (e: Exception) { 512 Log.d(TAG, "Failed to store screenshot", e) 513 if (LogConfig.DEBUG_CALLBACK) { 514 Log.d(TAG, "calling back with uri: null") 515 } 516 finisher.accept(null) 517 } 518 }, 519 mainExecutor, 520 ) 521 } 522 523 /** Logs success/failure of the screenshot saving task, and shows an error if it failed. */ 524 private fun logScreenshotResultStatus(uri: Uri?, owner: UserHandle) { 525 if (uri == null) { 526 uiEventLogger.log(ScreenshotEvent.SCREENSHOT_NOT_SAVED, 0, packageName) 527 notificationController.notifyScreenshotError(R.string.screenshot_failed_to_save_text) 528 } else { 529 uiEventLogger.log(ScreenshotEvent.SCREENSHOT_SAVED, 0, packageName) 530 if (userManager.isManagedProfile(owner.identifier)) { 531 uiEventLogger.log(ScreenshotEvent.SCREENSHOT_SAVED_TO_WORK_PROFILE, 0, packageName) 532 } 533 } 534 } 535 536 private fun isUserSetupComplete(owner: UserHandle): Boolean { 537 return Settings.Secure.getInt( 538 context.createContextAsUser(owner, 0).contentResolver, 539 SETTINGS_SECURE_USER_SETUP_COMPLETE, 540 0, 541 ) == 1 542 } 543 544 private val fullScreenRect: Rect 545 get() { 546 val displayMetrics = DisplayMetrics() 547 display.getRealMetrics(displayMetrics) 548 return Rect(0, 0, displayMetrics.widthPixels, displayMetrics.heightPixels) 549 } 550 551 /** Injectable factory to create screenshot controller instances for a specific display. */ 552 @AssistedFactory 553 interface Factory : InteractiveScreenshotHandler.Factory { 554 /** 555 * Creates an instance of the controller for that specific display. 556 * 557 * @param display display to capture 558 */ 559 override fun create(display: Display): ScreenshotController 560 } 561 562 companion object { 563 private val TAG: String = LogConfig.logTag(ScreenshotController::class.java) 564 565 // From WizardManagerHelper.java 566 private const val SETTINGS_SECURE_USER_SETUP_COMPLETE = "user_setup_complete" 567 568 const val SCREENSHOT_CORNER_DEFAULT_TIMEOUT_MILLIS: Int = 6000 569 570 /** Does the aspect ratio of the bitmap with insets removed match the bounds. */ 571 private fun aspectRatiosMatch( 572 bitmap: Bitmap, 573 bitmapInsets: Insets, 574 screenBounds: Rect?, 575 ): Boolean { 576 if (screenBounds == null) { 577 return false 578 } 579 val insettedWidth = bitmap.width - bitmapInsets.left - bitmapInsets.right 580 val insettedHeight = bitmap.height - bitmapInsets.top - bitmapInsets.bottom 581 582 if ( 583 insettedHeight == 0 || insettedWidth == 0 || bitmap.width == 0 || bitmap.height == 0 584 ) { 585 if (LogConfig.DEBUG_UI) { 586 Log.e( 587 TAG, 588 "Provided bitmap and insets create degenerate region: " + 589 "${bitmap.width} x ${bitmap.height} $bitmapInsets", 590 ) 591 } 592 return false 593 } 594 595 val insettedBitmapAspect = insettedWidth.toFloat() / insettedHeight 596 val boundsAspect = screenBounds.width().toFloat() / screenBounds.height() 597 598 val matchWithinTolerance = abs((insettedBitmapAspect - boundsAspect).toDouble()) < 0.1f 599 if (LogConfig.DEBUG_UI) { 600 Log.d( 601 TAG, 602 "aspectRatiosMatch: don't match bitmap: " + 603 "$insettedBitmapAspect, bounds: $boundsAspect", 604 ) 605 } 606 return matchWithinTolerance 607 } 608 } 609 } 610