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

15
.gitignore vendored Normal file
View File

@@ -0,0 +1,15 @@
# Gradle
.gradle/
build/
app/build/
# Android
.idea/
*.iml
local.properties
.DS_Store
# Garmin
bin/
gen/
mir/

78
README.md Normal file
View File

@@ -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)

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")

15
garmin-app/manifest.xml Normal file
View File

@@ -0,0 +1,15 @@
<iq:manifest xmlns:iq="http://www.garmin.com/xml/connectiq" version="3.0">
<iq:application entry="FotoCompanionApp" id="561284d7-4633-4d43-9876-068305612345" launcherIcon="@Drawables.LauncherIcon" name="@Strings.AppName" type="watch-app">
<iq:products>
<iq:product id="fr955"/>
<iq:product id="fenix7spro"/>
</iq:products>
<iq:permissions>
<iq:permission id="Communications"/>
</iq:permissions>
<iq:languages>
<iq:language>eng</iq:language>
<iq:language>deu</iq:language>
</iq:languages>
</iq:application>
</iq:manifest>

1
garmin-app/monkey.jungle Normal file
View File

@@ -0,0 +1 @@
project.manifest = manifest.xml

View File

@@ -0,0 +1,3 @@
<resources>
<bitmap id="LauncherIcon" filename="launcher_icon.png" />
</resources>

Binary file not shown.

After

Width:  |  Height:  |  Size: 675 B

View File

@@ -0,0 +1,4 @@
<resources>
<string id="AppName">Foto Companion</string>
<string id="PressToCapture">Press Start</string>
</resources>

View File

@@ -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<Views or InputDelegates>? {
view = new FotoCompanionView();
return [ view, new FotoCompanionDelegate(view) ] as Array<Views or InputDelegates>;
}
}

View File

@@ -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");
}
}

View File

@@ -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();
}
}
}
}

100
setup_and_build.sh Executable file
View File

@@ -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 ==="