Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
bbc8b27
feat(debug): PoC live device screen mirror + remote D-pad (Mirror tab)
jamesarich Aug 31, 2026
b90146f
fix(debug): keep the Mirror tab's D-pad above the fold
jamesarich Aug 31, 2026
3e5987e
refactor(debug): harden screen mirror from adversarial review
jamesarich Aug 31, 2026
42a1fa4
feat(metadata): describe the display in DeviceMetadata.display
jamesarich Aug 31, 2026
f672526
feat(debug): TV-remote-grade mirror controls and DisplayInfo awareness
jamesarich Aug 31, 2026
68ae85f
feat(debug): color-accurate mirror via DisplayPalette
jamesarich Aug 31, 2026
cff81ed
feat(debug): adaptive mirror layout and keyboard-capture exit
jamesarich Aug 31, 2026
1076313
refactor(debug): pre-PR review fixes for the mirror
jamesarich Aug 31, 2026
b92c950
feat(debug): render MUI dirty-rect streams (Format.RGB565)
jamesarich Aug 31, 2026
c774e47
feat(debug): view-only mirror treatment for MUI devices
jamesarich Aug 31, 2026
bd2fbd9
feat(debug): re-enable remote controls for MUI mirrors
jamesarich Aug 31, 2026
f15f439
fix(debug): send typed characters to the mirrored device
jamesarich Aug 31, 2026
33d93cd
refactor(mirror): harden rect reassembly and extract the compositor
jamesarich Sep 1, 2026
a84ec27
fix(mirror): honour managed mode on the Debug Panel route
jamesarich Sep 8, 2026
484a5cc
fix(mirror): follow main after merge
jamesarich Sep 19, 2026
78b020c
style(mirror): apply spotless to the mirror manager test
jamesarich Oct 2, 2026
f10b7b9
feat(mirror): type on the device through the shared keyboard sink
jamesarich Oct 3, 2026
55cf8a7
fix(mirror): keep the palette region list a val for type-resolved detekt
jamesarich Oct 3, 2026
c898e04
fix(mirror): mark the D-pad colour helper ReadOnlyComposable
jamesarich Oct 3, 2026
14b2f58
refactor(mirror): build display protos through Builder
jamesarich Oct 7, 2026
05411f4
feat(node): view a device's 3D model, and place it in AR on the googl…
jamesarich Oct 15, 2026
8981110
feat(node): fold the model notice behind an info button, as the maps do
jamesarich Oct 16, 2026
1aa0c0a
test(node): pass the model repository to the traceroute history use case
jamesarich Oct 16, 2026
a942b9c
feat(node): turn the AR model with a one-finger drag anywhere
jamesarich Oct 16, 2026
f144026
feat(node): choose which face of the AR model rests on the surface
jamesarich Oct 16, 2026
d501b9d
feat(node): save a picture of the AR view
jamesarich Oct 16, 2026
3a570ee
fix(mirror): latch the rect panel, close a completed palette, render …
jamesarich Oct 16, 2026
ecde49d
feat(node): shutter at bottom center, and keep the model loaded acros…
jamesarich Oct 16, 2026
3ffae73
fix(node): rebuild the model instance when the orbit scene returns fr…
jamesarich Oct 16, 2026
6d6fd78
fix(node): keep the 3D model open across a rotation
jamesarich Oct 16, 2026
3d4dcf0
fix(node): hold the orientation while AR is up so a rotation keeps th…
jamesarich Oct 16, 2026
110bcd5
Merge branch 'screen-mirror-poc' into spike/model-screen-mirror
jamesarich Oct 16, 2026
4eae168
spike(node): draw the connected device's live screen on its 3D model
jamesarich Oct 16, 2026
4a706dc
fix(node): save the AR picture on the injected IO dispatcher
jamesarich Oct 16, 2026
1d7fcb4
Merge branch 'feat/device-3d-model' into spike/model-screen-mirror
jamesarich Oct 16, 2026
22eed34
spike(node): swap the screen texture in a SideEffect, and name the AR…
jamesarich Oct 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .skills/compose-ui/strings-index.txt

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions androidApp/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,10 @@ dependencies {

googleImplementation(libs.location.services)
googleImplementation(libs.play.services.maps)
// ARCore for the device model viewer; proprietary, so google only. Same exclusions as feature/node's sceneview.
googleImplementation("io.github.sceneview:arsceneview:${libs.versions.sceneview.get()}") {
exclude(group = "org.jetbrains.kotlin", module = "kotlin-android-extensions-runtime")
}
googleImplementation(libs.maps.compose)
googleImplementation(libs.maps.compose.utils)
googleImplementation(libs.maps.compose.widgets)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.node

import org.meshtastic.core.ui.util.DeviceModelArViewer

/** ARCore is proprietary, so the fdroid flavor has no AR viewer and the action never shows. */
val deviceModelArViewer: DeviceModelArViewer? = null
95 changes: 95 additions & 0 deletions androidApp/src/google/kotlin/org/meshtastic/app/node/ArCapture.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.node

import android.content.ContentValues
import android.content.Context
import android.graphics.Bitmap
import android.net.Uri
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.provider.MediaStore
import android.view.PixelCopy
import android.view.SurfaceView
import android.view.View
import android.view.ViewGroup
import androidx.annotation.RequiresApi
import androidx.core.view.children
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import kotlin.coroutines.resume

private const val PICTURES_RELATIVE_PATH = "Pictures/Meshtastic"
private const val JPEG_QUALITY = 92

/**
* Copies the AR view's GL surface into Pictures/Meshtastic: the camera feed and the model, none of the Compose controls
* drawn over it. The scene renders every camera frame, so the surface is never stale. Null when nothing was saved.
*/
@RequiresApi(Build.VERSION_CODES.Q)
internal suspend fun captureArScene(root: View, title: String, io: CoroutineDispatcher): Uri? {
val surface = root.findSurfaceView()?.takeIf { it.width > 0 && it.height > 0 } ?: return null
val bitmap = Bitmap.createBitmap(surface.width, surface.height, Bitmap.Config.ARGB_8888)
val copied = suspendCancellableCoroutine { continuation ->
PixelCopy.request(
surface,
bitmap,
{ result -> continuation.resume(result == PixelCopy.SUCCESS) },
Handler(Looper.getMainLooper()),
)
}
return if (copied) withContext(io) { saveToPictures(root.context, bitmap, title) } else null
}

private fun View.findSurfaceView(): SurfaceView? = when (this) {
is SurfaceView -> this
is ViewGroup -> children.firstNotNullOfOrNull { it.findSurfaceView() }
else -> null
}

@RequiresApi(Build.VERSION_CODES.Q)
private fun saveToPictures(context: Context, bitmap: Bitmap, title: String): Uri? {
val resolver = context.contentResolver
val stamp = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date())
val name = "${title.replace(Regex("[^A-Za-z0-9]+"), "-").trim('-')}-$stamp.jpg"
val pending =
ContentValues().apply {
put(MediaStore.Images.Media.DISPLAY_NAME, name)
put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
put(MediaStore.Images.Media.RELATIVE_PATH, PICTURES_RELATIVE_PATH)
put(MediaStore.Images.Media.IS_PENDING, 1)
}
val uri = resolver.insert(MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY), pending)
val written =
uri != null &&
runCatching {
resolver.openOutputStream(uri)?.use { bitmap.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, it) }
}
.getOrNull() == true
// A row left pending would sit hidden in the gallery, so a failed write removes it.
if (written) {
resolver.update(uri, ContentValues().apply { put(MediaStore.Images.Media.IS_PENDING, 0) }, null, null)
} else if (uri != null) {
resolver.delete(uri, null, null)
}
return uri.takeIf { written }
}
Loading