2

I'm writing a screenshot app using Android MediaProjection Api in which an overlay button is shown on top of everything and user can click it to capture a screenshot anywhere. Since MediaProjection records screen content, overlay button itself is in captured screenshots. To hide the button when capturing screenshot, I tried to set view visibility to INVISIBLE, take screenshot and revert it back to VISIBLE but since changing visibility is an async operation in Android, sometimes overlay button is still present in recorded shots.

I Changed to below snippet and it worked in my experiments:

floatingButton?.setOnClickListener { view ->
    view.visibility = View.INVISIBLE
    view.postDelayed(100) {
        takeShot()
        view.post {view.visibility = View.VISIBLE}
    }
}

But it's basically saying I feeling good that in 100ms, button would be invisible. It's not a good solution and in the case of videos, in 100ms content could be very different from what user actually saw at that moment.

Android doesn't provide a onVisibiltyChangedListener kind of thing, so how could I perform a task after ensuring that a view visibility has changed?


Edit 1

Here's the takeShot() method:

private fun takeShot() {
    val image = imageReader.acquireLatestImage()
    val bitmap = image?.run {
         val planes = image.planes
         val buffer: ByteBuffer = planes[0].buffer
         val pixelStride = planes[0].pixelStride
         val rowStride = planes[0].rowStride
         val rowPadding = rowStride - pixelStride * width
         val bitmap = Bitmap.createBitmap(
                width + rowPadding / pixelStride,
                height,
                Bitmap.Config.ARGB_8888
         )
         bitmap.copyPixelsFromBuffer(buffer)
         image.close()

         bitmap
    }
    bitmap?.let{
        serviceScope.launch {
            gallery.store(it)
        }
    }
}

The codes are inside of a foreground service and when user accepts media projection, I create ImageReader and VirtualDisplay:

imageReader = ImageReader.newInstance(size.width, size.height, PixelFormat.RGBA_8888, 2)
virtualDisplay = mediaProjection.createVirtualDisplay(
            "screen-mirror",
            size.width,
            size.height,
            Resources.getSystem().displayMetrics.densityDpi,
            DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, // TODO: DisplayManager.VIRTUAL_DISPLAY_FLAG_OWN_CONTENT_ONLY | DisplayManager.VIRTUAL_DISPLAY_FLAG_PUBLIC ??
            imageReader.surface, null, null
        )

mediaProjection.registerCallback(object : MediaProjection.Callback() {
            override fun onStop() {
                virtualDisplay.release()
                mediaProjection.unregisterCallback(this)
            }
        }, null)

I've tried without suspension and coroutine stuff and result was the same, so they most likely are irrelevant to problem.

Alireza Farahani
  • 2,238
  • 3
  • 30
  • 54
  • Try [ViewTreeObserver](https://stackoverflow.com/questions/31995545/is-there-any-event-fired-when-android-view-becomes-visible-within-app). – ADM Dec 24 '20 at 07:49
  • I tried to use AndroidX `View.doOnPreDraw` but had no success. Could you explain it more? – Alireza Farahani Dec 24 '20 at 09:44
  • https://stackoverflow.com/a/32778292/4168607 . This should work i guess . – ADM Dec 24 '20 at 09:46
  • I used the approach and by setting logs, I see that capture method (`imageReader.acquireLatestImage()`) is actually being called when overlay button is invisible, but it's still sometimes present in screenshots! It seems that `acquireLatestImage()` doesn't necessary give you "latest image". Timing is non-deterministic. – Alireza Farahani Dec 24 '20 at 12:44
  • can you add the `takeShot()` method with question . Also check it in some other device . – ADM Dec 24 '20 at 13:01
  • @ADM Tried in another emulator and problem was there, too. Updated the question with more code – Alireza Farahani Dec 24 '20 at 18:14

1 Answers1

0

Seems my problem is related to MediaProjection and that would be a separate question, but this question itself is relevant.

I ended up using this (almost copy-pasting core-ktx code for doOnPreDraw()). Pay attention that:

  1. This doesn't work for View.INVISIBLE, because INVISIBLE doesn't trigger a "layout"

  2. I don't endorse this, since it's GLOBAL, meaning that every visibility change related to "someView" view hierarchy, will call the onGlobalLayout method (and therefore your action/runnable).

I save the accepted answer for a better solution.

// usage
// someView.doOnVisibilityChange(become = View.GONE) {
//     someView is GONE, do stuff here
// }
inline fun View.doOnVisibilityChange(become: Int, crossinline action: (view: View) -> Unit) {
    OneShotVisibilityChangeListener(this) { action(this) }
    visibility = newVisibility
}

class OneShotVisibilityChangeListener(
    private val view: View,
    private val runnable: Runnable
) : ViewTreeObserver.OnGlobalLayoutListener, View.OnAttachStateChangeListener {

    private var viewTreeObserver: ViewTreeObserver

    init {
        viewTreeObserver = view.viewTreeObserver
        viewTreeObserver.addOnGlobalLayoutListener(this)
        view.addOnAttachStateChangeListener(this)
    }

    override fun onGlobalLayout() {
        removeListener()
        runnable.run()
    }

    private fun removeListener() {
        if (viewTreeObserver.isAlive) {
            viewTreeObserver.removeOnGlobalLayoutListener(this)
        } else {
            view.viewTreeObserver.removeOnGlobalLayoutListener(this)
        }
        view.removeOnAttachStateChangeListener(this)
    }

    override fun onViewAttachedToWindow(v: View) {
        viewTreeObserver = v.viewTreeObserver
    }

    override fun onViewDetachedFromWindow(v: View) {
        removeListener()
    }
}
Alireza Farahani
  • 2,238
  • 3
  • 30
  • 54