commit 71bb4e16b92c6625ec16d8d1759657a871719769 Author: kayos Date: Tue Mar 10 12:19:48 2026 -0700 Initial commit: Varroa ADAMaps Android app Full rewrite of bee-debug-app as ADAMaps data proxy. - Detection forwarder to ADAMaps API (polls landmarks/last/200, dedup by id+ts) - 30s camera snapshot with 4-endpoint auto-detect - GPS mini-map via osmdroid/OpenStreetMap - Device status panel (firmware, GPS, AI ready flags) - Connection status bar + forwarding toggle - Foreground service with persistent notification - Dark amber theme, no Google Play Services - Package: com.adamaps.varroa, minSdk 26 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2e4d3bc --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +*.apk +*.aab +*.class +.gradle/ +build/ +local.properties +.idea/ +*.iml +.DS_Store diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..205cd43 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,73 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) +} + +android { + namespace = "com.adamaps.varroa" + compileSdk = 34 + + defaultConfig { + applicationId = "com.adamaps.varroa" + minSdk = 26 + targetSdk = 34 + versionCode = 1 + versionName = "1.0.0" + + vectorDrawables { + useSupportLibrary = true + } + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } + + buildFeatures { + compose = true + } + + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.lifecycle.service) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.ui.tooling.preview) + implementation(libs.androidx.material3) + implementation(libs.androidx.material.icons.extended) + implementation(libs.androidx.navigation.compose) + implementation(libs.okhttp) + implementation(libs.gson) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.osmdroid.android) + implementation(libs.datastore.preferences) + implementation(libs.coil.compose) + debugImplementation(libs.androidx.ui.tooling) +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..b39e795 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,2 @@ +-keep class com.adamaps.varroa.data.** { *; } +-keep class org.osmdroid.** { *; } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..6425d42 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/adamaps/varroa/MainActivity.kt b/app/src/main/java/com/adamaps/varroa/MainActivity.kt new file mode 100644 index 0000000..40dc3fb --- /dev/null +++ b/app/src/main/java/com/adamaps/varroa/MainActivity.kt @@ -0,0 +1,19 @@ +package com.adamaps.varroa + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import com.adamaps.varroa.ui.theme.VarroaTheme + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + VarroaTheme { + VarroaNavGraph() + } + } + } +} diff --git a/app/src/main/java/com/adamaps/varroa/Navigation.kt b/app/src/main/java/com/adamaps/varroa/Navigation.kt new file mode 100644 index 0000000..20bb168 --- /dev/null +++ b/app/src/main/java/com/adamaps/varroa/Navigation.kt @@ -0,0 +1,26 @@ +package com.adamaps.varroa + +import androidx.compose.runtime.Composable +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import com.adamaps.varroa.ui.dashboard.DashboardScreen +import com.adamaps.varroa.ui.settings.SettingsScreen + +object Routes { + const val DASHBOARD = "dashboard" + const val SETTINGS = "settings" +} + +@Composable +fun VarroaNavGraph() { + val nav = rememberNavController() + NavHost(navController = nav, startDestination = Routes.DASHBOARD) { + composable(Routes.DASHBOARD) { + DashboardScreen(onNavigateToSettings = { nav.navigate(Routes.SETTINGS) }) + } + composable(Routes.SETTINGS) { + SettingsScreen(onBack = { nav.popBackStack() }) + } + } +} diff --git a/app/src/main/java/com/adamaps/varroa/VarroaApplication.kt b/app/src/main/java/com/adamaps/varroa/VarroaApplication.kt new file mode 100644 index 0000000..24a4db7 --- /dev/null +++ b/app/src/main/java/com/adamaps/varroa/VarroaApplication.kt @@ -0,0 +1,16 @@ +package com.adamaps.varroa + +import android.app.Application +import org.osmdroid.config.Configuration + +class VarroaApplication : Application() { + override fun onCreate() { + super.onCreate() + // osmdroid: set user-agent and tile cache + Configuration.getInstance().apply { + userAgentValue = "Varroa/1.0 (ADAMaps)" + osmdroidBasePath = cacheDir + osmdroidTileCache = cacheDir + } + } +} diff --git a/app/src/main/java/com/adamaps/varroa/api/AdaMapsApiClient.kt b/app/src/main/java/com/adamaps/varroa/api/AdaMapsApiClient.kt new file mode 100644 index 0000000..3993845 --- /dev/null +++ b/app/src/main/java/com/adamaps/varroa/api/AdaMapsApiClient.kt @@ -0,0 +1,65 @@ +package com.adamaps.varroa.api + +import com.adamaps.varroa.data.AdaMapsIngestRequest +import com.adamaps.varroa.data.ApiResult +import com.google.gson.Gson +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import java.util.concurrent.TimeUnit + +class AdaMapsApiClient( + private var apiUrl: String = "https://api.adamaps.org", + private var apiKey: String = "mapnet-ingest-2026" +) { + + private val client = OkHttpClient.Builder() + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .build() + + private val gson = Gson() + private val json = "application/json; charset=utf-8".toMediaType() + + fun updateConfig(url: String, key: String) { + apiUrl = url.trimEnd('/') + apiKey = key + } + + suspend fun ingest(request: AdaMapsIngestRequest): ApiResult = withContext(Dispatchers.IO) { + try { + val body = gson.toJson(request).toRequestBody(json) + val req = Request.Builder() + .url("$apiUrl/api/ingest") + .addHeader("X-MapNet-Key", apiKey) + .addHeader("Content-Type", "application/json") + .post(body) + .build() + client.newCall(req).execute().use { resp -> + val respBody = resp.body?.string() ?: "" + if (resp.isSuccessful) ApiResult.Success(respBody) + else ApiResult.Error("HTTP ${resp.code}: ${resp.message}", resp.code) + } + } catch (e: Exception) { + ApiResult.Error(e.message ?: "Network error") + } + } + + suspend fun checkReachability(): Boolean = withContext(Dispatchers.IO) { + try { + val req = Request.Builder() + .url("$apiUrl/health") + .head() + .build() + client.newCall(req).execute().use { resp -> + resp.code < 500 + } + } catch (e: Exception) { + false + } + } +} diff --git a/app/src/main/java/com/adamaps/varroa/api/BeeApiClient.kt b/app/src/main/java/com/adamaps/varroa/api/BeeApiClient.kt new file mode 100644 index 0000000..3e9f300 --- /dev/null +++ b/app/src/main/java/com/adamaps/varroa/api/BeeApiClient.kt @@ -0,0 +1,142 @@ +package com.adamaps.varroa.api + +import android.content.Context +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import com.adamaps.varroa.data.ApiResult +import com.adamaps.varroa.data.BeeDetection +import com.adamaps.varroa.data.BeeDeviceInfo +import com.adamaps.varroa.data.GnssData +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import java.util.concurrent.TimeUnit + +class BeeApiClient( + private var baseUrl: String = "http://192.168.0.10:5000" +) { + + private var client = buildClient(null) + private val gson = Gson() + + fun updateBaseUrl(url: String) { + baseUrl = url.trimEnd('/') + } + + fun bindToWifiNetwork(context: Context) { + client = buildClient(getWifiNetwork(context)) + } + + private fun buildClient(net: Network?): OkHttpClient { + val b = OkHttpClient.Builder() + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + net?.let { b.socketFactory(it.socketFactory) } + return b.build() + } + + private fun getWifiNetwork(context: Context): Network? { + return try { + val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + val allWifi = cm.allNetworks.filter { n -> + cm.getNetworkCapabilities(n)?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true + } + // prefer unvalidated wifi (Bee AP has no internet) + allWifi.firstOrNull { n -> + cm.getNetworkCapabilities(n)?.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) == false + } ?: allWifi.firstOrNull() + } catch (e: Exception) { null } + } + + private suspend fun getRaw(path: String): ApiResult = withContext(Dispatchers.IO) { + try { + val req = Request.Builder().url("$baseUrl$path").get().build() + client.newCall(req).execute().use { resp -> + val body = resp.body?.string() ?: "" + if (resp.isSuccessful) ApiResult.Success(body) + else ApiResult.Error("HTTP ${resp.code}: ${resp.message}", resp.code) + } + } catch (e: Exception) { + ApiResult.Error(e.message ?: "Unknown error") + } + } + + private suspend fun getBytes(path: String): ApiResult = withContext(Dispatchers.IO) { + try { + val req = Request.Builder().url("$baseUrl$path").get().build() + client.newCall(req).execute().use { resp -> + val bytes = resp.body?.bytes() ?: ByteArray(0) + if (resp.isSuccessful && bytes.isNotEmpty()) ApiResult.Success(bytes) + else ApiResult.Error("HTTP ${resp.code}: ${resp.message}", resp.code) + } + } catch (e: Exception) { + ApiResult.Error(e.message ?: "Unknown error") + } + } + + suspend fun getLandmarks(): ApiResult> = withContext(Dispatchers.IO) { + when (val r = getRaw("/api/1/landmarks/last/200")) { + is ApiResult.Success -> try { + val type = object : TypeToken>() {}.type + val list: List = gson.fromJson(r.data, type) + ApiResult.Success(list) + } catch (e: Exception) { + ApiResult.Error("Parse error: ${e.message}") + } + is ApiResult.Error -> r + } + } + + suspend fun getGnss(): ApiResult = withContext(Dispatchers.IO) { + when (val r = getRaw("/api/1/gnssConcise/latestValid")) { + is ApiResult.Success -> try { + ApiResult.Success(gson.fromJson(r.data, GnssData::class.java)) + } catch (e: Exception) { + ApiResult.Error("Parse error: ${e.message}") + } + is ApiResult.Error -> r + } + } + + suspend fun getDeviceInfo(): ApiResult = withContext(Dispatchers.IO) { + when (val r = getRaw("/api/1/info")) { + is ApiResult.Success -> try { + ApiResult.Success(gson.fromJson(r.data, BeeDeviceInfo::class.java)) + } catch (e: Exception) { + ApiResult.Error("Parse error: ${e.message}") + } + is ApiResult.Error -> r + } + } + + /** + * Try the given endpoint; returns raw image bytes. + * The caller is responsible for trying fallback endpoints. + */ + suspend fun getCameraFrame(endpoint: String): ApiResult = getBytes(endpoint) + + /** + * Try multiple camera endpoints in order, return first success. + */ + suspend fun getCameraFrameAuto(configured: String): Pair> { + val candidates = listOf( + configured, + "/api/1/camera/frame", + "/api/1/camera/snapshot", + "/api/1/preview", + "/api/1/frame" + ).distinct() + for (ep in candidates) { + val r = getCameraFrame(ep) + if (r is ApiResult.Success) return ep to r + } + return configured to ApiResult.Error("No camera endpoint responded") + } + + suspend fun ping(): Boolean = getDeviceInfo() is ApiResult.Success +} diff --git a/app/src/main/java/com/adamaps/varroa/data/Models.kt b/app/src/main/java/com/adamaps/varroa/data/Models.kt new file mode 100644 index 0000000..171d682 --- /dev/null +++ b/app/src/main/java/com/adamaps/varroa/data/Models.kt @@ -0,0 +1,103 @@ +package com.adamaps.varroa.data + +import com.google.gson.annotations.SerializedName + +// ── Bee API responses ───────────────────────────────────────────────────────── + +data class BeeDetection( + @SerializedName("id") val id: Long = 0L, + @SerializedName("class_label") val classLabel: String? = null, + @SerializedName("class_label_confidence") val classLabelConfidence: Double? = null, + @SerializedName("overall_confidence") val overallConfidence: Double? = null, + @SerializedName("ts") val ts: Long? = null, + @SerializedName("lat") val lat: Double? = null, + @SerializedName("lon") val lon: Double? = null, + @SerializedName("alt") val alt: Double? = null, + @SerializedName("width") val width: Double? = null, + @SerializedName("height") val height: Double? = null, + @SerializedName("pos_confidence") val posConfidence: Double? = null, + @SerializedName("azimuth") val azimuth: Double? = null +) { + /** Dedup key */ + fun dedupKey(): String = "${id}_${ts}" +} + +data class GnssData( + @SerializedName("alt_m") val altM: Double? = null, + @SerializedName("lat_deg") val latDeg: Double? = null, + @SerializedName("lon_deg") val lonDeg: Double? = null, + @SerializedName("unix_milliseconds") val unixMs: Long? = null +) + +data class BeeDeviceInfo( + @SerializedName("version") val firmwareVersion: String? = null, + @SerializedName("api_version") val apiVersion: String? = null, + @SerializedName("build_date") val buildDate: String? = null, + @SerializedName("serial") val serial: String? = null, + @SerializedName("deviceId") val deviceId: String? = null, + @SerializedName("dashcam") val model: String? = null, + @SerializedName("hasGnssLock") val hasGnssLock: Boolean? = null, + @SerializedName("internetIsHealthy") val internetIsHealthy: Boolean? = null, + @SerializedName("pluginsLocked") val pluginsLocked: Boolean? = null, + @SerializedName("uploadMode") val uploadMode: String? = null, + @SerializedName("uptime") val uptime: Long? = null, + // IMEI may be top-level or nested + @SerializedName("imei") val imei: String? = null, + @SerializedName("ssid") val ssid: String? = null +) + +// ── ADAMaps ingest ──────────────────────────────────────────────────────────── + +data class AdaMapsDetection( + @SerializedName("id") val id: Long, + @SerializedName("class_label") val classLabel: String?, + @SerializedName("class_label_confidence") val classLabelConfidence: Double?, + @SerializedName("overall_confidence") val overallConfidence: Double?, + @SerializedName("ts") val ts: Long?, + @SerializedName("lat") val lat: Double?, + @SerializedName("lon") val lon: Double?, + @SerializedName("alt") val alt: Double?, + @SerializedName("width") val width: Double?, + @SerializedName("height") val height: Double?, + @SerializedName("pos_confidence") val posConfidence: Double?, + @SerializedName("azimuth") val azimuth: Double? +) + +data class AdaMapsIngestRequest( + @SerializedName("device_id") val deviceId: String, + @SerializedName("detections") val detections: List +) + +fun BeeDetection.toAdaMapsDetection() = AdaMapsDetection( + id = id, + classLabel = classLabel, + classLabelConfidence = classLabelConfidence, + overallConfidence = overallConfidence, + ts = ts, + lat = lat, + lon = lon, + alt = alt, + width = width, + height = height, + posConfidence = posConfidence, + azimuth = azimuth +) + +// ── App state ───────────────────────────────────────────────────────────────── + +data class SessionStats( + val collected: Int = 0, + val sent: Int = 0, + val queued: Int = 0 +) + +data class ConnectionState( + val beeConnected: Boolean = false, + val adamapsReachable: Boolean = false, + val forwardingActive: Boolean = false +) + +sealed class ApiResult { + data class Success(val data: T) : ApiResult() + data class Error(val message: String, val code: Int? = null) : ApiResult() +} diff --git a/app/src/main/java/com/adamaps/varroa/data/SettingsDataStore.kt b/app/src/main/java/com/adamaps/varroa/data/SettingsDataStore.kt new file mode 100644 index 0000000..0682876 --- /dev/null +++ b/app/src/main/java/com/adamaps/varroa/data/SettingsDataStore.kt @@ -0,0 +1,56 @@ +package com.adamaps.varroa.data + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +private val Context.dataStore: DataStore by preferencesDataStore(name = "varroa_settings") + +data class VarroaSettings( + val beeApiUrl: String = "http://192.168.0.10:5000", + val adamapsApiUrl: String = "https://api.adamaps.org", + val adamapsApiKey: String = "mapnet-ingest-2026", + val pollIntervalSeconds: Int = 30, + val cameraRefreshSeconds: Int = 30, + val cameraEndpoint: String = "/api/1/camera/frame" +) + +class SettingsDataStore(private val context: Context) { + + companion object { + private val KEY_BEE_URL = stringPreferencesKey("bee_api_url") + private val KEY_ADAMAPS_URL = stringPreferencesKey("adamaps_api_url") + private val KEY_ADAMAPS_KEY = stringPreferencesKey("adamaps_api_key") + private val KEY_POLL_INTERVAL = intPreferencesKey("poll_interval_seconds") + private val KEY_CAMERA_REFRESH = intPreferencesKey("camera_refresh_seconds") + private val KEY_CAMERA_ENDPOINT = stringPreferencesKey("camera_endpoint") + } + + val settings: Flow = context.dataStore.data.map { prefs -> + VarroaSettings( + beeApiUrl = prefs[KEY_BEE_URL] ?: "http://192.168.0.10:5000", + adamapsApiUrl = prefs[KEY_ADAMAPS_URL] ?: "https://api.adamaps.org", + adamapsApiKey = prefs[KEY_ADAMAPS_KEY] ?: "mapnet-ingest-2026", + pollIntervalSeconds = prefs[KEY_POLL_INTERVAL] ?: 30, + cameraRefreshSeconds = prefs[KEY_CAMERA_REFRESH] ?: 30, + cameraEndpoint = prefs[KEY_CAMERA_ENDPOINT] ?: "/api/1/camera/frame" + ) + } + + suspend fun save(s: VarroaSettings) { + context.dataStore.edit { prefs -> + prefs[KEY_BEE_URL] = s.beeApiUrl + prefs[KEY_ADAMAPS_URL] = s.adamapsApiUrl + prefs[KEY_ADAMAPS_KEY] = s.adamapsApiKey + prefs[KEY_POLL_INTERVAL] = s.pollIntervalSeconds + prefs[KEY_CAMERA_REFRESH] = s.cameraRefreshSeconds + prefs[KEY_CAMERA_ENDPOINT] = s.cameraEndpoint + } + } +} diff --git a/app/src/main/java/com/adamaps/varroa/service/ForwardingService.kt b/app/src/main/java/com/adamaps/varroa/service/ForwardingService.kt new file mode 100644 index 0000000..57ad432 --- /dev/null +++ b/app/src/main/java/com/adamaps/varroa/service/ForwardingService.kt @@ -0,0 +1,240 @@ +package com.adamaps.varroa.service + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Intent +import android.os.Build +import android.os.IBinder +import androidx.core.app.NotificationCompat +import androidx.lifecycle.LifecycleService +import androidx.lifecycle.lifecycleScope +import com.adamaps.varroa.MainActivity +import com.adamaps.varroa.R +import com.adamaps.varroa.api.AdaMapsApiClient +import com.adamaps.varroa.api.BeeApiClient +import com.adamaps.varroa.data.AdaMapsIngestRequest +import com.adamaps.varroa.data.ApiResult +import com.adamaps.varroa.data.BeeDetection +import com.adamaps.varroa.data.SessionStats +import com.adamaps.varroa.data.SettingsDataStore +import com.adamaps.varroa.data.VarroaSettings +import com.adamaps.varroa.data.toAdaMapsDetection +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +class ForwardingService : LifecycleService() { + + companion object { + const val NOTIF_CHANNEL_ID = "varroa_forwarding" + const val NOTIF_ID = 1001 + + const val ACTION_START = "com.adamaps.varroa.START_FORWARDING" + const val ACTION_STOP = "com.adamaps.varroa.STOP_FORWARDING" + + // Shared state exposed to ViewModels + private val _stats = MutableStateFlow(SessionStats()) + val stats: StateFlow = _stats.asStateFlow() + + private val _isRunning = MutableStateFlow(false) + val isRunning: StateFlow = _isRunning.asStateFlow() + + private val _lastError = MutableStateFlow(null) + val lastError: StateFlow = _lastError.asStateFlow() + + private val _beeConnected = MutableStateFlow(false) + val beeConnected: StateFlow = _beeConnected.asStateFlow() + + private val _adamapsReachable = MutableStateFlow(false) + val adamapsReachable: StateFlow = _adamapsReachable.asStateFlow() + } + + private val beeClient = BeeApiClient() + private val adamapsClient = AdaMapsApiClient() + + private val seenKeys = mutableSetOf() + private var pollJob: Job? = null + private var reachJob: Job? = null + + override fun onCreate() { + super.onCreate() + createNotificationChannel() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + super.onStartCommand(intent, flags, startId) + when (intent?.action) { + ACTION_STOP -> { + stopForwarding() + stopSelf() + } + else -> startForwarding() + } + return START_STICKY + } + + override fun onBind(intent: Intent): IBinder? { + super.onBind(intent) + return null + } + + override fun onDestroy() { + stopForwarding() + super.onDestroy() + } + + private fun startForwarding() { + startForeground(NOTIF_ID, buildNotification(0)) + _isRunning.value = true + + lifecycleScope.launch { + val settings = SettingsDataStore(applicationContext).settings.first() + applySettings(settings) + beeClient.bindToWifiNetwork(applicationContext) + + startPollLoop(settings.pollIntervalSeconds) + startReachabilityLoop() + } + } + + private fun stopForwarding() { + pollJob?.cancel() + reachJob?.cancel() + _isRunning.value = false + } + + private fun applySettings(s: VarroaSettings) { + beeClient.updateBaseUrl(s.beeApiUrl) + adamapsClient.updateConfig(s.adamapsApiUrl, s.adamapsApiKey) + } + + private fun startPollLoop(intervalSeconds: Int) { + pollJob?.cancel() + pollJob = lifecycleScope.launch { + while (true) { + runPollCycle() + delay(intervalSeconds * 1000L) + } + } + } + + private fun startReachabilityLoop() { + reachJob?.cancel() + reachJob = lifecycleScope.launch { + while (true) { + _adamapsReachable.value = adamapsClient.checkReachability() + delay(30_000L) + } + } + } + + private suspend fun runPollCycle() { + when (val result = beeClient.getLandmarks()) { + is ApiResult.Success -> { + _beeConnected.value = true + _lastError.value = null + val newDetections = result.data.filter { d -> + val key = d.dedupKey() + if (seenKeys.contains(key)) false + else { seenKeys.add(key); true } + } + + _stats.update { it.copy(collected = it.collected + newDetections.size) } + + if (newDetections.isNotEmpty()) { + sendToADAMaps(newDetections) + } + + updateNotification(_stats.value.sent) + } + is ApiResult.Error -> { + _beeConnected.value = false + _lastError.value = result.message + } + } + } + + private suspend fun sendToADAMaps(detections: List) { + // Get device ID from device info (best effort) + val deviceId = try { + (beeClient.getDeviceInfo() as? ApiResult.Success)?.data?.deviceId + ?: (beeClient.getDeviceInfo() as? ApiResult.Success)?.data?.serial + ?: "unknown" + } catch (e: Exception) { "unknown" } + + val request = AdaMapsIngestRequest( + deviceId = deviceId, + detections = detections.map { it.toAdaMapsDetection() } + ) + + _stats.update { it.copy(queued = it.queued + detections.size) } + + when (val result = adamapsClient.ingest(request)) { + is ApiResult.Success -> { + _stats.update { it.copy(sent = it.sent + detections.size, queued = it.queued - detections.size) } + _adamapsReachable.value = true + } + is ApiResult.Error -> { + _lastError.value = "ADAMaps: ${result.message}" + _adamapsReachable.value = false + // keep queued count — retry on next cycle not implemented yet + _stats.update { it.copy(queued = it.queued - detections.size) } + } + } + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + NOTIF_CHANNEL_ID, + getString(R.string.forwarding_notification_channel), + NotificationManager.IMPORTANCE_LOW + ).apply { + description = "Varroa detection forwarding" + setShowBadge(false) + } + val nm = getSystemService(NOTIFICATION_SERVICE) as NotificationManager + nm.createNotificationChannel(channel) + } + } + + private fun buildNotification(sentCount: Int): Notification { + val tapIntent = Intent(this, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP + } + val tapPending = PendingIntent.getActivity( + this, 0, tapIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + val stopIntent = Intent(this, ForwardingService::class.java).apply { + action = ACTION_STOP + } + val stopPending = PendingIntent.getService( + this, 1, stopIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + return NotificationCompat.Builder(this, NOTIF_CHANNEL_ID) + .setContentTitle(getString(R.string.forwarding_notification_title)) + .setContentText(getString(R.string.forwarding_notification_text, sentCount)) + .setSmallIcon(android.R.drawable.ic_menu_mylocation) + .setContentIntent(tapPending) + .addAction(android.R.drawable.ic_media_pause, "Stop", stopPending) + .setOngoing(true) + .setSilent(true) + .build() + } + + private fun updateNotification(sentCount: Int) { + val nm = getSystemService(NOTIFICATION_SERVICE) as NotificationManager + nm.notify(NOTIF_ID, buildNotification(sentCount)) + } +} diff --git a/app/src/main/java/com/adamaps/varroa/ui/dashboard/DashboardScreen.kt b/app/src/main/java/com/adamaps/varroa/ui/dashboard/DashboardScreen.kt new file mode 100644 index 0000000..3301ccd --- /dev/null +++ b/app/src/main/java/com/adamaps/varroa/ui/dashboard/DashboardScreen.kt @@ -0,0 +1,457 @@ +package com.adamaps.varroa.ui.dashboard + +import android.graphics.BitmapFactory +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.viewmodel.compose.viewModel +import com.adamaps.varroa.data.BeeDeviceInfo +import com.adamaps.varroa.data.GnssData +import com.adamaps.varroa.data.SessionStats +import com.adamaps.varroa.ui.theme.* +import com.adamaps.varroa.viewmodel.DashboardViewModel +import org.osmdroid.tileprovider.tilesource.TileSourceFactory +import org.osmdroid.util.GeoPoint +import org.osmdroid.views.MapView +import org.osmdroid.views.overlay.Marker + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DashboardScreen( + vm: DashboardViewModel = viewModel(), + onNavigateToSettings: () -> Unit +) { + val deviceInfo by vm.deviceInfo.collectAsState() + val gnss by vm.gnss.collectAsState() + val cameraBytes by vm.cameraBytes.collectAsState() + val stats by vm.stats.collectAsState() + val isForwarding by vm.isForwarding.collectAsState() + val beeConnected by vm.beeConnected.collectAsState() + val adamapsReachable by vm.adamapsReachable.collectAsState() + val lastError by vm.lastError.collectAsState() + + Scaffold( + containerColor = Background, + topBar = { + TopAppBar( + title = { + Text( + "VARROA", + color = Amber, + fontWeight = FontWeight.Bold, + fontFamily = FontFamily.Monospace, + fontSize = 18.sp, + letterSpacing = 4.sp + ) + }, + colors = TopAppBarDefaults.topAppBarColors(containerColor = Surface), + actions = { + IconButton(onClick = onNavigateToSettings) { + Icon(Icons.Default.Settings, contentDescription = "Settings", tint = Amber) + } + } + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + // Connection status bar + ConnectionStatusBar( + beeConnected = beeConnected, + adamapsReachable = adamapsReachable, + isForwarding = isForwarding, + onToggle = { vm.toggleForwarding() } + ) + + // Error banner + if (lastError != null) { + ErrorBanner(lastError!!) + } + + // Session stats + SessionStatsCard(stats) + + // Camera snapshot + CameraCard(cameraBytes) + + // GPS mini-map + gnss?.let { GpsMapCard(it) } + + // Device status + deviceInfo?.let { DeviceStatusCard(it) } + } + } +} + +@Composable +private fun ConnectionStatusBar( + beeConnected: Boolean, + adamapsReachable: Boolean, + isForwarding: Boolean, + onToggle: () -> Unit +) { + Card( + colors = CardDefaults.cardColors(containerColor = Surface), + shape = RoundedCornerShape(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + StatusDot( + label = "BEE", + active = beeConnected + ) + StatusDot( + label = "ADAMAPS", + active = adamapsReachable + ) + } + + // Forwarding toggle + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = if (isForwarding) "FORWARDING" else "PAUSED", + color = if (isForwarding) Amber else Color.Gray, + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 1.sp + ) + Spacer(Modifier.width(8.dp)) + Switch( + checked = isForwarding, + onCheckedChange = { onToggle() }, + colors = SwitchDefaults.colors( + checkedThumbColor = Amber, + checkedTrackColor = AmberDark, + uncheckedThumbColor = Color.Gray, + uncheckedTrackColor = SurfaceVariant + ) + ) + } + } + } +} + +@Composable +private fun StatusDot(label: String, active: Boolean) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Box( + modifier = Modifier + .size(8.dp) + .background( + color = if (active) Success else Color(0xFF6B7280), + shape = RoundedCornerShape(50) + ) + ) + Text( + text = label, + color = if (active) OnSurface else Color.Gray, + fontFamily = FontFamily.Monospace, + fontSize = 10.sp, + letterSpacing = 1.sp + ) + } +} + +@Composable +private fun ErrorBanner(message: String) { + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(6.dp)) + .background(Color(0xFF2D1515)) + .border(1.dp, Error.copy(alpha = 0.5f), RoundedCornerShape(6.dp)) + .padding(10.dp) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(Icons.Default.Warning, contentDescription = null, tint = Error, modifier = Modifier.size(16.dp)) + Text( + text = message, + color = Error, + fontFamily = FontFamily.Monospace, + fontSize = 11.sp + ) + } + } +} + +@Composable +private fun SessionStatsCard(stats: SessionStats) { + Card( + colors = CardDefaults.cardColors(containerColor = Surface), + shape = RoundedCornerShape(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.padding(14.dp)) { + SectionHeader("SESSION STATS") + Spacer(Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + StatItem("COLLECTED", stats.collected.toString()) + StatItem("SENT", stats.sent.toString()) + StatItem("QUEUED", stats.queued.toString()) + } + } + } +} + +@Composable +private fun StatItem(label: String, value: String) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = value, + color = Amber, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + fontSize = 24.sp + ) + Text( + text = label, + color = Color.Gray, + fontFamily = FontFamily.Monospace, + fontSize = 9.sp, + letterSpacing = 1.sp + ) + } +} + +@Composable +private fun CameraCard(bytes: ByteArray?) { + Card( + colors = CardDefaults.cardColors(containerColor = Surface), + shape = RoundedCornerShape(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.padding(14.dp)) { + SectionHeader("CAMERA") + Spacer(Modifier.height(8.dp)) + Box( + modifier = Modifier + .fillMaxWidth() + .height(180.dp) + .clip(RoundedCornerShape(6.dp)) + .background(SurfaceVariant), + contentAlignment = Alignment.Center + ) { + if (bytes != null && bytes.isNotEmpty()) { + val bitmap = remember(bytes) { + BitmapFactory.decodeByteArray(bytes, 0, bytes.size) + } + if (bitmap != null) { + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = "Camera feed", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Fit + ) + } else { + CameraPlaceholder("Failed to decode frame") + } + } else { + CameraPlaceholder("Awaiting frame…") + } + } + } + } +} + +@Composable +private fun CameraPlaceholder(msg: String) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + Icons.Default.CameraAlt, + contentDescription = null, + tint = Color.Gray, + modifier = Modifier.size(32.dp) + ) + Spacer(Modifier.height(6.dp)) + Text(msg, color = Color.Gray, fontFamily = FontFamily.Monospace, fontSize = 11.sp) + } +} + +@Composable +private fun GpsMapCard(gnss: GnssData) { + Card( + colors = CardDefaults.cardColors(containerColor = Surface), + shape = RoundedCornerShape(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.padding(14.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + SectionHeader("GPS") + val lat = gnss.latDeg + val lon = gnss.lonDeg + if (lat != null && lon != null) { + Text( + "%.4f, %.4f".format(lat, lon), + color = Color.Gray, + fontFamily = FontFamily.Monospace, + fontSize = 10.sp + ) + } + } + Spacer(Modifier.height(8.dp)) + Box( + modifier = Modifier + .fillMaxWidth() + .height(200.dp) + .clip(RoundedCornerShape(6.dp)) + ) { + OsmMapView(gnss) + } + } + } +} + +@Composable +private fun OsmMapView(gnss: GnssData) { + val lat = gnss.latDeg ?: 0.0 + val lon = gnss.lonDeg ?: 0.0 + val context = LocalContext.current + + AndroidView( + factory = { ctx -> + MapView(ctx).apply { + setTileSource(TileSourceFactory.MAPNIK) + setMultiTouchControls(true) + controller.setZoom(16.0) + controller.setCenter(GeoPoint(lat, lon)) + isHorizontalMapRepetitionEnabled = false + isVerticalMapRepetitionEnabled = false + setScrollableAreaLimitLatitude( + MapView.getTileSystem().maxLatitude, + MapView.getTileSystem().minLatitude, 0 + ) + } + }, + update = { mapView -> + val point = GeoPoint(lat, lon) + mapView.overlays.clear() + val marker = Marker(mapView).apply { + position = point + setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM) + title = "%.4f, %.4f".format(lat, lon) + } + mapView.overlays.add(marker) + mapView.controller.animateTo(point) + }, + modifier = Modifier.fillMaxSize() + ) +} + +@Composable +private fun DeviceStatusCard(info: BeeDeviceInfo) { + Card( + colors = CardDefaults.cardColors(containerColor = Surface), + shape = RoundedCornerShape(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.padding(14.dp)) { + SectionHeader("DEVICE STATUS") + Spacer(Modifier.height(8.dp)) + val rows: List> = listOf( + "Firmware" to (info.firmwareVersion ?: "—"), + "Device ID" to (info.deviceId ?: "—"), + "Serial" to (info.serial ?: "—"), + "IMEI" to (info.imei ?: "—"), + "Model" to (info.model ?: "—"), + "SSID" to (info.ssid ?: "—"), + "GPS Lock" to if (info.hasGnssLock == true) "YES" else "NO", + "Upload Mode" to (info.uploadMode ?: "—"), + "Uptime" to (info.uptime?.let { formatUptime(it) } ?: "—") + ) + rows.forEach { (k, v) -> DeviceRow(k, v) } + } + } +} + +@Composable +private fun DeviceRow(label: String, value: String) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 3.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = label, + color = Color.Gray, + fontFamily = FontFamily.Monospace, + fontSize = 11.sp + ) + Text( + text = value, + color = OnSurface, + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + fontWeight = FontWeight.Medium + ) + } +} + +@Composable +private fun SectionHeader(text: String) { + Text( + text = text, + color = Amber, + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 2.sp + ) +} + +private fun formatUptime(seconds: Long): String { + val h = seconds / 3600 + val m = (seconds % 3600) / 60 + val s = seconds % 60 + return "%02d:%02d:%02d".format(h, m, s) +} diff --git a/app/src/main/java/com/adamaps/varroa/ui/settings/SettingsScreen.kt b/app/src/main/java/com/adamaps/varroa/ui/settings/SettingsScreen.kt new file mode 100644 index 0000000..c901535 --- /dev/null +++ b/app/src/main/java/com/adamaps/varroa/ui/settings/SettingsScreen.kt @@ -0,0 +1,210 @@ +package com.adamaps.varroa.ui.settings + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.adamaps.varroa.data.VarroaSettings +import com.adamaps.varroa.ui.theme.* +import com.adamaps.varroa.viewmodel.SettingsViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen( + vm: SettingsViewModel = viewModel(), + onBack: () -> Unit +) { + val currentSettings by vm.settings.collectAsState() + val saved by vm.saved.collectAsState() + + // Local edit state — initialized from current settings + var beeApiUrl by remember(currentSettings) { mutableStateOf(currentSettings.beeApiUrl) } + var adamapsApiUrl by remember(currentSettings) { mutableStateOf(currentSettings.adamapsApiUrl) } + var adamapsApiKey by remember(currentSettings) { mutableStateOf(currentSettings.adamapsApiKey) } + var pollInterval by remember(currentSettings) { mutableStateOf(currentSettings.pollIntervalSeconds.toString()) } + var cameraRefresh by remember(currentSettings) { mutableStateOf(currentSettings.cameraRefreshSeconds.toString()) } + var cameraEndpoint by remember(currentSettings) { mutableStateOf(currentSettings.cameraEndpoint) } + + // Show snackbar on save + val snackbarHostState = remember { SnackbarHostState() } + LaunchedEffect(saved) { + if (saved) { + snackbarHostState.showSnackbar("Settings saved") + vm.clearSaved() + } + } + + Scaffold( + containerColor = Background, + snackbarHost = { SnackbarHost(snackbarHostState) }, + topBar = { + TopAppBar( + title = { + Text( + "SETTINGS", + color = Amber, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + letterSpacing = 3.sp + ) + }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.Default.ArrowBack, contentDescription = "Back", tint = Amber) + } + }, + colors = TopAppBarDefaults.topAppBarColors(containerColor = Surface), + actions = { + IconButton(onClick = { + vm.save( + VarroaSettings( + beeApiUrl = beeApiUrl.trim(), + adamapsApiUrl = adamapsApiUrl.trim(), + adamapsApiKey = adamapsApiKey.trim(), + pollIntervalSeconds = pollInterval.toIntOrNull() ?: 30, + cameraRefreshSeconds = cameraRefresh.toIntOrNull() ?: 30, + cameraEndpoint = cameraEndpoint.trim() + ) + ) + }) { + Icon(Icons.Default.Check, contentDescription = "Save", tint = Amber) + } + } + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + SettingsSection("BEE DEVICE") { + SettingsField( + label = "Bee API URL", + value = beeApiUrl, + onValueChange = { beeApiUrl = it }, + hint = "http://192.168.0.10:5000" + ) + } + + SettingsSection("ADAMAPS") { + SettingsField( + label = "ADAMaps API URL", + value = adamapsApiUrl, + onValueChange = { adamapsApiUrl = it }, + hint = "https://api.adamaps.org" + ) + Spacer(Modifier.height(8.dp)) + SettingsField( + label = "API Key", + value = adamapsApiKey, + onValueChange = { adamapsApiKey = it }, + hint = "mapnet-ingest-2026" + ) + } + + SettingsSection("CAMERA") { + SettingsField( + label = "Camera Endpoint", + value = cameraEndpoint, + onValueChange = { cameraEndpoint = it }, + hint = "/api/1/camera/frame" + ) + Spacer(Modifier.height(8.dp)) + Text( + "Fallback endpoints tried automatically:\n" + + "/api/1/camera/frame • /api/1/camera/snapshot\n" + + "/api/1/preview • /api/1/frame", + color = Color.Gray, + fontFamily = FontFamily.Monospace, + fontSize = 10.sp, + lineHeight = 16.sp + ) + } + + SettingsSection("POLLING") { + SettingsField( + label = "Detection Poll Interval (seconds)", + value = pollInterval, + onValueChange = { pollInterval = it }, + hint = "30", + numeric = true + ) + Spacer(Modifier.height(8.dp)) + SettingsField( + label = "Camera Refresh Interval (seconds)", + value = cameraRefresh, + onValueChange = { cameraRefresh = it }, + hint = "30", + numeric = true + ) + } + } + } +} + +@Composable +private fun SettingsSection(title: String, content: @Composable ColumnScope.() -> Unit) { + Card( + colors = CardDefaults.cardColors(containerColor = Surface), + modifier = Modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.padding(14.dp)) { + Text( + title, + color = Amber, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + fontSize = 11.sp, + letterSpacing = 2.sp + ) + Spacer(Modifier.height(10.dp)) + content() + } + } +} + +@Composable +private fun SettingsField( + label: String, + value: String, + onValueChange: (String) -> Unit, + hint: String = "", + numeric: Boolean = false +) { + OutlinedTextField( + value = value, + onValueChange = onValueChange, + label = { Text(label, fontFamily = FontFamily.Monospace, fontSize = 11.sp) }, + placeholder = { Text(hint, color = Color.Gray, fontFamily = FontFamily.Monospace, fontSize = 12.sp) }, + singleLine = true, + keyboardOptions = if (numeric) KeyboardOptions(keyboardType = KeyboardType.Number) + else KeyboardOptions.Default, + modifier = Modifier.fillMaxWidth(), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Amber, + unfocusedBorderColor = SurfaceVariant, + focusedLabelColor = Amber, + unfocusedLabelColor = Color.Gray, + cursorColor = Amber, + focusedTextColor = OnSurface, + unfocusedTextColor = OnSurface + ) + ) +} diff --git a/app/src/main/java/com/adamaps/varroa/ui/theme/Theme.kt b/app/src/main/java/com/adamaps/varroa/ui/theme/Theme.kt new file mode 100644 index 0000000..3b614bf --- /dev/null +++ b/app/src/main/java/com/adamaps/varroa/ui/theme/Theme.kt @@ -0,0 +1,41 @@ +package com.adamaps.varroa.ui.theme + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +val Amber = Color(0xFFF59E0B) +val AmberDark = Color(0xFFB45309) +val AmberLight = Color(0xFFFCD34D) +val Background = Color(0xFF0A0A0A) +val Surface = Color(0xFF1A1A1A) +val SurfaceVariant = Color(0xFF2A2A2A) +val OnBackground = Color(0xFFE5E5E5) +val OnSurface = Color(0xFFD4D4D4) +val Error = Color(0xFFEF4444) +val Success = Color(0xFF22C55E) + +private val DarkColorScheme = darkColorScheme( + primary = Amber, + onPrimary = Color(0xFF1A1000), + primaryContainer = AmberDark, + onPrimaryContainer = AmberLight, + secondary = AmberLight, + onSecondary = Color(0xFF1A1000), + background = Background, + onBackground = OnBackground, + surface = Surface, + onSurface = OnSurface, + surfaceVariant = SurfaceVariant, + error = Error, + onError = Color.White +) + +@Composable +fun VarroaTheme(content: @Composable () -> Unit) { + MaterialTheme( + colorScheme = DarkColorScheme, + content = content + ) +} diff --git a/app/src/main/java/com/adamaps/varroa/viewmodel/DashboardViewModel.kt b/app/src/main/java/com/adamaps/varroa/viewmodel/DashboardViewModel.kt new file mode 100644 index 0000000..4fc34e1 --- /dev/null +++ b/app/src/main/java/com/adamaps/varroa/viewmodel/DashboardViewModel.kt @@ -0,0 +1,156 @@ +package com.adamaps.varroa.viewmodel + +import android.app.Application +import android.content.Intent +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.adamaps.varroa.api.BeeApiClient +import com.adamaps.varroa.data.ApiResult +import com.adamaps.varroa.data.BeeDeviceInfo +import com.adamaps.varroa.data.GnssData +import com.adamaps.varroa.data.SettingsDataStore +import com.adamaps.varroa.data.VarroaSettings +import com.adamaps.varroa.service.ForwardingService +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +class DashboardViewModel(app: Application) : AndroidViewModel(app) { + + private val settingsStore = SettingsDataStore(app) + private val beeClient = BeeApiClient() + + // ── Settings ────────────────────────────────────────────────────────────── + val settings: StateFlow = settingsStore.settings + .stateIn(viewModelScope, SharingStarted.Eagerly, VarroaSettings()) + + // ── Device info ─────────────────────────────────────────────────────────── + private val _deviceInfo = MutableStateFlow(null) + val deviceInfo: StateFlow = _deviceInfo.asStateFlow() + + // ── GPS ─────────────────────────────────────────────────────────────────── + private val _gnss = MutableStateFlow(null) + val gnss: StateFlow = _gnss.asStateFlow() + + // ── Camera ──────────────────────────────────────────────────────────────── + private val _cameraBytes = MutableStateFlow(null) + val cameraBytes: StateFlow = _cameraBytes.asStateFlow() + + private val _cameraEndpointWorking = MutableStateFlow(null) + + // ── Forwarding service state (exposed from singleton) ───────────────────── + val stats = ForwardingService.stats + val isForwarding = ForwardingService.isRunning + val beeConnected = ForwardingService.beeConnected + val adamapsReachable = ForwardingService.adamapsReachable + val lastError = ForwardingService.lastError + + // ── Error ───────────────────────────────────────────────────────────────── + private val _error = MutableStateFlow(null) + val error: StateFlow = _error.asStateFlow() + + private var gpsJob: Job? = null + private var deviceJob: Job? = null + private var cameraJob: Job? = null + + init { + viewModelScope.launch { + val s = settings.first() + applySettings(s) + startPolling(s) + } + viewModelScope.launch { + settings.collect { s -> + applySettings(s) + } + } + } + + private fun applySettings(s: VarroaSettings) { + beeClient.updateBaseUrl(s.beeApiUrl) + } + + private fun startPolling(s: VarroaSettings) { + startGpsLoop(5_000L) + startDeviceLoop(10_000L) + startCameraLoop(s.cameraRefreshSeconds * 1000L, s.cameraEndpoint) + } + + private fun startGpsLoop(intervalMs: Long) { + gpsJob?.cancel() + gpsJob = viewModelScope.launch { + while (true) { + when (val r = beeClient.getGnss()) { + is ApiResult.Success -> _gnss.value = r.data + is ApiResult.Error -> { /* ignore, GPS just won't update */ } + } + delay(intervalMs) + } + } + } + + private fun startDeviceLoop(intervalMs: Long) { + deviceJob?.cancel() + deviceJob = viewModelScope.launch { + while (true) { + when (val r = beeClient.getDeviceInfo()) { + is ApiResult.Success -> _deviceInfo.value = r.data + is ApiResult.Error -> { /* ignore */ } + } + delay(intervalMs) + } + } + } + + private fun startCameraLoop(intervalMs: Long, configuredEndpoint: String) { + cameraJob?.cancel() + cameraJob = viewModelScope.launch { + while (true) { + val ep = _cameraEndpointWorking.value ?: configuredEndpoint + val (foundEp, result) = beeClient.getCameraFrameAuto(ep) + when (result) { + is ApiResult.Success -> { + _cameraEndpointWorking.value = foundEp + _cameraBytes.value = result.data + } + is ApiResult.Error -> { /* camera unavailable */ } + } + delay(intervalMs) + } + } + } + + fun startForwarding() { + val ctx = getApplication() + val intent = Intent(ctx, ForwardingService::class.java).apply { + action = ForwardingService.ACTION_START + } + ctx.startForegroundService(intent) + } + + fun stopForwarding() { + val ctx = getApplication() + val intent = Intent(ctx, ForwardingService::class.java).apply { + action = ForwardingService.ACTION_STOP + } + ctx.startService(intent) + } + + fun toggleForwarding() { + if (isForwarding.value) stopForwarding() else startForwarding() + } + + // Restart polling loops when settings change + fun refreshPolling() { + viewModelScope.launch { + val s = settings.first() + startCameraLoop(s.cameraRefreshSeconds * 1000L, s.cameraEndpoint) + } + } +} diff --git a/app/src/main/java/com/adamaps/varroa/viewmodel/SettingsViewModel.kt b/app/src/main/java/com/adamaps/varroa/viewmodel/SettingsViewModel.kt new file mode 100644 index 0000000..a703b95 --- /dev/null +++ b/app/src/main/java/com/adamaps/varroa/viewmodel/SettingsViewModel.kt @@ -0,0 +1,35 @@ +package com.adamaps.varroa.viewmodel + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.adamaps.varroa.data.SettingsDataStore +import com.adamaps.varroa.data.VarroaSettings +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +class SettingsViewModel(app: Application) : AndroidViewModel(app) { + + private val store = SettingsDataStore(app) + + val settings: StateFlow = store.settings + .stateIn(viewModelScope, SharingStarted.Eagerly, VarroaSettings()) + + private val _saved = MutableStateFlow(false) + val saved: StateFlow = _saved.asStateFlow() + + fun save(s: VarroaSettings) { + viewModelScope.launch { + store.save(s) + _saved.value = true + } + } + + fun clearSaved() { + _saved.value = false + } +} diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..0f2972b --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..ea5eaeb --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..6b78462 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..6b78462 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..371878f --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,7 @@ + + + Varroa + Varroa Forwarding + Varroa — Forwarding Active + %d detections sent + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..484493a --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,8 @@ + + + + diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..cf0b9bc --- /dev/null +++ b/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,8 @@ + + + + 192.168.0.10 + 10.0.0.1 + + + diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..9deb573 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,5 @@ +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.kotlin.compose) apply false +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..8d9e3fe --- /dev/null +++ b/gradle.properties @@ -0,0 +1,7 @@ +android.useAndroidX=true +android.enableJetifier=true +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +org.gradle.parallel=true +kotlin.code.style=official +-e android.useAndroidX=true +android.enableJetifier=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..f5506f6 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,40 @@ +[versions] +agp = "8.3.0" +kotlin = "2.0.0" +coreKtx = "1.12.0" +lifecycleRuntimeKtx = "2.7.0" +activityCompose = "1.8.2" +composeBom = "2024.02.00" +navigationCompose = "2.7.7" +okhttp = "4.12.0" +gson = "2.10.1" +coroutines = "1.8.0" +osmdroid = "6.1.18" +datastore = "1.0.0" +coil = "2.6.0" + +[libraries] +androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } +androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" } +androidx-lifecycle-service = { group = "androidx.lifecycle", name = "lifecycle-service", version.ref = "lifecycleRuntimeKtx" } +androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } +androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } +androidx-ui = { group = "androidx.compose.ui", name = "ui" } +androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } +androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } +androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +androidx-material3 = { group = "androidx.compose.material3", name = "material3" } +androidx-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } +androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } +okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } +gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" } +kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } +osmdroid-android = { group = "org.osmdroid", name = "osmdroid-android", version.ref = "osmdroid" } +datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } +coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..d64cd49 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..a80b22c --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..82f94a7 --- /dev/null +++ b/gradlew @@ -0,0 +1,143 @@ +#!/bin/sh +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# Gradle start up script for UN*X +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +app_path=$0 +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${0##*/} + +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME" + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" + ;; + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # temporary variables. (They would be zero if the args list was empty.) + set -- "$@" "$arg" + shift + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +eval "set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS \"-Dorg.gradle.appname=$APP_BASE_NAME\" -classpath \"$CLASSPATH\" org.gradle.wrapper.GradleWrapperMain \"\$@\"" + +exec "$JAVACMD" "$@" diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..66af386 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "Varroa" +include(":app")