Initial commit: Android & Garmin Remote Camera App with Live Preview

This commit is contained in:
Gemini Bot
2025-12-09 05:39:37 +00:00
commit 55f7df71d8
21 changed files with 951 additions and 0 deletions

View File

@@ -0,0 +1,82 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.example.fotocompanion"
compileSdk = 34
defaultConfig {
applicationId = "com.example.fotocompanion"
minSdk = 26 // Android 8.0, reasonable for modern apps
targetSdk = 34
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true
}
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.4"
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
dependencies {
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2")
implementation("androidx.activity:activity-compose:1.8.1")
implementation(platform("androidx.compose:compose-bom:2023.08.00"))
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
// CameraX
val cameraxVersion = "1.3.0"
implementation("androidx.camera:camera-core:${cameraxVersion}")
implementation("androidx.camera:camera-camera2:${cameraxVersion}")
implementation("androidx.camera:camera-lifecycle:${cameraxVersion}")
implementation("androidx.camera:camera-view:${cameraxVersion}")
implementation("androidx.camera:camera-extensions:${cameraxVersion}")
// Permissions
implementation("com.google.accompanist:accompanist-permissions:0.32.0")
// Local Libs (Connect IQ SDK)
// User needs to place connectiq.jar in app/libs/
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar"))))
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
androidTestImplementation(platform("androidx.compose:compose-bom:2023.08.00"))
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")
}

View File

@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-feature android:name="android.hardware.camera.any" />
<uses-permission android:name="android.permission.CAMERA" />
<!-- Connect IQ Permissions (some might be needed depending on SDK version) -->
<uses-permission android:name="com.garmin.android.connectiq.permission.CONNECTIQ_ALLOW_SDK_ACCESS" />
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="Foto Companion"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.FotoCompanion"
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.FotoCompanion">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -0,0 +1,121 @@
package com.example.fotocompanion
import android.content.Context
import android.util.Log
import com.garmin.android.connectiq.ConnectIQ
import com.garmin.android.connectiq.IQApp
import com.garmin.android.connectiq.IQDevice
import com.garmin.android.connectiq.exception.InvalidStateException
import com.garmin.android.connectiq.exception.ServiceUnavailableException
class ConnectIQHelper(context: Context, private val onCommandReceived: (String) -> Unit) {
private var connectIQ: ConnectIQ? = null
private var connectedDevice: IQDevice? = null
// Replace with the UUID from your Garmin App Manifest
private val MY_APP_ID = "561284d7-4633-4d43-9876-068305612345"
init {
// Initialize ConnectIQ
// Note: This requires the ConnectIQ SDK library to be in your libs folder
try {
connectIQ = ConnectIQ.getInstance(context, ConnectIQ.IQConnectType.WIRELESS)
connectIQ?.initialize(context, true, object : ConnectIQ.ConnectIQListener {
override fun onSdkReady() {
Log.d("ConnectIQ", "SDK Ready")
findDevices()
}
override fun onInitializeError(errStatus: ConnectIQ.IQSdkErrorStatus) {
Log.e("ConnectIQ", "Initialization error: $errStatus")
}
override fun onSdkShutDown() {
Log.d("ConnectIQ", "SDK Shutdown")
}
})
} catch (e: Exception) {
Log.e("ConnectIQ", "Failed to initialize ConnectIQ", e)
}
}
private fun findDevices() {
try {
val devices = connectIQ?.knownDevices ?: return
if (devices.isNotEmpty()) {
// Just pick the first one for now
val device = devices[0]
connectToDevice(device)
}
} catch (e: InvalidStateException) {
Log.e("ConnectIQ", "Invalid State during findDevices", e)
} catch (e: ServiceUnavailableException) {
Log.e("ConnectIQ", "Service Unavailable during findDevices", e)
}
}
private fun connectToDevice(device: IQDevice) {
try {
connectIQ?.registerForDeviceEvents(device, object : ConnectIQ.IQDeviceEventListener {
override fun onDeviceStatusChanged(dev: IQDevice, status: IQDevice.IQDeviceStatus) {
if (status == IQDevice.IQDeviceStatus.CONNECTED) {
Log.d("ConnectIQ", "Device Connected: ${dev.friendlyName}")
connectedDevice = dev
listenForAppEvents(dev)
}
}
})
} catch (e: Exception) {
Log.e("ConnectIQ", "Error connecting to device", e)
}
}
private fun listenForAppEvents(device: IQDevice) {
val app = IQApp(MY_APP_ID)
try {
connectIQ?.registerForAppEvents(device, app) { _, _, message, _ ->
// Handle messages from watch
if (message is List<*>) {
// Often messages come as lists
val cmd = message.firstOrNull()?.toString()
if (cmd != null) onCommandReceived(cmd)
} else {
onCommandReceived(message.toString())
}
}
} catch (e: Exception) {
Log.e("ConnectIQ", "Error registering for app events", e)
}
}
fun sendImageToWatch(imageBytes: ByteArray) {
val device = connectedDevice ?: return
val app = IQApp(MY_APP_ID)
try {
// Check if we need to chunk? SDK handles some fragmentation, but keeping it small is safe.
// sendMessage sends an object. A ByteArray is an object.
// Note: If image is too large, this will throw an exception or fail silently.
// For live preview, we need to be efficient.
// Converting ByteArray to a list of Integers might be required by Monkey C JSON parsing?
// No, Monkey C handles Byte Arrays.
// However, sendMessage is for "Object Messages" (JSON-like).
// Image data is binary.
// Connect IQ 3.1+ supports primitive types including Byte Arrays.
// Send as a list to match expected structure on watch if needed,
// or just the raw array if the watch expects it.
// Let's send a map: { "img": [bytes...] } or just bytes
connectIQ?.sendMessage(device, app, imageBytes) { device, app, status ->
if (status != ConnectIQ.IQMessageStatus.SUCCESS) {
Log.w("ConnectIQ", "Message failed: $status")
}
}
} catch (e: Exception) {
Log.e("ConnectIQ", "Error sending image", e)
}
}
}

View File

@@ -0,0 +1,283 @@
package com.example.fotocompanion
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.ImageFormat
import android.graphics.Matrix
import android.graphics.Rect
import android.graphics.YuvImage
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageCapture
import androidx.camera.core.ImageCaptureException
import androidx.camera.core.ImageProxy
import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import java.io.ByteArrayOutputStream
import java.util.concurrent.Executors
class MainActivity : ComponentActivity() {
private var imageCapture: ImageCapture? = null
// Placeholder for ConnectIQ Helper
private var connectIQHelper: ConnectIQHelper? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Initialize ConnectIQ Helper
connectIQHelper = ConnectIQHelper(this) { command ->
handleCommand(command)
}
setContent {
MaterialTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
FotoCompanionApp(
onImageAnalyzed = { byteArray ->
// Send image to watch via ConnectIQ
connectIQHelper?.sendImageToWatch(byteArray)
},
onCaptureInit = { capture ->
imageCapture = capture
}
)
}
}
}
}
private fun handleCommand(command: String) {
if (command == "TRIGGER") {
takePhoto()
}
}
private fun takePhoto() {
val imageCapture = imageCapture ?: return
// Create time stamped name and MediaStore entry.
val name = java.text.SimpleDateFormat("yyyy-MM-dd-HH-mm-ss-SSS", java.util.Locale.US)
.format(System.currentTimeMillis())
val contentValues = android.content.ContentValues().apply {
put(android.provider.MediaStore.MediaColumns.DISPLAY_NAME, name)
put(android.provider.MediaStore.MediaColumns.MIME_TYPE, "image/jpeg")
if(android.os.Build.VERSION.SDK_INT > android.os.Build.VERSION_CODES.P) {
put(android.provider.MediaStore.Images.Media.RELATIVE_PATH, "Pictures/FotoCompanion")
}
}
val outputOptions = ImageCapture.OutputFileOptions
.Builder(contentResolver, android.provider.MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)
.build()
imageCapture.takePicture(
outputOptions,
ContextCompat.getMainExecutor(this),
object : ImageCapture.OnImageSavedCallback {
override fun onError(exc: ImageCaptureException) {
Log.e("FotoCompanion", "Photo capture failed: ${exc.message}", exc)
}
override fun onImageSaved(output: ImageCapture.OutputFileResults) {
val msg = "Photo capture succeeded: ${output.savedUri}"
Log.d("FotoCompanion", msg)
// Optional: Send success message back to watch
}
}
)
}
}
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun FotoCompanionApp(
onImageAnalyzed: (ByteArray) -> Unit,
onCaptureInit: (ImageCapture) -> Unit
) {
val cameraPermissionState = rememberPermissionState(android.Manifest.permission.CAMERA)
if (cameraPermissionState.status.isGranted) {
CameraPreviewScreen(onImageAnalyzed, onCaptureInit)
} else {
LaunchedEffect(Unit) {
cameraPermissionState.launchPermissionRequest()
}
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("Waiting for Camera Permission...")
}
}
}
@Composable
fun CameraPreviewScreen(
onImageAnalyzed: (ByteArray) -> Unit,
onCaptureInit: (ImageCapture) -> Unit
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
// Last sent timestamp to control FPS
var lastSentTime by remember { mutableStateOf(0L) }
AndroidView(
factory = { ctx ->
val previewView = PreviewView(ctx)
val executor = ContextCompat.getMainExecutor(ctx)
val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx)
cameraProviderFuture.addListener({
val cameraProvider = cameraProviderFuture.get()
// Preview
val preview = Preview.Builder().build().also {
it.setSurfaceProvider(previewView.surfaceProvider)
}
// Image Capture
val imageCapture = ImageCapture.Builder().build()
onCaptureInit(imageCapture)
// Image Analysis (for Watch Preview)
val imageAnalyzer = ImageAnalysis.Builder()
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
.also {
it.setAnalyzer(Executors.newSingleThreadExecutor()) { imageProxy ->
val currentTime = System.currentTimeMillis()
// Limit FPS (e.g., 2 FPS = 500ms)
if (currentTime - lastSentTime > 500) {
// Target size for watch
val width = 120
val height = 120
val bytes = imageProxy.toGarminRawBytes(width, height)
if (bytes != null) {
onImageAnalyzed(bytes)
lastSentTime = currentTime
}
}
imageProxy.close()
}
}
// Select back camera
val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
try {
cameraProvider.unbindAll()
cameraProvider.bindToLifecycle(
lifecycleOwner,
cameraSelector,
preview,
imageCapture,
imageAnalyzer
)
} catch(exc: Exception) {
Log.e("FotoCompanion", "Use case binding failed", exc)
}
}, executor)
previewView
},
modifier = Modifier.fillMaxSize()
)
Box(modifier = Modifier.fillMaxSize().padding(16.dp), contentAlignment = Alignment.BottomCenter) {
Text("Foto Companion Running", color = Color.White)
}
}
// Extension to convert ImageProxy to Garmin Raw Bytes (8-bit palette index)
fun ImageProxy.toGarminRawBytes(reqWidth: Int, reqHeight: Int): ByteArray? {
if (format != ImageFormat.YUV_420_888) return null
val yBuffer = planes[0].buffer
val uBuffer = planes[1].buffer
val vBuffer = planes[2].buffer
val ySize = yBuffer.remaining()
val uSize = uBuffer.remaining()
val vSize = vBuffer.remaining()
val nv21 = ByteArray(ySize + uSize + vSize)
yBuffer.get(nv21, 0, ySize)
vBuffer.get(nv21, ySize, vSize)
uBuffer.get(nv21, ySize + vSize, uSize)
val yuvImage = YuvImage(nv21, ImageFormat.NV21, this.width, this.height, null)
val out = ByteArrayOutputStream()
// Compress to JPEG first to handle YUV->RGB conversion easily (inefficient but standard without libs)
yuvImage.compressToJpeg(Rect(0, 0, this.width, this.height), 50, out)
val originalBytes = out.toByteArray()
// Decode to Bitmap
val bitmap = BitmapFactory.decodeByteArray(originalBytes, 0, originalBytes.size)
// Scale
val scaledBitmap = Bitmap.createScaledBitmap(bitmap, reqWidth, reqHeight, false)
// Quantize to Garmin 64 colors (simulated simple mapping)
// Garmin 64 color palette usually: 00, 55, AA, FF combinations.
// We will map RGB to 6 bits (2 bits R, 2 bits G, 2 bits B) -> 64 colors
// Format: 00RRGGBB
val pixels = IntArray(reqWidth * reqHeight)
scaledBitmap.getPixels(pixels, 0, reqWidth, 0, 0, reqWidth, reqHeight)
val rawBytes = ByteArray(reqWidth * reqHeight)
for (i in pixels.indices) {
val color = pixels[i]
val r = (color shr 16) and 0xFF
val g = (color shr 8) and 0xFF
val b = color and 0xFF
// Map 0-255 to 0-3 (2 bits)
val r2 = (r / 85) // 0..3
val g2 = (g / 85)
val b2 = (b / 85)
// Garmin 64 color index: 00RRGGBB (binary) where RR, GG, BB are 00, 01, 10, 11
// Actually Garmin palette index is specific, but mapping to standard VGA-like 6 bits is easiest
// if we construct the palette on the watch side similarly.
val index = (r2 shl 4) or (g2 shl 2) or b2
rawBytes[i] = index.toByte()
}
return rawBytes
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 675 B

View File

@@ -0,0 +1,7 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.FotoCompanion" parent="android:Theme.Material.Light.NoActionBar">
<!-- Customize your light theme here. -->
<!-- <item name="colorPrimary">@color/my_light_primary</item> -->
</style>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<full-backup-content>
<include domain="user" path="." />
<include domain="root" path="." />
</full-backup-content>

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<cloud-backup>
<include domain="user" path="." />
<include domain="root" path="." />
</cloud-backup>
<device-transfer>
<include domain="user" path="." />
<include domain="root" path="." />
</device-transfer>
</data-extraction-rules>

View File

@@ -0,0 +1,5 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
id("com.android.application") version "8.2.0" apply false
id("org.jetbrains.kotlin.android") version "1.9.20" apply false
}

View File

@@ -0,0 +1,17 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
maven("https://jitpack.io") // Often useful
}
}
rootProject.name = "FotoCompanion"
include(":app")