Initial commit: Android & Garmin Remote Camera App with Live Preview
This commit is contained in:
15
.gitignore
vendored
Normal file
15
.gitignore
vendored
Normal 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
78
README.md
Normal 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)
|
||||
82
android-app/app/build.gradle.kts
Normal file
82
android-app/app/build.gradle.kts
Normal 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")
|
||||
}
|
||||
33
android-app/app/src/main/AndroidManifest.xml
Normal file
33
android-app/app/src/main/AndroidManifest.xml
Normal 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>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
BIN
android-app/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
BIN
android-app/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 675 B |
7
android-app/app/src/main/res/values/themes.xml
Normal file
7
android-app/app/src/main/res/values/themes.xml
Normal 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>
|
||||
5
android-app/app/src/main/res/xml/backup_rules.xml
Normal file
5
android-app/app/src/main/res/xml/backup_rules.xml
Normal 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>
|
||||
11
android-app/app/src/main/res/xml/data_extraction_rules.xml
Normal file
11
android-app/app/src/main/res/xml/data_extraction_rules.xml
Normal 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>
|
||||
5
android-app/build.gradle.kts
Normal file
5
android-app/build.gradle.kts
Normal 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
|
||||
}
|
||||
17
android-app/settings.gradle.kts
Normal file
17
android-app/settings.gradle.kts
Normal 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
15
garmin-app/manifest.xml
Normal 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
1
garmin-app/monkey.jungle
Normal file
@@ -0,0 +1 @@
|
||||
project.manifest = manifest.xml
|
||||
3
garmin-app/resources/drawables/drawables.xml
Normal file
3
garmin-app/resources/drawables/drawables.xml
Normal file
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<bitmap id="LauncherIcon" filename="launcher_icon.png" />
|
||||
</resources>
|
||||
BIN
garmin-app/resources/drawables/launcher_icon.png
Normal file
BIN
garmin-app/resources/drawables/launcher_icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 675 B |
4
garmin-app/resources/strings/strings.xml
Normal file
4
garmin-app/resources/strings/strings.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<resources>
|
||||
<string id="AppName">Foto Companion</string>
|
||||
<string id="PressToCapture">Press Start</string>
|
||||
</resources>
|
||||
31
garmin-app/source/FotoCompanionApp.mc
Normal file
31
garmin-app/source/FotoCompanionApp.mc
Normal 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>;
|
||||
}
|
||||
}
|
||||
39
garmin-app/source/FotoCompanionDelegate.mc
Normal file
39
garmin-app/source/FotoCompanionDelegate.mc
Normal 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");
|
||||
}
|
||||
}
|
||||
101
garmin-app/source/FotoCompanionView.mc
Normal file
101
garmin-app/source/FotoCompanionView.mc
Normal 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
100
setup_and_build.sh
Executable 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 ==="
|
||||
Reference in New Issue
Block a user