たくさんの自由帳

Hello Android 14。部分的な画面共有と MediaProjection 再入門

投稿日 : | 0 日前

文字数(だいたい) : 16914

どうもこんばんわ。
彼方の人魚姫 攻略しました。

久しぶりのシナリオゲー?かもしれない、OP 曲がいい!
共通が長いせいか、個別の最後の方は駆け足になってた気がするけど、引きずるよりかはいいのかも・・・?

Imgur

かわい~

Imgur

制服ちがうのってなんでだろう(言及してたのかもしれないけど私が見落としてただけ・・?)

Imgur

すごいどうでもいいけど、プログラミング何も分からんときにmultipart/form-dataでバイナリデータ(写真)を送れたときの感動にを思い出したこれ ↑

私は日輪ちゃんルートが一番かな
↓ この後にあるイベントCGがいい!

Imgur

Imgur

この子のルートの結末が読者次第というパターンのやつだった、ぐぬぬ、どっちなんだろう

Imgur

おすすめ、えちえちシーンもよかった

本題

このブログではHello Android xxシリーズを新しいバージョンが出るたびに何回か書いてましたが、14だけ書いてない!
なんかAndroid 14、新機能よりは仕様変更のがメインな気がする。

特にAndroid 14のフォアグラウンドサービスのForeground service types are requiredって何がしたいのかいまいち分からない。
フォアグラウンドサービスで行う作業を予めAndroidManifestに書けって言ってるんだけど、定義されてるユースケースが少なくてspecialUse(その他の用途)になりそうなふいんき(なぜか変換できない)

そんな中、Google I/Oで発表されてて気になってた機能がAndroid 14の正式版リリースから数ヶ月たった今、ようやくベータ版(Android 14 QBR 2)で試せるようになったので使ってみます。

正式版でも試せなくて白紙になったのかと思ったら14正式版には間に合わなかっただけっぽい。
だからAOSPにもまだ入ってない?どうなんだろう、既にAndroid 14を受け取った端末でもセキュリティアップデートと一緒に降ってくるとかなんですかね?

Android 15の機能として紹介された。14 QPR2で試験的に入って15でリリースなんですかね?15を待とう・・!
https://android-developers.googleblog.com/2024/02/first-developer-preview-android15.html

Pixel端末は2024年3月Feature Dropで対応したそう。もうわけがわからないよ

部分的な画面共有機能

Imgur

Imgur

画面共有をする際に、今までどおりの画面全体の画面共有に加えて、指定したアプリの画面だけの画面共有に対応しました。
画面分割中に、かたっぽのアプリだけ画面共有ができるようになりました。また画面分割をしないときも便利で、これだと通知やステータスバーが写り込まないんですよね

画面共有中に突然、heads up notification(ステータスバーから飛び出してくるあの通知)が来たとしても画面共有にはアプリの画面だけしか映らず、通知は写り込まないので安心して使えるようになります!

ちなみに試した限り、単一アプリの場合でも音声は端末全体になるっぽい?

試したい場合はクイック設定パネルの画面録画を使うと試せそう。対応済みらしく、単一アプリの画面録画ができます。

部分的な画面共有 API

https://developer.android.com/reference/kotlin/android/media/projection/MediaProjection.Callback
コールバックがあるので、単一アプリの画面共有の状態変化を知ることが出来ます。

  • MediaProjection.Callback#onCapturedContentResize
    • 単一アプリの画面サイズが変化したときに呼ばれます。
    • 画面分割とかで、1:1以外に1:2みたいな比率にも出来るので、そのときに呼ばれるんじゃないかな。
  • MediaProjection.Callback#oncapturedcontentvisibilitychanged
    • 単一アプリが画面外に移動したときに呼ばれます。
    • 例えば単一アプリ以外の別のアプリが画面に表示されている場合、単一アプリはユーザーから見えない状態になるので、このコールバックが呼ばれます。

部分的な画面共有に対応する

画面を録画するだけのアプリなら、多分何も対応が要らない・・?
→いや嘘、MediaProjection#registerCallbackでコールバックを追加する必要がある。が、コールバックを追加するだけで良いらしいのでほぼ対応無しだと思う。
(ちなみにメインスレッドで呼び出す必要が多分ある。)

MediaProjectionManager#createScreenCaptureIntentIntentが貰えるので、それをrememberLauncherForActivityResultに入れれば良いはず。
ですが、別にこのcreateScreenCaptureIntentはこれまで通りMediaProjectionを開始するときに使うやつなので、コードの変更はなしで対応できるんじゃないかな。

ちなみに、ユーザーに全画面の画面共有のみ利用できるようにする方法もあります。MediaProjectionConfig.createConfigForDefaultDisplayを渡せばいいです。
(つまり、部分的な画面共有を利用できなくさせる)

val mediaProjectionResult = rememberLauncherForActivityResult(
    contract = ActivityResultContracts.StartActivityForResult(),
    onResult = { result ->
        // TODO
    }
 
// createConfigForDefaultDisplay を指定すると、全画面の画面共有のみ利用できる(部分的な画面共有は利用できない)
mediaProjectionResult.launch(mediaProjectionManager.createScreenCaptureIntent(MediaProjectionConfig.createConfigForDefaultDisplay()))

Imgur

対応したほうがいい場合

この記事の後半に書きますが、画面を録画してその映像を生配信したい場合は、別途対応が必要かもしれません。
というのも、単一アプリで指定したアプリが画面に写っていない場合、映像データがエンコーダーから出てきません。そしてその修正は簡単には直せなさそうです。

単一アプリの画面が写ってない場合、最後の映像フレームがエンコーダーから出てくるわけでもなく、かといって黒い画面がエンコーダーから出てくるわけでもなく、
エンコーダーからは何も出てきません!

生配信とかだととにかく映像を送り続けたいと思うので、単一アプリが画面に映っていない時の対応が必要かもしれません。
これも対応できるので、詳しくは後半までスクロールしてね。

あ、あと、指定したアプリが画面に写っていない場合に代わりに画像を出すとかも難しそうです。
これも対応します。

Android の画面共有機能を支える技術

Android 5からある機能なので、そこそこ情報はあるんじゃないですかね、、

大昔に書いた記事が出てきた。はずい
https://takusan23.github.io/Bibouroku/2020/04/06/MediaProjection/

MediaProjection

画面の映像をMediaRecorderMediaCodecOpenGLに流したり出来るやつ、
端末内部の音声を収録するのにも使われる(AudioRecordPCMのバイト配列が入手できます)。

おそらく、Activity等では動かせず、Serviceじゃないと動かないはず。

MediaRecorder

画面の映像を受け取って、動画ファイルにしてくれるやつ。
ただ画面録画をするだけならMediaRecorderでいいはずですが、MediaCodecでも良いです。

画面録画+内部音声収録 アプリを作ってみる

部分的な画面共有を試すだけで良いのですが、今回はMediaProjectionを使って画面録画+内部音声収録が出来るアプリを作ってみようと思います。
もうAndroidのクイック設定パネルの画面録画で内部音声収録ありますが、内部音声を収録するための話があんまり無さそうだったので・・・!

先述の通り、特に何もしなくても部分的な画面共有に対応できるので、大昔に書いた記事と同じっちゃ同じなのですが、、、まあ再入門ってことで。

環境

なまえあたい
Android部分的な画面共有したい場合は 14 QBR 2 以降(現状PixelシリーズにAndroid ベータ版を入れるしか無い)
端末Pixel 8 Pro / Xperia 1 V
Android StudioAndroid Studio Hedgehog 2023.1.1 Patch 2

UIにはJetpack Composeを利用しますが、別に画面録画にはあんまり関係ないので、Viewで作ってもいいです。
Jetpack Composeだと記事にxml貼らなくて済むから地味に楽なんだよな。

あ、あと別スレッドを扱う必要があるので、その際にはKotlin コルーチンを使います。
が、そんな難しいことはしていないはず。

AndroidManifest.xml

多分この3つが必要。

  • フォアグラウンドサービス権限
  • マイク権限(内部音声収録しない場合はいらない)
  • MediaProjectionをフォアグラウンドサービスで使いますよ権限
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">
 
    <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
    <uses-permission android:name="android.permission.RECORD_AUDIO" />
    <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />

MainActivity.kt

サービスを開始するボタンと、終了するボタンを置きます。
それぞれボタンを押したらサービスを起動、終了するようにします。まだScreenRecordServiceは作ってないので赤くなります

先述の通り、画面録画にはあんまりUI関係ないので、Jetpack Composeじゃなくてもいいはず。
(まあ今更AndroidViewを選ぶ理由もないと思いますが。)

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MainScreen()
        }
    }
}
 
@Composable
fun MainScreen() {
    val context = LocalContext.current
    val mediaProjectionManager = remember { context.getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager }
 
    // 内部音声収録のためにマイク権限を貰う
    val permissionResult = rememberLauncherForActivityResult(
        contract = ActivityResultContracts.RequestPermission(),
        onResult = { isGrant ->
            if (isGrant) {
                Toast.makeText(context, "許可されました", Toast.LENGTH_SHORT).show()
            }
        }
    )
 
    // 画面共有の合否
    val mediaProjectionResult = rememberLauncherForActivityResult(
        contract = ActivityResultContracts.StartActivityForResult(),
        onResult = { result ->
            // 許可がもらえたら
            if (result.resultCode == ComponentActivity.RESULT_OK && result.data != null) {
                ScreenRecordService.startService(context, result.resultCode, result.data!!)
            }
        }
    )
 
    // マイク権限無ければ貰う
    LaunchedEffect(key1 = Unit) {
        if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
            permissionResult.launch(Manifest.permission.RECORD_AUDIO)
        }
    }
 
    Column {
        Button(onClick = {
            // 許可をリクエスト
            mediaProjectionResult.launch(mediaProjectionManager.createScreenCaptureIntent())
        }) {
            Text(text = "録画サービス起動")
        }
 
        Button(onClick = {
            ScreenRecordService.stopService(context)
        }) {
            Text(text = "録画サービス終了")
        }
    }
}

フォアグラウンドサービスを作る

どうでもいいですが、Serviceを作る時はコンテキストメニューから作ると良いと思います。
よくAndroidManifest.xmlに書き忘れるんだよなあこれ

Imgur

一点、サービス作ったらAndroidManifest.xmlを開いて、<service>を探して、android:foregroundServiceType="mediaProjection"を付けてあげる必要があります。

<service
    android:name=".ScreenRecordService"
    android:enabled="true"
    android:exported="true"
    android:foregroundServiceType="mediaProjection"></service>

とりあえずフォアグラウンドサービスを起動するまでのコードを書きます。
画面録画はこの後!

ユーザーに許可をもらった後、rememberLauncherForActivityResult (旧:onActivityResult)経由でIntentを貰いますが、そのIntentresultCodedataをサービス側に渡してあげる必要があります。
MediaProjectionを開始するのに必要なので・・・!
(大昔に書いた記事書くときに、Intentの中にputExtraIntent入れられるんだ・・って思ったのを思い出しました)

一点、stopService関数ですが、Context#stopServiceではなく、Context#startServiceした後に、onStartCommand内でstopSelfを呼び出して終了するようにしています。
なんでこんな回りくどい方法を取っているか、なんですが、録画終了時にちょっと時間がかかる処理をする必要があって、Context#stopServiceだと処理中にServiceが終了しかねないんですよね。
onDestroyでやっても間に合わないかも)

そのため、サービス終了しろよというフラグを乗せたIntentを投げて、処理が終わった後stopSelfするようにするため、この様な形になってます。
ちょっとややこしい

/** 画面録画サービス */
class ScreenRecordService : Service() {
    private val notificationManager by lazy { NotificationManagerCompat.from(this) }
 
    override fun onBind(intent: Intent): IBinder? = null
 
    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        // 通知を出す
        notifyForegroundServiceNotification()
 
        // Intent から値を取り出す
        if (intent?.getBooleanExtra(INTENT_KEY_START_OR_STOP, false) == true) {
            // 開始命令
        } else {
            // 終了命令
            stopSelf()
        }
 
        return START_NOT_STICKY
    }
 
    private fun notifyForegroundServiceNotification() {
        if (notificationManager.getNotificationChannel(CHANNEL_ID) == null) {
            val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_LOW).apply {
                setName("画面録画サービス起動中通知")
            }.build()
            notificationManager.createNotificationChannel(channel)
        }
        val notification = NotificationCompat.Builder(this, CHANNEL_ID).apply {
            setContentTitle("画面録画")
            setContentText("録画中です")
            setSmallIcon(R.drawable.ic_launcher_foreground)
        }.build()
 
        // ForegroundServiceType は必須です
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            ServiceCompat.startForeground(this, NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION)
        } else {
            ServiceCompat.startForeground(this, NOTIFICATION_ID, notification, 0)
        }
    }
 
    companion object {
        // 通知関連
        private const val CHANNEL_ID = "screen_recorder_service"
        private const val NOTIFICATION_ID = 4545
 
        // Intent に MediaProjection の結果を入れるのでそのキー
        private const val INTENT_KEY_MEDIA_PROJECTION_RESULT_CODE = "result_code"
        private const val INTENT_KEY_MEDIA_PROJECTION_RESULT_DATA = "result_code"
 
        // サービス終了時は、終了しろというフラグを Intent に入れて、startService することにする
        // 多分 onDestroy でファイル操作とかやっちゃいけない気がする
        // true だったら start 、false だったら stop
        private const val INTENT_KEY_START_OR_STOP = "start_or_stop"
 
        fun startService(context: Context, resultCode: Int, data: Intent) {
            val intent = Intent(context, ScreenRecordService::class.java).apply {
                putExtra(INTENT_KEY_MEDIA_PROJECTION_RESULT_CODE, resultCode)
                putExtra(INTENT_KEY_MEDIA_PROJECTION_RESULT_DATA, data)
                putExtra(INTENT_KEY_START_OR_STOP, true)
            }
            ContextCompat.startForegroundService(context, intent)
        }
 
        fun stopService(context: Context) {
            val intent = Intent(context, ScreenRecordService::class.java).apply {
                putExtra(INTENT_KEY_START_OR_STOP, false)
            }
            ContextCompat.startForegroundService(context, intent)
        }
    }
}

画面録画のためのコード ScreenRecorder.kt

MainActivityとかがある階層にScreenRecorder.ktを作りました。
画面録画のための処理をここに書きます(画面録画だけならServiceに直接書いてもそこまで長くならないので良いはず?)

/** 画面録画のためのクラス */
class ScreenRecorder(
    private val context: Context,
    private val resultCode: Int,
    private val resultData: Intent
) {
    private val mediaProjectionManager by lazy { context.getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager }
 
    private var mediaProjection: MediaProjection? = null
    private var mediaRecorder: MediaRecorder? = null
    private var virtualDisplay: VirtualDisplay? = null
 
    fun startRecord() {
        // このあとすぐ
    }
 
    fun stopRecord() {
        // このあとすぐ
    }
 
    companion object {
        private const val VIDEO_WIDTH = 1280
        private const val VIDEO_HEIGHT = 720
    }
}

startRecord()では画面録画を行うのに必要な、MediaProjectionを作ったり、MediaRecorderを作ったりして、実際に録画を開始する処理を書きます。
MediaCodecとかは(まだ)登場しないので安心してください

/** 録画を開始する */
fun startRecord() {
    mediaRecorder = (if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) MediaRecorder(context) else MediaRecorder()).apply {
        // 呼び出し順が存在します
        // 音声トラックは録画終了時にやります
        setVideoSource(MediaRecorder.VideoSource.SURFACE)
        setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
        setVideoEncoder(MediaRecorder.VideoEncoder.H264)
        setVideoEncodingBitRate(6_000_000)
        setVideoFrameRate(60)
        // 解像度、縦動画の場合は、代わりに回転情報を付与する(縦横の解像度はそのまま)
        setVideoSize(VIDEO_WIDTH, VIDEO_HEIGHT)
        // 保存先。
        // sdcard/Android/data/{アプリケーションID} に保存されますが、後で端末の動画フォルダーに移動します
        videoRecordingFile = context.getExternalFilesDir(null)?.resolve("video_track.mp4")
        setOutputFile(videoRecordingFile!!.path)
        prepare()
    }
 
    mediaProjection = mediaProjectionManager.getMediaProjection(resultCode, resultData).apply {
        // 画面録画中のコールバック
        registerCallback(object : MediaProjection.Callback() {
            override fun onCapturedContentResize(width: Int, height: Int) {
                super.onCapturedContentResize(width, height)
                // サイズが変化したら呼び出される
            }
 
            override fun onCapturedContentVisibilityChanged(isVisible: Boolean) {
                super.onCapturedContentVisibilityChanged(isVisible)
                // 録画中の画面の表示・非表示が切り替わったら呼び出される
            }
 
            override fun onStop() {
                super.onStop()
                // MediaProjection 終了時
                // do nothing
            }
        }, null)
    }
    virtualDisplay = mediaProjection?.createVirtualDisplay(
        "io.github.takusan23.androidpartialscreeninternalaudiorecorder",
        VIDEO_WIDTH,
        VIDEO_HEIGHT,
        context.resources.configuration.densityDpi,
        DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
        mediaRecorder?.surface,
        null,
        null
    )
    // 録画開始
    mediaRecorder?.start()
}

stopRecord()では録画を止めて、画面録画に使ったクラスのリソース開放をします。

/** 録画を終了する */
fun stopRecord() {
    mediaRecorder?.stop()
    mediaRecorder?.release()
    mediaProjection?.stop()
    virtualDisplay?.release()
}

一旦これで、ScreenRecordServiceに組み込みます。
trueでインスタンスを作って録画開始、falseならstopRecordを呼び出します。

まだ内部音声収録機能とか、端末の動画フォルダーに移動する機能がないのですが・・・

override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
    // 通知を出す
    notifyForegroundServiceNotification()
 
    // Intent から値を取り出す
    if (intent?.getBooleanExtra(INTENT_KEY_START_OR_STOP, false) == true) {
        // 開始命令
        screenRecorder = ScreenRecorder(
            context = this,
            resultCode = intent.getIntExtra(INTENT_KEY_MEDIA_PROJECTION_RESULT_CODE, -1),
            resultData = intent.getParcelableExtra(INTENT_KEY_MEDIA_PROJECTION_RESULT_DATA)!!
        )
        screenRecorder?.startRecord()
    } else {
        // 終了命令
        screenRecorder?.stopRecord()
        stopSelf()
    }
 
    return START_NOT_STICKY
}

そういえば、getParcelableExtraが非推奨になったけど代替メソッドにはまた別の問題があるとかで、結局非推奨の方を使わないといけないとかいう話、
あれ進展あるのかな。Compat出た?

動画だけの録画ができるか確認

どうでしょう。開始ボタンを押したら、ステータスバーにキャストアイコンが出て、終了を押したら消えましたか?
ファイルの確認ですが、Android Studioが使える場合は、Device Explorerを開いて、sdcard/Android/data/{アプリケーションID}/filesに動画が保存されているはず

Imgur

Android端末しかない場合は、アプリケーションIDcom.android.documentsuiを何らかの方法で開くことで、ファイルマネージャーが開くので、
そこでAndroid/data/{アプリケーションID}/filesを確認すると良いと思います。

開く方法はショートカットアプリを入れて、パッケージ名com.android.documentsuiから始まるActivityを選ぶと良いはず。

内部音声収録機能をつける

ここから難しくなります。。。とりあえずコピペで動かしてみると良いと思います。

さて、ここからが難しいです。
内部音声を収録する機能ですが、残念ながら高レベルAPIMediaRecorderでは出来ません。
というのも、MediaRecorder#setAudioSourceにはマイクしか選択肢がないのです。端末内の音声を取るためにはMediaRecorderじゃダメなんですね。

じゃあどうするんだって話ですが、低レベルAPIを組み合わせて作るしか無いはずです。
AudioRecord + MediaCodec + MediaMuxer です。

AudioRecord

マイクとか、内部音声の収録で使うやつで、音声データがそのまま取得できます。
MediaRecorderだと勝手にファイルに書き込んでくれますが、AudioRecordはあくまでも音を拾って拾った結果をそのまま吐き出す(PCM)だけなので、
拾ってきた音を圧縮(エンコード)して、ファイルに書き込む処理は別途作る必要があります・・!

MediaRecorderには無いから自前で機能を作りたい場合はこの辺お世話になりそうですね。
今回は内部音声収録のために使うので凝ったことはしません!

MediaCodec

拾ってきた音を圧縮(エンコード)するのに使います。
内部音声収録をして生の音声データが出てくるので、これを使ってAACとか言うファイルにします。
とにかくエラーがわからない・・・

映像の圧縮もあるのですが今回は音声のエンコードだけなのでパス!

MediaMuxer

MediaCodecから出てきたエンコードされたデータをファイルに書き込むためのやつです。

MediaExtractor

mp4webmファイルから、エンコードされたデータを取り出したり、メタデータを取り出したりします。
この先で使うのでここで説明させてね。

う~ん。こうやって見るとMediaRecorderってこの辺いい感じにしてくれてたんですねえ・・・

音声エンコーダー AudioEncoder.kt

まずはMediaCodecを使いやすくしたクラスを作ります。
このブログでは何回か出てきているあれです。

私も何でこれで動いているのかよく分からないのでコピペしてください。MediaCodec何もわからない・・・
startAudioEncodeのなかでwhileループしているので、呼び出し元のコルーチンがキャンセルされるまでずっとコルーチンが一時停止し続けます。

/**
 * 音声エンコーダー
 * MediaCodecを使いやすくしただけ
 *
 * 内部音声が生のまま(PCM)送られてくるので、 AAC / Opus にエンコードする。
 */
class AudioEncoder {
 
    /** MediaCodec エンコーダー */
    private var mediaCodec: MediaCodec? = null
 
    /**
     * エンコーダーを初期化する
     *
     * @param samplingRate サンプリングレート
     * @param channelCount チャンネル数
     * @param bitRate ビットレート
     */
    fun prepareEncoder(
        samplingRate: Int = 44_100,
        channelCount: Int = 2,
        bitRate: Int = 192_000,
    ) {
        val codec = MediaFormat.MIMETYPE_AUDIO_AAC
        val audioEncodeFormat = MediaFormat.createAudioFormat(codec, samplingRate, channelCount).apply {
            setInteger(MediaFormat.KEY_AAC_PROFILE, MediaCodecInfo.CodecProfileLevel.AACObjectLC)
            setInteger(MediaFormat.KEY_BIT_RATE, bitRate)
        }
        // エンコーダー用意
        mediaCodec = MediaCodec.createEncoderByType(codec).apply {
            configure(audioEncodeFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
        }
    }
 
    /**
     * エンコーダーを開始する。
     * キャンセルされるまでコルーチンが一時停止します。
     *
     * @param onRecordInput ByteArrayを渡すので、音声データを入れて、サイズを返してください
     * @param onOutputBufferAvailable エンコードされたデータが流れてきます
     * @param onOutputFormatAvailable エンコード後のMediaFormatが入手できる
     */
    suspend fun startAudioEncode(
        onRecordInput: suspend (ByteArray) -> Int,
        onOutputBufferAvailable: suspend (ByteBuffer, MediaCodec.BufferInfo) -> Unit,
        onOutputFormatAvailable: suspend (MediaFormat) -> Unit,
    ) = withContext(Dispatchers.Default) {
        val bufferInfo = MediaCodec.BufferInfo()
        mediaCodec!!.start()
 
        try {
            while (isActive) {
                // もし -1 が返ってくれば configure() が間違ってる
                val inputBufferId = mediaCodec!!.dequeueInputBuffer(TIMEOUT_US)
                if (inputBufferId >= 0) {
                    // AudioRecodeのデータをこの中に入れる
                    val inputBuffer = mediaCodec!!.getInputBuffer(inputBufferId)!!
                    val capacity = inputBuffer.capacity()
                    // サイズに合わせて作成
                    val byteArray = ByteArray(capacity)
                    // byteArrayへデータを入れてもらう
                    val readByteSize = onRecordInput(byteArray)
                    if (readByteSize > 0) {
                        // 書き込む。書き込んだデータは[onOutputBufferAvailable]で受け取れる
                        inputBuffer.put(byteArray, 0, readByteSize)
                        mediaCodec!!.queueInputBuffer(inputBufferId, 0, readByteSize, System.nanoTime() / 1000, 0)
                    }
                }
                // 出力
                val outputBufferId = mediaCodec!!.dequeueOutputBuffer(bufferInfo, TIMEOUT_US)
                if (outputBufferId >= 0) {
                    val outputBuffer = mediaCodec!!.getOutputBuffer(outputBufferId)!!
                    if (bufferInfo.size > 1) {
                        if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG == 0) {
                            // ファイルに書き込む...
                            onOutputBufferAvailable(outputBuffer, bufferInfo)
                        }
                    }
                    // 返却
                    mediaCodec!!.releaseOutputBuffer(outputBufferId, false)
                } else if (outputBufferId == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
                    // MediaFormat、MediaMuxerに入れるときに使うやつ
                    // たぶんこっちのほうが先に呼ばれる
                    onOutputFormatAvailable(mediaCodec!!.outputFormat)
                }
            }
        } catch (e: Exception) {
            e.printStackTrace()
        } finally {
            // リソース開放
            mediaCodec?.stop()
            mediaCodec?.release()
        }
    }
 
    companion object {
 
        /** MediaCodec タイムアウト */
        private const val TIMEOUT_US = 10_000L
 
    }
}

内部音声収録をする InternalAudioRecorder.kt

内部音声収録機能はそれだけで結構複雑なので、更に別のクラスに切り出してみました。
まずは内部音声収録に使うAudioRecordを初期化します。
内部音声収録はAndroid 10以降が必要なので、ちょっと注意。

で、で、で、ここでサンプリングレートチャンネル数とかいう聞き慣れない単語が出てくるのですが、
あんまりAndroidとは関係ないので深入りはしませんが。AudioRecordMediaCodecで同じ値を指定しておく必要があります

  • サンプリングレート
    • 音を一秒間に何回記録するかです
    • 大体4410048000のどっちかです
      • ほぼ44100です
  • チャンネル数
    • 1 だと左右同じ音が出ます
      • モノラル とか言います
    • 2 だと左右から違う音が出ます
      • ステレオ とか言います
    • ほとんどが 2 です
@SuppressLint("MissingPermission")
@RequiresApi(Build.VERSION_CODES.Q)
class InternalAudioRecorder {
    private var audioRecord: AudioRecord? = null
    private var audioEncoder: AudioEncoder? = null
    private var mediaMuxer: MediaMuxer? = null
 
    var audioRecordingFile: File? = null
        private set
 
    /**
     * 内部音声収録の初期化をする
     *
     * @param samplingRate サンプリングレート
     * @param channelCount チャンネル数
     */
    fun prepareRecorder(
        context: Context,
        mediaProjection: MediaProjection,
        samplingRate: Int,
        channelCount: Int
    ) {
        // 内部音声取るのに使う
        val playbackConfig = AudioPlaybackCaptureConfiguration.Builder(mediaProjection).apply {
            addMatchingUsage(AudioAttributes.USAGE_MEDIA)
            addMatchingUsage(AudioAttributes.USAGE_GAME)
            addMatchingUsage(AudioAttributes.USAGE_UNKNOWN)
        }.build()
        val audioFormat = AudioFormat.Builder().apply {
            setEncoding(AudioFormat.ENCODING_PCM_16BIT)
            setSampleRate(samplingRate)
            setChannelMask(if (channelCount == 1) AudioFormat.CHANNEL_IN_MONO else AudioFormat.CHANNEL_IN_STEREO)
        }.build()
        audioRecord = AudioRecord.Builder().apply {
            setAudioPlaybackCaptureConfig(playbackConfig)
            setAudioFormat(audioFormat)
        }.build()
        // エンコーダーの初期化
        audioEncoder = AudioEncoder().apply {
            prepareEncoder(
                samplingRate = samplingRate,
                channelCount = channelCount
            )
        }
        // コンテナフォーマットに書き込むやつ
        audioRecordingFile = context.getExternalFilesDir(null)?.resolve("audio_track.mp4")
        mediaMuxer = MediaMuxer(audioRecordingFile!!.path, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
    }
 
    fun startRecord() {
        // このあと
    }
 
}

そしたらstartRecordですね。stopが無いやん!って話ですが、呼び出し元のコルーチンがキャンセルされればこちらもfinallyが呼ばれて終了します。
多分こんな感じ!

/** 録音中はコルーチンが一時停止します */
suspend fun startRecord() = withContext(Dispatchers.Default) {
    val audioRecord = audioRecord ?: return@withContext
    val audioEncoder = audioEncoder ?: return@withContext
    val mediaMuxer = mediaMuxer ?: return@withContext
 
    try {
        // 録音とエンコーダーを開始する
        audioRecord.startRecording()
        var trackIndex = -1
        audioEncoder.startAudioEncode(
            onRecordInput = { byteArray ->
                // PCM音声を取り出しエンコする
                audioRecord.read(byteArray, 0, byteArray.size)
            },
            onOutputBufferAvailable = { byteBuffer, bufferInfo ->
                // エンコードされたデータが来る
                mediaMuxer.writeSampleData(trackIndex, byteBuffer, bufferInfo)
            },
            onOutputFormatAvailable = { mediaFormat ->
                // onOutputBufferAvailable よりも先にこちらが呼ばれるはずです
                trackIndex = mediaMuxer.addTrack(mediaFormat)
                mediaMuxer.start()
            }
        )
    } finally {
        // リソース開放
        audioRecord.stop()
        audioRecord.release()
        mediaMuxer.stop()
        mediaMuxer.release()
    }
}

出来たら、呼び出しを追加する前に最後の処理を先に書いちゃいましょう。
(全部揃ってから呼び出しします。)

画面録画の映像と、内部音声収録の音声を一つの mp4 にする処理

このままだとvideo_track.mp4audio_track.mp4と、それぞれ分かれちゃっています。
これを一つにします。

どうすればいいかと言うと、MediaMuxerを使います。
mp4(というかコンテナフォーマット)にはトラックというエンコードされたデータの入れ物みたいな概念があって、
映像データと音声データをそれぞれ一つのファイルに保存するために使われます。

これを使って、音声トラックと映像トラックでそれぞれあるファイルを、一つのmp4にします。

さっきMediaMuxer使ったのにまた使うんかい!って感じですが、まあ音声と映像は別々で処理して最後にトラックを入れるって方法が多分良いと思う。
音声も映像もMediaCodecにすれば、一度のMediaMuxerですむと思いますが、うーん。逆に難しくなりそう。

で、コードはこんな感じ。
MediaMuxerTool.ktっていうユーティリティクラスに書いてみました。
↑ の説明通りのを作ってみた感じです。コメント参照してね。

ちなみにですが、あくまでもトラックを入れ直すだけなので、2つの音声トラックを1つの音声トラックにしたい(音を混ぜたい)とかであればまた全然違う処理を書く必要があります。前ちょっとやったのでこの辺が役に立つかもしれません。
https://takusan.negitoro.dev/posts/summer_vacation_music_vocal_only/

object MediaMuxerTool {
 
    /** それぞれ音声トラック、映像トラックを取り出して、2つのトラックを一つの mp4 にする */
    @SuppressLint("WrongConstant")
    suspend fun mixAvTrack(
        audioTrackFile: File,
        videoTrackFile: File,
        resultFile: File
    ) = withContext(Dispatchers.IO) {
        // 出力先
        val mediaMuxer = MediaMuxer(resultFile.path, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
 
        val (
            audioPair,
            videoPair
        ) = listOf(
            // mimeType と ファイル
            audioTrackFile to "audio/",
            videoTrackFile to "video/"
        ).map { (file, mimeType) ->
            // MediaExtractor を作る
            val mediaExtractor = MediaExtractor().apply {
                setDataSource(file.path)
            }
            // トラックを探す
            // 今回の場合、TrackFile には 1 トラックしか無いはずだが、一応探す処理を入れる
            // 本当は音声と映像で 2 トラックあるので、どっちのデータが欲しいかをこれで切り替える
            val trackIndex = (0 until mediaExtractor.trackCount)
                .map { index -> mediaExtractor.getTrackFormat(index) }
                .indexOfFirst { mediaFormat -> mediaFormat.getString(MediaFormat.KEY_MIME)?.startsWith(mimeType) == true }
            mediaExtractor.selectTrack(trackIndex)
            // MediaExtractor と MediaFormat の返り値を返す
            mediaExtractor to mediaExtractor.getTrackFormat(trackIndex)
        }
 
        val (audioExtractor, audioFormat) = audioPair
        val (videoExtractor, videoFormat) = videoPair
 
        // MediaMuxer に追加して開始
        val audioTrackIndex = mediaMuxer.addTrack(audioFormat)
        val videoTrackIndex = mediaMuxer.addTrack(videoFormat)
        mediaMuxer.start()
 
        // MediaExtractor からトラックを取り出して、MediaMuxer に入れ直す処理
        listOf(
            audioExtractor to audioTrackIndex,
            videoExtractor to videoTrackIndex,
        ).forEach { (extractor, trackIndex) ->
            val byteBuffer = ByteBuffer.allocate(1024 * 4096)
            val bufferInfo = MediaCodec.BufferInfo()
            // データが無くなるまで回す
            while (isActive) {
                // データを読み出す
                val offset = byteBuffer.arrayOffset()
                bufferInfo.size = extractor.readSampleData(byteBuffer, offset)
                // もう無い場合
                if (bufferInfo.size < 0) break
                // 書き込む
                bufferInfo.presentationTimeUs = extractor.sampleTime
                bufferInfo.flags = extractor.sampleFlags // Lintがキレるけど黙らせる
                mediaMuxer.writeSampleData(trackIndex, byteBuffer, bufferInfo)
                // 次のデータに進める
                extractor.advance()
            }
            // あとしまつ
            extractor.release()
        }
 
        // 後始末
        mediaMuxer.stop()
        mediaMuxer.release()
    }
}

端末の動画フォルダーに移動する処理

MediaStoreに動画ファイルを追加すると、ファイルパスの代わりになるUriがもらえて、これでOutputStreamが取得できるので、Fileのデータをコピーすれば良い。
そういえば、MediaStoreUriからFileDescriptorがもらえて、これをMediaMuxerの出力先として使う方法もあったのですが、、、、Android 8以降だったので辞めておきました。
7以下を切れれば使えそうですね。

話を戻して、getExternalFilesDirにあるファイルを、端末の動画フォルダーに保存するのはこんな感じです。

object MediaStoreTool {
 
    /** 端末の動画フォルダーにコピーする */
    suspend fun copyToVideoFolder(
        context: Context,
        file: File,
        fileName: String
    ) = withContext(Dispatchers.IO) {
        val contentResolver = context.contentResolver
        val contentValues = contentValuesOf(
            MediaStore.MediaColumns.DISPLAY_NAME to fileName,
            MediaStore.MediaColumns.RELATIVE_PATH to "${Environment.DIRECTORY_MOVIES}/AndroidPartialScreenInternalAudioRecorder",
        )
        val uri = contentResolver.insert(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, contentValues) ?: return@withContext
        contentResolver.openOutputStream(uri)?.use { outputStream ->
            file.inputStream().use { inputStream ->
                inputStream.copyTo(outputStream)
            }
        }
    }
 
}

組み合わせる!

まずはScreenRecorder.kt
内部音声収録の機能と、音声と映像のミックス、端末の動画フォルダーに移動する処理が追加されます。
suspend funを使ったので、launch { }でくくる必要があります

これちょっと内部音声収録のためのAndroid 10かどうかの分岐が邪魔くさいですね。面倒なのでやりませんが、、、

/** 画面録画のためのクラス */
class ScreenRecorder(
    private val context: Context,
    private val resultCode: Int,
    private val resultData: Intent
) {
    private val mediaProjectionManager by lazy { context.getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager }
    private val scope = CoroutineScope(Dispatchers.Default + Job())
 
    private var recordingJob: Job? = null
    private var mediaProjection: MediaProjection? = null
    private var mediaRecorder: MediaRecorder? = null
    private var videoRecordingFile: File? = null
    private var virtualDisplay: VirtualDisplay? = null
    private var internalAudioRecorder: InternalAudioRecorder? = null
 
    /** 録画を開始する */
    fun startRecord() {
        recordingJob = scope.launch {
            mediaRecorder = (if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) MediaRecorder(context) else MediaRecorder()).apply {
                // 呼び出し順が存在します
                // 音声トラックは録画終了時にやります
                setVideoSource(MediaRecorder.VideoSource.SURFACE)
                setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
                setVideoEncoder(MediaRecorder.VideoEncoder.H264)
                setVideoEncodingBitRate(6_000_000)
                setVideoFrameRate(60)
                // 解像度、縦動画の場合は、代わりに回転情報を付与する(縦横の解像度はそのまま)
                setVideoSize(VIDEO_WIDTH, VIDEO_HEIGHT)
                // 保存先。
                // sdcard/Android/data/{アプリケーションID} に保存されますが、後で端末の動画フォルダーに移動します
                videoRecordingFile = context.getExternalFilesDir(null)?.resolve("video_track.mp4")
                setOutputFile(videoRecordingFile!!.path)
                prepare()
            }
 
            mediaProjection = mediaProjectionManager.getMediaProjection(resultCode, resultData)
            // メインスレッドで呼び出す
            withContext(Dispatchers.Main) {
                // 画面録画中のコールバック
                mediaProjection?.registerCallback(object : MediaProjection.Callback() {
                    override fun onCapturedContentResize(width: Int, height: Int) {
                        super.onCapturedContentResize(width, height)
                        // サイズが変化したら呼び出される
                    }
 
                    override fun onCapturedContentVisibilityChanged(isVisible: Boolean) {
                        super.onCapturedContentVisibilityChanged(isVisible)
                        // 録画中の画面の表示・非表示が切り替わったら呼び出される
                    }
 
                    override fun onStop() {
                        super.onStop()
                        // MediaProjection 終了時
                        // do nothing
                    }
                }, null)
            }
            // 画面ミラーリング
            virtualDisplay = mediaProjection?.createVirtualDisplay(
                "io.github.takusan23.androidpartialscreeninternalaudiorecorder",
                VIDEO_WIDTH,
                VIDEO_HEIGHT,
                context.resources.configuration.densityDpi,
                DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
                mediaRecorder?.surface,
                null,
                null
            )
            // 内部音声収録
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
                internalAudioRecorder = InternalAudioRecorder().apply {
                    prepareRecorder(context, mediaProjection!!, AUDIO_SAMPLING_RATE, AUDIO_CHANNEL_COUNT)
                }
            }
 
            // 画面録画開始
            mediaRecorder?.start()
            // 内部音声収録開始。
            // この関数のあとに処理を書いても、startRecord が一時停止し続けるので注意。
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
                internalAudioRecorder?.startRecord()
            }
        }
    }
 
    /** 録画を終了する */
    suspend fun stopRecord() = withContext(Dispatchers.IO) {
        // 終了を待つ
        recordingJob?.cancelAndJoin()
        // リソース開放をする
        mediaRecorder?.stop()
        mediaRecorder?.release()
        mediaProjection?.stop()
        virtualDisplay?.release()
 
        // 内部音声収録をしている場合、音声と映像が別れているので、2トラックをまとめた mp4 にする
        val resultFile = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            context.getExternalFilesDir(null)?.resolve("mix_track.mp4")!!.also { mixFile ->
                MediaMuxerTool.mixAvTrack(
                    audioTrackFile = internalAudioRecorder?.audioRecordingFile!!,
                    videoTrackFile = videoRecordingFile!!,
                    resultFile = mixFile
                )
            }
        } else {
            videoRecordingFile!!
        }
 
        // 端末の動画フォルダーに移動
        MediaStoreTool.copyToVideoFolder(
            context = context,
            file = resultFile,
            fileName = "AndroidPartialScreenInternalAudioRecorder_${System.currentTimeMillis()}.mp4"
        )
 
        // 要らないのを消す
        videoRecordingFile!!.delete()
        resultFile.delete()
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            internalAudioRecorder?.audioRecordingFile!!.delete()
        }
    }
 
    companion object {
        private const val VIDEO_WIDTH = 1280
        private const val VIDEO_HEIGHT = 720
        private const val AUDIO_SAMPLING_RATE = 44_100
        private const val AUDIO_CHANNEL_COUNT = 2
    }
}

これに合わせてScreenRecordService.ktも修正しました。
stopRecordsuspend funになり、ファイルの保存が確実に終わるまではstopSelfに進まないようになっています。

class ScreenRecordService : Service() {
    private val notificationManager by lazy { NotificationManagerCompat.from(this) }
    private val scope = MainScope()
 
    private var screenRecorder: ScreenRecorder? = null
 
    override fun onBind(intent: Intent): IBinder? = null
 
    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        // 通知を出す
        notifyForegroundServiceNotification()
 
        // Intent から値を取り出す
        if (intent?.getBooleanExtra(INTENT_KEY_START_OR_STOP, false) == true) {
            // 開始命令
            screenRecorder = ScreenRecorder(
                context = this,
                resultCode = intent.getIntExtra(INTENT_KEY_MEDIA_PROJECTION_RESULT_CODE, -1),
                resultData = intent.getParcelableExtra(INTENT_KEY_MEDIA_PROJECTION_RESULT_DATA)!!
            )
            // 録画開始
            screenRecorder?.startRecord()
        } else {
            // 終了命令
            scope.launch {
                // 終了を待ってから stopSelf する
                screenRecorder?.stopRecord()
                stopSelf()
            }
        }
 
        return START_NOT_STICKY
    }
 
    override fun onDestroy() {
        super.onDestroy()
        scope.cancel()
    }
 
    // これ以降は変更なし

どうだろう?これで完成だと思うよ?

これが部分的な画面共有!!です!

ステータスバーが映り込んでない! これがAndroid 14だあああ。
どうやら画面分割の場合、createVirtualDisplayのサイズはそのままで、黒帯が表示されたりする感じなんですね。

そしてちゃんと内部音声収録もされています!
ゲーム実況できそう!

https://www.youtube.com/watch?v=0KcolWHVjV0

ソースコード

コードとか後半は断片的になっちゃったから。。。どうぞ

https://github.com/takusan23/AndroidPartialScreenInternalAudioRecorder

https://github.com/takusan23/AndroidPartialScreenInternalAudioRecorder/tree/master/app/src/main/java/io/github/takusan23/androidpartialscreeninternalaudiorecorder

追記 2024/10/17 Android 15 QPR1

Android 15の次のバージョンのベータ版Android 15 QPR1MediaProjectionに変更が入ってます。
https://developer.android.com/about/versions/15/behavior-changes-all#media-projection-status-bar-chip

まず、ステータスバーからMediaProjectionを利用している旨のChipが追加されました。
しかも押せるようになってて、ここから終了すると、MediaProjection.CallbackonStop()が呼び出されます。

あと通知領域を開いたとしても、詳しい内容は隠されるようになりました。
また、スマホをスリープにしたときにも、MediaProjection.CallbackonStop()が呼ばれるようになります。今まではAlways On Displayとかが写ってた?

Imgur

Imgur

Imgur

で、この記事では onStop() が呼び出されても何もしていません、が、おそらくサービスや画面録画を終了するような処理を追加で書く必要があると思います。
雑に対応してみた→ https://github.com/takusan23/ZeroMirror/commit/9d7cb51224c989e9e3342f7da6a8a462855a751e

mediaProjection.registerCallback(object : MediaProjection.Callback() {
    override fun onCapturedContentVisibilityChanged(isVisible: Boolean) {
        super.onCapturedContentVisibilityChanged(isVisible)
        // 単一アプリが非表示になった
        // 代わりに代替画像を流す
    }
 
    override fun onCapturedContentResize(width: Int, height: Int) {
        super.onCapturedContentResize(width, height)
        // 単一アプリのサイズが変化した
    }
 
    override fun onStop() {
        super.onStop()
        // MediaProjection が終了したとき
        // 録画を停止するとか、サービスを終了するとか、、、
    }
}, null)

番外編 単一アプリが画面に映っていないときの話

さて、ここから先はおまけです。ほとんどの人は関係ありません
冒頭の方で、単一アプリの表示中で、その単一アプリが画面外に移動した場合、エンコーダーからデータが流れてこないんですよね。

MediaRecorderは高レベル過ぎて分からないですが、MediaCodecの場合MediaCodec#dequeueOutputBufferの返り値が、単一アプリが画面外にいる場合は-1を返すので、データが流れてきません!

データが流れてこないと何が困るかと言うと、前作ったミラーリングアプリ(ぜろみらー)が動かなくなっちゃうんですよね。
このアプリは映像データを細切れにしてブラウザに配信するアプリなのですが、おそらく仕様上ずっと映像データを送り続けないといけないんですよね。

なので、私としては単一アプリが画面外にいる場合、最後のフレームでも、真っ黒の映像でもなんでも良いので途切れてほしくはなかったのですが、、、
現状何もしない場合はエンコーダーからデータが来ないので、ブラウザに配信するファイルも途切れてしまいます。
(・・・まあ画面外に有るのに出続けるのもおかしいか)

単一アプリが画面に映ってないときに代わりに映す

Imgur

というわけで、画面外に単一アプリが移動した場合、「今は映らないよ!アプリ戻ってきたら映るよ!」的な文字を動画に入れたいわけですが、
それをするにはおそらくOpenGLを使うしか無い・・・

やらないといけないこと

  • 代替画像の作成
    • これはBitmap作って適当にCanvasで文字を書けばいいでしょう
  • OpenGL で画面共有の映像を描画する
    • 最大の敵でた
    • おそらく映像データに手を入れたい場合はOpenGLを使うしか無い
    • ・・・が、相変わらずAOSPMediaCodecテストで使われてるOpenGL関連のやつをコピペして、フラグメントシェーダーをちょっと直せば動くはずなのでそれで行きます

もしやりたい場合

OpenGL

OpenGLのコードをコピペします。
もう全部貼るのはつかれたというかまぶたが重くて、、、GitHubからコピーしてきてね

MediaCodecシリーズではよく出てくるこのコード、MediaRecorderでも使えます。
https://takusan.negitoro.dev/posts/tag/MediaCodec/

これはMediaRecorder - MediaProjection (VirtualDisplay)の関係から、MediaRecorder - OpenGL - MediaProjection (VirtualDiplay)という感じに、間にOpenGLを挟むようにします。
MediaProjectionの映像はOpenGLのテクスチャとして使える(SurfaceTextureクラス参照)ので、フラグメントシェーダーからはtexture2Dで描画できます。

このフラグメントシェーダーで、単一アプリが画面に表示されている場合はMediaProjectionの画面共有を描画して、
表示されていない時は代わりの画像uAltImageを描画するようにしてみました。これでエンコーダー(MediaRecorder)へ流す映像が途切れなくはなりましたね。

        private const val FRAGMENT_SHADER = """
#extension GL_OES_EGL_image_external : require
precision mediump float;
varying vec2 vTextureCoord;
uniform samplerExternalOES sTexture;
uniform sampler2D uAltImage;
 
// 映像を描画するのか、画像を表示するのか
uniform int uDrawAltImage;
 
void main() {
  if (bool(uDrawAltImage)) {
    gl_FragColor = texture2D(uAltImage, vTextureCoord);
  } else {
    gl_FragColor = texture2D(sTexture, vTextureCoord);  
  }
}
"""

あとはglDrawArraysとかやって、画面共有か代替画像かどっちかを描画するように

/** 録画映像の代わりに代替画像を描画する */
fun drawAltImage() {
    GLES20.glUseProgram(mProgram)
    checkGlError("glUseProgram")
    // AltImage を描画する
    GLES20.glUniform1i(uDrawAltImageHandle, 1)
    checkGlError("glUniform1i uDrawAltImageHandle")
    // 描画する
    GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4)
    checkGlError("glDrawArrays")
    GLES20.glFinish()
}
 
/** 画面録画の映像を描画する */
fun drawFrame(st: SurfaceTexture) {
    checkGlError("onDrawFrame start")
    st.getTransformMatrix(mSTMatrix)
    GLES20.glUseProgram(mProgram)
    checkGlError("glUseProgram")
    GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
    // SurfaceTexture テクスチャユニットは GLES20.GL_TEXTURE0 なので 0
    GLES20.glUniform1i(uTextureHandle, 0)
    checkGlError("glUniform1i uTextureHandle")
    // AltImage ではなく SurfaceTexture を描画する
    GLES20.glUniform1i(uDrawAltImageHandle, 0)
    checkGlError("glUniform1i uDrawAltImageHandle")
    GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, screenRecordTextureId)
    mTriangleVertices.position(TRIANGLE_VERTICES_DATA_POS_OFFSET)
    GLES20.glVertexAttribPointer(maPositionHandle, 3, GLES20.GL_FLOAT, false, TRIANGLE_VERTICES_DATA_STRIDE_BYTES, mTriangleVertices)
    checkGlError("glVertexAttribPointer maPosition")
    GLES20.glEnableVertexAttribArray(maPositionHandle)
    checkGlError("glEnableVertexAttribArray maPositionHandle")
    mTriangleVertices.position(TRIANGLE_VERTICES_DATA_UV_OFFSET)
    GLES20.glVertexAttribPointer(maTextureHandle, 2, GLES20.GL_FLOAT, false, TRIANGLE_VERTICES_DATA_STRIDE_BYTES, mTriangleVertices)
    checkGlError("glVertexAttribPointer maTextureHandle")
    GLES20.glEnableVertexAttribArray(maTextureHandle)
    checkGlError("glEnableVertexAttribArray maTextureHandle")
    GLES20.glUniformMatrix4fv(muSTMatrixHandle, 1, false, mSTMatrix, 0)
    GLES20.glUniformMatrix4fv(muMVPMatrixHandle, 1, false, mMVPMatrix, 0)
    GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4)
    checkGlError("glDrawArrays")
    GLES20.glFinish()
}

そういえば、SurfaceTextureのコールバックと周りはちょっと罠があって、
https://stackoverflow.com/questions/14185661/

こんな感じに別スレッドでフラグを操作する場合はSynchronizedとかMutex()を使って、同時に複数スレッドからフラグを操作されないようにしないと、いつまでたっても映像が来ない勘違いをすることになります。。。
Stackoverflowを見る感じ、OpenGLの描画が遅くて以下のisNewFrameAvailableのフラグが不正に書き換わっちゃうらしい)

/**
 * [SurfaceTexture.OnFrameAvailableListener.onFrameAvailable] と [awaitIsNewFrameAvailable] からそれぞれ別スレッドで [isNewFrameAvailable] にアクセスするため、
 * 同時アクセスできないように制御する [Mutex]。
 */
private val frameSyncMutex = Mutex()
 
/** 新しい映像フレームが来ていれば true */
private var isNewFrameAvailable = false
 
/**
 * 新しい映像フレームが来ているか
 *
 * @return true の場合は[updateTexImage] [drawImage] [swapBuffers]を呼び出して描画する。
 */
suspend fun awaitIsNewFrameAvailable(): Boolean {
    return frameSyncMutex.withLock {
        if (isNewFrameAvailable) {
            // onFrameAvailable が来るまで倒しておく
            isNewFrameAvailable = false
            // 描画すべきなので true
            true
        } else {
            // まだ来てない
            false
        }
    }
}
 
/** これは UI Thread から呼ばれる */
override fun onFrameAvailable(st: SurfaceTexture) {
    scope.launch {
        frameSyncMutex.withLock {
            // 新しい映像フレームが来たら true
            isNewFrameAvailable = true
        }
    }
}

それ以外は何やってんのか知らないので、次行きます。

録画部分に組み込む話と Kotlin Coroutine の話

Kotlin コルーチンがマジで便利。
というのも、OpenGLmakeCurrentしたスレッドじゃないと描画できないので、ちょっとややこしい。

https://kotlinlang.org/docs/coroutine-context-and-dispatchers.html

Kotlin coroutineで、実行したいスレッドを指定したい時Dispatchers.MainとかDispatchers.IOとか使うと思いますが、
Main以外は裏側で複数のスレッドが待機していて処理されるはずで(スレッドプールとか言うらしいですけどよくわかりません?)、
例えばDispatchers.Defaultlaunch / withContextに指定したとしても、すべてで同じスレッドが使われるかどうかはわからないわけです。

runBlocking {
    // 適当に 100 個、子コルーチンを起動する
    // Dispatchers.Default を指定する
    (0 until 100).map { i ->
        launch(Dispatchers.Default) {
            println("$i = ${Thread.currentThread().name}")
        }
    }.joinAll()
}

適当に実行してみた結果、この様な結果になります。最後の方をコピペしてみました。
Thread.currentThread().nameが起動するたびに違う事がありますね。

90 = DefaultDispatcher-worker-2
91 = DefaultDispatcher-worker-1
92 = DefaultDispatcher-worker-2
93 = DefaultDispatcher-worker-5
94 = DefaultDispatcher-worker-5
95 = DefaultDispatcher-worker-2
96 = DefaultDispatcher-worker-1
97 = DefaultDispatcher-worker-1
98 = DefaultDispatcher-worker-2
99 = DefaultDispatcher-worker-1

OpenGL の場合はスレッドが変わっちゃうと動かなくなるので、これだと困る!固定して欲しい!

というわけで実行するスレッドを固定してみます。
newSingleThreadContextってやつを使うことで、常に同じスレッドで処理してくれるDispatcherを作ることが出来ます

fun main() {
    // newSingleThreadContext は作るのにコストがかかるので、
    // companion object に置くなどして、一回だけ作って使い回す方が良いです
    val singleThreadDispatcher = newSingleThreadContext("OpenGLContextRelatedThread")
 
    runBlocking {
        // 適当に 100 個、子コルーチンを起動する
        // Dispatchers.Default を指定する
        (0 until 100).map { i ->
            launch(singleThreadDispatcher) {
                println("$i = ${Thread.currentThread().name}")
            }
        }.joinAll()
    }
}
90 = OpenGLContextRelatedThread
91 = OpenGLContextRelatedThread
92 = OpenGLContextRelatedThread
93 = OpenGLContextRelatedThread
94 = OpenGLContextRelatedThread
95 = OpenGLContextRelatedThread
96 = OpenGLContextRelatedThread
97 = OpenGLContextRelatedThread
98 = OpenGLContextRelatedThread
99 = OpenGLContextRelatedThread

これだとすべて同じスレッドで処理されることが分かりますね。
これがあればOpenGLも怖くない!

というわけで組み込んでみました。こうです。
onCapturedContentVisibilityChangedのコールバックで、代替画像を表示するかのフラグを更新するのを追加しました。
また、OpenGL周りの初期化を追加しました。スレッド注意です。
そしてVirtualDisplaySurfaceOpenGL (SurfaceTexture)の物に差し替えました。差し替え忘れないようにしてください。

あとは録画が停止するまで、OpenGLのメインループ?で新しい画面共有のフレームが来ていれば描画するし、代替画像を表示する場合はここで代替画像を描画して、エンコーダーへ渡します。

/** 画面録画のためのクラス */
class ScreenRecorder(
    private val context: Context,
    private val resultCode: Int,
    private val resultData: Intent
) {
    private val mediaProjectionManager by lazy { context.getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager }
    private val scope = CoroutineScope(Dispatchers.Default + Job())
 
    private var recordingJob: Job? = null
    private var mediaProjection: MediaProjection? = null
    private var mediaRecorder: MediaRecorder? = null
    private var videoRecordingFile: File? = null
    private var virtualDisplay: VirtualDisplay? = null
    private var internalAudioRecorder: InternalAudioRecorder? = null
    private var inputOpenGlSurface: InputSurface? = null
    private var isDrawAltImage = false
 
    /** 録画を開始する */
    fun startRecord() {
        recordingJob = scope.launch {
            mediaRecorder = (if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) MediaRecorder(context) else MediaRecorder()).apply {
                // 呼び出し順が存在します
                // 音声トラックは録画終了時にやります
                setVideoSource(MediaRecorder.VideoSource.SURFACE)
                setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
                setVideoEncoder(MediaRecorder.VideoEncoder.H264)
                setVideoEncodingBitRate(6_000_000)
                setVideoFrameRate(60)
                // 解像度、縦動画の場合は、代わりに回転情報を付与する(縦横の解像度はそのまま)
                setVideoSize(VIDEO_WIDTH, VIDEO_HEIGHT)
                // 保存先。
                // sdcard/Android/data/{アプリケーションID} に保存されますが、後で端末の動画フォルダーに移動します
                videoRecordingFile = context.getExternalFilesDir(null)?.resolve("video_track.mp4")
                setOutputFile(videoRecordingFile!!.path)
                prepare()
            }
 
            mediaProjection = mediaProjectionManager.getMediaProjection(resultCode, resultData)
            // メインスレッドで呼び出す
            withContext(Dispatchers.Main) {
                // 画面録画中のコールバック
                mediaProjection?.registerCallback(object : MediaProjection.Callback() {
                    override fun onCapturedContentResize(width: Int, height: Int) {
                        super.onCapturedContentResize(width, height)
                        // サイズが変化したら呼び出される
                    }
 
                    override fun onCapturedContentVisibilityChanged(isVisible: Boolean) {
                        super.onCapturedContentVisibilityChanged(isVisible)
                        // 録画中の画面の表示・非表示が切り替わったら呼び出される
                        isDrawAltImage = !isVisible
                    }
 
                    override fun onStop() {
                        super.onStop()
                        // MediaProjection 終了時
                        // do nothing
                    }
                }, null)
            }
            // OpenGL を経由して、画面共有の映像を MediaRecorder へ渡す
            // スレッド注意
            withContext(openGlRelatedDispatcher) {
                inputOpenGlSurface = InputSurface(mediaRecorder?.surface!!, TextureRenderer())
                inputOpenGlSurface?.makeCurrent()
                inputOpenGlSurface?.createRender(VIDEO_WIDTH, VIDEO_HEIGHT)
                // 単一アプリが画面に写っていないときに描画する、代替画像をセット
                inputOpenGlSurface?.setAltImageTexture(createAltImage())
            }
            // 画面ミラーリング
            virtualDisplay = mediaProjection?.createVirtualDisplay(
                "io.github.takusan23.androidpartialscreeninternalaudiorecorder",
                VIDEO_WIDTH,
                VIDEO_HEIGHT,
                context.resources.configuration.densityDpi,
                DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
                inputOpenGlSurface?.drawSurface,
                null,
                null
            )
            // 内部音声収録
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
                internalAudioRecorder = InternalAudioRecorder().apply {
                    prepareRecorder(context, mediaProjection!!, AUDIO_SAMPLING_RATE, AUDIO_CHANNEL_COUNT)
                }
            }
 
            // 画面録画開始
            mediaRecorder?.start()
            // OpenGL と 内部音声の処理を始める
            // 並列で
            listOf(
                launch(openGlRelatedDispatcher) {
                    // OpenGL で画面共有か代替画像のどちらかを描画する
                    while (isActive) {
                        try {
                            if (isDrawAltImage) {
                                inputOpenGlSurface?.drawAltImage()
                                inputOpenGlSurface?.swapBuffers()
                                delay(16) // 60fps が 16ミリ秒 らしいので適当に待つ。多分待たないといけない
                            } else {
                                // 映像フレームが来ていれば OpenGL のテクスチャを更新
                                val isNewFrameAvailable = inputOpenGlSurface?.awaitIsNewFrameAvailable()
                                // 描画する
                                if (isNewFrameAvailable == true) {
                                    inputOpenGlSurface?.updateTexImage()
                                    inputOpenGlSurface?.drawImage()
                                    inputOpenGlSurface?.swapBuffers()
                                }
                            }
                        } catch (e: Exception) {
                            e.printStackTrace()
                        }
                    }
                },
                launch {
                    // 内部音声収録開始。
                    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
                        internalAudioRecorder?.startRecord()
                    }
                }
            ).joinAll() // 終わるまで一時停止
        }
    }
 
    /** 録画を終了する */
    suspend fun stopRecord() = withContext(Dispatchers.IO) {
        // 終了を待つ
        recordingJob?.cancelAndJoin()
        // リソース開放をする
        mediaRecorder?.stop()
        mediaRecorder?.release()
        mediaProjection?.stop()
        virtualDisplay?.release()
 
        // 内部音声収録をしている場合、音声と映像が別れているので、2トラックをまとめた mp4 にする
        val resultFile = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            context.getExternalFilesDir(null)?.resolve("mix_track.mp4")!!.also { mixFile ->
                MediaMuxerTool.mixAvTrack(
                    audioTrackFile = internalAudioRecorder?.audioRecordingFile!!,
                    videoTrackFile = videoRecordingFile!!,
                    resultFile = mixFile
                )
            }
        } else {
            videoRecordingFile!!
        }
 
        // 端末の動画フォルダーに移動
        MediaStoreTool.copyToVideoFolder(
            context = context,
            file = resultFile,
            fileName = "AndroidPartialScreenInternalAudioRecorder_${System.currentTimeMillis()}.mp4"
        )
 
        // 要らないのを消す
        videoRecordingFile!!.delete()
        resultFile.delete()
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            internalAudioRecorder?.audioRecordingFile!!.delete()
        }
    }
 
    /** 単一アプリの画面録画時に、指定した単一アプリが画面外に移動した際に代わりに描画する画像を生成する */
    private fun createAltImage(): Bitmap = Bitmap.createBitmap(VIDEO_WIDTH, VIDEO_HEIGHT, Bitmap.Config.ARGB_8888).also { bitmap ->
        val canvas = Canvas(bitmap)
        val paint = Paint().apply {
            color = Color.WHITE
            textSize = 50f
        }
 
        canvas.drawColor(Color.BLACK)
        canvas.drawText("指定したアプリは今画面に写ってません。", 100f, 100f, paint)
        canvas.drawText("戻ってきたら映像が再開されます。", 100f, 200f, paint)
    }
 
    companion object {
 
        /**
         * OpenGL はスレッドでコンテキストを識別するので、OpenGL 関連はこの openGlRelatedDispatcher から呼び出す。
         * どういうことかと言うと、OpenGL は makeCurrent したスレッド以外で、OpenGL の関数を呼び出してはいけない。
         * (makeCurrent したスレッドのみ swapBuffers 等できる)。
         *
         * 独自 Dispatcher を作ることで、処理するスレッドを指定できたりする。
         */
        @OptIn(DelicateCoroutinesApi::class)
        private val openGlRelatedDispatcher = newSingleThreadContext("OpenGLContextRelatedThread")
 
        private const val VIDEO_WIDTH = 1280
        private const val VIDEO_HEIGHT = 720
        private const val AUDIO_SAMPLING_RATE = 44_100
        private const val AUDIO_CHANNEL_COUNT = 2
    }
}

どうでしょうか。
単一アプリが離れた時は代わりの画像が出てますでしょうか?

ちなみに、最後のフレーム(アプリが画面外に行く前の最後の状態)を写し続けたい場合は、こんな画像を作ったりはしなくて良いはずで、
画面外にあれば更新しなきゃいいだけのはず。まあ、最後のフレームを再送し続けるにしろOpenGLからは避けれない人生。

// OpenGL で画面共有のフレームを描画する
while (isActive) {
    try {
        // isVisibleCaptureContent は onCapturedContentVisibilityChanged で true だったら true になるフラグ
        if (isVisibleCaptureContent) {
            // 映像フレームが来ていれば OpenGL のテクスチャを更新
            val isNewFrameAvailable = inputOpenGlSurface?.awaitIsNewFrameAvailable()
            // 描画する
            if (isNewFrameAvailable == true) {
                inputOpenGlSurface?.updateTexImage()
                inputOpenGlSurface?.drawImage()
                inputOpenGlSurface?.swapBuffers()
            }
        }
    } catch (e: Exception) {
        e.printStackTrace()
    }
}

おわりに

ソースコードです!

https://github.com/takusan23/AndroidPartialScreenInternalAudioRecorder

ぜろみらー も対応しました

単一アプリ共有時に画面外に移動してもちゃんと動きます!
Imgur

ついでに安定性を向上させました。んなアプリストアのリリースノートみたいなこと言うなよって言われるので話すと、セグメントの生成がちゃんと時間通りに配信出来るように調整しました。
記事書き終わったらリリースするので、読む頃にはリリースされてるんじゃないでしょうか?

https://github.com/takusan23/ZeroMirror/commit/b93cd452c708718b4cf03cad94464b9268d09978

そういえば

MediaCodecだけだと思いますが、指定したミリ秒の間に映像が流れてこないときに(今回みたいに単一アプリ共有で画面外にいる等で?)、
前回のフレームをエンコーダーに入れるオプションがあります。MediaFormat.KEY_REPEAT_PREVIOUS_FRAME_AFTERっていうんですけど。

ただ、これREPEATとか定数名に使ってるだけあって映像が流れてこない場合はずっと最後のフレームをエンコーダーに再送するのかと思ってたらそうではないらしく、
なんか1回だけしか動かないらしい?(REPEATって何なんだ)

https://issuetracker.google.com/issues/171023079

そのほか

めっちゃ長くなってしまった...すいません
システムUIが写り込まない画面共有、余計な SystemUI が映らなくなるので、画面録画中に通知が来ても安心!