From 55f7df71d8ec02a4e2252ad9780a6c15693a00a6 Mon Sep 17 00:00:00 2001 From: Gemini Bot Date: Tue, 9 Dec 2025 05:39:37 +0000 Subject: [PATCH] Initial commit: Android & Garmin Remote Camera App with Live Preview --- .gitignore | 15 + README.md | 78 +++++ android-app/app/build.gradle.kts | 82 +++++ android-app/app/src/main/AndroidManifest.xml | 33 ++ .../example/fotocompanion/ConnectIQHelper.kt | 121 ++++++++ .../com/example/fotocompanion/MainActivity.kt | 283 ++++++++++++++++++ .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 675 bytes .../app/src/main/res/values/themes.xml | 7 + .../app/src/main/res/xml/backup_rules.xml | 5 + .../main/res/xml/data_extraction_rules.xml | 11 + android-app/build.gradle.kts | 5 + android-app/settings.gradle.kts | 17 ++ garmin-app/manifest.xml | 15 + garmin-app/monkey.jungle | 1 + garmin-app/resources/drawables/drawables.xml | 3 + .../resources/drawables/launcher_icon.png | Bin 0 -> 675 bytes garmin-app/resources/strings/strings.xml | 4 + garmin-app/source/FotoCompanionApp.mc | 31 ++ garmin-app/source/FotoCompanionDelegate.mc | 39 +++ garmin-app/source/FotoCompanionView.mc | 101 +++++++ setup_and_build.sh | 100 +++++++ 21 files changed, 951 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 android-app/app/build.gradle.kts create mode 100644 android-app/app/src/main/AndroidManifest.xml create mode 100644 android-app/app/src/main/java/com/example/fotocompanion/ConnectIQHelper.kt create mode 100644 android-app/app/src/main/java/com/example/fotocompanion/MainActivity.kt create mode 100644 android-app/app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 android-app/app/src/main/res/values/themes.xml create mode 100644 android-app/app/src/main/res/xml/backup_rules.xml create mode 100644 android-app/app/src/main/res/xml/data_extraction_rules.xml create mode 100644 android-app/build.gradle.kts create mode 100644 android-app/settings.gradle.kts create mode 100644 garmin-app/manifest.xml create mode 100644 garmin-app/monkey.jungle create mode 100644 garmin-app/resources/drawables/drawables.xml create mode 100644 garmin-app/resources/drawables/launcher_icon.png create mode 100644 garmin-app/resources/strings/strings.xml create mode 100644 garmin-app/source/FotoCompanionApp.mc create mode 100644 garmin-app/source/FotoCompanionDelegate.mc create mode 100644 garmin-app/source/FotoCompanionView.mc create mode 100755 setup_and_build.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..49dc112 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# Gradle +.gradle/ +build/ +app/build/ + +# Android +.idea/ +*.iml +local.properties +.DS_Store + +# Garmin +bin/ +gen/ +mir/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..5936f1c --- /dev/null +++ b/README.md @@ -0,0 +1,78 @@ +# Foto Companion & Garmin Remote + +Ein Fernauslöser-System für Samsung Galaxy Smartphones und Garmin Smartwatches (Forerunner 955, Fenix 7s Pro) mit Live-Vorschau. + +## Repository +`https://git.klenzel.net/admin/garmin_fernausloeser` + +--- + +## 🖥️ Anleitung für Windows (Schritt-für-Schritt) + +Diese Anleitung ist für Nutzer gedacht, die das Projekt auf einem Windows-PC kompilieren und installieren möchten. + +### 1. Vorbereitungen installieren +Bevor es losgeht, installiere bitte folgende Programme: + +1. **Java JDK 17:** + * Lade das "JDK 17" für Windows (x64 Installer) herunter: [Oracle Java Downloads](https://www.oracle.com/java/technologies/downloads/#java17) + * Führe die `.exe` aus und klicke dich durch ("Weiter", "Weiter"...). +2. **Android Studio:** + * Lade Android Studio herunter: [developer.android.com](https://developer.android.com/studio) + * Installiere es mit den Standard-Einstellungen. +3. **Visual Studio Code (VS Code):** + * Lade VS Code herunter: [code.visualstudio.com](https://code.visualstudio.com/) + * Installiere es. +4. **Garmin SDK Manager:** + * Gehe zu [Garmin Developer](https://developer.garmin.com/connect-iq/sdk-manager/) und lade den SDK Manager herunter. + * Starte ihn, logge dich ein (oder erstelle einen Account) und lade das neueste SDK sowie die Geräte "Forerunner 955" und "Fenix 7s Pro" herunter. + +### 2. Android App einrichten (Das Handy-Programm) + +1. **Projekt öffnen:** + * Starte Android Studio. + * Klicke auf "Open" und wähle den Ordner `android-app` aus diesem Projekt aus. + * Warte kurz, bis Android Studio alles geladen hat (unten rechts läuft ein Balken). +2. **Connect IQ Bibliothek hinzufügen:** + * Lade das **"Connect IQ Mobile SDK"** von der Garmin-Webseite herunter. + * Entpacke die ZIP-Datei. + * Suche darin die Datei `connectiq.jar` (manchmal in einem Unterordner). + * Kopiere diese Datei in deinen Projektordner nach: `android-app/app/libs/` (wenn der Ordner `libs` fehlt, erstelle ihn einfach). +3. **App installieren:** + * Schalte auf deinem Samsung Handy den **Entwicklermodus** an (Einstellungen -> Telefoninfo -> Softwareinformationen -> 7x auf "Buildnummer" tippen). + * Aktiviere **USB-Debugging** in den Entwickleroptionen. + * Verbinde das Handy per USB mit dem PC. + * In Android Studio: Wähle oben dein Handy aus und klicke auf den grünen **"Play"**-Button (Run). + * Die App sollte nun auf dem Handy starten. Erlaube den Zugriff auf die Kamera! + +### 3. Garmin App einrichten (Das Uhr-Programm) + +1. **VS Code einrichten:** + * Starte Visual Studio Code. + * Klicke links auf das "Puzzle"-Symbol (Extensions). + * Suche nach **"Monkey C"** (von Garmin) und installiere es. +2. **Projekt öffnen:** + * Gehe in VS Code auf "Datei" -> "Ordner öffnen..." und wähle den Ordner `garmin-app` aus. +3. **Entwickler-Schlüssel erstellen:** + * Drücke `Strg + Umschalt + P`. + * Tippe ein: `Connect IQ: Generate Developer Key`. + * Speichere die Datei einfach im Ordner `garmin-app` als `developer_key.der`. +4. **App auf die Uhr laden:** + * Verbinde deine Garmin Uhr per USB-Kabel mit dem PC. + * Drücke in VS Code wieder `Strg + Umschalt + P`. + * Tippe ein: `Connect IQ: Build for Device`. + * Wähle deine Uhr aus (z.B. `fenix7spro`). + * Es entsteht eine Datei im Ordner `bin/` (z.B. `foto_companion.prg`). + * Kopiere diese `.prg` Datei im Windows Explorer auf das Laufwerk deiner Uhr in den Ordner: `GARMIN/APPS/`. + * Ziehe das Kabel ab. Die App ist nun auf der Uhr installiert! + +--- + +## 🐧 Anleitung für Linux / Experten + +### Struktur +* `android-app/`: Android Quellcode (Kotlin, CameraX, Compose). +* `garmin-app/`: Connect IQ Quellcode (Monkey C). + +### Voraussetzungen +... (siehe bisherige Anleitung unten) diff --git a/android-app/app/build.gradle.kts b/android-app/app/build.gradle.kts new file mode 100644 index 0000000..73cdc7b --- /dev/null +++ b/android-app/app/build.gradle.kts @@ -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") +} diff --git a/android-app/app/src/main/AndroidManifest.xml b/android-app/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..305401d --- /dev/null +++ b/android-app/app/src/main/AndroidManifest.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/android-app/app/src/main/java/com/example/fotocompanion/ConnectIQHelper.kt b/android-app/app/src/main/java/com/example/fotocompanion/ConnectIQHelper.kt new file mode 100644 index 0000000..211bf7e --- /dev/null +++ b/android-app/app/src/main/java/com/example/fotocompanion/ConnectIQHelper.kt @@ -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) + } + } +} diff --git a/android-app/app/src/main/java/com/example/fotocompanion/MainActivity.kt b/android-app/app/src/main/java/com/example/fotocompanion/MainActivity.kt new file mode 100644 index 0000000..44f4154 --- /dev/null +++ b/android-app/app/src/main/java/com/example/fotocompanion/MainActivity.kt @@ -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 +} diff --git a/android-app/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android-app/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..94a176499edf29503b98c36e1b31b78fe01e1b59 GIT binary patch literal 675 zcmV;U0$lxxP)gV3&x#F_HQr~2AENnBD#PrY~Kl+ zG8NDn#F{W(EI>l#f`%ZjP3lepN-dgsZ=us<@bcm0&?K|;7ElJQGUK6qJ#4ELy zxQ=1r9izld#*GoVu*i*&GI+#9y7k4FkfTg=`!7nF|BIKX2n{9eJJE>N_b18%Orm@X zma_Jpu)+b}lm>VU2N)?0FbW5lD;^BM0O|uw(MAoII6>~G#T-3dQc0WMC=bv@8TqAc z=jm-%U4Q{f*bQxemud}E1ZZME@@#jXYAXd`<5qaRaT{|l;~xONa2{*PbWWKH0N63@ z|03A(0ZM_bY@>(=JfTJLL{CL32~tTmKr?W;e~+QB=8#H*mhMfN00SKO@~_cfTE9-6%nTj&IniM9Qo9hPI9zfU>XiMB8Ma`8rZr2Ef!a z=gF?<`~Ghs>zN}>(+6mK@~T2Tx$4Porw>qbW$gs~I9J7xG8R=!AK=QBRTLhOimv>t z^Z^R4thz8j-IZTRAHY3%eijCJ_Drx-2XK!8En$F`XDncVjq5_%f5h7VMztTvw4X|7 zKbg~hI&Hv$M*AhD0gHO=w^a<-*fL;iQTxqZ1Gd)aP?UG%ijiEzh|*nEEbE!VzF48pl=V{$;xb%HSYib002ov JPDHLkV1mqDII92v literal 0 HcmV?d00001 diff --git a/android-app/app/src/main/res/values/themes.xml b/android-app/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..da81613 --- /dev/null +++ b/android-app/app/src/main/res/values/themes.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android-app/app/src/main/res/xml/backup_rules.xml b/android-app/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 0000000..7c72f34 --- /dev/null +++ b/android-app/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android-app/app/src/main/res/xml/data_extraction_rules.xml b/android-app/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..0fb1504 --- /dev/null +++ b/android-app/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/android-app/build.gradle.kts b/android-app/build.gradle.kts new file mode 100644 index 0000000..b0677f1 --- /dev/null +++ b/android-app/build.gradle.kts @@ -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 +} diff --git a/android-app/settings.gradle.kts b/android-app/settings.gradle.kts new file mode 100644 index 0000000..6bc50a1 --- /dev/null +++ b/android-app/settings.gradle.kts @@ -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") diff --git a/garmin-app/manifest.xml b/garmin-app/manifest.xml new file mode 100644 index 0000000..d7dd4a0 --- /dev/null +++ b/garmin-app/manifest.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + eng + deu + + + diff --git a/garmin-app/monkey.jungle b/garmin-app/monkey.jungle new file mode 100644 index 0000000..87796c7 --- /dev/null +++ b/garmin-app/monkey.jungle @@ -0,0 +1 @@ +project.manifest = manifest.xml diff --git a/garmin-app/resources/drawables/drawables.xml b/garmin-app/resources/drawables/drawables.xml new file mode 100644 index 0000000..bf04745 --- /dev/null +++ b/garmin-app/resources/drawables/drawables.xml @@ -0,0 +1,3 @@ + + + diff --git a/garmin-app/resources/drawables/launcher_icon.png b/garmin-app/resources/drawables/launcher_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..94a176499edf29503b98c36e1b31b78fe01e1b59 GIT binary patch literal 675 zcmV;U0$lxxP)gV3&x#F_HQr~2AENnBD#PrY~Kl+ zG8NDn#F{W(EI>l#f`%ZjP3lepN-dgsZ=us<@bcm0&?K|;7ElJQGUK6qJ#4ELy zxQ=1r9izld#*GoVu*i*&GI+#9y7k4FkfTg=`!7nF|BIKX2n{9eJJE>N_b18%Orm@X zma_Jpu)+b}lm>VU2N)?0FbW5lD;^BM0O|uw(MAoII6>~G#T-3dQc0WMC=bv@8TqAc z=jm-%U4Q{f*bQxemud}E1ZZME@@#jXYAXd`<5qaRaT{|l;~xONa2{*PbWWKH0N63@ z|03A(0ZM_bY@>(=JfTJLL{CL32~tTmKr?W;e~+QB=8#H*mhMfN00SKO@~_cfTE9-6%nTj&IniM9Qo9hPI9zfU>XiMB8Ma`8rZr2Ef!a z=gF?<`~Ghs>zN}>(+6mK@~T2Tx$4Porw>qbW$gs~I9J7xG8R=!AK=QBRTLhOimv>t z^Z^R4thz8j-IZTRAHY3%eijCJ_Drx-2XK!8En$F`XDncVjq5_%f5h7VMztTvw4X|7 zKbg~hI&Hv$M*AhD0gHO=w^a<-*fL;iQTxqZ1Gd)aP?UG%ijiEzh|*nEEbE!VzF48pl=V{$;xb%HSYib002ov JPDHLkV1mqDII92v literal 0 HcmV?d00001 diff --git a/garmin-app/resources/strings/strings.xml b/garmin-app/resources/strings/strings.xml new file mode 100644 index 0000000..b856c9c --- /dev/null +++ b/garmin-app/resources/strings/strings.xml @@ -0,0 +1,4 @@ + + Foto Companion + Press Start + diff --git a/garmin-app/source/FotoCompanionApp.mc b/garmin-app/source/FotoCompanionApp.mc new file mode 100644 index 0000000..68148a2 --- /dev/null +++ b/garmin-app/source/FotoCompanionApp.mc @@ -0,0 +1,31 @@ +import Toybox.Application; +import Toybox.Lang; +import Toybox.WatchUi; +import Toybox.Communications; + +class FotoCompanionApp extends Application.AppBase { + var view; + + function initialize() { + AppBase.initialize(); + } + + function onStart(state as Dictionary?) as Void { + // Register for messages from the phone + Communications.registerForPhoneAppMessages(method(:onPhoneMessage)); + } + + function onStop(state as Dictionary?) as Void { + } + + function onPhoneMessage(msg as Communications.Message) as Void { + if (view != null) { + view.updateImage(msg.data); + } + } + + function getInitialView() as Array? { + view = new FotoCompanionView(); + return [ view, new FotoCompanionDelegate(view) ] as Array; + } +} diff --git a/garmin-app/source/FotoCompanionDelegate.mc b/garmin-app/source/FotoCompanionDelegate.mc new file mode 100644 index 0000000..2338011 --- /dev/null +++ b/garmin-app/source/FotoCompanionDelegate.mc @@ -0,0 +1,39 @@ +import Toybox.Lang; +import Toybox.WatchUi; +import Toybox.Communications; +import Toybox.System; + +class FotoCompanionDelegate extends WatchUi.BehaviorDelegate { + var view; + + function initialize(v) { + BehaviorDelegate.initialize(); + view = v; + } + + function onSelect() as Boolean { + // Send Trigger command to Android + // We use a simple string "TRIGGER" + try { + Communications.transmit("TRIGGER", null, new CommsListener()); + System.println("Trigger sent"); + } catch (ex) { + System.println("Error sending: " + ex.getErrorMessage()); + } + return true; + } +} + +class CommsListener extends Communications.ConnectionListener { + function initialize() { + ConnectionListener.initialize(); + } + + function onComplete() { + System.println("Tx Complete"); + } + + function onError() { + System.println("Tx Error"); + } +} diff --git a/garmin-app/source/FotoCompanionView.mc b/garmin-app/source/FotoCompanionView.mc new file mode 100644 index 0000000..33e97e6 --- /dev/null +++ b/garmin-app/source/FotoCompanionView.mc @@ -0,0 +1,101 @@ +import Toybox.Graphics; +import Toybox.WatchUi; +import Toybox.Lang; + +class FotoCompanionView extends WatchUi.View { + var bitmap = null; + var palette = null; + + function initialize() { + View.initialize(); + // Initialize 64 color palette matching Android side (00, 55, AA, FF) + // R (2 bits), G (2 bits), B (2 bits) + palette = new [64]; + for (var i = 0; i < 64; i++) { + var r = (i >> 4) & 0x03; + var g = (i >> 2) & 0x03; + var b = i & 0x03; + // Map 0..3 to 0..255 (0, 85, 170, 255) + palette[i] = (r * 85) << 16 | (g * 85) << 8 | (b * 85); + } + } + + function onLayout(dc as Dc) as Void { + } + + function onUpdate(dc as Dc) as Void { + dc.setColor(Graphics.COLOR_BLACK, Graphics.COLOR_BLACK); + dc.clear(); + + if (bitmap != null) { + var cx = (dc.getWidth() - bitmap.getWidth()) / 2; + var cy = (dc.getHeight() - bitmap.getHeight()) / 2; + dc.drawBitmap(cx, cy, bitmap); + } else { + dc.setColor(Graphics.COLOR_WHITE, Graphics.COLOR_TRANSPARENT); + dc.drawText(dc.getWidth() / 2, dc.getHeight() / 2, Graphics.FONT_MEDIUM, "Waiting for\nCamera...", Graphics.TEXT_JUSTIFY_CENTER | Graphics.TEXT_JUSTIFY_VCENTER); + } + + // Draw Button Hint + dc.setColor(Graphics.COLOR_GREEN, Graphics.COLOR_TRANSPARENT); + dc.drawText(dc.getWidth() / 2, dc.getHeight() - 30, Graphics.FONT_XTINY, "PRESS START", Graphics.TEXT_JUSTIFY_CENTER); + } + + function updateImage(data) { + if (data instanceof Toybox.Lang.Array) { + // Create a BufferedBitmap + // Width/Height hardcoded to match Android (120x120) + var opts = { + :width => 120, + :height => 120, + :palette => palette + }; + + if (Graphics has :createBufferedBitmap) { + var bbRef = Graphics.createBufferedBitmap(opts); + var bb = bbRef.get(); + + // Copy data + // BufferedBitmap.setPalette is implicit via options + // Unfortunately, there is no direct "setBytes" for the whole bitmap in older SDKs easily exposed without a resource. + // But Connect IQ 4.0+ helps. + // If we can't do bulk set, we iterate? Too slow. + // Actually, resource creation from bytes is tricky. + // Let's try the most robust way: + // If the data is indeed the palette indices, we just need to get it into the bitmap buffer. + + // Hack for performance if no setBytes: + // Use a resource? No dynamic resource creation. + + // Alternative: Send a custom String/JSON and parse? No. + + // If the device supports direct palette mapping we might be good. + // Let's assume standard behavior: + // We CAN iterate 14400 pixels in Monkey C if optimized? Maybe 0.5s. + + // Let's try to find a bulk setter. + // There isn't one publicly documented for raw byte array -> bitmap pixels easily. + // Wait! Strings! + // If we encode as a string on Android, drawText? No. + + // Okay, we will iterate. It's 14400 pixels. + // To optimize, Android sends RLE? + // For now, simple iteration. + + var dc = bb.getDc(); + for (var i = 0; i < 14400; i++) { + if (i < data.size()) { + var cIndex = data[i]; + if (cIndex >= 0 && cIndex < 64) { + dc.setColor(palette[cIndex], Graphics.COLOR_TRANSPARENT); + // x = i % 120, y = i / 120 + dc.drawPoint(i % 120, i / 120); + } + } + } + bitmap = bbRef; + WatchUi.requestUpdate(); + } + } + } +} diff --git a/setup_and_build.sh b/setup_and_build.sh new file mode 100755 index 0000000..bd30684 --- /dev/null +++ b/setup_and_build.sh @@ -0,0 +1,100 @@ +#!/bin/bash +set -e + +# Configuration +ANDROID_COMPILE_SDK="34" +ANDROID_BUILD_TOOLS="34.0.0" +CMDLINE_TOOLS_URL="https://dl.google.com/android/repository/commandlinetools-linux-10406996_latest.zip" +CIQ_SDK_URL="https://developer.garmin.com/downloads/connect-iq/sdks/connectiq-sdk-linux-6.4.1-2023-12-13-176868848.zip" +CIQ_DEVICE="fenix7spro" # Default device for build + +PROJECT_ROOT=$(pwd) +TOOLS_DIR="$PROJECT_ROOT/_build_tools" +ANDROID_SDK_ROOT="$TOOLS_DIR/android-sdk" +CIQ_SDK_ROOT="$TOOLS_DIR/ciq-sdk" + +mkdir -p "$TOOLS_DIR" + +echo "=== FotoCompanion Build Setup ===" + +# 1. Check Java +if ! command -v java &> /dev/null; then + echo "Error: Java (JDK) not found. Please install JDK 17." + exit 1 +fi + +# 2. Setup Android SDK +if [ ! -d "$ANDROID_SDK_ROOT/cmdline-tools" ]; then + echo "Downloading Android Command Line Tools..." + wget -q -O "$TOOLS_DIR/cmdline-tools.zip" "$CMDLINE_TOOLS_URL" + mkdir -p "$ANDROID_SDK_ROOT/cmdline-tools/latest" + unzip -q "$TOOLS_DIR/cmdline-tools.zip" -d "$TOOLS_DIR/cmdline-tools-temp" + mv "$TOOLS_DIR/cmdline-tools-temp/cmdline-tools/"* "$ANDROID_SDK_ROOT/cmdline-tools/latest/" + rm -rf "$TOOLS_DIR/cmdline-tools-temp" "$TOOLS_DIR/cmdline-tools.zip" +fi + +export ANDROID_HOME="$ANDROID_SDK_ROOT" +export PATH="$ANDROID_SDK_ROOT/cmdline-tools/latest/bin:$PATH" + +echo "Accepting Android Licenses..." +yes | sdkmanager --licenses > /dev/null 2>&1 || true + +echo "Installing Android Platform Tools & SDK..." +sdkmanager "platform-tools" "platforms;android-$ANDROID_COMPILE_SDK" "build-tools;$ANDROID_BUILD_TOOLS" > /dev/null + +# 3. Setup Garmin Connect IQ SDK +if [ ! -d "$CIQ_SDK_ROOT" ]; then + echo "Downloading Garmin Connect IQ SDK..." + wget -q -O "$TOOLS_DIR/ciq-sdk.zip" "$CIQ_SDK_URL" + mkdir -p "$CIQ_SDK_ROOT" + unzip -q "$TOOLS_DIR/ciq-sdk.zip" -d "$CIQ_SDK_ROOT" + rm "$TOOLS_DIR/ciq-sdk.zip" +fi + +# 4. Copy CIQ Lib to Android Project +# Note: The SDK download might not contain the mobile lib jar directly in root. +# It's usually a separate download "Connect IQ Mobile SDK". +# For automation, we might skip this if the user hasn't provided it, OR we download the mobile sdk too. +# Let's try to download the Mobile SDK specifically for the JAR. +MOBILE_SDK_URL="https://github.com/garmin/connectiq-mobile-sdk-android/releases/download/v1.5/connectiq-mobile-sdk-android-1.5.zip" +# Note: GitHub URL is hypothetical/example. Garmin hosts them on their site behind login sometimes or direct links. +# Actually, the JAR is often inside the main SDK or separately. +# The user instruction in README says "Download manually". We will warn if missing. + +if [ ! -f "android-app/app/libs/connectiq.jar" ]; then + echo "WARNING: connectiq.jar not found in android-app/app/libs/." + echo "Please download the Connect IQ Mobile SDK for Android and copy connectiq.jar there." + echo "Skipping Android Build." +else + echo "Building Android App..." + cd android-app + chmod +x gradlew + ./gradlew assembleDebug + cd .. + echo "Android APK built: android-app/app/build/outputs/apk/debug/app-debug.apk" +fi + +# 5. Build Garmin App +echo "Building Garmin App..." +MONKEYC="$CIQ_SDK_ROOT/bin/monkeyc" +MONKEYDO="$CIQ_SDK_ROOT/bin/monkeydo" + +# Generate Developer Key if missing +DEV_KEY="$TOOLS_DIR/developer_key.der" +if [ ! -f "$DEV_KEY" ]; then + echo "Generating Developer Key..." + openssl genrsa -out "$TOOLS_DIR/developer_key.pem" 4096 + openssl pkcs8 -topk8 -inform PEM -outform DER -in "$TOOLS_DIR/developer_key.pem" -out "$DEV_KEY" -nocrypt +fi + +# Build +"$MONKEYC" \ + -o "garmin-app/bin/foto_companion.prg" \ + -f "garmin-app/monkey.jungle" \ + -y "$DEV_KEY" \ + -d "$CIQ_DEVICE" \ + -w + +echo "Garmin App built: garmin-app/bin/foto_companion.prg" + +echo "=== Done ==="