Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 11 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
Loading