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