diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 5b59701..ddd662e 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -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)
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 6425d42..7515f46 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -33,6 +33,13 @@
+
+
+
+
): List
+
+ /**
+ * 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
+
+ /**
+ * Mark detections as synced after successful upload.
+ */
+ @Query("UPDATE detections SET synced = 1, syncedAt = :syncedAt WHERE id IN (:ids)")
+ suspend fun markSynced(ids: List, syncedAt: Long = System.currentTimeMillis())
+
+ /**
+ * Count of unsynced detections (for UI).
+ */
+ @Query("SELECT COUNT(*) FROM detections WHERE synced = 0")
+ fun countUnsynced(): Flow
+
+ /**
+ * Count of synced detections (for UI stats).
+ */
+ @Query("SELECT COUNT(*) FROM detections WHERE synced = 1")
+ fun countSynced(): Flow
+
+ /**
+ * Total count of all detections ever collected.
+ */
+ @Query("SELECT COUNT(*) FROM detections")
+ fun countTotal(): Flow
+
+ /**
+ * 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
+}
diff --git a/app/src/main/java/com/adamaps/varroa/data/local/DetectionEntity.kt b/app/src/main/java/com/adamaps/varroa/data/local/DetectionEntity.kt
new file mode 100644
index 0000000..0684857
--- /dev/null
+++ b/app/src/main/java/com/adamaps/varroa/data/local/DetectionEntity.kt
@@ -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
+)
diff --git a/app/src/main/java/com/adamaps/varroa/data/local/VarroaDatabase.kt b/app/src/main/java/com/adamaps/varroa/data/local/VarroaDatabase.kt
new file mode 100644
index 0000000..c65d783
--- /dev/null
+++ b/app/src/main/java/com/adamaps/varroa/data/local/VarroaDatabase.kt
@@ -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
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/adamaps/varroa/network/NetworkStateMonitor.kt b/app/src/main/java/com/adamaps/varroa/network/NetworkStateMonitor.kt
new file mode 100644
index 0000000..218c310
--- /dev/null
+++ b/app/src/main/java/com/adamaps/varroa/network/NetworkStateMonitor.kt
@@ -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 = _hasValidatedInternet.asStateFlow()
+
+ private val _hasBeeNetwork = MutableStateFlow(false)
+ val hasBeeNetwork: StateFlow = _hasBeeNetwork.asStateFlow()
+
+ private val _beeNetwork = MutableStateFlow(null)
+ val beeNetwork: StateFlow = _beeNetwork.asStateFlow()
+
+ private val _internetNetwork = MutableStateFlow(null)
+ val internetNetwork: StateFlow = _internetNetwork.asStateFlow()
+
+ // Detailed status for UI
+ private val _networkStatus = MutableStateFlow("Initializing...")
+ val networkStatus: StateFlow = _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
+}
diff --git a/app/src/main/java/com/adamaps/varroa/service/AdaMapsUploadWorker.kt b/app/src/main/java/com/adamaps/varroa/service/AdaMapsUploadWorker.kt
new file mode 100644
index 0000000..efb16e4
--- /dev/null
+++ b/app/src/main/java/com/adamaps/varroa/service/AdaMapsUploadWorker.kt
@@ -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 = _isUploading.asStateFlow()
+
+ private val _lastUploadTime = MutableStateFlow(null)
+ val lastUploadTime: StateFlow = _lastUploadTime.asStateFlow()
+
+ private val _uploadError = MutableStateFlow(null)
+ val uploadError: StateFlow = _uploadError.asStateFlow()
+
+ private val _totalUploaded = MutableStateFlow(0)
+ val totalUploaded: StateFlow = _totalUploaded.asStateFlow()
+
+ /**
+ * Schedule periodic uploads when connected to internet.
+ */
+ fun schedule(context: Context) {
+ val constraints = Constraints.Builder()
+ .setRequiredNetworkType(NetworkType.CONNECTED)
+ .build()
+
+ val uploadRequest = PeriodicWorkRequestBuilder(
+ 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()
+ .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): 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()
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/adamaps/varroa/service/BeeCollectorService.kt b/app/src/main/java/com/adamaps/varroa/service/BeeCollectorService.kt
new file mode 100644
index 0000000..4284932
--- /dev/null
+++ b/app/src/main/java/com/adamaps/varroa/service/BeeCollectorService.kt
@@ -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 = _isRunning.asStateFlow()
+
+ private val _beeConnected = MutableStateFlow(false)
+ val beeConnected: StateFlow = _beeConnected.asStateFlow()
+
+ private val _beeStatus = MutableStateFlow("Stopped")
+ val beeStatus: StateFlow = _beeStatus.asStateFlow()
+
+ private val _lastError = MutableStateFlow(null)
+ val lastError: StateFlow = _lastError.asStateFlow()
+
+ private val _sessionCollected = MutableStateFlow(0)
+ val sessionCollected: StateFlow = _sessionCollected.asStateFlow()
+
+ // Device ID cache
+ private val _currentDeviceId = MutableStateFlow("unknown")
+ val currentDeviceId: StateFlow = _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()
+ 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) {
+ 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))
+ }
+}
diff --git a/app/src/main/java/com/adamaps/varroa/ui/dashboard/DashboardScreen.kt b/app/src/main/java/com/adamaps/varroa/ui/dashboard/DashboardScreen.kt
index b1d6d1a..0fe7c3a 100644
--- a/app/src/main/java/com/adamaps/varroa/ui/dashboard/DashboardScreen.kt
+++ b/app/src/main/java/com/adamaps/varroa/ui/dashboard/DashboardScreen.kt
@@ -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() -> {
diff --git a/app/src/main/java/com/adamaps/varroa/viewmodel/DashboardViewModel.kt b/app/src/main/java/com/adamaps/varroa/viewmodel/DashboardViewModel.kt
index b28e83b..5b4cab7 100644
--- a/app/src/main/java/com/adamaps/varroa/viewmodel/DashboardViewModel.kt
+++ b/app/src/main/java/com/adamaps/varroa/viewmodel/DashboardViewModel.kt
@@ -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 = settingsStore.settings
@@ -44,18 +57,40 @@ class DashboardViewModel(app: Application) : AndroidViewModel(app) {
private val _cameraEndpointWorking = MutableStateFlow(null)
- // ── Forwarding service state (exposed from singleton) ─────────────────────
- val stats = ForwardingService.stats
- val isForwarding = ForwardingService.isRunning
- val beeConnected = ForwardingService.beeConnected
- val adamapsReachable = ForwardingService.adamapsReachable
- val lastError = ForwardingService.lastError
- 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(null)
- val error: StateFlow = _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 = database.detectionDao().countUnsynced()
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0)
+
+ val localTotal: StateFlow = database.detectionDao().countTotal()
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0)
+
+ val localSynced: StateFlow = 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()
- 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()
- 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()
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 371878f..1fe46d0 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,7 +1,14 @@
Varroa
+
+
Varroa Forwarding
Varroa — Forwarding Active
%d detections sent
+
+
+ Varroa Collector
+ Varroa — Collecting
+ %d detections collected
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index f5506f6..b391d4a 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -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" }