From 964d175454ed56b2e1ccc2df9fcdc134218e5e1e Mon Sep 17 00:00:00 2001 From: Kayos Date: Wed, 11 Mar 2026 11:11:05 -0700 Subject: [PATCH] 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) --- app/build.gradle.kts | 11 +- app/src/main/AndroidManifest.xml | 7 + .../com/adamaps/varroa/VarroaApplication.kt | 21 +- .../com/adamaps/varroa/api/BeeApiClient.kt | 19 + .../adamaps/varroa/data/local/DetectionDao.kt | 75 ++++ .../varroa/data/local/DetectionEntity.kt | 45 +++ .../varroa/data/local/VarroaDatabase.kt | 35 ++ .../varroa/network/NetworkStateMonitor.kt | 246 +++++++++++++ .../varroa/service/AdaMapsUploadWorker.kt | 217 ++++++++++++ .../varroa/service/BeeCollectorService.kt | 328 ++++++++++++++++++ .../varroa/ui/dashboard/DashboardScreen.kt | 230 +++++++++--- .../varroa/viewmodel/DashboardViewModel.kt | 133 +++++-- app/src/main/res/values/strings.xml | 7 + gradle/libs.versions.toml | 8 + 14 files changed, 1296 insertions(+), 86 deletions(-) create mode 100644 app/src/main/java/com/adamaps/varroa/data/local/DetectionDao.kt create mode 100644 app/src/main/java/com/adamaps/varroa/data/local/DetectionEntity.kt create mode 100644 app/src/main/java/com/adamaps/varroa/data/local/VarroaDatabase.kt create mode 100644 app/src/main/java/com/adamaps/varroa/network/NetworkStateMonitor.kt create mode 100644 app/src/main/java/com/adamaps/varroa/service/AdaMapsUploadWorker.kt create mode 100644 app/src/main/java/com/adamaps/varroa/service/BeeCollectorService.kt 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" }