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
This commit is contained in:
kayos 2026-03-10 12:19:48 -07:00
commit 71bb4e16b9
31 changed files with 1963 additions and 0 deletions

9
.gitignore vendored Normal file
View file

@ -0,0 +1,9 @@
*.apk
*.aab
*.class
.gradle/
build/
local.properties
.idea/
*.iml
.DS_Store

73
app/build.gradle.kts Normal file
View file

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

2
app/proguard-rules.pro vendored Normal file
View file

@ -0,0 +1,2 @@
-keep class com.adamaps.varroa.data.** { *; }
-keep class org.osmdroid.** { *; }

View file

@ -0,0 +1,43 @@
<?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-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28"
tools:ignore="ScopedStorage" />
<application
android:name=".VarroaApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.Varroa"
android:networkSecurityConfig="@xml/network_security_config"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.Varroa">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".service.ForwardingService"
android:exported="false"
android:foregroundServiceType="dataSync" />
</application>
</manifest>

View file

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

View file

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

View file

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

View file

@ -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<String> = 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
}
}
}

View file

@ -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<String> = 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<ByteArray> = 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<List<BeeDetection>> = withContext(Dispatchers.IO) {
when (val r = getRaw("/api/1/landmarks/last/200")) {
is ApiResult.Success -> try {
val type = object : TypeToken<List<BeeDetection>>() {}.type
val list: List<BeeDetection> = 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<GnssData> = 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<BeeDeviceInfo> = 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<ByteArray> = getBytes(endpoint)
/**
* Try multiple camera endpoints in order, return first success.
*/
suspend fun getCameraFrameAuto(configured: String): Pair<String, ApiResult<ByteArray>> {
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
}

View file

@ -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<AdaMapsDetection>
)
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<out T> {
data class Success<T>(val data: T) : ApiResult<T>()
data class Error(val message: String, val code: Int? = null) : ApiResult<Nothing>()
}

View file

@ -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<Preferences> 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<VarroaSettings> = 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
}
}
}

View file

@ -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<SessionStats> = _stats.asStateFlow()
private val _isRunning = MutableStateFlow(false)
val isRunning: StateFlow<Boolean> = _isRunning.asStateFlow()
private val _lastError = MutableStateFlow<String?>(null)
val lastError: StateFlow<String?> = _lastError.asStateFlow()
private val _beeConnected = MutableStateFlow(false)
val beeConnected: StateFlow<Boolean> = _beeConnected.asStateFlow()
private val _adamapsReachable = MutableStateFlow(false)
val adamapsReachable: StateFlow<Boolean> = _adamapsReachable.asStateFlow()
}
private val beeClient = BeeApiClient()
private val adamapsClient = AdaMapsApiClient()
private val seenKeys = mutableSetOf<String>()
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<BeeDetection>) {
// 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))
}
}

View file

@ -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<Pair<String, String>> = 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)
}

View file

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

View file

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

View file

@ -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<VarroaSettings> = settingsStore.settings
.stateIn(viewModelScope, SharingStarted.Eagerly, VarroaSettings())
// ── Device info ───────────────────────────────────────────────────────────
private val _deviceInfo = MutableStateFlow<BeeDeviceInfo?>(null)
val deviceInfo: StateFlow<BeeDeviceInfo?> = _deviceInfo.asStateFlow()
// ── GPS ───────────────────────────────────────────────────────────────────
private val _gnss = MutableStateFlow<GnssData?>(null)
val gnss: StateFlow<GnssData?> = _gnss.asStateFlow()
// ── Camera ────────────────────────────────────────────────────────────────
private val _cameraBytes = MutableStateFlow<ByteArray?>(null)
val cameraBytes: StateFlow<ByteArray?> = _cameraBytes.asStateFlow()
private val _cameraEndpointWorking = MutableStateFlow<String?>(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<String?>(null)
val error: StateFlow<String?> = _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<Application>()
val intent = Intent(ctx, ForwardingService::class.java).apply {
action = ForwardingService.ACTION_START
}
ctx.startForegroundService(intent)
}
fun stopForwarding() {
val ctx = getApplication<Application>()
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)
}
}
}

View file

@ -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<VarroaSettings> = store.settings
.stateIn(viewModelScope, SharingStarted.Eagerly, VarroaSettings())
private val _saved = MutableStateFlow(false)
val saved: StateFlow<Boolean> = _saved.asStateFlow()
fun save(s: VarroaSettings) {
viewModelScope.launch {
store.save(s)
_saved.value = true
}
}
fun clearSaved() {
_saved.value = false
}
}

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#0A0A0A" />
</shape>

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#F59E0B"
android:pathData="M54,20 C35.2,20 20,35.2 20,54 C20,72.8 35.2,88 54,88 C72.8,88 88,72.8 88,54 C88,35.2 72.8,20 54,20 Z M54,30 C67.3,30 78,40.7 78,54 C78,67.3 67.3,78 54,78 C40.7,78 30,67.3 30,54 C30,40.7 40.7,30 54,30 Z" />
<path
android:fillColor="#F59E0B"
android:pathData="M54,40 L58,50 L68,50 L60,57 L63,68 L54,61 L45,68 L48,57 L40,50 L50,50 Z" />
</vector>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Varroa</string>
<string name="forwarding_notification_channel">Varroa Forwarding</string>
<string name="forwarding_notification_title">Varroa — Forwarding Active</string>
<string name="forwarding_notification_text">%d detections sent</string>
</resources>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Varroa" parent="android:Theme.DeviceDefault.NoActionBar">
<item name="android:statusBarColor">@android:color/black</item>
<item name="android:navigationBarColor">@android:color/black</item>
<item name="android:windowBackground">@android:color/black</item>
</style>
</resources>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">192.168.0.10</domain>
<domain includeSubdomains="true">10.0.0.1</domain>
</domain-config>
<base-config cleartextTrafficPermitted="false" />
</network-security-config>

5
build.gradle.kts Normal file
View file

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

7
gradle.properties Normal file
View file

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

40
gradle/libs.versions.toml Normal file
View file

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

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View file

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

143
gradlew vendored Executable file
View file

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

18
settings.gradle.kts Normal file
View file

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