Edited at

MediaProjection API を使ってミラーリングしてみた


概要


  • Androidのミラーリングをサクッと出来るようにしたい!

  • Oculus Questに入れても動く!


成果物

こんなものを作ってみました。


ミラーリング先(デスクトップアプリ)

SnapCrab_screen-mirror-app_2019-10-7_1-18-48_No-00.png

src: https://github.com/TakenokoTech/ScreenMirrorAndroid/tree/master/server


ミラーリング元(Androidアプリ)

src: https://github.com/TakenokoTech/ScreenMirrorAndroid


MediaProjection API とは?

Android 5.0 (Lollipop / SDK Level 21) で追加された別アプリのスクリーンショットを撮影できる機能。

Android の画面キャストの機能なんかで使われます。

リファレンス: https://developer.android.com/reference/android/media/projection/MediaProjection


今回作ったアプリの構成

castAndroid.png


  • デスクトップアプリは Electron を使用してみました。


  • H.264などの動画圧縮の規格を使用することも出来ますが扱いが難しいのでJPEGを投げてます。

  • 無線化が目的なのでローカルにサーバーを立てて、Wi-Fi経由で接続するようにしてます。


【実装編: Android】

全体的なフローはこんな感じ

Androidaaaaaaaaa.png

今回QR読み取りする部分は省きます。(ZXing Android Embeddedを使用しました)


MediaProjectionの取得

src: MediaProjectionModel.kt


AndroidManifest.xml

    <application>

<activity android:name=".MainActivity" ... />
+ <activity
+ android:name=".model.MediaProjectionModel"
+ android:theme="@android:style/Theme.Translucent" />


MediaProjectionModel.kt

class MediaProjectionModel : Activity() {

private lateinit var mediaProjectionManager: MediaProjectionManager

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
mediaProjectionManager = getSystemService(Service.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
startActivityForResult(mediaProjectionManager.createScreenCaptureIntent(), REQUEST_CAPTURE)
}

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == REQUEST_CAPTURE && resultCode == RESULT_OK && data != null) {
projection(mediaProjectionManager.getMediaProjection(resultCode, data))
}
finish()
}

companion object {
private const val REQUEST_CAPTURE = 1

private var projection: (MediaProjection?) -> Unit = {}
val run: (context: Context, (MediaProjection?) -> Unit) -> Unit = { context, callback ->
projection = callback
context.startActivity(Intent(context, MediaProjectionModel::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}
}
}



  • 透明なActivityを使って onActivityResult を拾ってます。(たぶん危険)


WebSocketを実装

src: WebSocketModel.kt


WebSocketModel.kt

class WebSocketModel (uri: URI) : WebSocketClient(uri) {

override fun onOpen(handshakedata: ServerHandshake?) {}
override fun onClose(code: Int, reason: String?, remote: Boolean) {}
override fun onMessage(message: String?) {}
override fun onError(ex: Exception?) {}
}


  • 一旦は空実装


ミラーリングする部分

src: MirrorModel.kt


MirrorModel.kt

class MirrorModel(private val metrics: DisplayMetrics, private val callback: MirrorCallback) : ImageReader.OnImageAvailableListener {

enum class StatesType { Stop, Running }

private var mediaProjection: MediaProjection? = null
private var virtualDisplay: VirtualDisplay? = null

private lateinit var heepPlane: Image.Plane
private lateinit var heepBitmap: Bitmap

private val scale = 0.5 // 端末ごとに調整して下さい

fun setMediaProjection(mediaProjection: MediaProjection?) {
this.mediaProjection = mediaProjection
}

fun disconnect() {
runCatching {
virtualDisplay?.release()
mediaProjection?.stop()
}.exceptionOrNull()?.printStackTrace()
callback.changeState(StatesType.Stop)
}

@SuppressLint("WrongConstant")
fun setupVirtualDisplay(): ImageReader? {
val width = (metrics.widthPixels * scale).toInt()
val height = (metrics.heightPixels * scale).toInt()
val reader = ImageReader.newInstance(width , height, PixelFormat.RGBA_8888, 2).also { it.setOnImageAvailableListener(this, null) }
virtualDisplay = mediaProjection?.createVirtualDisplay(
"Capturing Display",
width,
height,
metrics.densityDpi,
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
reader!!.surface,
null,
null
)
callback.changeState(StatesType.Running)
return reader
}

override fun onImageAvailable(reader: ImageReader) {
reader.acquireLatestImage().use { img ->
runCatching {
heepPlane = img?.planes?.get(0) ?: return@use null
val width = heepPlane.rowStride / heepPlane.pixelStride
val height = (metrics.heightPixels * scale).toInt()
heepBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888).apply { copyPixelsFromBuffer(heepPlane.buffer) }
callback.changeBitmap(heepBitmap)
}
}
}

interface MirrorCallback {
fun changeState(states: StatesType)
fun changeBitmap(image: Bitmap?)
}
}




  • createVirtualDisplay の width, height は端末によっては大きすぎる事があるので、スケーリング出来るようにした方が良さそう。


作ったロジックを合体

src: MirroringUsecase.kt


MirroringUsecase.kt

class MirroringUsecase(private val context: Context): MirrorModel.MirrorCallback {

private var mirrorModel: MirrorModel = MirrorModel(context.resources.displayMetrics, this)
private var webSocketModel: WebSocketModel = WebSocketModel(URI("ws://10.0.2.2:8080"))

private var reader: ImageReader? = null
private var sending: Boolean = false

init {
changeState(MirrorModel.StatesType.Stop)
}

fun start() {
MediaProjectionModel.run(context) {
runCatching {
mirrorModel.setMediaProjection(it)
webSocketModel.connect()
reader = mirrorModel.setupVirtualDisplay()
}.exceptionOrNull()?.printStackTrace()
}
}

fun restart() {
runCatching {
reader = mirrorModel.setupVirtualDisplay()
}.exceptionOrNull()?.printStackTrace()
}

fun stop() {
mirrorModel.disconnect()
webSocketModel.close()
}

override fun changeState(states: MirrorModel.StatesType) {
stateLivaData.value = states
}

override fun changeBitmap(image: Bitmap?) {
if(!webSocketModel.isOpen) MirroringService.stop(context)
sending = if(!sending) true else return
GlobalScope.launch(Dispatchers.Main) {
ByteArrayOutputStream().use { stream ->
image?.compress(Bitmap.CompressFormat.JPEG, 80, stream).also {
sending = false
webSocketModel.send(stream.toByteArray())
}
}
}
imageLivaData.value = image
}

companion object {
val stateLivaData: MutableLiveData<MirrorModel.StatesType> = MutableLiveData()
val imageLivaData: MutableLiveData<Bitmap> = MutableLiveData()
}
}



フォアグラウンドサービスの作成

src: MirroringService.kt


MirroringService.kt

class MirroringService : Service() {

lateinit var mirroringUsecase: MirroringUsecase

override fun onBind(intent: Intent?): IBinder? {
return null
}

@RequiresApi(Build.VERSION_CODES.O)
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {

(getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager?).also { manager ->
kotlin.runCatching {
if (manager?.getNotificationChannel(CHANNEL_ID) == null) manager?.createNotificationChannel(
NotificationChannel(CHANNEL_ID, CHANNEL_NAME, NotificationManager.IMPORTANCE_HIGH).apply {
description = CHANNEL_DESC
}
)
}
}

startForeground(ID, NotificationCompat.Builder(this, CHANNEL_ID).apply {
color = ContextCompat.getColor(applicationContext, R.color.colorNotificationBack)
setColorized(true)
setSmallIcon(R.mipmap.ic_launcher)
setStyle(NotificationCompat.DecoratedCustomViewStyle())
setContentTitle(NOTIFY_TITLE)
setContentText(NOTIFY_TEXT)
}.build())

return START_NOT_STICKY
}

override fun onCreate() {
super.onCreate()
mirroringUsecase = MirroringUsecase(this)
mirroringUsecase.start()
registerReceiver(configChangeBroadcastReciver, IntentFilter("android.intent.action.CONFIGURATION_CHANGED"))
}

override fun onDestroy() {
unregisterReceiver(configChangeBroadcastReciver)
mirroringUsecase.stop()
super.onDestroy()
}

private val configChangeBroadcastReciver = object: BroadcastReceiver(){
override fun onReceive(context: Context?, intent: Intent?) {
mirroringUsecase.restart()
}
}

companion object {
const val ID = 1
const val CHANNEL_ID = "mirrorForeground"
const val CHANNEL_NAME = "ミラーリング"
const val CHANNEL_DESC = "録画するよ"
const val NOTIFY_TITLE = "ScreenMirror"
const val NOTIFY_TEXT = "ミラーリング中だよ"

val start: (Context) -> Unit = {
val intent = Intent(it, MirroringService::class.java)
if (Build.VERSION.SDK_INT >= 26) it.startService(intent)
else it.startService(intent)
}

val stop: (Context) -> Unit = {
val intent = Intent(it, MirroringService::class.java)
it.stopService(intent)
}
}
}



ミラーリング開始


Xxxx.kt

MirroringService.start(context)



  • ActivityでもFragmentでもどこでもいいので作ったサービスを起動する。


ミラーリング終了


Xxxx.kt

MirroringService.stop(context)



【実装編: Electron】

全体的なフローはこんな感じ

Electronaaaaaaaaa.png

今回は省略しますが、使えるローカルアドレスをQRで表示しておくと

AndroidアプリからWebSocketに接続するときのアドレスがスムーズに渡せれるのでお勧めです。

jquery.qrcode.jsを使用しました)


Electronのインストール

「はじめての Electron アプリ」 を参考にElectronでデスクトップアプリを動かせるようにする。

(Typescript用に webpack.config.js も作成したので必要なら使ってください)


WebSocket(Server)を実装

src: Websocket.ts


Websocket.ts

import WebSocket from 'ws';

const server = new WebSocket.Server({ port: 8080 });
let tempMessage = '';

export function startWebsocket() {
server.on('connection', ws => {
ws.on('message', message => {
if (message == 'polling') {
if (tempMessage != '') ws.send(tempMessage);
return;
}
tempMessage = message;
});
});
}



アプリ開始時にWebSocket(Server)を起動させる

src: main.ts


main.ts

app.on('ready', () => {

+ startWebsocket();



ウェブページを作成

src: index.html


index.html

<html>

<head>
<base href="./" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self' data: gap: ws: blob: http://localhost:* 'unsafe-inline'; " />
<link href="./style.css" rel="stylesheet" />
</head>
<body>
<img id="image" />
<script type="text/javascript" src="renderer.js"></script>
</body>
</html>


WebSocket(Client)を実装

src: renderer.ts


render.ts

const image = document.getElementById('image');

class WebSocketClient {
private socket = null;
private timer = null;

open(url) {
this.close();
this.socket = new WebSocket(url);
this.socket.addEventListener('open', e => {
console.log('open');
this.timer = setInterval(() => this.socket.send('polling'), 60);
});
this.socket.addEventListener('message', e => (image.src = URL.createObjectURL(e.data)));
this.socket.addEventListener('close', e => console.log('close', e));
this.socket.addEventListener('error', e => console.log('error', e));
}

close() {
clearInterval(this.timer);
if (this.socket) this.socket.close();
}
}

new WebSocketClient().open(`ws://localhost:8080`);



最後に動作確認

アプリを同時に起動させればミラーリング出来ているかと思います!

SnapCrab_NoName_2019-10-7_1-45-45_No-00.png


Oculus Quest でも動作確認

SideQuest を使ってOculus Questで動かしてみた。

SnapCrab_screen-mirror-app_2019-10-6_14-18-4_No-00.png

お見事!


まとめ

何となく手探りで作ってみましたが、意外と形になりました。

パフォーマンス面は不安がまだまだあるので、これからも試行錯誤していきたいと思います。

OculusQuestのキャストが不安定で何とかならんかな~って思い立ったのがきっかけで作り始めたので動作確認まで出来て良かったです。


ネイティブAndroid もフル活用して Good な VR Life を!