v7: store-and-forward architecture

BREAKING: Complete rewrite of data flow to fix network isolation issue.

Problem: When connected to Bee's WiFi AP (192.168.0.10), the phone has
NO internet access. The Bee AP is just a direct connection to the dashcam
with no upstream gateway. The old design tried to simultaneously pull from
Bee and push to ADAMaps, which fundamentally couldn't work.

Solution: Store-and-forward with two independent subsystems:

1. BeeCollectorService - Collection only
   - Binds to unvalidated WiFi (Bee AP)
   - Polls Bee for detections
   - Stores to local Room database
   - Does NOT attempt internet uploads

2. AdaMapsUploadWorker - Upload only
   - WorkManager-based background worker
   - Only runs when device has VALIDATED internet
   - Reads from local DB, batch uploads to ADAMaps
   - Marks as synced, retries with backoff

New components:
- Room database (DetectionEntity, DetectionDao, VarroaDatabase)
- NetworkStateMonitor for tracking validated vs unvalidated networks
- Improved UI with BEE/LOCAL/UPLOAD indicators
- Manual sync trigger when internet + pending data

Version: 1.7.0 (versionCode 7)
This commit is contained in:
Kayos 2026-03-11 11:11:05 -07:00
parent 2cebb4fb5d
commit 964d175454
14 changed files with 1296 additions and 86 deletions

View file

@ -2,6 +2,7 @@ plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.ksp)
}
android {
@ -12,8 +13,8 @@ android {
applicationId = "com.adamaps.varroa"
minSdk = 26
targetSdk = 34
versionCode = 5
versionName = "1.4.0"
versionCode = 7
versionName = "1.7.0"
vectorDrawables {
useSupportLibrary = true
@ -69,5 +70,11 @@ dependencies {
implementation(libs.osmdroid.android)
implementation(libs.datastore.preferences)
implementation(libs.coil.compose)
// Room (local database)
implementation(libs.room.runtime)
implementation(libs.room.ktx)
ksp(libs.room.compiler)
// WorkManager (background uploads)
implementation(libs.work.runtime.ktx)
debugImplementation(libs.androidx.ui.tooling)
}

View file

@ -33,6 +33,13 @@
</intent-filter>
</activity>
<!-- v7: New Bee Collector Service (store-and-forward) -->
<service
android:name=".service.BeeCollectorService"
android:exported="false"
android:foregroundServiceType="dataSync" />
<!-- Legacy ForwardingService (deprecated, kept for migration) -->
<service
android:name=".service.ForwardingService"
android:exported="false"

View file

@ -1,16 +1,35 @@
package com.adamaps.varroa
import android.app.Application
import com.adamaps.varroa.network.NetworkStateMonitor
import com.adamaps.varroa.service.AdaMapsUploadWorker
import org.osmdroid.config.Configuration
class VarroaApplication : Application() {
lateinit var networkMonitor: NetworkStateMonitor
private set
override fun onCreate() {
super.onCreate()
// osmdroid: set user-agent and tile cache
Configuration.getInstance().apply {
userAgentValue = "Varroa/1.0 (ADAMaps)"
userAgentValue = "Varroa/1.7 (ADAMaps)"
osmdroidBasePath = cacheDir
osmdroidTileCache = cacheDir
}
// Initialize network state monitoring
networkMonitor = NetworkStateMonitor.getInstance(this)
networkMonitor.startMonitoring()
// Schedule background uploads when internet is available
AdaMapsUploadWorker.schedule(this)
}
override fun onTerminate() {
networkMonitor.stopMonitoring()
super.onTerminate()
}
}

View file

@ -4,6 +4,7 @@ import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.util.Log
import com.adamaps.varroa.data.ApiResult
import com.adamaps.varroa.data.BeeDetection
import com.adamaps.varroa.data.BeeDeviceInfo
@ -22,6 +23,9 @@ class BeeApiClient(
private var primaryUrl: String = "http://192.168.0.10:5000",
private var altUrl: String = "http://192.168.0.155:5000"
) {
companion object {
private const val TAG = "BeeApiClient"
}
private var client = buildClient(null)
private var fastClient = buildFastClient(null)
@ -41,8 +45,23 @@ class BeeApiClient(
activeUrl = null
}
/**
* Bind to a specific network (e.g., unvalidated WiFi for Bee AP).
* This is the preferred method when using NetworkStateMonitor.
*/
fun bindToNetwork(network: Network) {
Log.d(TAG, "Binding to network: $network")
client = buildClient(network)
fastClient = buildFastClient(network)
}
/**
* Legacy binding method - finds WiFi network automatically.
* Prefer bindToNetwork() with explicit network from NetworkStateMonitor.
*/
fun bindToWifiNetwork(context: Context) {
val net = getWifiNetwork(context)
Log.d(TAG, "Legacy binding to WiFi: $net")
client = buildClient(net)
fastClient = buildFastClient(net)
}

View file

@ -0,0 +1,75 @@
package com.adamaps.varroa.data.local
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
@Dao
interface DetectionDao {
/**
* Insert a batch of detections. Ignores duplicates based on unique index.
*/
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertAll(detections: List<DetectionEntity>): List<Long>
/**
* Get a batch of unsynced detections for upload.
*/
@Query("SELECT * FROM detections WHERE synced = 0 ORDER BY createdAt ASC LIMIT :limit")
suspend fun getUnsyncedBatch(limit: Int = 50): List<DetectionEntity>
/**
* Mark detections as synced after successful upload.
*/
@Query("UPDATE detections SET synced = 1, syncedAt = :syncedAt WHERE id IN (:ids)")
suspend fun markSynced(ids: List<Long>, syncedAt: Long = System.currentTimeMillis())
/**
* Count of unsynced detections (for UI).
*/
@Query("SELECT COUNT(*) FROM detections WHERE synced = 0")
fun countUnsynced(): Flow<Int>
/**
* Count of synced detections (for UI stats).
*/
@Query("SELECT COUNT(*) FROM detections WHERE synced = 1")
fun countSynced(): Flow<Int>
/**
* Total count of all detections ever collected.
*/
@Query("SELECT COUNT(*) FROM detections")
fun countTotal(): Flow<Int>
/**
* Cleanup old synced records, keeping the most recent N.
* Call this periodically to prevent unbounded DB growth.
*/
@Query("""
DELETE FROM detections
WHERE synced = 1
AND id NOT IN (
SELECT id FROM detections
WHERE synced = 1
ORDER BY syncedAt DESC
LIMIT :keepCount
)
""")
suspend fun cleanupOldSynced(keepCount: Int = 1000): Int
/**
* Get the timestamp of the most recently collected detection.
*/
@Query("SELECT MAX(createdAt) FROM detections")
suspend fun getLastCollectedTime(): Long?
/**
* Check if a detection already exists (for dedup).
*/
@Query("SELECT EXISTS(SELECT 1 FROM detections WHERE beeDetectionId = :beeId AND timestamp = :ts)")
suspend fun exists(beeId: Long, ts: Long?): Boolean
}

View file

@ -0,0 +1,45 @@
package com.adamaps.varroa.data.local
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
/**
* Local storage for detections awaiting upload to ADAMaps.
* Survives app restarts and network changes.
*/
@Entity(
tableName = "detections",
indices = [
Index(value = ["synced"]),
Index(value = ["beeDetectionId", "timestamp"], unique = true)
]
)
data class DetectionEntity(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
// Device identification
val deviceId: String,
// Original detection ID from Bee API
val beeDetectionId: Long,
// Detection data
val classLabel: String?,
val classLabelConfidence: Double?,
val overallConfidence: Double?,
val timestamp: Long?,
val lat: Double?,
val lon: Double?,
val alt: Double?,
val width: Double?,
val height: Double?,
val posConfidence: Double?,
val azimuth: Double?,
// Sync state
val synced: Boolean = false,
val createdAt: Long = System.currentTimeMillis(),
val syncedAt: Long? = null
)

View file

@ -0,0 +1,35 @@
package com.adamaps.varroa.data.local
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
@Database(
entities = [DetectionEntity::class],
version = 1,
exportSchema = false
)
abstract class VarroaDatabase : RoomDatabase() {
abstract fun detectionDao(): DetectionDao
companion object {
@Volatile
private var INSTANCE: VarroaDatabase? = null
fun getInstance(context: Context): VarroaDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
VarroaDatabase::class.java,
"varroa_db"
)
.fallbackToDestructiveMigration()
.build()
INSTANCE = instance
instance
}
}
}
}

View file

@ -0,0 +1,246 @@
package com.adamaps.varroa.network
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.util.Log
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* Monitors network state to distinguish between:
* - Bee AP (unvalidated WiFi at 192.168.0.10) - good for collecting detections
* - Real internet (validated cellular or WiFi) - good for uploading to ADAMaps
*
* This is critical because the phone CANNOT reach the internet while connected
* to the Bee's WiFi AP - it has no upstream gateway.
*/
class NetworkStateMonitor(private val context: Context) {
companion object {
private const val TAG = "NetworkState"
@Volatile
private var INSTANCE: NetworkStateMonitor? = null
fun getInstance(context: Context): NetworkStateMonitor {
return INSTANCE ?: synchronized(this) {
val instance = NetworkStateMonitor(context.applicationContext)
INSTANCE = instance
instance
}
}
}
private val connectivityManager =
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
// Current network states
private val _hasValidatedInternet = MutableStateFlow(false)
val hasValidatedInternet: StateFlow<Boolean> = _hasValidatedInternet.asStateFlow()
private val _hasBeeNetwork = MutableStateFlow(false)
val hasBeeNetwork: StateFlow<Boolean> = _hasBeeNetwork.asStateFlow()
private val _beeNetwork = MutableStateFlow<Network?>(null)
val beeNetwork: StateFlow<Network?> = _beeNetwork.asStateFlow()
private val _internetNetwork = MutableStateFlow<Network?>(null)
val internetNetwork: StateFlow<Network?> = _internetNetwork.asStateFlow()
// Detailed status for UI
private val _networkStatus = MutableStateFlow("Initializing...")
val networkStatus: StateFlow<String> = _networkStatus.asStateFlow()
private var isMonitoring = false
// Callback for validated internet (real connectivity)
private val validatedCallback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
Log.d(TAG, "Validated network available: $network")
_hasValidatedInternet.value = true
_internetNetwork.value = network
updateStatus()
}
override fun onLost(network: Network) {
Log.d(TAG, "Validated network lost: $network")
if (_internetNetwork.value == network) {
_hasValidatedInternet.value = false
_internetNetwork.value = null
}
updateStatus()
}
override fun onCapabilitiesChanged(network: Network, caps: NetworkCapabilities) {
val hasInternet = caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
if (hasInternet) {
_hasValidatedInternet.value = true
_internetNetwork.value = network
}
updateStatus()
}
}
// Callback for unvalidated WiFi (Bee AP)
private val unvalidatedWifiCallback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
val caps = connectivityManager.getNetworkCapabilities(network)
val isWifi = caps?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true
val isValidated = caps?.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) == true
Log.d(TAG, "WiFi network available: $network, validated=$isValidated")
// Bee AP is unvalidated WiFi
if (isWifi && !isValidated) {
_hasBeeNetwork.value = true
_beeNetwork.value = network
updateStatus()
}
}
override fun onLost(network: Network) {
Log.d(TAG, "WiFi network lost: $network")
if (_beeNetwork.value == network) {
_hasBeeNetwork.value = false
_beeNetwork.value = null
}
updateStatus()
}
override fun onCapabilitiesChanged(network: Network, caps: NetworkCapabilities) {
val isWifi = caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
val isValidated = caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
if (isWifi && !isValidated) {
// This is likely the Bee AP
_hasBeeNetwork.value = true
_beeNetwork.value = network
} else if (_beeNetwork.value == network && isValidated) {
// Network became validated (switched to home WiFi)
_hasBeeNetwork.value = false
_beeNetwork.value = null
}
updateStatus()
}
}
private fun updateStatus() {
val bee = _hasBeeNetwork.value
val internet = _hasValidatedInternet.value
_networkStatus.value = when {
bee && internet -> "Bee AP + Internet"
bee -> "Bee AP (offline)"
internet -> "Online (no Bee)"
else -> "No network"
}
}
/**
* Start monitoring network changes.
* Call from Application.onCreate() or early in the app lifecycle.
*/
fun startMonitoring() {
if (isMonitoring) return
isMonitoring = true
// Request for validated internet (real connectivity)
val validatedRequest = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.addCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
.build()
// Request for WiFi (may be unvalidated Bee AP)
val wifiRequest = NetworkRequest.Builder()
.addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
.build()
try {
connectivityManager.registerNetworkCallback(validatedRequest, validatedCallback)
connectivityManager.registerNetworkCallback(wifiRequest, unvalidatedWifiCallback)
Log.d(TAG, "Network monitoring started")
// Initial state check
checkCurrentState()
} catch (e: Exception) {
Log.e(TAG, "Failed to register network callbacks", e)
}
}
/**
* Stop monitoring (call from onDestroy if needed).
*/
fun stopMonitoring() {
if (!isMonitoring) return
isMonitoring = false
try {
connectivityManager.unregisterNetworkCallback(validatedCallback)
connectivityManager.unregisterNetworkCallback(unvalidatedWifiCallback)
} catch (e: Exception) {
Log.e(TAG, "Failed to unregister network callbacks", e)
}
}
/**
* Check current network state (for initial sync).
*/
@Suppress("DEPRECATION")
private fun checkCurrentState() {
// Find all networks and categorize them
val networks = connectivityManager.allNetworks
var foundBee = false
var foundInternet = false
for (network in networks) {
val caps = connectivityManager.getNetworkCapabilities(network) ?: continue
val isWifi = caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
val isValidated = caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
val hasInternet = caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
if (isWifi && !isValidated) {
// Unvalidated WiFi = Bee AP
_hasBeeNetwork.value = true
_beeNetwork.value = network
foundBee = true
}
if (hasInternet && isValidated) {
// Validated internet connection
_hasValidatedInternet.value = true
_internetNetwork.value = network
foundInternet = true
}
}
if (!foundBee) {
_hasBeeNetwork.value = false
_beeNetwork.value = null
}
if (!foundInternet) {
_hasValidatedInternet.value = false
_internetNetwork.value = null
}
updateStatus()
}
/**
* Get the unvalidated WiFi network for binding Bee API requests.
* Returns null if Bee AP is not available.
*/
fun getBeeNetworkForBinding(): Network? = _beeNetwork.value
/**
* Get the validated internet network for ADAMaps requests.
* Returns null if no internet is available.
*/
fun getInternetNetworkForBinding(): Network? = _internetNetwork.value
}

View file

@ -0,0 +1,217 @@
package com.adamaps.varroa.service
import android.content.Context
import android.util.Log
import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import com.adamaps.varroa.api.AdaMapsApiClient
import com.adamaps.varroa.data.AdaMapsDetection
import com.adamaps.varroa.data.ApiResult
import com.adamaps.varroa.data.SettingsDataStore
import com.adamaps.varroa.data.local.DetectionEntity
import com.adamaps.varroa.data.local.VarroaDatabase
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import java.util.concurrent.TimeUnit
/**
* AdaMapsUploadWorker - Uploads stored detections to ADAMaps when internet is available.
*
* This is the SECOND HALF of the store-and-forward architecture:
* - Only runs when device has VALIDATED internet (not Bee AP)
* - Reads unsynced detections from local Room database
* - Uploads in batches to ADAMaps
* - Marks as synced after successful upload
* - WorkManager handles scheduling and retries
*/
class AdaMapsUploadWorker(
context: Context,
params: WorkerParameters
) : CoroutineWorker(context, params) {
companion object {
private const val TAG = "AdaMapsUploader"
private const val WORK_NAME = "adamaps_upload"
private const val BATCH_SIZE = 50
private const val CLEANUP_KEEP_COUNT = 1000
// Upload state for UI
private val _isUploading = MutableStateFlow(false)
val isUploading: StateFlow<Boolean> = _isUploading.asStateFlow()
private val _lastUploadTime = MutableStateFlow<Long?>(null)
val lastUploadTime: StateFlow<Long?> = _lastUploadTime.asStateFlow()
private val _uploadError = MutableStateFlow<String?>(null)
val uploadError: StateFlow<String?> = _uploadError.asStateFlow()
private val _totalUploaded = MutableStateFlow(0)
val totalUploaded: StateFlow<Int> = _totalUploaded.asStateFlow()
/**
* Schedule periodic uploads when connected to internet.
*/
fun schedule(context: Context) {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val uploadRequest = PeriodicWorkRequestBuilder<AdaMapsUploadWorker>(
15, TimeUnit.MINUTES, // Run every 15 minutes
5, TimeUnit.MINUTES // Flex interval
)
.setConstraints(constraints)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
WORK_NAME,
ExistingPeriodicWorkPolicy.KEEP,
uploadRequest
)
Log.d(TAG, "Upload worker scheduled")
}
/**
* Cancel scheduled uploads.
*/
fun cancel(context: Context) {
WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME)
Log.d(TAG, "Upload worker cancelled")
}
/**
* Trigger an immediate upload attempt.
*/
fun triggerNow(context: Context) {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val uploadRequest = androidx.work.OneTimeWorkRequestBuilder<AdaMapsUploadWorker>()
.setConstraints(constraints)
.build()
WorkManager.getInstance(context).enqueue(uploadRequest)
Log.d(TAG, "Immediate upload triggered")
}
}
private val database = VarroaDatabase.getInstance(applicationContext)
private val adamapsClient = AdaMapsApiClient()
private val settingsStore = SettingsDataStore(applicationContext)
override suspend fun doWork(): Result {
Log.d(TAG, "Upload worker starting")
_isUploading.value = true
_uploadError.value = null
try {
// Load settings
val settings = settingsStore.settings.first()
adamapsClient.updateConfig(settings.adamapsApiUrl, settings.adamapsApiKey)
// Process batches until no more unsynced detections
var totalUploaded = 0
var batchNum = 0
while (true) {
val batch = database.detectionDao().getUnsyncedBatch(BATCH_SIZE)
if (batch.isEmpty()) {
Log.d(TAG, "No more unsynced detections")
break
}
batchNum++
Log.d(TAG, "Uploading batch $batchNum with ${batch.size} detections")
when (val result = uploadBatch(batch)) {
is Result.Success -> {
// Mark as synced
val ids = batch.map { it.id }
database.detectionDao().markSynced(ids)
totalUploaded += batch.size
_totalUploaded.value += batch.size
Log.d(TAG, "Batch $batchNum uploaded successfully")
}
is Result.Retry -> {
// Network error, WorkManager will retry
Log.w(TAG, "Batch $batchNum failed, will retry")
_isUploading.value = false
return Result.retry()
}
is Result.Failure -> {
// Permanent error (shouldn't happen often)
Log.e(TAG, "Batch $batchNum failed permanently")
_isUploading.value = false
return Result.failure()
}
}
}
// Cleanup old synced records periodically
if (totalUploaded > 0) {
val deleted = database.detectionDao().cleanupOldSynced(CLEANUP_KEEP_COUNT)
if (deleted > 0) {
Log.d(TAG, "Cleaned up $deleted old synced records")
}
}
_lastUploadTime.value = System.currentTimeMillis()
_isUploading.value = false
Log.d(TAG, "Upload worker completed: $totalUploaded detections uploaded")
return Result.success()
} catch (e: Exception) {
Log.e(TAG, "Upload worker error", e)
_uploadError.value = e.message
_isUploading.value = false
return Result.retry()
}
}
private suspend fun uploadBatch(batch: List<DetectionEntity>): Result {
// Convert to ADAMaps format
val request = batch.map { entity ->
AdaMapsDetection(
deviceId = entity.deviceId,
id = entity.beeDetectionId,
classLabel = entity.classLabel,
classLabelConfidence = entity.classLabelConfidence,
overallConfidence = entity.overallConfidence,
ts = entity.timestamp,
lat = entity.lat,
lon = entity.lon,
alt = entity.alt,
width = entity.width,
height = entity.height,
posConfidence = entity.posConfidence,
azimuth = entity.azimuth
)
}
return when (val result = adamapsClient.ingest(request)) {
is ApiResult.Success -> {
Result.success()
}
is ApiResult.Error -> {
_uploadError.value = result.message
// Determine if this is a retryable error
val isRetryable = result.code == null || // Network error
result.code >= 500 || // Server error
result.code == 429 // Rate limited
if (isRetryable) Result.retry() else Result.failure()
}
}
}
}

View file

@ -0,0 +1,328 @@
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 android.util.Log
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.BeeApiClient
import com.adamaps.varroa.data.ApiResult
import com.adamaps.varroa.data.BeeDetection
import com.adamaps.varroa.data.SettingsDataStore
import com.adamaps.varroa.data.local.DetectionEntity
import com.adamaps.varroa.data.local.VarroaDatabase
import com.adamaps.varroa.network.NetworkStateMonitor
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
import kotlin.math.min
/**
* BeeCollectorService - Collects detections from the Bee and stores them locally.
*
* This is ONE HALF of the store-and-forward architecture:
* - This service ONLY collects from Bee (via unvalidated WiFi)
* - Stores to local Room database
* - Does NOT attempt any internet uploads
*
* The AdaMapsUploadWorker handles uploads separately when internet is available.
*/
class BeeCollectorService : LifecycleService() {
companion object {
private const val TAG = "BeeCollector"
const val NOTIF_CHANNEL_ID = "varroa_collector"
const val NOTIF_ID = 1001
const val ACTION_START = "com.adamaps.varroa.START_COLLECTOR"
const val ACTION_STOP = "com.adamaps.varroa.STOP_COLLECTOR"
// Exponential backoff constants
private const val MIN_BACKOFF_MS = 5_000L
private const val MAX_BACKOFF_MS = 60_000L
private const val BACKOFF_MULTIPLIER = 2.0
// Shared state for UI
private val _isRunning = MutableStateFlow(false)
val isRunning: StateFlow<Boolean> = _isRunning.asStateFlow()
private val _beeConnected = MutableStateFlow(false)
val beeConnected: StateFlow<Boolean> = _beeConnected.asStateFlow()
private val _beeStatus = MutableStateFlow("Stopped")
val beeStatus: StateFlow<String> = _beeStatus.asStateFlow()
private val _lastError = MutableStateFlow<String?>(null)
val lastError: StateFlow<String?> = _lastError.asStateFlow()
private val _sessionCollected = MutableStateFlow(0)
val sessionCollected: StateFlow<Int> = _sessionCollected.asStateFlow()
// Device ID cache
private val _currentDeviceId = MutableStateFlow("unknown")
val currentDeviceId: StateFlow<String> = _currentDeviceId.asStateFlow()
}
private lateinit var beeClient: BeeApiClient
private lateinit var database: VarroaDatabase
private lateinit var networkMonitor: NetworkStateMonitor
private lateinit var settingsStore: SettingsDataStore
private val seenKeys = mutableSetOf<String>()
private var pollJob: Job? = null
private var currentBackoffMs = MIN_BACKOFF_MS
private var consecutiveFailures = 0
override fun onCreate() {
super.onCreate()
createNotificationChannel()
beeClient = BeeApiClient()
database = VarroaDatabase.getInstance(applicationContext)
networkMonitor = NetworkStateMonitor.getInstance(applicationContext)
settingsStore = SettingsDataStore(applicationContext)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId)
when (intent?.action) {
ACTION_STOP -> {
stopCollecting()
stopSelf()
}
else -> startCollecting()
}
return START_STICKY
}
override fun onBind(intent: Intent): IBinder? {
super.onBind(intent)
return null
}
override fun onDestroy() {
stopCollecting()
super.onDestroy()
}
private fun startCollecting() {
startForeground(NOTIF_ID, buildNotification(0))
_isRunning.value = true
_beeStatus.value = "Connecting..."
_sessionCollected.value = 0
lifecycleScope.launch {
val settings = settingsStore.settings.first()
beeClient.updateUrls(settings.beeApiUrl, settings.beeAltApiUrl)
// Bind to Bee network (unvalidated WiFi)
bindToBeeNetwork()
// Fetch device ID once
fetchDeviceId()
// Start polling loop
startPollLoop(settings.pollIntervalSeconds)
}
}
private fun stopCollecting() {
pollJob?.cancel()
_isRunning.value = false
_beeStatus.value = "Stopped"
_beeConnected.value = false
}
private fun bindToBeeNetwork() {
// Use NetworkStateMonitor to get the unvalidated WiFi network (Bee AP)
val beeNet = networkMonitor.getBeeNetworkForBinding()
if (beeNet != null) {
beeClient.bindToNetwork(beeNet)
Log.d(TAG, "Bound to Bee network: $beeNet")
} else {
// Fallback to legacy binding
beeClient.bindToWifiNetwork(applicationContext)
Log.d(TAG, "Using legacy WiFi binding")
}
}
private suspend fun fetchDeviceId() {
when (val result = beeClient.getDeviceInfo()) {
is ApiResult.Success -> {
val deviceId = result.data.deviceId ?: result.data.serial ?: "unknown"
_currentDeviceId.value = deviceId
Log.d(TAG, "Device ID: $deviceId")
}
is ApiResult.Error -> {
Log.w(TAG, "Failed to get device ID: ${result.message}")
}
}
}
private fun startPollLoop(intervalSeconds: Int) {
pollJob?.cancel()
pollJob = lifecycleScope.launch {
while (true) {
// Re-bind to Bee network periodically in case it changed
bindToBeeNetwork()
runPollCycle()
val delayMs = if (_beeConnected.value) {
intervalSeconds * 1000L
} else {
currentBackoffMs
}
delay(delayMs)
}
}
}
private suspend fun runPollCycle() {
when (val result = beeClient.getLandmarks()) {
is ApiResult.Success -> {
_beeConnected.value = true
consecutiveFailures = 0
currentBackoffMs = MIN_BACKOFF_MS
_lastError.value = null
val activeUrl = beeClient.getActiveUrl()
_beeStatus.value = if (activeUrl?.contains("155") == true) {
"Connected (home WiFi)"
} else {
"Connected (AP mode)"
}
// Filter for new detections
val newDetections = result.data.filter { d ->
val key = d.dedupKey()
if (seenKeys.contains(key)) false
else { seenKeys.add(key); true }
}
if (newDetections.isNotEmpty()) {
storeDetections(newDetections)
}
updateNotification(_sessionCollected.value)
}
is ApiResult.Error -> {
_beeConnected.value = false
consecutiveFailures++
currentBackoffMs = min(
(MIN_BACKOFF_MS * Math.pow(BACKOFF_MULTIPLIER, (consecutiveFailures - 1).toDouble())).toLong(),
MAX_BACKOFF_MS
)
if (result.message == "Bee offline" ||
result.message.contains("timeout", ignoreCase = true) ||
result.message.contains("connect", ignoreCase = true) ||
result.message.contains("refused", ignoreCase = true) ||
result.message.contains("unreachable", ignoreCase = true)) {
_beeStatus.value = "Bee offline (retry in ${currentBackoffMs / 1000}s)"
_lastError.value = null
} else {
_beeStatus.value = "Connection error"
_lastError.value = result.message
}
}
}
}
private suspend fun storeDetections(detections: List<BeeDetection>) {
val deviceId = _currentDeviceId.value
val entities = detections.map { d ->
DetectionEntity(
deviceId = deviceId,
beeDetectionId = d.id,
classLabel = d.classLabel,
classLabelConfidence = d.classLabelConfidence,
overallConfidence = d.overallConfidence,
timestamp = d.ts,
lat = d.lat,
lon = d.lon,
alt = d.alt,
width = d.width,
height = d.height,
posConfidence = d.posConfidence,
azimuth = d.azimuth
)
}
try {
val inserted = database.detectionDao().insertAll(entities)
val newCount = inserted.count { it != -1L }
if (newCount > 0) {
_sessionCollected.update { it + newCount }
Log.d(TAG, "Stored $newCount new detections (${inserted.size - newCount} duplicates)")
}
} catch (e: Exception) {
Log.e(TAG, "Failed to store detections", e)
_lastError.value = "DB error: ${e.message}"
}
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
NOTIF_CHANNEL_ID,
getString(R.string.collector_notification_channel),
NotificationManager.IMPORTANCE_LOW
).apply {
description = "Varroa detection collection"
setShowBadge(false)
}
val nm = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
nm.createNotificationChannel(channel)
}
}
private fun buildNotification(collectedCount: 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, BeeCollectorService::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.collector_notification_title))
.setContentText(getString(R.string.collector_notification_text, collectedCount))
.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(collectedCount: Int) {
val nm = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
nm.notify(NOTIF_ID, buildNotification(collectedCount))
}
}

View file

@ -4,6 +4,7 @@ import android.graphics.BitmapFactory
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
@ -27,7 +28,6 @@ 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
@ -44,12 +44,30 @@ fun DashboardScreen(
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()
// Collection state
val isCollecting by vm.isCollecting.collectAsState()
val beeConnected by vm.beeConnected.collectAsState()
val adamapsReachable by vm.adamapsReachable.collectAsState()
val lastError by vm.lastError.collectAsState()
val beeStatus by vm.beeStatus.collectAsState()
val beeError by vm.beeError.collectAsState()
val sessionCollected by vm.sessionCollected.collectAsState()
// Upload state
val isUploading by vm.isUploading.collectAsState()
val hasInternet by vm.hasInternet.collectAsState()
val uploadError by vm.uploadError.collectAsState()
val totalUploaded by vm.totalUploaded.collectAsState()
// Local storage stats
val localUnsynced by vm.localUnsynced.collectAsState()
val localTotal by vm.localTotal.collectAsState()
val localSynced by vm.localSynced.collectAsState()
// Network status
val networkStatus by vm.networkStatus.collectAsState()
// Combined error display
val displayError = beeError ?: uploadError
Scaffold(
containerColor = Background,
@ -82,22 +100,31 @@ fun DashboardScreen(
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
// Connection status bar
ConnectionStatusBar(
// Connection status bar (v7 store-and-forward)
ConnectionStatusBarV7(
beeConnected = beeConnected,
beeStatus = beeStatus,
adamapsReachable = adamapsReachable,
isForwarding = isForwarding,
onToggle = { vm.toggleForwarding() }
localUnsynced = localUnsynced,
hasInternet = hasInternet,
isUploading = isUploading,
isCollecting = isCollecting,
networkStatus = networkStatus,
onToggle = { vm.toggleCollecting() },
onTriggerUpload = { vm.triggerUpload() }
)
// Error banner (only show for actual errors, not "offline" state)
if (lastError != null) {
ErrorBanner(lastError!!)
// Error banner
if (displayError != null) {
ErrorBanner(displayError!!)
}
// Session stats
SessionStatsCard(stats)
// Session stats (v7 format)
SessionStatsCardV7(
sessionCollected = sessionCollected,
localUnsynced = localUnsynced,
totalUploaded = totalUploaded,
localTotal = localTotal
)
// Camera snapshot
CameraCard(cameraBytes, beeConnected)
@ -112,12 +139,16 @@ fun DashboardScreen(
}
@Composable
private fun ConnectionStatusBar(
private fun ConnectionStatusBarV7(
beeConnected: Boolean,
beeStatus: String,
adamapsReachable: Boolean,
isForwarding: Boolean,
onToggle: () -> Unit
localUnsynced: Int,
hasInternet: Boolean,
isUploading: Boolean,
isCollecting: Boolean,
networkStatus: String,
onToggle: () -> Unit,
onTriggerUpload: () -> Unit
) {
Card(
colors = CardDefaults.cardColors(containerColor = Surface),
@ -131,32 +162,47 @@ private fun ConnectionStatusBar(
verticalAlignment = Alignment.CenterVertically
) {
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
// BEE indicator
StatusDot(
label = "BEE",
active = beeConnected
active = beeConnected,
activeColor = Success
)
// LOCAL indicator (shows unsynced count)
StatusDotWithCount(
label = "LOCAL",
count = localUnsynced,
hasData = localUnsynced > 0,
activeColor = Amber
)
// ADAMAPS indicator (shows internet + upload state)
StatusDot(
label = "ADAMAPS",
active = adamapsReachable
label = "UPLOAD",
active = hasInternet && isUploading,
waiting = hasInternet && !isUploading && localUnsynced > 0,
activeColor = Success,
waitingColor = Color(0xFF4A90D9) // Blue for "ready to upload"
)
}
// Forwarding toggle
// Collection toggle
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = if (isForwarding) "FORWARDING" else "PAUSED",
color = if (isForwarding) Amber else Color.Gray,
text = if (isCollecting) "COLLECTING" else "PAUSED",
color = if (isCollecting) Amber else Color.Gray,
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 1.sp
)
Spacer(Modifier.width(8.dp))
Switch(
checked = isForwarding,
checked = isCollecting,
onCheckedChange = { onToggle() },
colors = SwitchDefaults.colors(
checkedThumbColor = Amber,
@ -167,35 +213,98 @@ private fun ConnectionStatusBar(
)
}
}
// Show Bee status text below the status dots
Spacer(Modifier.height(6.dp))
Text(
text = beeStatus,
color = if (beeConnected) Color.Gray else Color(0xFF9CA3AF),
fontFamily = FontFamily.Monospace,
fontSize = 10.sp
)
// Status line
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = beeStatus,
color = if (beeConnected) Color.Gray else Color(0xFF9CA3AF),
fontFamily = FontFamily.Monospace,
fontSize = 10.sp
)
// Manual upload trigger (when there's data and internet)
if (hasInternet && localUnsynced > 0 && !isUploading) {
Text(
text = "TAP TO SYNC",
color = Amber,
fontFamily = FontFamily.Monospace,
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier
.clip(RoundedCornerShape(4.dp))
.clickable { onTriggerUpload() }
.background(AmberDark.copy(alpha = 0.3f))
.padding(horizontal = 8.dp, vertical = 2.dp)
)
}
}
}
}
}
@Composable
private fun StatusDot(label: String, active: Boolean) {
private fun StatusDot(
label: String,
active: Boolean,
waiting: Boolean = false,
activeColor: Color = Success,
waitingColor: Color = Color.Blue
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Box(
modifier = Modifier
.size(8.dp)
.background(
color = if (active) Success else Color(0xFF6B7280),
color = when {
active -> activeColor
waiting -> waitingColor
else -> Color(0xFF6B7280)
},
shape = RoundedCornerShape(50)
)
)
Text(
text = label,
color = if (active) OnSurface else Color.Gray,
color = if (active || waiting) OnSurface else Color.Gray,
fontFamily = FontFamily.Monospace,
fontSize = 10.sp,
letterSpacing = 1.sp
)
}
}
@Composable
private fun StatusDotWithCount(
label: String,
count: Int,
hasData: Boolean,
activeColor: Color = Amber
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Box(
modifier = Modifier
.size(8.dp)
.background(
color = if (hasData) activeColor else Color(0xFF6B7280),
shape = RoundedCornerShape(50)
)
)
Text(
text = if (hasData) "$label:$count" else label,
color = if (hasData) OnSurface else Color.Gray,
fontFamily = FontFamily.Monospace,
fontSize = 10.sp,
letterSpacing = 1.sp
@ -229,29 +338,51 @@ private fun ErrorBanner(message: String) {
}
@Composable
private fun SessionStatsCard(stats: SessionStats) {
private fun SessionStatsCardV7(
sessionCollected: Int,
localUnsynced: Int,
totalUploaded: Int,
localTotal: Int
) {
Card(
colors = CardDefaults.cardColors(containerColor = Surface),
shape = RoundedCornerShape(8.dp),
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.padding(14.dp)) {
SectionHeader("SESSION STATS")
SectionHeader("STORE & FORWARD")
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())
StatItem("COLLECTED", sessionCollected.toString(), subtitle = "session")
StatItem("LOCAL", localUnsynced.toString(), subtitle = "pending")
StatItem("UPLOADED", totalUploaded.toString(), subtitle = "session")
}
// Total stats line
Spacer(Modifier.height(8.dp))
Divider(color = SurfaceVariant, thickness = 1.dp)
Spacer(Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
Text(
text = "Total stored: $localTotal detections",
color = Color.Gray,
fontFamily = FontFamily.Monospace,
fontSize = 10.sp
)
}
}
}
}
@Composable
private fun StatItem(label: String, value: String) {
private fun StatItem(label: String, value: String, subtitle: String? = null) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = value,
@ -267,6 +398,14 @@ private fun StatItem(label: String, value: String) {
fontSize = 9.sp,
letterSpacing = 1.sp
)
if (subtitle != null) {
Text(
text = subtitle,
color = Color(0xFF6B7280),
fontFamily = FontFamily.Monospace,
fontSize = 8.sp
)
}
}
}
@ -290,7 +429,6 @@ private fun CameraCard(bytes: ByteArray?, beeConnected: Boolean) {
) {
when {
!beeConnected -> {
// Show "Bee offline" instead of "Awaiting frame"
CameraPlaceholder("Bee offline", Icons.Default.CloudOff)
}
bytes != null && bytes.isNotEmpty() -> {

View file

@ -10,7 +10,10 @@ 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 com.adamaps.varroa.data.local.VarroaDatabase
import com.adamaps.varroa.network.NetworkStateMonitor
import com.adamaps.varroa.service.AdaMapsUploadWorker
import com.adamaps.varroa.service.BeeCollectorService
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
@ -21,10 +24,20 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
/**
* ViewModel for the Dashboard screen.
*
* v7 Store-and-Forward architecture:
* - BeeCollectorService handles collection from Bee (stores to local DB)
* - AdaMapsUploadWorker handles uploads (when internet is available)
* - This ViewModel displays stats from both systems
*/
class DashboardViewModel(app: Application) : AndroidViewModel(app) {
private val settingsStore = SettingsDataStore(app)
private val beeClient = BeeApiClient()
private val database = VarroaDatabase.getInstance(app)
private val networkMonitor = NetworkStateMonitor.getInstance(app)
// ── Settings ──────────────────────────────────────────────────────────────
val settings: StateFlow<VarroaSettings> = settingsStore.settings
@ -44,18 +57,40 @@ class DashboardViewModel(app: Application) : AndroidViewModel(app) {
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
val beeStatus = ForwardingService.beeStatus
// ── Collector service state ───────────────────────────────────────────────
val isCollecting = BeeCollectorService.isRunning
val beeConnected = BeeCollectorService.beeConnected
val beeStatus = BeeCollectorService.beeStatus
val beeError = BeeCollectorService.lastError
val sessionCollected = BeeCollectorService.sessionCollected
// ── Error ─────────────────────────────────────────────────────────────────
private val _error = MutableStateFlow<String?>(null)
val error: StateFlow<String?> = _error.asStateFlow()
// ── Uploader state ────────────────────────────────────────────────────────
val isUploading = AdaMapsUploadWorker.isUploading
val uploadError = AdaMapsUploadWorker.uploadError
val totalUploaded = AdaMapsUploadWorker.totalUploaded
// ── Network state ─────────────────────────────────────────────────────────
val hasInternet = networkMonitor.hasValidatedInternet
val hasBeeNetwork = networkMonitor.hasBeeNetwork
val networkStatus = networkMonitor.networkStatus
// ── Local database stats (Flow-based, auto-updates) ──────────────────────
val localUnsynced: StateFlow<Int> = database.detectionDao().countUnsynced()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0)
val localTotal: StateFlow<Int> = database.detectionDao().countTotal()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0)
val localSynced: StateFlow<Int> = database.detectionDao().countSynced()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0)
// ── Legacy compatibility ──────────────────────────────────────────────────
// For UI components that still reference old ForwardingService states
val isForwarding = isCollecting
val adamapsReachable = hasInternet
val lastError = beeError
// ── Polling jobs ──────────────────────────────────────────────────────────
private var gpsJob: Job? = null
private var deviceJob: Job? = null
private var cameraJob: Job? = null
@ -75,7 +110,14 @@ class DashboardViewModel(app: Application) : AndroidViewModel(app) {
private fun applySettings(s: VarroaSettings) {
beeClient.updateUrls(s.beeApiUrl, s.beeAltApiUrl)
beeClient.bindToWifiNetwork(getApplication())
// Bind to Bee network if available
val beeNet = networkMonitor.getBeeNetworkForBinding()
if (beeNet != null) {
beeClient.bindToNetwork(beeNet)
} else {
beeClient.bindToWifiNetwork(getApplication())
}
}
private fun startPolling(s: VarroaSettings) {
@ -88,9 +130,11 @@ class DashboardViewModel(app: Application) : AndroidViewModel(app) {
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 */ }
if (beeConnected.value) {
when (val r = beeClient.getGnss()) {
is ApiResult.Success -> _gnss.value = r.data
is ApiResult.Error -> { /* ignore */ }
}
}
delay(intervalMs)
}
@ -101,9 +145,11 @@ class DashboardViewModel(app: Application) : AndroidViewModel(app) {
deviceJob?.cancel()
deviceJob = viewModelScope.launch {
while (true) {
when (val r = beeClient.getDeviceInfo()) {
is ApiResult.Success -> _deviceInfo.value = r.data
is ApiResult.Error -> { /* ignore */ }
if (beeConnected.value) {
when (val r = beeClient.getDeviceInfo()) {
is ApiResult.Success -> _deviceInfo.value = r.data
is ApiResult.Error -> { /* ignore */ }
}
}
delay(intervalMs)
}
@ -114,44 +160,57 @@ class DashboardViewModel(app: Application) : AndroidViewModel(app) {
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 -> {
// Clear camera bytes when Bee is offline
_cameraBytes.value = null
if (beeConnected.value) {
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 -> {
_cameraBytes.value = null
}
}
} else {
_cameraBytes.value = null
}
delay(intervalMs)
}
}
}
fun startForwarding() {
// ── Service control ───────────────────────────────────────────────────────
fun startCollecting() {
val ctx = getApplication<Application>()
val intent = Intent(ctx, ForwardingService::class.java).apply {
action = ForwardingService.ACTION_START
val intent = Intent(ctx, BeeCollectorService::class.java).apply {
action = BeeCollectorService.ACTION_START
}
ctx.startForegroundService(intent)
}
fun stopForwarding() {
fun stopCollecting() {
val ctx = getApplication<Application>()
val intent = Intent(ctx, ForwardingService::class.java).apply {
action = ForwardingService.ACTION_STOP
val intent = Intent(ctx, BeeCollectorService::class.java).apply {
action = BeeCollectorService.ACTION_STOP
}
ctx.startService(intent)
}
fun toggleForwarding() {
if (isForwarding.value) stopForwarding() else startForwarding()
fun toggleCollecting() {
if (isCollecting.value) stopCollecting() else startCollecting()
}
// Restart polling loops when settings change
fun triggerUpload() {
AdaMapsUploadWorker.triggerNow(getApplication())
}
// Legacy compatibility
fun startForwarding() = startCollecting()
fun stopForwarding() = stopCollecting()
fun toggleForwarding() = toggleCollecting()
fun refreshPolling() {
viewModelScope.launch {
val s = settings.first()

View file

@ -1,7 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Varroa</string>
<!-- Old forwarding service (deprecated) -->
<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>
<!-- New collector service -->
<string name="collector_notification_channel">Varroa Collector</string>
<string name="collector_notification_title">Varroa — Collecting</string>
<string name="collector_notification_text">%d detections collected</string>
</resources>

View file

@ -1,6 +1,7 @@
[versions]
agp = "8.3.0"
kotlin = "2.0.0"
ksp = "2.0.0-1.0.21"
coreKtx = "1.12.0"
lifecycleRuntimeKtx = "2.7.0"
activityCompose = "1.8.2"
@ -12,6 +13,8 @@ coroutines = "1.8.0"
osmdroid = "6.1.18"
datastore = "1.0.0"
coil = "2.6.0"
room = "2.6.1"
work = "2.9.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@ -33,8 +36,13 @@ kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-
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" }
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work" }
[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" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }