MediaProjection - ForegroundService 이용하기



개인 광고 영역

2016년에 작성한 MediaProjection 프로젝트를 수정하여, 수정하면서 달라진 부분과 MediaProjection 사용 시 달라진 점들을 살펴본다.

안드로이드 화면 캡쳐 API - MediaProjection 사용해보기 MediaProjection Example - MVP 패턴 적용

크게 달라진 부분을 제외하고, API는 위 2개의 글을 통해 확인이 가능하다.


MediaProjection 사용하기

MediaProjection, VirtualDisplay는 MediaProjectionManager를 이용하여 접근하고, 활용할 수 있다. MediaProjection 실시간으로 Display에 출력하는 데이터를 그대로 보여주는 역할을 하는데, 내부적으로 VirtualDisplay를 이용하여 화면 갱신을 돕는다.

실제 Display로 보내진느 데이터와 동일한 데이터를 VirtualDisplay에 올려주고 이를 개발자가 Surface를 이용하여 확인할 수 있는데, 보통 녹화할 때는 EGL을 이용하여 녹화하지만, 본 글에 사용한 샘플 코드는 Surface를 실제 화면에 노출하는 형태로 접근한다. 어디까지나 화면에 보이는지를 확인하기 위한 샘플 코드이다.

참고로 이와 동일한 화면은 개발자 옵션 > 보조 디스플레이 시뮬레이션에서 볼 수 있는것과 동일하다.


테스트 코드 주소

테스트 코드는 Android Studio 3.6.3+에서 동작합니다.

  • gradle : 3.6.3
  • kotlin : 1.3.72
  • targetSdk : 29
  • minSdk : 24
  • ViewBinding 활용

GitHub Example : MediaProjection example


API

MediaProjection API 문서를 보면 크게 3가지 API를 볼 수 있는데,

MediaProjection

VirtualDisplay을 초기화하고, Stop callback을 초기화할 수 있다.

Android MediaProjection은 동시에 기기 전체에서 VirtualDisplay 하나만 생성할 수 있다.

MediaProjection.Callback

기기 전체에 VirtualDisplay 하나만 생성할 수 있기 때문에 MediaProjection.Callback을 등록해두고, onStop 이벤트를 받아야 한다.

예를 들면 화면을 녹화하고있다. ChromeCast나 다른 녹화 프로그램을 이용하여 미러링을 시도한다면 권한을 빼앗기게 된다. 권한을 빼앗겼기에 Stop 이벤트가 이 Callback으로 온다.

이 이벤트를 받아 녹화를 멈추도록 작업해야 한다.

MediaProjectionManager

MediaProjectionManager은 MediaProjection 사용시 꼭 필요한 권한을 받기 위해 사용하는데, Intent를 생성하고, 결과로 받은 이벤트를 통해 MediaProjection을 생성하는 역할을 한다.


MediaProjection 달라진 점

Google 내부의 MediaProjectionDemo를 통해 MediaProjectionDemo을 볼 수 있으나, 2016년에는 이 코드로 동작하는데 문제가 없었다.

하지만 Android 10/11이 나온 이 시점에서는 아래의 코드를 그대로 사용할때는 동작에 문제가 발생한다.

getMediaProjection 권한을 받으려면

위 샘플 코드와 과거 필자가 작성한 코드에서는 Activity에서 권한을 요청하고, onActivityResult로 이벤트를 받아 projectionManager.getMediaProjection()을 통해 MediaProjection을 생성하였었다.

하지만 Android 10부터는 foregroundService가 아니라면 아래와 같은 오류가 발생한다.

Caused by: java.lang.SecurityException: Media projections require a foreground service of type ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION

backgroundService를 이용할 수 있었던 과거와는 달리 foreground를 이용하고, notification 아이콘을 노출해야 한다.

기존에 작업할 수 있는 구조는 아래와 같았다.

Activity에서 MediaProjection의 권한을 획득하고, 그 권환을 이용하여 MediaProjection을 생성할 수 있었으나

sample_image_01

이젠 foregroundService가 없다면 동작하지 않는 형태가 되어,

Activity는 권한을 획득하기 위한 도구로써 사용하고, Service로 시작하여, Service에서 모든 걸 처리하고, 종료하는 형태로 변했다.

sample_image_02


기존 코드와 달라진 부분

Service

위 이미지를 통해서도 확인할 수 있는데, 진입점이 Service라는 점이다.

Service를 사용할 때는 다음과 같은 키워드도 manifest에도 등록해야 하는데, foregroundServiceType을 지정해줘야 한다.

<service
    android:name=".ui.MediaProjectionAccessService"
    android:foregroundServiceType="mediaProjection" />

MediaProjection을 생성하는 모든 ForegroundService에서는 mediaProjection을 지정해줘야 오류가 나지 않는다.

그리고 서비스 내부에는 startForeground()를 처리해줘야 하는데 다음과 같다.

services#Foreground 문서를 참고하여 아래와 같이 작성한다.

그리고 Android O에서 NotificationChannel도 처리해줘야 하니 다음과 같다.

private const val FOREGROUND_SERVICE_ID = 1000

open fun startForegroundService() {
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
          val serviceChannel = NotificationChannel(
              CHANNEL_ID,
              "Foreground Service Channel",
              NotificationManager.IMPORTANCE_DEFAULT
          )
          (getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager).apply {
              createNotificationChannel(serviceChannel)
          }
      }

    val notificationIntent = Intent(this, MediaProjectionAccessActivity::class.java)
    val pendingIntent = PendingIntent.getActivity(
        this,
        0, notificationIntent, 0
    )

    val notification = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
        Notification.Builder(this, CHANNEL_ID)
    } else {
        Notification.Builder(this)
    }
        .setContentTitle("Foreground Service")
        .setSmallIcon(R.drawable.ic_baseline_fiber_manual_record_24)
        .setContentIntent(pendingIntent)
        .build()
    startForeground(FOREGROUND_SERVICE_ID, notification)
}

MediaProjection status

필자는 5개의 status를 구분하였다.

  • OnInitialized - 서비스를 동작하고, Start 가능한 상태
  • OnReject - 권한 획득을 실패한 경우
  • OnStarted - 서비스를 실행하고 성공한 단계
  • OnStop - 동작중 onStop callback으로 멈춘 상태
  • OnFail - 그 외 Exception

이 이벤트는 LocalBroadCastReceiver을 이용하여 처리하였다.

registerReceiver, unregisterReceiver을 이용하여 처리하고, sendBroadcast(android.content.Intent)을 이용한다.


Permission Activity

startActivityForResult와 onActivityResult는 Activity 1.2.0-alpha04에서 Deprecated 처리되어 아래와 같이 이용할 수 있다.

startActivityForResult(
    mediaProjectionManager.createScreenCaptureIntent(),
    REQ_CODE_MEDIA_PROJECTION
)

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
    when (requestCode) {
        REQ_CODE_MEDIA_PROJECTION -> sendBroadcast(resultCode, data)
        else -> super.onActivityResult(requestCode, resultCode, data)
    }
}

위 코드 대신 아래 코드를 통해 권한 획득이 가능한데, ActivityResultContract을 상속받아 구현하고, 이를 registerForActivityResult()를 이용하여 등록하고, launch를 이용하여 실행한다.

createIntent에 필요한 데이터는 launch를 할 때 넘겨줄 수 있고, activity-ktx를 이용하면 launch 대신 result()로 바로 사용하는 게 가능하다.

val result: ActivityResultLauncher = registerForActivityResult(MediaProjectionResultContract()) {
    sendBroadcast(it)
    finish()
}

val mediaProjectionManager = getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
// launch로 실행
result.launch(mediaProjectionManager.createScreenCaptureIntent())

// ActivityResultContract 구현
internal class MediaProjectionResultContract : ActivityResultContract<Intent, Intent>() {

    override fun createIntent(context: Context, input: Intent?): Intent =
        input!!

    override fun parseResult(resultCode: Int, intent: Intent?): Intent {
        return if (resultCode == Activity.RESULT_OK && intent != null) {
            MediaProjectionAccessBroadcastReceiver.newInstance(resultCode, intent)
        } else {
            MediaProjectionAccessBroadcastReceiver.newReject()
        }
    }
}

장점은 onActivityResult의 requestCode를 구분할 필요 없이 좀 더 공통적이고 단독으로 사용할 수 있는 ActivityResultContract을 활용할 수 있다.


마무리

추가로 작성한 글에서는 foregroundService 처리방법이 가장 중요하다. 그 부분 덕에 전체적인 구조가 변경되었다.

Activity의 용도는 권한을 받는 정도로 만 필요하게 되었고, 서비스 중심으로 동작하도록 변경되었다.

GitHub Example : MediaProjection example



About Taehwan

My name is Taehwan Kwon. I have developed Android for 6 years and blog has been active for eight years.

Comments