🪞

[MetaQuest3]Mirroringアプリ(スクショを共有し続ける送信アプリ)作成してみた。(失敗談)

2024/08/16に公開3

経緯

とある案件で、HMDでみている映像を取得して不正な使用がないかを確認したい。との要件があったため、その検証でミラーリング機能を実装したアプリを作ることになりました。
結論できなかったのですが、失敗で終わるだけじゃあれなので、今後どなたかの参考になればと記事にしました。

簡易な要件

  • 見ている画面すべてを送りたい。

  • バックグラウンドで実行できるものがよいはず。

    • Unity/UE/OpenXRは無理かと思う
    • おそらくOSまで触るのかなと思ったのでKotlinを選びました。
      • 実際にはMediaProjectionAPIを使用する予定でした。
  • 今回は検証用なのでローカルネットワーク下です。

使用した言語・ツール

  • デスクトップアプリ
    • Electron
    • TypeScript
    • JavaScript
    • Node
  • 端末側(Android/Meta側)
    • Kotlin
  • その他
    • AndroidStudio
    • Meta Developer Hub

実装編


デスクトップアプリ

  • 検証用なのでWebsocketを受け付けて、画像がきたら更新するみたいな簡易なものです。
  • 長くなるのでinstall手順などは省きます。

本来うまくいけばsocketioとws比較したかったのですが、今回はwsのみしか使用していません。

package.json

{
  "name": "recv_mirror",
  "version": "1.0.0",
  "main": "dist/main.js",
  "scripts": {
    "start": "tsc && node copy-html.js && electron .",
    "build": "tsc && node copy-html.js"
  },
  "devDependencies": {
    "@types/express": "^4.17.13",
    "@types/node": "^16.0.0",
    "@types/socket.io": "^3.0.2",
    "@types/ws": "^8.5.11",
    "electron": "^24.0.0",
    "ts-node": "^10.2.1",
    "typescript": "^4.3.5"
  },
  "dependencies": {
    "axios": "^1.7.2",
    "express": "^4.17.1",
    "socket.io": "^4.1.3",
    "winston": "^3.13.1",
    "ws": "^8.18.0"
  }
}
  • 主にサーバー立ち上げとws接続系を記載しています。
main.ts

import { app, BrowserWindow, ipcMain } from 'electron';
import * as path from 'path';
import express from 'express';
import { createServer } from 'http';
import WebSocket, { Server } from 'ws';
import * as fs from 'fs';
import winston from 'winston';

// ロガーの設定
const logger = winston.createLogger({
  level: 'info',
  format: winston.format.json(),
  transports: [
    new winston.transports.Console(),
    new winston.transports.File({ filename: 'combined.log' })
  ]
});

// Expressサーバーのセットアップ
const server = express();
const httpServer = createServer(server);
const wss = new Server({ server: httpServer });

let mainWindow: BrowserWindow;

function createWindow() {
  mainWindow = new BrowserWindow({
    width: 800,
    height: 600,
    webPreferences: {
      preload: path.join(__dirname, 'preload.js'),
      contextIsolation: true,
      nodeIntegration: false
    }
  });

  mainWindow.loadFile(path.join(__dirname, 'index.html'));
}

app.on('ready', () => {
  createWindow();
  httpServer.listen(3000, () => {
    logger.info('Express server listening on port 3000');
  });
});

app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') {
    app.quit();
  }
});

app.on('activate', () => {
  if (BrowserWindow.getAllWindows().length === 0) {
    createWindow();
  }
});

// WebSocket接続の処理
wss.on('connection', (ws: WebSocket) => {
  logger.info('New client connected');
  
  ws.on('message', (message: WebSocket.RawData) => {
    const imagePath = path.join(__dirname, 'received_image.png');
    const buffer = Buffer.isBuffer(message) ? message : Buffer.from(new Uint8Array(message as ArrayBuffer));
    fs.writeFileSync(imagePath, buffer);
    logger.info(`Image received and saved as ${imagePath}`);
    mainWindow.webContents.send('image', imagePath);
  });

  ws.on('close', (code: number, reason: Buffer) => {
    logger.info(`Client disconnected: ${code} ${reason}`);
  });

  ws.on('error', (error: Error) => {
    logger.error(`WebSocket error: ${error}`);
  });
});

// エクスプレスサーバーのルートを設定(必要に応じて追加)
server.get('/', (req, res) => {
  res.send('Hello World!');
});


preload.ts

const { contextBridge, ipcRenderer } = require('electron');

contextBridge.exposeInMainWorld('electron', {
  receiveImage: (callback: (event: any, imagePath: string) => void) => {
    console.log('Setting up IPC receiver for images');
    ipcRenderer.on('image', (event, imagePath) => {
      console.log(`Received image path in preload: ${imagePath}`);
      callback(event, imagePath);
    });
  }
});


index.html


<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Screen Capture Receiver</title>
</head>
<body>
    <h1>Screen Capture Receiver</h1>
    <img id="receivedImage" alt="Received Image" style="width: 100%; height: auto;" />
    <script src="renderer.js"></script>
</body>
</html>

  • imageの更新をしています。
renderer.ts

"use strict";
window.electron.receiveImage((event, imagePath) => {
    console.log(`Received image path in renderer: ${imagePath}`);
    const img = document.getElementById('receivedImage');
    if (img) {
        img.src = `${imagePath}?timestamp=${new Date().getTime()}`;
        console.log('Image updated on screen');
    }
    else {
        console.error('Image element not found');
    }
});



デバイス側

Logging系/layoutなどは省いています。

Android.xml


<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    package="com.example.mirroring">

    <!-- パーミッションの追加 -->
    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
    <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
    <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
    <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />

    <application
        android:name=".Logging"
        android:icon="@drawable/ic_launcher_foreground"
        android:label="@string/app_name"
        android:roundIcon="@drawable/ic_launcher_foreground"
        android:supportsRtl="true"
        android:theme="@style/Theme.Mirroring"
        android:usesCleartextTraffic="true">

        <activity
            android:name=".MainActivity"
            android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
        <service
            android:name=".TestWebSocketService"
            android:exported="true" />
        <service
            android:name=".ScreenCaptureService"
            android:exported="true"
            android:foregroundServiceType="mediaProjection"
            tools:ignore="ForegroundServicePermission" />
    </application>
</manifest>

MainActivity.kt

package com.example.mirroring

import android.app.Activity
import android.content.Intent
import android.media.projection.MediaProjectionManager
import android.os.Bundle
import android.widget.Button
import androidx.appcompat.app.AppCompatActivity
import timber.log.Timber

class MainActivity : AppCompatActivity() {
    private lateinit var startCaptureButton: Button
    private lateinit var startTestButton: Button
    private lateinit var projectionManager: MediaProjectionManager
    private val REQUEST_CODE = 1000

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        startCaptureButton = findViewById(R.id.startCaptureButton)
        startTestButton = findViewById(R.id.startTestButton)
        projectionManager = getSystemService(MEDIA_PROJECTION_SERVICE) as MediaProjectionManager

        startCaptureButton.setOnClickListener {
            Timber.d("Requesting screen capture permission")
            requestScreenCapturePermission()
        }

        startTestButton.setOnClickListener {
            Timber.d("Start Test Button Clicked")
            val intent = Intent(this, TestWebSocketService::class.java)
            startForegroundService(intent)
        }

        Timber.plant(Timber.DebugTree())
        Timber.d("MainActivity created")
    }

    private fun requestScreenCapturePermission() {
        val captureIntent = projectionManager.createScreenCaptureIntent()
        startActivityForResult(captureIntent, REQUEST_CODE)
    }

    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        super.onActivityResult(requestCode, resultCode, data)
        if (requestCode == REQUEST_CODE) {
            if (resultCode == Activity.RESULT_OK && data != null) {
                Timber.d("Screen capture permission granted")
                val intent = Intent(this, ScreenCaptureService::class.java)
                intent.putExtra(ScreenCaptureService.EXTRA_RESULT_CODE, resultCode)
                intent.putExtra(ScreenCaptureService.EXTRA_DATA, data)
                startForegroundService(intent)
            } else {
                Timber.e("Screen capture permission not granted. resultCode: $resultCode")
                // 条件が失敗した場合に、数秒後に再試行
                startCaptureButton.postDelayed({
                    requestScreenCapturePermission()
                }, 2000)
            }
        }
    }
}

ScreenCaputureService.kt

package com.example.mirroring

import android.app.*
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.PixelFormat
import android.graphics.Rect
import android.hardware.display.DisplayManager
import android.hardware.display.VirtualDisplay
import android.media.ImageReader
import android.media.projection.MediaProjection
import android.media.projection.MediaProjectionManager
import android.os.Build
import android.os.Handler
import android.os.HandlerThread
import android.os.IBinder
import android.util.DisplayMetrics
import android.view.WindowManager
import androidx.core.app.NotificationCompat
import kotlinx.coroutines.*
import org.java_websocket.client.WebSocketClient
import org.java_websocket.handshake.ServerHandshake
import timber.log.Timber
import java.io.ByteArrayOutputStream
import java.net.URI
import java.nio.ByteBuffer

class ScreenCaptureService : Service() {
    private lateinit var projectionManager: MediaProjectionManager
    private var mediaProjection: MediaProjection? = null
    private var virtualDisplay: VirtualDisplay? = null
    private var imageReader: ImageReader? = null
    private lateinit var handler: Handler
    private var webSocketClient: WebSocketClient? = null
    private lateinit var serverUri: URI

    override fun onCreate() {
        super.onCreate()
        projectionManager = getSystemService(MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
        val handlerThread = HandlerThread("ImageCaptureHandler")
        handlerThread.start()
        handler = Handler(handlerThread.looper)

        startForegroundService()
    }

    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        val resultCode = intent?.getIntExtra(EXTRA_RESULT_CODE, Activity.RESULT_CANCELED)
        val data = intent?.getParcelableExtra<Intent>(EXTRA_DATA)
        val ipAddress = "IPアドレス"
        val port = "3000"

        if (resultCode == Activity.RESULT_OK && data != null && ipAddress != null && port != null) {
            mediaProjection = projectionManager.getMediaProjection(resultCode, data)
            serverUri = URI("ws://$ipAddress:$port")
            initializeWebSocket()
            initializeImageReader()
            startScreenCapture()
        }

        return START_STICKY
    }

    private fun initializeWebSocket() {
        Timber.d("Attempting to connect to WebSocket at $serverUri")
        webSocketClient = object : WebSocketClient(serverUri) {
            override fun onOpen(handshakedata: ServerHandshake?) {
                Timber.d("WebSocket opened")
            }

            override fun onMessage(message: String?) {
                Timber.d("Received message: $message")
            }

            override fun onClose(code: Int, reason: String?, remote: Boolean) {
                Timber.d("WebSocket closed: $reason")
                Timber.d("WebSocket close code: $code")
            }

            override fun onError(ex: Exception?) {
                Timber.e(ex, "WebSocket error")
                ex?.printStackTrace()
            }
        }
        webSocketClient?.connect()
    }

    private fun startForegroundService() {
        val notificationChannelId = "SCREEN_CAPTURE_CHANNEL"
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            val channel = NotificationChannel(
                notificationChannelId,
                "Screen Capture Service",
                NotificationManager.IMPORTANCE_DEFAULT
            )
            val manager = getSystemService(NotificationManager::class.java)
            manager.createNotificationChannel(channel)
        }

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

        val notification = NotificationCompat.Builder(this, notificationChannelId)
            .setContentTitle("Screen Capture Service")
            .setContentText("Capturing screen in the background")
            .setSmallIcon(R.drawable.ic_launcher_foreground)
            .setContentIntent(pendingIntent)
            .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
            .setCategory(NotificationCompat.CATEGORY_SERVICE)
            .setOngoing(true)
            .build()

        startForeground(1, notification)
    }

    private fun initializeImageReader() {
        val windowManager = getSystemService(WINDOW_SERVICE) as WindowManager
        val displayMetrics = DisplayMetrics()
        @Suppress("DEPRECATION")
        windowManager.defaultDisplay.getMetrics(displayMetrics)
        val width = displayMetrics.widthPixels
        val height = displayMetrics.heightPixels

        imageReader = ImageReader.newInstance(width, height, PixelFormat.RGBA_8888, 2)
        imageReader?.setOnImageAvailableListener({ reader ->
            val image = reader.acquireLatestImage()
            image?.let {
                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)

                val baos = ByteArrayOutputStream()
                val compressed = bitmap.compress(Bitmap.CompressFormat.PNG, 100, baos)
                val imageBytes = baos.toByteArray()

                if (compressed) {
                    Timber.d("Compressed image size: ${imageBytes.size} bytes")
                    GlobalScope.launch(Dispatchers.IO) {
                        Timber.d("Sending image data via WebSocket")
                        webSocketClient?.send(imageBytes)
                    }
                } else {
                    Timber.e("Failed to compress bitmap")
                }
                image.close()
            }
        }, handler)
    }

    private fun startScreenCapture() {
        val windowManager = getSystemService(WINDOW_SERVICE) as WindowManager
        val width: Int
        val height: Int
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
            val bounds: Rect = windowManager.currentWindowMetrics.bounds
            width = bounds.width()
            height = bounds.height()
        } else {
            val displayMetrics = DisplayMetrics()
            @Suppress("DEPRECATION")
            windowManager.defaultDisplay.getMetrics(displayMetrics)
            width = displayMetrics.widthPixels
            height = displayMetrics.heightPixels
        }

        virtualDisplay = mediaProjection?.createVirtualDisplay(
            "ScreenCapture",
            width, height, DisplayMetrics.DENSITY_DEFAULT,
            DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
            imageReader?.surface, null, handler
        )

        handler.post(captureAndSendRunnable)
    }

    private val captureAndSendRunnable = object : Runnable {
        override fun run() {
            // 5秒ごとにキャプチャ
            handler.postDelayed(this, 5000)
        }
    }

    override fun onDestroy() {
        super.onDestroy()
        virtualDisplay?.release()
        mediaProjection?.stop()
        webSocketClient?.close()
    }

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

    companion object {
        const val EXTRA_RESULT_CODE = "EXTRA_RESULT_CODE"
        const val EXTRA_DATA = "EXTRA_DATA"
        const val EXTRA_IP_ADDRESS = "EXTRA_IP_ADDRESS"
        const val EXTRA_PORT = "EXTRA_PORT"
        private const val TAG = "ScreenCaptureService"
    }
}


検証


Androidデバイスでの検証

無事表示できました。画像も更新されています。仮想デバイスはPixel8のAPI32にしました。(Quest3と同等)
共有前

共有開始後


MetaQuest3での検証

こちらはWebSocketはうまくいったのですが、権限許可を求めるものが出てこなかったです。
Kotlin触ったことなかったので調べながらいろいろ試行錯誤したがどうやら、MediaProjectionが禁止されているような?
Forumで調べてみると、

persist.device_config.oculus_shared_os_services.vros_enable_media_projection

というPropertyが無効になっているようだった。

ただ気になる点としてadbで接続するとこのProperty自体が存在してないようだった。(存在していない場合デフォでfalseなのか??)


$ adb -s 2G0YC1ZF9X04JC shell getprop | grep persist.device_config.oculus
[persist.device_config.oculus_shared_os_services.oculus_jemalloc_performance]: [true]
[persist.device_config.oculus_shared_os_services.xros_audio_aaudio_mmap]: [false]
[persist.device_config.oculus_shared_os_services.xros_audio_aaudio_mmap_capture]: [false]

Metaのソリューションエンジニアの方にも聞いてみましたが、無効なままのようで、一応社内検討はされているような形でした。

結論

パススルーしたときの扱いなどからセキュリティの観点から難しいとは思うのですが、今後こう言った要件は広がると思うので手段の一つとして、メタの方よろしくお願いします。

今後別方向からのアプローチとしてadb接続で取得することを検証のもありかなと思っています。
もしくはアップデートで許可がされるまで全体を取得(アプリ外の映像の取得)をあきらめるになるかなと思います。
何か情報ある方はぜひ教えていただきたい限りです。

ではまた。

参考文献

VideogLabo

Discussion

liveasnotesliveasnotes

自分のメモも兼ねて共有させていただきます。

すでに把握されているかもしれませんが、アプリ内ブラウザで公式のキャストを開いて画像認識を行うことは可能です。外部ネットワーク経由で特定アプリの使用状況を把握する用途なら、こちらのほうがユーザーの手間が少ないかもしれません。Meta が今後もこの仕様を残すかは気になります。
e.g. https://www.uploadvr.com/quest-3-raw-camera-access-workaround-found/

アプリ内ブラウザである必要がなければ,映像受信用の PC 上でオプション引数でプロファイルを指定して Chrome を複数起動するのも良いと思います。また、こちらのほうが Meta の動向に影響されにくいとは思います。
cf. https://phst.hateblo.jp/entry/2022/08/16/080000

ADB も複数起動できるようなので、USB接続が問題にならなければ、こちらのほうがシンプルに実装できるかもしれません。
cf. https://developer.android.com/tools/adb#directingcommands
e.g. https://yumeno.me/androidadb

画面共有に重点を置くなら scrcpy のほうがよりシンプルかもしれません。こちらも接続先を指定できます。cf. https://github.com/Genymobile/scrcpy/blob/master/doc/connection.md

ちなみに Meta Quest の OS は Android 12 以降をベースとしていることが推定されます。
cf. https://developer.oculus.com/blog/meta-quest-apps-android-12l-june-30/
Android 11 以降では USB 接続なしで ADB 接続できますが、実際に Quest 上でも Wireless ADB をアプリ内から有効化できた事例があるようです。
e.g. https://github.com/thedroidgeek/oculus-wireless-adb
直近の更新がないので最新のOSでどう動くかは不明です。個人的には Unity 製アプリに組み込めるアセットが出てきてくれると嬉しいです。

現時点でのキャスト利用時の留意点は、本体上での録画処理と衝突することです。PC 側から Quest の視界を見ているときに Quest 上で録画を開始すると PC 上での記録処理が止まったり、何も表示されない動画が出来たりします。エラーを検知してリトライする処理を入れる、利用者に十分な注意喚起をする、録画処理の開始・停止を PC 側に命令できる仕組みを導入し、そちらを使うように誘導する、などの対応が考えられます。

nomshonomsho

コメント&情報を共有していただき、ありがとうございます。

現時点でのキャスト利用時の留意点は、本体上での録画処理と衝突することです。PC 側から Quest の視界を見ているときに Quest 上で録画を開始すると PC 上での記録処理が止まったり、何も表示されない動画が出来たりします。エラーを検知してリトライする処理を入れる、利用者に十分な注意喚起をする、録画処理の開始・停止を PC 側に命令できる仕組みを導入し、そちらを使うように誘導する、などの対応が考えられます。

この仕様は全く知らなかったので大変助かります。録画とキャストの競合問題については、確かにユーザーエクスペリエンスに大きな影響を与える可能性があるため改善措置を考えなくてはいけないように思います。

ADB も複数起動できるようなので、USB接続が問題にならなければ、こちらのほうがシンプルに実装できるかもしれません。
cf. https://developer.android.com/tools/adb#directingcommands
e.g. https://yumeno.me/androidadb

画面共有に重点を置くなら scrcpy のほうがよりシンプルかもしれません。こちらも接続先を指定できます。cf. https://github.com/Genymobile/scrcpy/blob/master/doc/connection.md
ちなみに Meta Quest の OS は Android 12 以降をベースとしていることが推定されます。
cf. https://developer.oculus.com/blog/meta-quest-apps-android-12l-june-30/
Android 11 以降では USB 接続なしで ADB 接続できますが、実際に Quest 上でも Wireless ADB をアプリ内から有効化できた事例があるようです。
e.g. https://github.com/thedroidgeek/oculus-wireless-adb
直近の更新がないので最新のOSでどう動くかは不明です。個人的には Unity 製アプリに組み込めるアセットが出てきてくれると嬉しいです。

私のほうでは今回の取り組みの続きとしてADBを使用した何かを検証していこうかなと考えています。さすがにADBなどは禁止しないだろうと思うので、、、