The open source OpenXR runtime

auxiliary: add android_cardboard

Co-authored-by: Rylie Pavlik <rylie.pavlik@collabora.com>
Co-authored-by: Korcan Hussein <korcan.hussein@collabora.com>
Part-of: <https://gitlab.freedesktop.org/monado/monado/-/merge_requests/2560>

authored by Simon Zeni Rylie Pavlik Korcan Hussein and committed by Korcan Hussein b51bbed0 2f957239

Changed files
+452
.reuse
src
xrt
auxiliary
android
src
main
java
org
freedesktop
monado
auxiliary
android_cardboard
+5
.reuse/dep5
··· 136 136 Copyright: No copyright 137 137 License: CC0-1.0 138 138 Comment: Computer-generated files do not have a copyright per US law. 139 + 140 + Files: src/xrt/auxiliary/android_cardboard/src/main/res/drawable/cardboard_oss_qr.png 141 + Copyright: 2019 Google LLC 142 + License: Apache-2.0 143 + Comment: SPDX-License-Identifier missing.
+3
build.gradle
··· 27 27 28 28 // ktfmt version 29 29 ktfmtVersion = "0.53" 30 + 31 + // Google Cardboard QR Code scan 32 + barcodeScannerViewVersion = "1.6.4" 30 33 } 31 34 repositories { 32 35 google()
+13
settings.gradle
··· 17 17 repositories { 18 18 google() 19 19 mavenCentral() 20 + maven { url 'https://jitpack.io' } 20 21 } 21 22 } 22 23 23 24 24 25 rootProject.name = 'monado' 25 26 27 + // Typically required by all Monado-based runtimes on AOSP platforms 26 28 include ':src:xrt:auxiliary' 27 29 project(':src:xrt:auxiliary').projectDir = new File(rootDir, 'src/xrt/auxiliary/android') 28 30 31 + // Only used by Google Cardboard support in the installable Monado 32 + include ':src:xrt:auxiliary:cardboard' 33 + project(':src:xrt:auxiliary:cardboard').projectDir = new File(rootDir, 'src/xrt/auxiliary/android_cardboard') 34 + 35 + // Required for out-of-process builds 29 36 include ':src:xrt:ipc' 30 37 project(':src:xrt:ipc').projectDir = new File(rootDir, 'src/xrt/ipc/android') 31 38 39 + // Typically required by all Monado-based runtimes on AOSP platforms 40 + // Use dependency injection to customize 32 41 include ':src:xrt:targets:android_common' 42 + 43 + // End-level (leaf) target - builds on android_common with dependency injection 44 + // to make an installable APK providing a runtime with a Cardboard-like experience 45 + // by default. 33 46 include ':src:xrt:targets:openxr_android'
+26
src/xrt/auxiliary/android/src/main/java/org/freedesktop/monado/auxiliary/ScannerProvider.kt
··· 1 + // Copyright 2025, Collabora, Ltd. 2 + // SPDX-License-Identifier: BSL-1.0 3 + /*! 4 + * @file 5 + * @brief Interface for target-specific scanner-related things on Android. 6 + * @author Simon Zeni <simon.zeni@collabora.com> 7 + * @ingroup aux_android 8 + */ 9 + 10 + package org.freedesktop.monado.auxiliary 11 + 12 + import android.content.Intent 13 + import android.graphics.drawable.Drawable 14 + 15 + /** 16 + * Scanner activity. This interface must be provided by any Android "XRT Target". 17 + * 18 + * Intended for use in dependency injection. 19 + */ 20 + interface ScannerProvider { 21 + /** Get a drawable icon for use the UI, for the runtime/Monado-incorporating target. */ 22 + fun getIcon(): Drawable 23 + 24 + /** Make a {@code Intent} to launch a scanner activity, if provided by the target. */ 25 + fun makeScannerIntent(): Intent 26 + }
+71
src/xrt/auxiliary/android_cardboard/build.gradle
··· 1 + // Copyright 2020-2025, Collabora, Ltd. 2 + // SPDX-License-Identifier: BSL-1.0 3 + 4 + plugins { 5 + id 'com.android.library' 6 + id 'kotlin-android' 7 + id 'com.diffplug.spotless' 8 + } 9 + 10 + android { 11 + compileSdk project.sharedCompileSdk 12 + buildToolsVersion = buildToolsVersion 13 + 14 + defaultConfig { 15 + minSdkVersion 24 16 + targetSdkVersion project.sharedTargetSdk 17 + } 18 + 19 + buildTypes { 20 + release { 21 + minifyEnabled false 22 + // Gradle plugin produces proguard-android-optimize.txt from @Keep annotations 23 + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 24 + } 25 + } 26 + 27 + compileOptions { 28 + sourceCompatibility JavaVersion.VERSION_17 29 + targetCompatibility JavaVersion.VERSION_17 30 + } 31 + kotlinOptions { 32 + jvmTarget = JavaVersion.VERSION_17 33 + } 34 + packagingOptions { 35 + resources { 36 + excludes += ['META-INF/*.kotlin_module'] 37 + } 38 + } 39 + namespace 'org.freedesktop.monado.auxiliary.cardboard' 40 + lint { 41 + fatal 'StopShip' 42 + } 43 + buildFeatures { 44 + viewBinding true 45 + } 46 + } 47 + 48 + dependencies { 49 + implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion" 50 + implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion" 51 + implementation "androidx.constraintlayout:constraintlayout:$androidxConstraintLayoutVersion" 52 + implementation "com.google.android.material:material:$materialVersion" 53 + implementation "com.github.markusfisch:BarcodeScannerView:$barcodeScannerViewVersion" 54 + implementation 'com.google.dagger:hilt-android:2.51.1' 55 + } 56 + 57 + spotless { 58 + java { 59 + target 'src/main/java/**/*.java' 60 + // apply a specific flavor of google-java-format. 61 + googleJavaFormat('1.18.1').aosp().reflowLongStrings() 62 + // fix formatting of type annotations. 63 + formatAnnotations() 64 + } 65 + 66 + kotlin { 67 + target 'src/main/java/**/*.kt' 68 + // Use ktfmt(https://github.com/facebook/ktfmt) as the default Kotlin formatter. 69 + ktfmt("$ktfmtVersion").kotlinlangStyle() 70 + } 71 + }
+4
src/xrt/auxiliary/android_cardboard/proguard-rules.pro
··· 1 + # Copyright 2020, Collabora, Ltd. 2 + # SPDX-License-Identifier: BSL-1.0 3 + # see http://developer.android.com/guide/developing/tools/proguard.html 4 + # Trying to keep most of them in source code annotations and let Gradle do the work
+23
src/xrt/auxiliary/android_cardboard/src/main/AndroidManifest.xml
··· 1 + <?xml version="1.0" encoding="utf-8"?> 2 + <manifest xmlns:android="http://schemas.android.com/apk/res/android"> 3 + <!-- 4 + Copyright 2020-2025, Collabora, Ltd. 5 + SPDX-License-Identifier: BSL-1.0 6 + --> 7 + 8 + <!-- Cardboard QR code scanner. --> 9 + <uses-permission android:name="android.permission.INTERNET"/> 10 + <uses-permission android:name="android.permission.CAMERA"/> 11 + <uses-feature android:name="android.hardware.camera"/> 12 + <uses-feature android:name="android.hardware.camera.autofocus" 13 + android:required="false"/> 14 + 15 + <application 16 + android:requestLegacyExternalStorage="true"> 17 + <activity android:name=".QrScannerActivity" 18 + android:exported="true" 19 + android:theme="@style/Theme.AppCompat.NoActionBar" 20 + android:label="Monado"/> 21 + </application> 22 + 23 + </manifest>
+129
src/xrt/auxiliary/android_cardboard/src/main/java/org/freedesktop/monado/auxiliary/cardboard/CardboardParamUtils.kt
··· 1 + // Copyright 2024-2025, Collabora, Ltd. 2 + // SPDX-License-Identifier: BSL-1.0 3 + /*! 4 + * @file 5 + * @brief Utilities for Google Cardboard codes. 6 + * @author Simon Zeni <simon.zeni@collabora.com> 7 + * @author Rylie Pavlik <rylie.pavlik@collabora.com> 8 + */ 9 + 10 + package org.freedesktop.monado.auxiliary.cardboard 11 + 12 + import android.content.Context 13 + import android.net.Uri 14 + import android.util.Base64 15 + import android.util.Log 16 + import androidx.core.net.toUri 17 + import java.io.File 18 + import java.io.FileOutputStream 19 + import java.net.HttpURLConnection 20 + import java.net.URL 21 + 22 + private const val TAG = "CardboardParamUtils" 23 + 24 + @Suppress("SpellCheckingInspection") 25 + private const val cardboardV1ParamValue: String = 26 + "CgxHb29nbGUsIEluYy4SDENhcmRib2FyZCB2MR0xCCw9JY_CdT0qEAAAIEIAACBCAAAgQgAAIEJYADUpXA89OgiuR-E-mpkZPlABYAE" 27 + private const val googleDomain = "google.com" 28 + 29 + private const val cardboardConfigPath = "/cardboard/cfg" 30 + 31 + private const val cardboardV1EquivUri = 32 + "https://$googleDomain$cardboardConfigPath?p=$cardboardV1ParamValue" 33 + 34 + /** Cardboard V1 had a QR code with no parameters encoded. */ 35 + fun isOriginalCardboard(uri: Uri): Boolean { 36 + return uri.authority.equals("g.co") && uri.path.equals("/cardboard") 37 + } 38 + 39 + /** Newer Cardboard-compatible devices have parameters encoded in a URI (eventually) */ 40 + fun isCardboardParamUri(uri: Uri): Boolean { 41 + return uri.authority.equals(googleDomain) && uri.path.equals(cardboardConfigPath) 42 + } 43 + 44 + /** Cardboard URIs include those with params and the original human-targeted Cardboard V1 URI. */ 45 + fun isCardboardUri(uri: Uri): Boolean { 46 + return isOriginalCardboard(uri) || isCardboardParamUri(uri) 47 + } 48 + 49 + /** 50 + * Some QR codes are missing the scheme. Further, we do not want to visit plain http URIs looking 51 + * for a redirect, therefore we replace the scheme if applicable. 52 + */ 53 + private fun sanitizeAndParseUri(value: String): Uri { 54 + return if (!value.startsWith("http")) { 55 + // missing protocol 56 + "https://$value".toUri() 57 + } else if (value.startsWith("http://")) { 58 + // non-https 59 + value.replaceFirst("http", "https").toUri() 60 + } else { 61 + value.toUri() 62 + } 63 + } 64 + 65 + /** Save current_device_params with approximate Cardboard V1 params */ 66 + fun saveCardboardV1Params(qrScannerActivity: QrScannerActivity) { 67 + saveCardboardParamsFromResolvedUri(qrScannerActivity, cardboardV1EquivUri.toUri()) 68 + } 69 + 70 + /** Save current_device_params from a Cardboard param URI. */ 71 + fun saveCardboardParamsFromResolvedUri(context: Context, uri: Uri) { 72 + assert(isCardboardParamUri(uri)) 73 + val paramEncoded = uri.getQueryParameter("p") 74 + val flags = Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING 75 + val data = Base64.decode(paramEncoded, flags) 76 + 77 + val configFile = File(context.filesDir, "current_device_params") 78 + configFile.createNewFile() 79 + 80 + Log.d(TAG, "Saving to file $configFile") 81 + FileOutputStream(configFile).use { it.write(data) } 82 + } 83 + 84 + /** 85 + * Given some data from a QR Code, try to get to a Cardboard URI: either the original Cardboard web 86 + * site, or a parameter URI. 87 + * 88 + * @return null if we could not get to a Cardboard URI even after several redirects. 89 + */ 90 + fun qrCodeValueToCardboardURI(value: String): Uri? { 91 + 92 + Log.i(TAG, "QR code scan data $value") 93 + 94 + var uri = sanitizeAndParseUri(value) 95 + 96 + Log.i(TAG, "QR code URL $uri") 97 + 98 + // Try fetching the URL, following redirects, repeatedly. 99 + for (i in 0..5) { 100 + if (isCardboardUri(uri)) { 101 + // got one! 102 + break 103 + } 104 + val connection = URL(uri.toString()).openConnection() as HttpURLConnection 105 + connection.setRequestMethod("HEAD") 106 + connection.instanceFollowRedirects = false 107 + connection.connect() 108 + val code = connection.getResponseCode() 109 + if ( 110 + code == HttpURLConnection.HTTP_MOVED_PERM || code == HttpURLConnection.HTTP_MOVED_TEMP 111 + ) { 112 + // Sanitize: we only consider visiting https, for privacy 113 + uri = sanitizeAndParseUri(connection.getHeaderField("Location")) 114 + 115 + Log.i(TAG, "followed url to $uri") 116 + } else { 117 + // was not a redirect, all done 118 + break 119 + } 120 + } 121 + 122 + Log.i(TAG, "ending with url $uri") 123 + 124 + if (!isCardboardUri(uri)) { 125 + Log.e(TAG, "url is not cardboard config") 126 + return null 127 + } 128 + return uri 129 + }
+120
src/xrt/auxiliary/android_cardboard/src/main/java/org/freedesktop/monado/auxiliary/cardboard/QrScannerActivity.kt
··· 1 + // Copyright 2024-2025, Collabora, Ltd. 2 + // SPDX-License-Identifier: BSL-1.0 3 + /*! 4 + * @file 5 + * @brief QR code scanner activity for Google Cardboard codes. 6 + * @author Simon Zeni <simon.zeni@collabora.com> 7 + * @author Rylie Pavlik <rylie.pavlik@collabora.com> 8 + */ 9 + 10 + package org.freedesktop.monado.auxiliary.cardboard 11 + 12 + import android.Manifest 13 + import android.content.pm.PackageManager 14 + import android.os.Bundle 15 + import android.util.Log 16 + import android.widget.Toast 17 + import androidx.appcompat.app.AppCompatActivity 18 + import androidx.core.app.ActivityCompat 19 + import androidx.core.content.ContextCompat 20 + import de.markusfisch.android.barcodescannerview.widget.BarcodeScannerView 21 + import org.freedesktop.monado.auxiliary.cardboard.databinding.ActivityScannerBinding 22 + 23 + class QrScannerActivity : AppCompatActivity() { 24 + private lateinit var viewBinding: ActivityScannerBinding 25 + private lateinit var scannerView: BarcodeScannerView 26 + 27 + companion object { 28 + private const val TAG = "MonadoQrScanner" 29 + private const val PERMISSION_REQUEST_CODE = 200 30 + } 31 + 32 + private fun tryGetPermissions(): Boolean { 33 + 34 + if ( 35 + ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == 36 + PackageManager.PERMISSION_GRANTED 37 + ) { 38 + Log.i(TAG, "Camera permission has been granted") 39 + return true 40 + } 41 + Log.i(TAG, "Camera permission has not been granted, requesting") 42 + val permissionStrings = arrayOf(Manifest.permission.CAMERA) 43 + ActivityCompat.requestPermissions(this, permissionStrings, PERMISSION_REQUEST_CODE) 44 + return false 45 + } 46 + 47 + override fun onCreate(savedInstanceState: Bundle?) { 48 + super.onCreate(savedInstanceState) 49 + 50 + viewBinding = ActivityScannerBinding.inflate(layoutInflater) 51 + setContentView(viewBinding.root) 52 + if (tryGetPermissions()) { 53 + startScanner() 54 + } 55 + } 56 + 57 + private fun startScanner() { 58 + scannerView = findViewById(R.id.barcode_scanner) 59 + scannerView.cropRatio = .75f 60 + 61 + scannerView.setOnBarcodeListener { result -> processScan(result.text) } 62 + } 63 + 64 + public override fun onResume() { 65 + super.onResume() 66 + if (this::scannerView.isInitialized) { 67 + 68 + scannerView.openAsync() 69 + } else { 70 + tryGetPermissions() 71 + } 72 + } 73 + 74 + public override fun onPause() { 75 + super.onPause() 76 + if (this::scannerView.isInitialized) { 77 + scannerView.close() 78 + } 79 + } 80 + 81 + override fun onRequestPermissionsResult( 82 + requestCode: Int, 83 + permissions: Array<String>, 84 + grantResults: IntArray, 85 + ) { 86 + super.onRequestPermissionsResult(requestCode, permissions, grantResults) 87 + Log.i(TAG, "onRequestPermissionResult") 88 + if (requestCode == PERMISSION_REQUEST_CODE) { 89 + if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { 90 + startScanner() 91 + } 92 + } 93 + } 94 + 95 + private fun processScan(value: String): Boolean { 96 + val uri = qrCodeValueToCardboardURI(value) 97 + if (uri == null) { 98 + Log.e(TAG, "url is not cardboard config") 99 + runOnUiThread { Toast.makeText(this, "QR code invalid", Toast.LENGTH_SHORT).show() } 100 + return true 101 + } 102 + 103 + if (isOriginalCardboard(uri)) { 104 + // This is an approximation of the original Cardboard config - only 2 digits for 105 + // distortion, not 3, but good enough. 106 + Log.d(TAG, "QR code is for Cardboard V1 with no parameters in it, using default") 107 + saveCardboardV1Params(this) 108 + } else { 109 + saveCardboardParamsFromResolvedUri(this, uri) 110 + } 111 + 112 + Log.d(TAG, "QR code valid") 113 + runOnUiThread { 114 + Toast.makeText(applicationContext, "Cardboard parameters saved", Toast.LENGTH_SHORT) 115 + .show() 116 + } 117 + finish() 118 + return false 119 + } 120 + }
src/xrt/auxiliary/android_cardboard/src/main/res/drawable/cardboard_oss_qr.png

This is a binary file and will not be displayed.

+48
src/xrt/auxiliary/android_cardboard/src/main/res/layout/activity_scanner.xml
··· 1 + <?xml version="1.0" encoding="utf-8"?> 2 + <!-- 3 + Copyright 2024, Collabora, Ltd. 4 + SPDX-License-Identifier: BSL-1.0 5 + --> 6 + <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" 7 + xmlns:app="http://schemas.android.com/apk/res-auto" 8 + xmlns:tools="http://schemas.android.com/tools" 9 + android:layout_width="match_parent" 10 + android:layout_height="match_parent" 11 + tools:context=".QrScannerActivity"> 12 + 13 + <de.markusfisch.android.barcodescannerview.widget.BarcodeScannerView 14 + android:id="@+id/barcode_scanner" 15 + android:layout_width="match_parent" 16 + android:layout_height="match_parent"/> 17 + 18 + <LinearLayout 19 + android:layout_width="match_parent" 20 + android:layout_height="wrap_content" 21 + android:orientation="horizontal" 22 + android:paddingTop="32dp" 23 + android:paddingBottom="32dp" 24 + android:paddingLeft="24dp" 25 + android:paddingRight="24dp" 26 + android:background="?attr/colorPrimary" 27 + app:layout_constraintBottom_toBottomOf="parent"> 28 + 29 + <ImageView 30 + android:id="@+id/imageView3" 31 + android:layout_width="0dp" 32 + android:layout_height="64dp" 33 + android:layout_weight="0" 34 + android:importantForAccessibility="no" app:srcCompat="@drawable/cardboard_oss_qr" /> 35 + 36 + <TextView 37 + android:layout_width="match_parent" 38 + android:layout_height="wrap_content" 39 + android:lineSpacingExtra="4dp" 40 + android:paddingLeft="24dp" 41 + android:paddingTop="10dp" 42 + android:text="@string/look_for_the_qr_code_on_your_cardboard_headset" 43 + android:textAppearance="@style/TextAppearance.AppCompat.Medium"/> 44 + </LinearLayout> 45 + 46 + 47 + </androidx.constraintlayout.widget.ConstraintLayout> 48 +
+10
src/xrt/auxiliary/android_cardboard/src/main/res/values/strings.xml
··· 1 + <resources> 2 + <!-- 3 + Copyright 2025, Collabora, Ltd. 4 + SPDX-License-Identifier: BSL-1.0 5 + --> 6 + 7 + <string name="qr">Cardboard QR scan</string> 8 + <string name="look_for_the_qr_code_on_your_cardboard_headset">Look for the QR code on your Cardboard headset</string> 9 + 10 + </resources>