diff --git a/.reuse/dep5 b/.reuse/dep5 index 0e333ba6e..381b2a50c 100644 --- a/.reuse/dep5 +++ b/.reuse/dep5 @@ -136,3 +136,8 @@ Files: flake.lock Copyright: No copyright License: CC0-1.0 Comment: Computer-generated files do not have a copyright per US law. + +Files: src/xrt/auxiliary/android_cardboard/src/main/res/drawable/cardboard_oss_qr.png +Copyright: 2019 Google LLC +License: Apache-2.0 +Comment: SPDX-License-Identifier missing. diff --git a/build.gradle b/build.gradle index 8a2b43f4e..e31a9be77 100644 --- a/build.gradle +++ b/build.gradle @@ -27,6 +27,9 @@ buildscript { // ktfmt version ktfmtVersion = "0.53" + + // Google Cardboard QR Code scan + barcodeScannerViewVersion = "1.6.4" } repositories { google() diff --git a/settings.gradle b/settings.gradle index 35119ddbd..723cd7667 100644 --- a/settings.gradle +++ b/settings.gradle @@ -17,17 +17,30 @@ dependencyResolutionManagement { repositories { google() mavenCentral() + maven { url 'https://jitpack.io' } } } rootProject.name = 'monado' +// Typically required by all Monado-based runtimes on AOSP platforms include ':src:xrt:auxiliary' project(':src:xrt:auxiliary').projectDir = new File(rootDir, 'src/xrt/auxiliary/android') +// Only used by Google Cardboard support in the installable Monado +include ':src:xrt:auxiliary:cardboard' +project(':src:xrt:auxiliary:cardboard').projectDir = new File(rootDir, 'src/xrt/auxiliary/android_cardboard') + +// Required for out-of-process builds include ':src:xrt:ipc' project(':src:xrt:ipc').projectDir = new File(rootDir, 'src/xrt/ipc/android') +// Typically required by all Monado-based runtimes on AOSP platforms +// Use dependency injection to customize include ':src:xrt:targets:android_common' + +// End-level (leaf) target - builds on android_common with dependency injection +// to make an installable APK providing a runtime with a Cardboard-like experience +// by default. include ':src:xrt:targets:openxr_android' diff --git a/src/xrt/auxiliary/android/src/main/java/org/freedesktop/monado/auxiliary/ScannerProvider.kt b/src/xrt/auxiliary/android/src/main/java/org/freedesktop/monado/auxiliary/ScannerProvider.kt new file mode 100644 index 000000000..a77474199 --- /dev/null +++ b/src/xrt/auxiliary/android/src/main/java/org/freedesktop/monado/auxiliary/ScannerProvider.kt @@ -0,0 +1,26 @@ +// Copyright 2025, Collabora, Ltd. +// SPDX-License-Identifier: BSL-1.0 +/*! + * @file + * @brief Interface for target-specific scanner-related things on Android. + * @author Simon Zeni + * @ingroup aux_android + */ + +package org.freedesktop.monado.auxiliary + +import android.content.Intent +import android.graphics.drawable.Drawable + +/** + * Scanner activity. This interface must be provided by any Android "XRT Target". + * + * Intended for use in dependency injection. + */ +interface ScannerProvider { + /** Get a drawable icon for use the UI, for the runtime/Monado-incorporating target. */ + fun getIcon(): Drawable + + /** Make a {@code Intent} to launch a scanner activity, if provided by the target. */ + fun makeScannerIntent(): Intent +} diff --git a/src/xrt/auxiliary/android_cardboard/build.gradle b/src/xrt/auxiliary/android_cardboard/build.gradle new file mode 100644 index 000000000..b9746421f --- /dev/null +++ b/src/xrt/auxiliary/android_cardboard/build.gradle @@ -0,0 +1,71 @@ +// Copyright 2020-2025, Collabora, Ltd. +// SPDX-License-Identifier: BSL-1.0 + +plugins { + id 'com.android.library' + id 'kotlin-android' + id 'com.diffplug.spotless' +} + +android { + compileSdk project.sharedCompileSdk + buildToolsVersion = buildToolsVersion + + defaultConfig { + minSdkVersion 24 + targetSdkVersion project.sharedTargetSdk + } + + buildTypes { + release { + minifyEnabled false + // Gradle plugin produces proguard-android-optimize.txt from @Keep annotations + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17 + } + packagingOptions { + resources { + excludes += ['META-INF/*.kotlin_module'] + } + } + namespace 'org.freedesktop.monado.auxiliary.cardboard' + lint { + fatal 'StopShip' + } + buildFeatures { + viewBinding true + } +} + +dependencies { + implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion" + implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion" + implementation "androidx.constraintlayout:constraintlayout:$androidxConstraintLayoutVersion" + implementation "com.google.android.material:material:$materialVersion" + implementation "com.github.markusfisch:BarcodeScannerView:$barcodeScannerViewVersion" + implementation 'com.google.dagger:hilt-android:2.51.1' +} + +spotless { + java { + target 'src/main/java/**/*.java' + // apply a specific flavor of google-java-format. + googleJavaFormat('1.18.1').aosp().reflowLongStrings() + // fix formatting of type annotations. + formatAnnotations() + } + + kotlin { + target 'src/main/java/**/*.kt' + // Use ktfmt(https://github.com/facebook/ktfmt) as the default Kotlin formatter. + ktfmt("$ktfmtVersion").kotlinlangStyle() + } +} diff --git a/src/xrt/auxiliary/android_cardboard/proguard-rules.pro b/src/xrt/auxiliary/android_cardboard/proguard-rules.pro new file mode 100644 index 000000000..686f8abdb --- /dev/null +++ b/src/xrt/auxiliary/android_cardboard/proguard-rules.pro @@ -0,0 +1,4 @@ +# Copyright 2020, Collabora, Ltd. +# SPDX-License-Identifier: BSL-1.0 +# see http://developer.android.com/guide/developing/tools/proguard.html +# Trying to keep most of them in source code annotations and let Gradle do the work diff --git a/src/xrt/auxiliary/android_cardboard/src/main/AndroidManifest.xml b/src/xrt/auxiliary/android_cardboard/src/main/AndroidManifest.xml new file mode 100644 index 000000000..90334becc --- /dev/null +++ b/src/xrt/auxiliary/android_cardboard/src/main/AndroidManifest.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + diff --git a/src/xrt/auxiliary/android_cardboard/src/main/java/org/freedesktop/monado/auxiliary/cardboard/CardboardParamUtils.kt b/src/xrt/auxiliary/android_cardboard/src/main/java/org/freedesktop/monado/auxiliary/cardboard/CardboardParamUtils.kt new file mode 100644 index 000000000..5e650a53c --- /dev/null +++ b/src/xrt/auxiliary/android_cardboard/src/main/java/org/freedesktop/monado/auxiliary/cardboard/CardboardParamUtils.kt @@ -0,0 +1,129 @@ +// Copyright 2024-2025, Collabora, Ltd. +// SPDX-License-Identifier: BSL-1.0 +/*! + * @file + * @brief Utilities for Google Cardboard codes. + * @author Simon Zeni + * @author Rylie Pavlik + */ + +package org.freedesktop.monado.auxiliary.cardboard + +import android.content.Context +import android.net.Uri +import android.util.Base64 +import android.util.Log +import androidx.core.net.toUri +import java.io.File +import java.io.FileOutputStream +import java.net.HttpURLConnection +import java.net.URL + +private const val TAG = "CardboardParamUtils" + +@Suppress("SpellCheckingInspection") +private const val cardboardV1ParamValue: String = + "CgxHb29nbGUsIEluYy4SDENhcmRib2FyZCB2MR0xCCw9JY_CdT0qEAAAIEIAACBCAAAgQgAAIEJYADUpXA89OgiuR-E-mpkZPlABYAE" +private const val googleDomain = "google.com" + +private const val cardboardConfigPath = "/cardboard/cfg" + +private const val cardboardV1EquivUri = + "https://$googleDomain$cardboardConfigPath?p=$cardboardV1ParamValue" + +/** Cardboard V1 had a QR code with no parameters encoded. */ +fun isOriginalCardboard(uri: Uri): Boolean { + return uri.authority.equals("g.co") && uri.path.equals("/cardboard") +} + +/** Newer Cardboard-compatible devices have parameters encoded in a URI (eventually) */ +fun isCardboardParamUri(uri: Uri): Boolean { + return uri.authority.equals(googleDomain) && uri.path.equals(cardboardConfigPath) +} + +/** Cardboard URIs include those with params and the original human-targeted Cardboard V1 URI. */ +fun isCardboardUri(uri: Uri): Boolean { + return isOriginalCardboard(uri) || isCardboardParamUri(uri) +} + +/** + * Some QR codes are missing the scheme. Further, we do not want to visit plain http URIs looking + * for a redirect, therefore we replace the scheme if applicable. + */ +private fun sanitizeAndParseUri(value: String): Uri { + return if (!value.startsWith("http")) { + // missing protocol + "https://$value".toUri() + } else if (value.startsWith("http://")) { + // non-https + value.replaceFirst("http", "https").toUri() + } else { + value.toUri() + } +} + +/** Save current_device_params with approximate Cardboard V1 params */ +fun saveCardboardV1Params(qrScannerActivity: QrScannerActivity) { + saveCardboardParamsFromResolvedUri(qrScannerActivity, cardboardV1EquivUri.toUri()) +} + +/** Save current_device_params from a Cardboard param URI. */ +fun saveCardboardParamsFromResolvedUri(context: Context, uri: Uri) { + assert(isCardboardParamUri(uri)) + val paramEncoded = uri.getQueryParameter("p") + val flags = Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING + val data = Base64.decode(paramEncoded, flags) + + val configFile = File(context.filesDir, "current_device_params") + configFile.createNewFile() + + Log.d(TAG, "Saving to file $configFile") + FileOutputStream(configFile).use { it.write(data) } +} + +/** + * Given some data from a QR Code, try to get to a Cardboard URI: either the original Cardboard web + * site, or a parameter URI. + * + * @return null if we could not get to a Cardboard URI even after several redirects. + */ +fun qrCodeValueToCardboardURI(value: String): Uri? { + + Log.i(TAG, "QR code scan data $value") + + var uri = sanitizeAndParseUri(value) + + Log.i(TAG, "QR code URL $uri") + + // Try fetching the URL, following redirects, repeatedly. + for (i in 0..5) { + if (isCardboardUri(uri)) { + // got one! + break + } + val connection = URL(uri.toString()).openConnection() as HttpURLConnection + connection.setRequestMethod("HEAD") + connection.instanceFollowRedirects = false + connection.connect() + val code = connection.getResponseCode() + if ( + code == HttpURLConnection.HTTP_MOVED_PERM || code == HttpURLConnection.HTTP_MOVED_TEMP + ) { + // Sanitize: we only consider visiting https, for privacy + uri = sanitizeAndParseUri(connection.getHeaderField("Location")) + + Log.i(TAG, "followed url to $uri") + } else { + // was not a redirect, all done + break + } + } + + Log.i(TAG, "ending with url $uri") + + if (!isCardboardUri(uri)) { + Log.e(TAG, "url is not cardboard config") + return null + } + return uri +} diff --git a/src/xrt/auxiliary/android_cardboard/src/main/java/org/freedesktop/monado/auxiliary/cardboard/QrScannerActivity.kt b/src/xrt/auxiliary/android_cardboard/src/main/java/org/freedesktop/monado/auxiliary/cardboard/QrScannerActivity.kt new file mode 100644 index 000000000..9ae9456d8 --- /dev/null +++ b/src/xrt/auxiliary/android_cardboard/src/main/java/org/freedesktop/monado/auxiliary/cardboard/QrScannerActivity.kt @@ -0,0 +1,120 @@ +// Copyright 2024-2025, Collabora, Ltd. +// SPDX-License-Identifier: BSL-1.0 +/*! + * @file + * @brief QR code scanner activity for Google Cardboard codes. + * @author Simon Zeni + * @author Rylie Pavlik + */ + +package org.freedesktop.monado.auxiliary.cardboard + +import android.Manifest +import android.content.pm.PackageManager +import android.os.Bundle +import android.util.Log +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import de.markusfisch.android.barcodescannerview.widget.BarcodeScannerView +import org.freedesktop.monado.auxiliary.cardboard.databinding.ActivityScannerBinding + +class QrScannerActivity : AppCompatActivity() { + private lateinit var viewBinding: ActivityScannerBinding + private lateinit var scannerView: BarcodeScannerView + + companion object { + private const val TAG = "MonadoQrScanner" + private const val PERMISSION_REQUEST_CODE = 200 + } + + private fun tryGetPermissions(): Boolean { + + if ( + ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == + PackageManager.PERMISSION_GRANTED + ) { + Log.i(TAG, "Camera permission has been granted") + return true + } + Log.i(TAG, "Camera permission has not been granted, requesting") + val permissionStrings = arrayOf(Manifest.permission.CAMERA) + ActivityCompat.requestPermissions(this, permissionStrings, PERMISSION_REQUEST_CODE) + return false + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + viewBinding = ActivityScannerBinding.inflate(layoutInflater) + setContentView(viewBinding.root) + if (tryGetPermissions()) { + startScanner() + } + } + + private fun startScanner() { + scannerView = findViewById(R.id.barcode_scanner) + scannerView.cropRatio = .75f + + scannerView.setOnBarcodeListener { result -> processScan(result.text) } + } + + public override fun onResume() { + super.onResume() + if (this::scannerView.isInitialized) { + + scannerView.openAsync() + } else { + tryGetPermissions() + } + } + + public override fun onPause() { + super.onPause() + if (this::scannerView.isInitialized) { + scannerView.close() + } + } + + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray, + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + Log.i(TAG, "onRequestPermissionResult") + if (requestCode == PERMISSION_REQUEST_CODE) { + if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + startScanner() + } + } + } + + private fun processScan(value: String): Boolean { + val uri = qrCodeValueToCardboardURI(value) + if (uri == null) { + Log.e(TAG, "url is not cardboard config") + runOnUiThread { Toast.makeText(this, "QR code invalid", Toast.LENGTH_SHORT).show() } + return true + } + + if (isOriginalCardboard(uri)) { + // This is an approximation of the original Cardboard config - only 2 digits for + // distortion, not 3, but good enough. + Log.d(TAG, "QR code is for Cardboard V1 with no parameters in it, using default") + saveCardboardV1Params(this) + } else { + saveCardboardParamsFromResolvedUri(this, uri) + } + + Log.d(TAG, "QR code valid") + runOnUiThread { + Toast.makeText(applicationContext, "Cardboard parameters saved", Toast.LENGTH_SHORT) + .show() + } + finish() + return false + } +} diff --git a/src/xrt/auxiliary/android_cardboard/src/main/res/drawable/cardboard_oss_qr.png b/src/xrt/auxiliary/android_cardboard/src/main/res/drawable/cardboard_oss_qr.png new file mode 100644 index 0000000000000000000000000000000000000000..5a873a89f8132f7a5df8ea05eeae62e1628252b6 GIT binary patch literal 625 zcmeAS@N?(olHy`uVBq!ia0y~yVAuk}jLZxS4D8HP3KO2LH--w-plEqq|73) zW>5aSkXqddU(VY({?;~~DZ4qq>_J$JTZ2D?oazIk@|0VDEQ43htF(PCRJ`AQqFIZl z{mwW3HzL{o1}5e1n>RUKuKc^n_D|PJ+-J;u=e+OWoJ!6K%-jYq0`p{(KN~z)a3vgA-FdquBYbu7fx^d}N3vdc z#@^Ant6#ib{?5ygt#RreSKNTvtvWuFWHAy<^DC-EsekKPKr}Jb1%l?fpP5LFV;!#%FbSWV)|-H*!CJm zy%MXl4T`sSPm)}DpRxbt?T_l`go?LMZeKnB`_Wk!-p=6nDf<1@eEA-y%lDo+Rxh}H z*ROMD*qk{}Is?_8KG`vOmiD%P=Jqpw?`4`lufgI`*}fXd55iS78R9wXum9xOaW0?T z{_?Yh*LADx({6Mwt2{Vi_wxUrX1LF}x_fVe(+uA5Sb6Q@?XxwW8Ek28V14oZr+{Ca z#p-hg(;1fs9a?+ix&b4<)=#5wv+TK+b44wGsz?>jVD3J*B0GHc<`?HTE<2mu&i_OB zm%+;a?-lvQt20+ePyW!IzoCWml>fWCm+vsotGp2X|8d@5_ms-L#lK^3CVb|6v9IXj ijNiepH}?LT@t?VrbL#6$kG5)pl8&dVpUXO@geCyy7ciIr literal 0 HcmV?d00001 diff --git a/src/xrt/auxiliary/android_cardboard/src/main/res/layout/activity_scanner.xml b/src/xrt/auxiliary/android_cardboard/src/main/res/layout/activity_scanner.xml new file mode 100644 index 000000000..a9d53a2c6 --- /dev/null +++ b/src/xrt/auxiliary/android_cardboard/src/main/res/layout/activity_scanner.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + diff --git a/src/xrt/auxiliary/android_cardboard/src/main/res/values/strings.xml b/src/xrt/auxiliary/android_cardboard/src/main/res/values/strings.xml new file mode 100644 index 000000000..27a1ccc2a --- /dev/null +++ b/src/xrt/auxiliary/android_cardboard/src/main/res/values/strings.xml @@ -0,0 +1,10 @@ + + + + Cardboard QR scan + Look for the QR code on your Cardboard headset + +