• 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 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