v7: store-and-forward architecture
BREAKING: Complete rewrite of data flow to fix network isolation issue. Problem: When connected to Bee's WiFi AP (192.168.0.10), the phone has NO internet access. The Bee AP is just a direct connection to the dashcam with no upstream gateway. The old design tried to simultaneously pull from Bee and push to ADAMaps, which fundamentally couldn't work. Solution: Store-and-forward with two independent subsystems: 1. BeeCollectorService - Collection only - Binds to unvalidated WiFi (Bee AP) - Polls Bee for detections - Stores to local Room database - Does NOT attempt internet uploads 2. AdaMapsUploadWorker - Upload only - WorkManager-based background worker - Only runs when device has VALIDATED internet - Reads from local DB, batch uploads to ADAMaps - Marks as synced, retries with backoff New components: - Room database (DetectionEntity, DetectionDao, VarroaDatabase) - NetworkStateMonitor for tracking validated vs unvalidated networks - Improved UI with BEE/LOCAL/UPLOAD indicators - Manual sync trigger when internet + pending data Version: 1.7.0 (versionCode 7)
This commit is contained in:
parent
46048cc20f
commit
ebaf3ce47d
14 changed files with 1296 additions and 86 deletions
|
|
@ -2,6 +2,7 @@ plugins {
|
||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
alias(libs.plugins.kotlin.android)
|
alias(libs.plugins.kotlin.android)
|
||||||
alias(libs.plugins.kotlin.compose)
|
alias(libs.plugins.kotlin.compose)
|
||||||
|
alias(libs.plugins.ksp)
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
|
|
@ -12,8 +13,8 @@ android {
|
||||||
applicationId = "com.adamaps.varroa"
|
applicationId = "com.adamaps.varroa"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 34
|
targetSdk = 34
|
||||||
versionCode = 5
|
versionCode = 7
|
||||||
versionName = "1.4.0"
|
versionName = "1.7.0"
|
||||||
|
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
useSupportLibrary = true
|
useSupportLibrary = true
|
||||||
|
|
@ -69,5 +70,11 @@ dependencies {
|
||||||
implementation(libs.osmdroid.android)
|
implementation(libs.osmdroid.android)
|
||||||
implementation(libs.datastore.preferences)
|
implementation(libs.datastore.preferences)
|
||||||
implementation(libs.coil.compose)
|
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)
|
debugImplementation(libs.androidx.ui.tooling)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,13 @@
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<!-- v7: New Bee Collector Service (store-and-forward) -->
|
||||||
|
<service
|
||||||
|
android:name=".service.BeeCollectorService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="dataSync" />
|
||||||
|
|
||||||
|
<!-- Legacy ForwardingService (deprecated, kept for migration) -->
|
||||||
<service
|
<service
|
||||||
android:name=".service.ForwardingService"
|
android:name=".service.ForwardingService"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,35 @@
|
||||||
package com.adamaps.varroa
|
package com.adamaps.varroa
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import com.adamaps.varroa.network.NetworkStateMonitor
|
||||||
|
import com.adamaps.varroa.service.AdaMapsUploadWorker
|
||||||
import org.osmdroid.config.Configuration
|
import org.osmdroid.config.Configuration
|
||||||
|
|
||||||
class VarroaApplication : Application() {
|
class VarroaApplication : Application() {
|
||||||
|
|
||||||
|
lateinit var networkMonitor: NetworkStateMonitor
|
||||||
|
private set
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
|
||||||
// osmdroid: set user-agent and tile cache
|
// osmdroid: set user-agent and tile cache
|
||||||
Configuration.getInstance().apply {
|
Configuration.getInstance().apply {
|
||||||
userAgentValue = "Varroa/1.0 (ADAMaps)"
|
userAgentValue = "Varroa/1.7 (ADAMaps)"
|
||||||
osmdroidBasePath = cacheDir
|
osmdroidBasePath = cacheDir
|
||||||
osmdroidTileCache = cacheDir
|
osmdroidTileCache = cacheDir
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize network state monitoring
|
||||||
|
networkMonitor = NetworkStateMonitor.getInstance(this)
|
||||||
|
networkMonitor.startMonitoring()
|
||||||
|
|
||||||
|
// Schedule background uploads when internet is available
|
||||||
|
AdaMapsUploadWorker.schedule(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onTerminate() {
|
||||||
|
networkMonitor.stopMonitoring()
|
||||||
|
super.onTerminate()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import android.content.Context
|
||||||
import android.net.ConnectivityManager
|
import android.net.ConnectivityManager
|
||||||
import android.net.Network
|
import android.net.Network
|
||||||
import android.net.NetworkCapabilities
|
import android.net.NetworkCapabilities
|
||||||
|
import android.util.Log
|
||||||
import com.adamaps.varroa.data.ApiResult
|
import com.adamaps.varroa.data.ApiResult
|
||||||
import com.adamaps.varroa.data.BeeDetection
|
import com.adamaps.varroa.data.BeeDetection
|
||||||
import com.adamaps.varroa.data.BeeDeviceInfo
|
import com.adamaps.varroa.data.BeeDeviceInfo
|
||||||
|
|
@ -22,6 +23,9 @@ class BeeApiClient(
|
||||||
private var primaryUrl: String = "http://192.168.0.10:5000",
|
private var primaryUrl: String = "http://192.168.0.10:5000",
|
||||||
private var altUrl: String = "http://192.168.197.1:5000"
|
private var altUrl: String = "http://192.168.197.1:5000"
|
||||||
) {
|
) {
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "BeeApiClient"
|
||||||
|
}
|
||||||
|
|
||||||
private var client = buildClient(null)
|
private var client = buildClient(null)
|
||||||
private var fastClient = buildFastClient(null)
|
private var fastClient = buildFastClient(null)
|
||||||
|
|
@ -41,8 +45,23 @@ class BeeApiClient(
|
||||||
activeUrl = null
|
activeUrl = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bind to a specific network (e.g., unvalidated WiFi for Bee AP).
|
||||||
|
* This is the preferred method when using NetworkStateMonitor.
|
||||||
|
*/
|
||||||
|
fun bindToNetwork(network: Network) {
|
||||||
|
Log.d(TAG, "Binding to network: $network")
|
||||||
|
client = buildClient(network)
|
||||||
|
fastClient = buildFastClient(network)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy binding method - finds WiFi network automatically.
|
||||||
|
* Prefer bindToNetwork() with explicit network from NetworkStateMonitor.
|
||||||
|
*/
|
||||||
fun bindToWifiNetwork(context: Context) {
|
fun bindToWifiNetwork(context: Context) {
|
||||||
val net = getWifiNetwork(context)
|
val net = getWifiNetwork(context)
|
||||||
|
Log.d(TAG, "Legacy binding to WiFi: $net")
|
||||||
client = buildClient(net)
|
client = buildClient(net)
|
||||||
fastClient = buildFastClient(net)
|
fastClient = buildFastClient(net)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
package com.adamaps.varroa.data.local
|
||||||
|
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Insert
|
||||||
|
import androidx.room.OnConflictStrategy
|
||||||
|
import androidx.room.Query
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface DetectionDao {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert a batch of detections. Ignores duplicates based on unique index.
|
||||||
|
*/
|
||||||
|
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||||
|
suspend fun insertAll(detections: List<DetectionEntity>): List<Long>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a batch of unsynced detections for upload.
|
||||||
|
*/
|
||||||
|
@Query("SELECT * FROM detections WHERE synced = 0 ORDER BY createdAt ASC LIMIT :limit")
|
||||||
|
suspend fun getUnsyncedBatch(limit: Int = 50): List<DetectionEntity>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark detections as synced after successful upload.
|
||||||
|
*/
|
||||||
|
@Query("UPDATE detections SET synced = 1, syncedAt = :syncedAt WHERE id IN (:ids)")
|
||||||
|
suspend fun markSynced(ids: List<Long>, syncedAt: Long = System.currentTimeMillis())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count of unsynced detections (for UI).
|
||||||
|
*/
|
||||||
|
@Query("SELECT COUNT(*) FROM detections WHERE synced = 0")
|
||||||
|
fun countUnsynced(): Flow<Int>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count of synced detections (for UI stats).
|
||||||
|
*/
|
||||||
|
@Query("SELECT COUNT(*) FROM detections WHERE synced = 1")
|
||||||
|
fun countSynced(): Flow<Int>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Total count of all detections ever collected.
|
||||||
|
*/
|
||||||
|
@Query("SELECT COUNT(*) FROM detections")
|
||||||
|
fun countTotal(): Flow<Int>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup old synced records, keeping the most recent N.
|
||||||
|
* Call this periodically to prevent unbounded DB growth.
|
||||||
|
*/
|
||||||
|
@Query("""
|
||||||
|
DELETE FROM detections
|
||||||
|
WHERE synced = 1
|
||||||
|
AND id NOT IN (
|
||||||
|
SELECT id FROM detections
|
||||||
|
WHERE synced = 1
|
||||||
|
ORDER BY syncedAt DESC
|
||||||
|
LIMIT :keepCount
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
suspend fun cleanupOldSynced(keepCount: Int = 1000): Int
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the timestamp of the most recently collected detection.
|
||||||
|
*/
|
||||||
|
@Query("SELECT MAX(createdAt) FROM detections")
|
||||||
|
suspend fun getLastCollectedTime(): Long?
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a detection already exists (for dedup).
|
||||||
|
*/
|
||||||
|
@Query("SELECT EXISTS(SELECT 1 FROM detections WHERE beeDetectionId = :beeId AND timestamp = :ts)")
|
||||||
|
suspend fun exists(beeId: Long, ts: Long?): Boolean
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,246 @@
|
||||||
|
package com.adamaps.varroa.network
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.ConnectivityManager
|
||||||
|
import android.net.Network
|
||||||
|
import android.net.NetworkCapabilities
|
||||||
|
import android.net.NetworkRequest
|
||||||
|
import android.util.Log
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Monitors network state to distinguish between:
|
||||||
|
* - Bee AP (unvalidated WiFi at 192.168.0.10) - good for collecting detections
|
||||||
|
* - Real internet (validated cellular or WiFi) - good for uploading to ADAMaps
|
||||||
|
*
|
||||||
|
* This is critical because the phone CANNOT reach the internet while connected
|
||||||
|
* to the Bee's WiFi AP - it has no upstream gateway.
|
||||||
|
*/
|
||||||
|
class NetworkStateMonitor(private val context: Context) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "NetworkState"
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var INSTANCE: NetworkStateMonitor? = null
|
||||||
|
|
||||||
|
fun getInstance(context: Context): NetworkStateMonitor {
|
||||||
|
return INSTANCE ?: synchronized(this) {
|
||||||
|
val instance = NetworkStateMonitor(context.applicationContext)
|
||||||
|
INSTANCE = instance
|
||||||
|
instance
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val connectivityManager =
|
||||||
|
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||||
|
|
||||||
|
// Current network states
|
||||||
|
private val _hasValidatedInternet = MutableStateFlow(false)
|
||||||
|
val hasValidatedInternet: StateFlow<Boolean> = _hasValidatedInternet.asStateFlow()
|
||||||
|
|
||||||
|
private val _hasBeeNetwork = MutableStateFlow(false)
|
||||||
|
val hasBeeNetwork: StateFlow<Boolean> = _hasBeeNetwork.asStateFlow()
|
||||||
|
|
||||||
|
private val _beeNetwork = MutableStateFlow<Network?>(null)
|
||||||
|
val beeNetwork: StateFlow<Network?> = _beeNetwork.asStateFlow()
|
||||||
|
|
||||||
|
private val _internetNetwork = MutableStateFlow<Network?>(null)
|
||||||
|
val internetNetwork: StateFlow<Network?> = _internetNetwork.asStateFlow()
|
||||||
|
|
||||||
|
// Detailed status for UI
|
||||||
|
private val _networkStatus = MutableStateFlow("Initializing...")
|
||||||
|
val networkStatus: StateFlow<String> = _networkStatus.asStateFlow()
|
||||||
|
|
||||||
|
private var isMonitoring = false
|
||||||
|
|
||||||
|
// Callback for validated internet (real connectivity)
|
||||||
|
private val validatedCallback = object : ConnectivityManager.NetworkCallback() {
|
||||||
|
override fun onAvailable(network: Network) {
|
||||||
|
Log.d(TAG, "Validated network available: $network")
|
||||||
|
_hasValidatedInternet.value = true
|
||||||
|
_internetNetwork.value = network
|
||||||
|
updateStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onLost(network: Network) {
|
||||||
|
Log.d(TAG, "Validated network lost: $network")
|
||||||
|
if (_internetNetwork.value == network) {
|
||||||
|
_hasValidatedInternet.value = false
|
||||||
|
_internetNetwork.value = null
|
||||||
|
}
|
||||||
|
updateStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCapabilitiesChanged(network: Network, caps: NetworkCapabilities) {
|
||||||
|
val hasInternet = caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
|
||||||
|
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
|
||||||
|
if (hasInternet) {
|
||||||
|
_hasValidatedInternet.value = true
|
||||||
|
_internetNetwork.value = network
|
||||||
|
}
|
||||||
|
updateStatus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Callback for unvalidated WiFi (Bee AP)
|
||||||
|
private val unvalidatedWifiCallback = object : ConnectivityManager.NetworkCallback() {
|
||||||
|
override fun onAvailable(network: Network) {
|
||||||
|
val caps = connectivityManager.getNetworkCapabilities(network)
|
||||||
|
val isWifi = caps?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true
|
||||||
|
val isValidated = caps?.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) == true
|
||||||
|
|
||||||
|
Log.d(TAG, "WiFi network available: $network, validated=$isValidated")
|
||||||
|
|
||||||
|
// Bee AP is unvalidated WiFi
|
||||||
|
if (isWifi && !isValidated) {
|
||||||
|
_hasBeeNetwork.value = true
|
||||||
|
_beeNetwork.value = network
|
||||||
|
updateStatus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onLost(network: Network) {
|
||||||
|
Log.d(TAG, "WiFi network lost: $network")
|
||||||
|
if (_beeNetwork.value == network) {
|
||||||
|
_hasBeeNetwork.value = false
|
||||||
|
_beeNetwork.value = null
|
||||||
|
}
|
||||||
|
updateStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCapabilitiesChanged(network: Network, caps: NetworkCapabilities) {
|
||||||
|
val isWifi = caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
|
||||||
|
val isValidated = caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
|
||||||
|
|
||||||
|
if (isWifi && !isValidated) {
|
||||||
|
// This is likely the Bee AP
|
||||||
|
_hasBeeNetwork.value = true
|
||||||
|
_beeNetwork.value = network
|
||||||
|
} else if (_beeNetwork.value == network && isValidated) {
|
||||||
|
// Network became validated (switched to home WiFi)
|
||||||
|
_hasBeeNetwork.value = false
|
||||||
|
_beeNetwork.value = null
|
||||||
|
}
|
||||||
|
updateStatus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateStatus() {
|
||||||
|
val bee = _hasBeeNetwork.value
|
||||||
|
val internet = _hasValidatedInternet.value
|
||||||
|
|
||||||
|
_networkStatus.value = when {
|
||||||
|
bee && internet -> "Bee AP + Internet"
|
||||||
|
bee -> "Bee AP (offline)"
|
||||||
|
internet -> "Online (no Bee)"
|
||||||
|
else -> "No network"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start monitoring network changes.
|
||||||
|
* Call from Application.onCreate() or early in the app lifecycle.
|
||||||
|
*/
|
||||||
|
fun startMonitoring() {
|
||||||
|
if (isMonitoring) return
|
||||||
|
isMonitoring = true
|
||||||
|
|
||||||
|
// Request for validated internet (real connectivity)
|
||||||
|
val validatedRequest = NetworkRequest.Builder()
|
||||||
|
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||||
|
.addCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
// Request for WiFi (may be unvalidated Bee AP)
|
||||||
|
val wifiRequest = NetworkRequest.Builder()
|
||||||
|
.addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
try {
|
||||||
|
connectivityManager.registerNetworkCallback(validatedRequest, validatedCallback)
|
||||||
|
connectivityManager.registerNetworkCallback(wifiRequest, unvalidatedWifiCallback)
|
||||||
|
Log.d(TAG, "Network monitoring started")
|
||||||
|
|
||||||
|
// Initial state check
|
||||||
|
checkCurrentState()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to register network callbacks", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop monitoring (call from onDestroy if needed).
|
||||||
|
*/
|
||||||
|
fun stopMonitoring() {
|
||||||
|
if (!isMonitoring) return
|
||||||
|
isMonitoring = false
|
||||||
|
|
||||||
|
try {
|
||||||
|
connectivityManager.unregisterNetworkCallback(validatedCallback)
|
||||||
|
connectivityManager.unregisterNetworkCallback(unvalidatedWifiCallback)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to unregister network callbacks", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check current network state (for initial sync).
|
||||||
|
*/
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
private fun checkCurrentState() {
|
||||||
|
// Find all networks and categorize them
|
||||||
|
val networks = connectivityManager.allNetworks
|
||||||
|
|
||||||
|
var foundBee = false
|
||||||
|
var foundInternet = false
|
||||||
|
|
||||||
|
for (network in networks) {
|
||||||
|
val caps = connectivityManager.getNetworkCapabilities(network) ?: continue
|
||||||
|
val isWifi = caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
|
||||||
|
val isValidated = caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
|
||||||
|
val hasInternet = caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||||
|
|
||||||
|
if (isWifi && !isValidated) {
|
||||||
|
// Unvalidated WiFi = Bee AP
|
||||||
|
_hasBeeNetwork.value = true
|
||||||
|
_beeNetwork.value = network
|
||||||
|
foundBee = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasInternet && isValidated) {
|
||||||
|
// Validated internet connection
|
||||||
|
_hasValidatedInternet.value = true
|
||||||
|
_internetNetwork.value = network
|
||||||
|
foundInternet = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!foundBee) {
|
||||||
|
_hasBeeNetwork.value = false
|
||||||
|
_beeNetwork.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!foundInternet) {
|
||||||
|
_hasValidatedInternet.value = false
|
||||||
|
_internetNetwork.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
updateStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the unvalidated WiFi network for binding Bee API requests.
|
||||||
|
* Returns null if Bee AP is not available.
|
||||||
|
*/
|
||||||
|
fun getBeeNetworkForBinding(): Network? = _beeNetwork.value
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validated internet network for ADAMaps requests.
|
||||||
|
* Returns null if no internet is available.
|
||||||
|
*/
|
||||||
|
fun getInternetNetworkForBinding(): Network? = _internetNetwork.value
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,217 @@
|
||||||
|
package com.adamaps.varroa.service
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.work.BackoffPolicy
|
||||||
|
import androidx.work.Constraints
|
||||||
|
import androidx.work.CoroutineWorker
|
||||||
|
import androidx.work.ExistingPeriodicWorkPolicy
|
||||||
|
import androidx.work.NetworkType
|
||||||
|
import androidx.work.PeriodicWorkRequestBuilder
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import androidx.work.WorkerParameters
|
||||||
|
import com.adamaps.varroa.api.AdaMapsApiClient
|
||||||
|
import com.adamaps.varroa.data.AdaMapsDetection
|
||||||
|
import com.adamaps.varroa.data.ApiResult
|
||||||
|
import com.adamaps.varroa.data.SettingsDataStore
|
||||||
|
import com.adamaps.varroa.data.local.DetectionEntity
|
||||||
|
import com.adamaps.varroa.data.local.VarroaDatabase
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AdaMapsUploadWorker - Uploads stored detections to ADAMaps when internet is available.
|
||||||
|
*
|
||||||
|
* This is the SECOND HALF of the store-and-forward architecture:
|
||||||
|
* - Only runs when device has VALIDATED internet (not Bee AP)
|
||||||
|
* - Reads unsynced detections from local Room database
|
||||||
|
* - Uploads in batches to ADAMaps
|
||||||
|
* - Marks as synced after successful upload
|
||||||
|
* - WorkManager handles scheduling and retries
|
||||||
|
*/
|
||||||
|
class AdaMapsUploadWorker(
|
||||||
|
context: Context,
|
||||||
|
params: WorkerParameters
|
||||||
|
) : CoroutineWorker(context, params) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "AdaMapsUploader"
|
||||||
|
private const val WORK_NAME = "adamaps_upload"
|
||||||
|
private const val BATCH_SIZE = 50
|
||||||
|
private const val CLEANUP_KEEP_COUNT = 1000
|
||||||
|
|
||||||
|
// Upload state for UI
|
||||||
|
private val _isUploading = MutableStateFlow(false)
|
||||||
|
val isUploading: StateFlow<Boolean> = _isUploading.asStateFlow()
|
||||||
|
|
||||||
|
private val _lastUploadTime = MutableStateFlow<Long?>(null)
|
||||||
|
val lastUploadTime: StateFlow<Long?> = _lastUploadTime.asStateFlow()
|
||||||
|
|
||||||
|
private val _uploadError = MutableStateFlow<String?>(null)
|
||||||
|
val uploadError: StateFlow<String?> = _uploadError.asStateFlow()
|
||||||
|
|
||||||
|
private val _totalUploaded = MutableStateFlow(0)
|
||||||
|
val totalUploaded: StateFlow<Int> = _totalUploaded.asStateFlow()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule periodic uploads when connected to internet.
|
||||||
|
*/
|
||||||
|
fun schedule(context: Context) {
|
||||||
|
val constraints = Constraints.Builder()
|
||||||
|
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val uploadRequest = PeriodicWorkRequestBuilder<AdaMapsUploadWorker>(
|
||||||
|
15, TimeUnit.MINUTES, // Run every 15 minutes
|
||||||
|
5, TimeUnit.MINUTES // Flex interval
|
||||||
|
)
|
||||||
|
.setConstraints(constraints)
|
||||||
|
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
|
||||||
|
WORK_NAME,
|
||||||
|
ExistingPeriodicWorkPolicy.KEEP,
|
||||||
|
uploadRequest
|
||||||
|
)
|
||||||
|
Log.d(TAG, "Upload worker scheduled")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel scheduled uploads.
|
||||||
|
*/
|
||||||
|
fun cancel(context: Context) {
|
||||||
|
WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME)
|
||||||
|
Log.d(TAG, "Upload worker cancelled")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger an immediate upload attempt.
|
||||||
|
*/
|
||||||
|
fun triggerNow(context: Context) {
|
||||||
|
val constraints = Constraints.Builder()
|
||||||
|
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val uploadRequest = androidx.work.OneTimeWorkRequestBuilder<AdaMapsUploadWorker>()
|
||||||
|
.setConstraints(constraints)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
WorkManager.getInstance(context).enqueue(uploadRequest)
|
||||||
|
Log.d(TAG, "Immediate upload triggered")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val database = VarroaDatabase.getInstance(applicationContext)
|
||||||
|
private val adamapsClient = AdaMapsApiClient()
|
||||||
|
private val settingsStore = SettingsDataStore(applicationContext)
|
||||||
|
|
||||||
|
override suspend fun doWork(): Result {
|
||||||
|
Log.d(TAG, "Upload worker starting")
|
||||||
|
_isUploading.value = true
|
||||||
|
_uploadError.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Load settings
|
||||||
|
val settings = settingsStore.settings.first()
|
||||||
|
adamapsClient.updateConfig(settings.adamapsApiUrl, settings.adamapsApiKey)
|
||||||
|
|
||||||
|
// Process batches until no more unsynced detections
|
||||||
|
var totalUploaded = 0
|
||||||
|
var batchNum = 0
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
val batch = database.detectionDao().getUnsyncedBatch(BATCH_SIZE)
|
||||||
|
if (batch.isEmpty()) {
|
||||||
|
Log.d(TAG, "No more unsynced detections")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
batchNum++
|
||||||
|
Log.d(TAG, "Uploading batch $batchNum with ${batch.size} detections")
|
||||||
|
|
||||||
|
when (val result = uploadBatch(batch)) {
|
||||||
|
is Result.Success -> {
|
||||||
|
// Mark as synced
|
||||||
|
val ids = batch.map { it.id }
|
||||||
|
database.detectionDao().markSynced(ids)
|
||||||
|
totalUploaded += batch.size
|
||||||
|
_totalUploaded.value += batch.size
|
||||||
|
Log.d(TAG, "Batch $batchNum uploaded successfully")
|
||||||
|
}
|
||||||
|
is Result.Retry -> {
|
||||||
|
// Network error, WorkManager will retry
|
||||||
|
Log.w(TAG, "Batch $batchNum failed, will retry")
|
||||||
|
_isUploading.value = false
|
||||||
|
return Result.retry()
|
||||||
|
}
|
||||||
|
is Result.Failure -> {
|
||||||
|
// Permanent error (shouldn't happen often)
|
||||||
|
Log.e(TAG, "Batch $batchNum failed permanently")
|
||||||
|
_isUploading.value = false
|
||||||
|
return Result.failure()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup old synced records periodically
|
||||||
|
if (totalUploaded > 0) {
|
||||||
|
val deleted = database.detectionDao().cleanupOldSynced(CLEANUP_KEEP_COUNT)
|
||||||
|
if (deleted > 0) {
|
||||||
|
Log.d(TAG, "Cleaned up $deleted old synced records")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_lastUploadTime.value = System.currentTimeMillis()
|
||||||
|
_isUploading.value = false
|
||||||
|
Log.d(TAG, "Upload worker completed: $totalUploaded detections uploaded")
|
||||||
|
return Result.success()
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Upload worker error", e)
|
||||||
|
_uploadError.value = e.message
|
||||||
|
_isUploading.value = false
|
||||||
|
return Result.retry()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun uploadBatch(batch: List<DetectionEntity>): Result {
|
||||||
|
// Convert to ADAMaps format
|
||||||
|
val request = batch.map { entity ->
|
||||||
|
AdaMapsDetection(
|
||||||
|
deviceId = entity.deviceId,
|
||||||
|
id = entity.beeDetectionId,
|
||||||
|
classLabel = entity.classLabel,
|
||||||
|
classLabelConfidence = entity.classLabelConfidence,
|
||||||
|
overallConfidence = entity.overallConfidence,
|
||||||
|
ts = entity.timestamp,
|
||||||
|
lat = entity.lat,
|
||||||
|
lon = entity.lon,
|
||||||
|
alt = entity.alt,
|
||||||
|
width = entity.width,
|
||||||
|
height = entity.height,
|
||||||
|
posConfidence = entity.posConfidence,
|
||||||
|
azimuth = entity.azimuth
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return when (val result = adamapsClient.ingest(request)) {
|
||||||
|
is ApiResult.Success -> {
|
||||||
|
Result.success()
|
||||||
|
}
|
||||||
|
is ApiResult.Error -> {
|
||||||
|
_uploadError.value = result.message
|
||||||
|
|
||||||
|
// Determine if this is a retryable error
|
||||||
|
val isRetryable = result.code == null || // Network error
|
||||||
|
result.code >= 500 || // Server error
|
||||||
|
result.code == 429 // Rate limited
|
||||||
|
|
||||||
|
if (isRetryable) Result.retry() else Result.failure()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,328 @@
|
||||||
|
package com.adamaps.varroa.service
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.lifecycle.LifecycleService
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import com.adamaps.varroa.MainActivity
|
||||||
|
import com.adamaps.varroa.R
|
||||||
|
import com.adamaps.varroa.api.BeeApiClient
|
||||||
|
import com.adamaps.varroa.data.ApiResult
|
||||||
|
import com.adamaps.varroa.data.BeeDetection
|
||||||
|
import com.adamaps.varroa.data.SettingsDataStore
|
||||||
|
import com.adamaps.varroa.data.local.DetectionEntity
|
||||||
|
import com.adamaps.varroa.data.local.VarroaDatabase
|
||||||
|
import com.adamaps.varroa.network.NetworkStateMonitor
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlin.math.min
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BeeCollectorService - Collects detections from the Bee and stores them locally.
|
||||||
|
*
|
||||||
|
* This is ONE HALF of the store-and-forward architecture:
|
||||||
|
* - This service ONLY collects from Bee (via unvalidated WiFi)
|
||||||
|
* - Stores to local Room database
|
||||||
|
* - Does NOT attempt any internet uploads
|
||||||
|
*
|
||||||
|
* The AdaMapsUploadWorker handles uploads separately when internet is available.
|
||||||
|
*/
|
||||||
|
class BeeCollectorService : LifecycleService() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "BeeCollector"
|
||||||
|
const val NOTIF_CHANNEL_ID = "varroa_collector"
|
||||||
|
const val NOTIF_ID = 1001
|
||||||
|
|
||||||
|
const val ACTION_START = "com.adamaps.varroa.START_COLLECTOR"
|
||||||
|
const val ACTION_STOP = "com.adamaps.varroa.STOP_COLLECTOR"
|
||||||
|
|
||||||
|
// Exponential backoff constants
|
||||||
|
private const val MIN_BACKOFF_MS = 5_000L
|
||||||
|
private const val MAX_BACKOFF_MS = 60_000L
|
||||||
|
private const val BACKOFF_MULTIPLIER = 2.0
|
||||||
|
|
||||||
|
// Shared state for UI
|
||||||
|
private val _isRunning = MutableStateFlow(false)
|
||||||
|
val isRunning: StateFlow<Boolean> = _isRunning.asStateFlow()
|
||||||
|
|
||||||
|
private val _beeConnected = MutableStateFlow(false)
|
||||||
|
val beeConnected: StateFlow<Boolean> = _beeConnected.asStateFlow()
|
||||||
|
|
||||||
|
private val _beeStatus = MutableStateFlow("Stopped")
|
||||||
|
val beeStatus: StateFlow<String> = _beeStatus.asStateFlow()
|
||||||
|
|
||||||
|
private val _lastError = MutableStateFlow<String?>(null)
|
||||||
|
val lastError: StateFlow<String?> = _lastError.asStateFlow()
|
||||||
|
|
||||||
|
private val _sessionCollected = MutableStateFlow(0)
|
||||||
|
val sessionCollected: StateFlow<Int> = _sessionCollected.asStateFlow()
|
||||||
|
|
||||||
|
// Device ID cache
|
||||||
|
private val _currentDeviceId = MutableStateFlow("unknown")
|
||||||
|
val currentDeviceId: StateFlow<String> = _currentDeviceId.asStateFlow()
|
||||||
|
}
|
||||||
|
|
||||||
|
private lateinit var beeClient: BeeApiClient
|
||||||
|
private lateinit var database: VarroaDatabase
|
||||||
|
private lateinit var networkMonitor: NetworkStateMonitor
|
||||||
|
private lateinit var settingsStore: SettingsDataStore
|
||||||
|
|
||||||
|
private val seenKeys = mutableSetOf<String>()
|
||||||
|
private var pollJob: Job? = null
|
||||||
|
|
||||||
|
private var currentBackoffMs = MIN_BACKOFF_MS
|
||||||
|
private var consecutiveFailures = 0
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
createNotificationChannel()
|
||||||
|
beeClient = BeeApiClient()
|
||||||
|
database = VarroaDatabase.getInstance(applicationContext)
|
||||||
|
networkMonitor = NetworkStateMonitor.getInstance(applicationContext)
|
||||||
|
settingsStore = SettingsDataStore(applicationContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
super.onStartCommand(intent, flags, startId)
|
||||||
|
when (intent?.action) {
|
||||||
|
ACTION_STOP -> {
|
||||||
|
stopCollecting()
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
else -> startCollecting()
|
||||||
|
}
|
||||||
|
return START_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent): IBinder? {
|
||||||
|
super.onBind(intent)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
stopCollecting()
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startCollecting() {
|
||||||
|
startForeground(NOTIF_ID, buildNotification(0))
|
||||||
|
_isRunning.value = true
|
||||||
|
_beeStatus.value = "Connecting..."
|
||||||
|
_sessionCollected.value = 0
|
||||||
|
|
||||||
|
lifecycleScope.launch {
|
||||||
|
val settings = settingsStore.settings.first()
|
||||||
|
beeClient.updateUrls(settings.beeApiUrl, settings.beeAltApiUrl)
|
||||||
|
|
||||||
|
// Bind to Bee network (unvalidated WiFi)
|
||||||
|
bindToBeeNetwork()
|
||||||
|
|
||||||
|
// Fetch device ID once
|
||||||
|
fetchDeviceId()
|
||||||
|
|
||||||
|
// Start polling loop
|
||||||
|
startPollLoop(settings.pollIntervalSeconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopCollecting() {
|
||||||
|
pollJob?.cancel()
|
||||||
|
_isRunning.value = false
|
||||||
|
_beeStatus.value = "Stopped"
|
||||||
|
_beeConnected.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindToBeeNetwork() {
|
||||||
|
// Use NetworkStateMonitor to get the unvalidated WiFi network (Bee AP)
|
||||||
|
val beeNet = networkMonitor.getBeeNetworkForBinding()
|
||||||
|
if (beeNet != null) {
|
||||||
|
beeClient.bindToNetwork(beeNet)
|
||||||
|
Log.d(TAG, "Bound to Bee network: $beeNet")
|
||||||
|
} else {
|
||||||
|
// Fallback to legacy binding
|
||||||
|
beeClient.bindToWifiNetwork(applicationContext)
|
||||||
|
Log.d(TAG, "Using legacy WiFi binding")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun fetchDeviceId() {
|
||||||
|
when (val result = beeClient.getDeviceInfo()) {
|
||||||
|
is ApiResult.Success -> {
|
||||||
|
val deviceId = result.data.deviceId ?: result.data.serial ?: "unknown"
|
||||||
|
_currentDeviceId.value = deviceId
|
||||||
|
Log.d(TAG, "Device ID: $deviceId")
|
||||||
|
}
|
||||||
|
is ApiResult.Error -> {
|
||||||
|
Log.w(TAG, "Failed to get device ID: ${result.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startPollLoop(intervalSeconds: Int) {
|
||||||
|
pollJob?.cancel()
|
||||||
|
pollJob = lifecycleScope.launch {
|
||||||
|
while (true) {
|
||||||
|
// Re-bind to Bee network periodically in case it changed
|
||||||
|
bindToBeeNetwork()
|
||||||
|
|
||||||
|
runPollCycle()
|
||||||
|
|
||||||
|
val delayMs = if (_beeConnected.value) {
|
||||||
|
intervalSeconds * 1000L
|
||||||
|
} else {
|
||||||
|
currentBackoffMs
|
||||||
|
}
|
||||||
|
delay(delayMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun runPollCycle() {
|
||||||
|
when (val result = beeClient.getLandmarks()) {
|
||||||
|
is ApiResult.Success -> {
|
||||||
|
_beeConnected.value = true
|
||||||
|
consecutiveFailures = 0
|
||||||
|
currentBackoffMs = MIN_BACKOFF_MS
|
||||||
|
_lastError.value = null
|
||||||
|
|
||||||
|
val activeUrl = beeClient.getActiveUrl()
|
||||||
|
_beeStatus.value = if (activeUrl?.contains("155") == true) {
|
||||||
|
"Connected (home WiFi)"
|
||||||
|
} else {
|
||||||
|
"Connected (AP mode)"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter for new detections
|
||||||
|
val newDetections = result.data.filter { d ->
|
||||||
|
val key = d.dedupKey()
|
||||||
|
if (seenKeys.contains(key)) false
|
||||||
|
else { seenKeys.add(key); true }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newDetections.isNotEmpty()) {
|
||||||
|
storeDetections(newDetections)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateNotification(_sessionCollected.value)
|
||||||
|
}
|
||||||
|
is ApiResult.Error -> {
|
||||||
|
_beeConnected.value = false
|
||||||
|
consecutiveFailures++
|
||||||
|
|
||||||
|
currentBackoffMs = min(
|
||||||
|
(MIN_BACKOFF_MS * Math.pow(BACKOFF_MULTIPLIER, (consecutiveFailures - 1).toDouble())).toLong(),
|
||||||
|
MAX_BACKOFF_MS
|
||||||
|
)
|
||||||
|
|
||||||
|
if (result.message == "Bee offline" ||
|
||||||
|
result.message.contains("timeout", ignoreCase = true) ||
|
||||||
|
result.message.contains("connect", ignoreCase = true) ||
|
||||||
|
result.message.contains("refused", ignoreCase = true) ||
|
||||||
|
result.message.contains("unreachable", ignoreCase = true)) {
|
||||||
|
_beeStatus.value = "Bee offline (retry in ${currentBackoffMs / 1000}s)"
|
||||||
|
_lastError.value = null
|
||||||
|
} else {
|
||||||
|
_beeStatus.value = "Connection error"
|
||||||
|
_lastError.value = result.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun storeDetections(detections: List<BeeDetection>) {
|
||||||
|
val deviceId = _currentDeviceId.value
|
||||||
|
|
||||||
|
val entities = detections.map { d ->
|
||||||
|
DetectionEntity(
|
||||||
|
deviceId = deviceId,
|
||||||
|
beeDetectionId = d.id,
|
||||||
|
classLabel = d.classLabel,
|
||||||
|
classLabelConfidence = d.classLabelConfidence,
|
||||||
|
overallConfidence = d.overallConfidence,
|
||||||
|
timestamp = d.ts,
|
||||||
|
lat = d.lat,
|
||||||
|
lon = d.lon,
|
||||||
|
alt = d.alt,
|
||||||
|
width = d.width,
|
||||||
|
height = d.height,
|
||||||
|
posConfidence = d.posConfidence,
|
||||||
|
azimuth = d.azimuth
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val inserted = database.detectionDao().insertAll(entities)
|
||||||
|
val newCount = inserted.count { it != -1L }
|
||||||
|
|
||||||
|
if (newCount > 0) {
|
||||||
|
_sessionCollected.update { it + newCount }
|
||||||
|
Log.d(TAG, "Stored $newCount new detections (${inserted.size - newCount} duplicates)")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to store detections", e)
|
||||||
|
_lastError.value = "DB error: ${e.message}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createNotificationChannel() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
NOTIF_CHANNEL_ID,
|
||||||
|
getString(R.string.collector_notification_channel),
|
||||||
|
NotificationManager.IMPORTANCE_LOW
|
||||||
|
).apply {
|
||||||
|
description = "Varroa detection collection"
|
||||||
|
setShowBadge(false)
|
||||||
|
}
|
||||||
|
val nm = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
nm.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildNotification(collectedCount: Int): Notification {
|
||||||
|
val tapIntent = Intent(this, MainActivity::class.java).apply {
|
||||||
|
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||||
|
}
|
||||||
|
val tapPending = PendingIntent.getActivity(
|
||||||
|
this, 0, tapIntent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
|
||||||
|
val stopIntent = Intent(this, BeeCollectorService::class.java).apply {
|
||||||
|
action = ACTION_STOP
|
||||||
|
}
|
||||||
|
val stopPending = PendingIntent.getService(
|
||||||
|
this, 1, stopIntent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
|
||||||
|
return NotificationCompat.Builder(this, NOTIF_CHANNEL_ID)
|
||||||
|
.setContentTitle(getString(R.string.collector_notification_title))
|
||||||
|
.setContentText(getString(R.string.collector_notification_text, collectedCount))
|
||||||
|
.setSmallIcon(android.R.drawable.ic_menu_mylocation)
|
||||||
|
.setContentIntent(tapPending)
|
||||||
|
.addAction(android.R.drawable.ic_media_pause, "Stop", stopPending)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setSilent(true)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateNotification(collectedCount: Int) {
|
||||||
|
val nm = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
nm.notify(NOTIF_ID, buildNotification(collectedCount))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ import android.graphics.BitmapFactory
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
|
@ -27,7 +28,6 @@ import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import com.adamaps.varroa.data.BeeDeviceInfo
|
import com.adamaps.varroa.data.BeeDeviceInfo
|
||||||
import com.adamaps.varroa.data.GnssData
|
import com.adamaps.varroa.data.GnssData
|
||||||
import com.adamaps.varroa.data.SessionStats
|
|
||||||
import com.adamaps.varroa.ui.theme.*
|
import com.adamaps.varroa.ui.theme.*
|
||||||
import com.adamaps.varroa.viewmodel.DashboardViewModel
|
import com.adamaps.varroa.viewmodel.DashboardViewModel
|
||||||
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
|
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
|
||||||
|
|
@ -44,12 +44,30 @@ fun DashboardScreen(
|
||||||
val deviceInfo by vm.deviceInfo.collectAsState()
|
val deviceInfo by vm.deviceInfo.collectAsState()
|
||||||
val gnss by vm.gnss.collectAsState()
|
val gnss by vm.gnss.collectAsState()
|
||||||
val cameraBytes by vm.cameraBytes.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 beeConnected by vm.beeConnected.collectAsState()
|
||||||
val adamapsReachable by vm.adamapsReachable.collectAsState()
|
|
||||||
val lastError by vm.lastError.collectAsState()
|
|
||||||
val beeStatus by vm.beeStatus.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(
|
Scaffold(
|
||||||
containerColor = Background,
|
containerColor = Background,
|
||||||
|
|
@ -82,22 +100,31 @@ fun DashboardScreen(
|
||||||
.padding(12.dp),
|
.padding(12.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
) {
|
) {
|
||||||
// Connection status bar
|
// Connection status bar (v7 store-and-forward)
|
||||||
ConnectionStatusBar(
|
ConnectionStatusBarV7(
|
||||||
beeConnected = beeConnected,
|
beeConnected = beeConnected,
|
||||||
beeStatus = beeStatus,
|
beeStatus = beeStatus,
|
||||||
adamapsReachable = adamapsReachable,
|
localUnsynced = localUnsynced,
|
||||||
isForwarding = isForwarding,
|
hasInternet = hasInternet,
|
||||||
onToggle = { vm.toggleForwarding() }
|
isUploading = isUploading,
|
||||||
|
isCollecting = isCollecting,
|
||||||
|
networkStatus = networkStatus,
|
||||||
|
onToggle = { vm.toggleCollecting() },
|
||||||
|
onTriggerUpload = { vm.triggerUpload() }
|
||||||
)
|
)
|
||||||
|
|
||||||
// Error banner (only show for actual errors, not "offline" state)
|
// Error banner
|
||||||
if (lastError != null) {
|
if (displayError != null) {
|
||||||
ErrorBanner(lastError!!)
|
ErrorBanner(displayError!!)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Session stats
|
// Session stats (v7 format)
|
||||||
SessionStatsCard(stats)
|
SessionStatsCardV7(
|
||||||
|
sessionCollected = sessionCollected,
|
||||||
|
localUnsynced = localUnsynced,
|
||||||
|
totalUploaded = totalUploaded,
|
||||||
|
localTotal = localTotal
|
||||||
|
)
|
||||||
|
|
||||||
// Camera snapshot
|
// Camera snapshot
|
||||||
CameraCard(cameraBytes, beeConnected)
|
CameraCard(cameraBytes, beeConnected)
|
||||||
|
|
@ -112,12 +139,16 @@ fun DashboardScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ConnectionStatusBar(
|
private fun ConnectionStatusBarV7(
|
||||||
beeConnected: Boolean,
|
beeConnected: Boolean,
|
||||||
beeStatus: String,
|
beeStatus: String,
|
||||||
adamapsReachable: Boolean,
|
localUnsynced: Int,
|
||||||
isForwarding: Boolean,
|
hasInternet: Boolean,
|
||||||
onToggle: () -> Unit
|
isUploading: Boolean,
|
||||||
|
isCollecting: Boolean,
|
||||||
|
networkStatus: String,
|
||||||
|
onToggle: () -> Unit,
|
||||||
|
onTriggerUpload: () -> Unit
|
||||||
) {
|
) {
|
||||||
Card(
|
Card(
|
||||||
colors = CardDefaults.cardColors(containerColor = Surface),
|
colors = CardDefaults.cardColors(containerColor = Surface),
|
||||||
|
|
@ -131,32 +162,47 @@ private fun ConnectionStatusBar(
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
|
// BEE indicator
|
||||||
StatusDot(
|
StatusDot(
|
||||||
label = "BEE",
|
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(
|
StatusDot(
|
||||||
label = "ADAMAPS",
|
label = "UPLOAD",
|
||||||
active = adamapsReachable
|
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) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(
|
Text(
|
||||||
text = if (isForwarding) "FORWARDING" else "PAUSED",
|
text = if (isCollecting) "COLLECTING" else "PAUSED",
|
||||||
color = if (isForwarding) Amber else Color.Gray,
|
color = if (isCollecting) Amber else Color.Gray,
|
||||||
fontFamily = FontFamily.Monospace,
|
fontFamily = FontFamily.Monospace,
|
||||||
fontSize = 11.sp,
|
fontSize = 10.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
letterSpacing = 1.sp
|
letterSpacing = 1.sp
|
||||||
)
|
)
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
Switch(
|
Switch(
|
||||||
checked = isForwarding,
|
checked = isCollecting,
|
||||||
onCheckedChange = { onToggle() },
|
onCheckedChange = { onToggle() },
|
||||||
colors = SwitchDefaults.colors(
|
colors = SwitchDefaults.colors(
|
||||||
checkedThumbColor = Amber,
|
checkedThumbColor = Amber,
|
||||||
|
|
@ -167,35 +213,98 @@ private fun ConnectionStatusBar(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Show Bee status text below the status dots
|
|
||||||
Spacer(Modifier.height(6.dp))
|
Spacer(Modifier.height(6.dp))
|
||||||
Text(
|
|
||||||
text = beeStatus,
|
// Status line
|
||||||
color = if (beeConnected) Color.Gray else Color(0xFF9CA3AF),
|
Row(
|
||||||
fontFamily = FontFamily.Monospace,
|
modifier = Modifier.fillMaxWidth(),
|
||||||
fontSize = 10.sp
|
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
|
@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(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(8.dp)
|
.size(8.dp)
|
||||||
.background(
|
.background(
|
||||||
color = if (active) Success else Color(0xFF6B7280),
|
color = when {
|
||||||
|
active -> activeColor
|
||||||
|
waiting -> waitingColor
|
||||||
|
else -> Color(0xFF6B7280)
|
||||||
|
},
|
||||||
shape = RoundedCornerShape(50)
|
shape = RoundedCornerShape(50)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = label,
|
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,
|
fontFamily = FontFamily.Monospace,
|
||||||
fontSize = 10.sp,
|
fontSize = 10.sp,
|
||||||
letterSpacing = 1.sp
|
letterSpacing = 1.sp
|
||||||
|
|
@ -229,29 +338,51 @@ private fun ErrorBanner(message: String) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SessionStatsCard(stats: SessionStats) {
|
private fun SessionStatsCardV7(
|
||||||
|
sessionCollected: Int,
|
||||||
|
localUnsynced: Int,
|
||||||
|
totalUploaded: Int,
|
||||||
|
localTotal: Int
|
||||||
|
) {
|
||||||
Card(
|
Card(
|
||||||
colors = CardDefaults.cardColors(containerColor = Surface),
|
colors = CardDefaults.cardColors(containerColor = Surface),
|
||||||
shape = RoundedCornerShape(8.dp),
|
shape = RoundedCornerShape(8.dp),
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.padding(14.dp)) {
|
Column(modifier = Modifier.padding(14.dp)) {
|
||||||
SectionHeader("SESSION STATS")
|
SectionHeader("STORE & FORWARD")
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceEvenly
|
horizontalArrangement = Arrangement.SpaceEvenly
|
||||||
) {
|
) {
|
||||||
StatItem("COLLECTED", stats.collected.toString())
|
StatItem("COLLECTED", sessionCollected.toString(), subtitle = "session")
|
||||||
StatItem("SENT", stats.sent.toString())
|
StatItem("LOCAL", localUnsynced.toString(), subtitle = "pending")
|
||||||
StatItem("QUEUED", stats.queued.toString())
|
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
|
@Composable
|
||||||
private fun StatItem(label: String, value: String) {
|
private fun StatItem(label: String, value: String, subtitle: String? = null) {
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
Text(
|
Text(
|
||||||
text = value,
|
text = value,
|
||||||
|
|
@ -267,6 +398,14 @@ private fun StatItem(label: String, value: String) {
|
||||||
fontSize = 9.sp,
|
fontSize = 9.sp,
|
||||||
letterSpacing = 1.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 {
|
when {
|
||||||
!beeConnected -> {
|
!beeConnected -> {
|
||||||
// Show "Bee offline" instead of "Awaiting frame"
|
|
||||||
CameraPlaceholder("Bee offline", Icons.Default.CloudOff)
|
CameraPlaceholder("Bee offline", Icons.Default.CloudOff)
|
||||||
}
|
}
|
||||||
bytes != null && bytes.isNotEmpty() -> {
|
bytes != null && bytes.isNotEmpty() -> {
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,10 @@ import com.adamaps.varroa.data.BeeDeviceInfo
|
||||||
import com.adamaps.varroa.data.GnssData
|
import com.adamaps.varroa.data.GnssData
|
||||||
import com.adamaps.varroa.data.SettingsDataStore
|
import com.adamaps.varroa.data.SettingsDataStore
|
||||||
import com.adamaps.varroa.data.VarroaSettings
|
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.Job
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
|
@ -21,10 +24,20 @@ import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
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) {
|
class DashboardViewModel(app: Application) : AndroidViewModel(app) {
|
||||||
|
|
||||||
private val settingsStore = SettingsDataStore(app)
|
private val settingsStore = SettingsDataStore(app)
|
||||||
private val beeClient = BeeApiClient()
|
private val beeClient = BeeApiClient()
|
||||||
|
private val database = VarroaDatabase.getInstance(app)
|
||||||
|
private val networkMonitor = NetworkStateMonitor.getInstance(app)
|
||||||
|
|
||||||
// ── Settings ──────────────────────────────────────────────────────────────
|
// ── Settings ──────────────────────────────────────────────────────────────
|
||||||
val settings: StateFlow<VarroaSettings> = settingsStore.settings
|
val settings: StateFlow<VarroaSettings> = settingsStore.settings
|
||||||
|
|
@ -44,18 +57,40 @@ class DashboardViewModel(app: Application) : AndroidViewModel(app) {
|
||||||
|
|
||||||
private val _cameraEndpointWorking = MutableStateFlow<String?>(null)
|
private val _cameraEndpointWorking = MutableStateFlow<String?>(null)
|
||||||
|
|
||||||
// ── Forwarding service state (exposed from singleton) ─────────────────────
|
// ── Collector service state ───────────────────────────────────────────────
|
||||||
val stats = ForwardingService.stats
|
val isCollecting = BeeCollectorService.isRunning
|
||||||
val isForwarding = ForwardingService.isRunning
|
val beeConnected = BeeCollectorService.beeConnected
|
||||||
val beeConnected = ForwardingService.beeConnected
|
val beeStatus = BeeCollectorService.beeStatus
|
||||||
val adamapsReachable = ForwardingService.adamapsReachable
|
val beeError = BeeCollectorService.lastError
|
||||||
val lastError = ForwardingService.lastError
|
val sessionCollected = BeeCollectorService.sessionCollected
|
||||||
val beeStatus = ForwardingService.beeStatus
|
|
||||||
|
|
||||||
// ── Error ─────────────────────────────────────────────────────────────────
|
// ── Uploader state ────────────────────────────────────────────────────────
|
||||||
private val _error = MutableStateFlow<String?>(null)
|
val isUploading = AdaMapsUploadWorker.isUploading
|
||||||
val error: StateFlow<String?> = _error.asStateFlow()
|
val uploadError = AdaMapsUploadWorker.uploadError
|
||||||
|
val totalUploaded = AdaMapsUploadWorker.totalUploaded
|
||||||
|
|
||||||
|
// ── Network state ─────────────────────────────────────────────────────────
|
||||||
|
val hasInternet = networkMonitor.hasValidatedInternet
|
||||||
|
val hasBeeNetwork = networkMonitor.hasBeeNetwork
|
||||||
|
val networkStatus = networkMonitor.networkStatus
|
||||||
|
|
||||||
|
// ── Local database stats (Flow-based, auto-updates) ──────────────────────
|
||||||
|
val localUnsynced: StateFlow<Int> = database.detectionDao().countUnsynced()
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0)
|
||||||
|
|
||||||
|
val localTotal: StateFlow<Int> = database.detectionDao().countTotal()
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0)
|
||||||
|
|
||||||
|
val localSynced: StateFlow<Int> = database.detectionDao().countSynced()
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0)
|
||||||
|
|
||||||
|
// ── Legacy compatibility ──────────────────────────────────────────────────
|
||||||
|
// For UI components that still reference old ForwardingService states
|
||||||
|
val isForwarding = isCollecting
|
||||||
|
val adamapsReachable = hasInternet
|
||||||
|
val lastError = beeError
|
||||||
|
|
||||||
|
// ── Polling jobs ──────────────────────────────────────────────────────────
|
||||||
private var gpsJob: Job? = null
|
private var gpsJob: Job? = null
|
||||||
private var deviceJob: Job? = null
|
private var deviceJob: Job? = null
|
||||||
private var cameraJob: Job? = null
|
private var cameraJob: Job? = null
|
||||||
|
|
@ -75,7 +110,14 @@ class DashboardViewModel(app: Application) : AndroidViewModel(app) {
|
||||||
|
|
||||||
private fun applySettings(s: VarroaSettings) {
|
private fun applySettings(s: VarroaSettings) {
|
||||||
beeClient.updateUrls(s.beeApiUrl, s.beeAltApiUrl)
|
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) {
|
private fun startPolling(s: VarroaSettings) {
|
||||||
|
|
@ -88,9 +130,11 @@ class DashboardViewModel(app: Application) : AndroidViewModel(app) {
|
||||||
gpsJob?.cancel()
|
gpsJob?.cancel()
|
||||||
gpsJob = viewModelScope.launch {
|
gpsJob = viewModelScope.launch {
|
||||||
while (true) {
|
while (true) {
|
||||||
when (val r = beeClient.getGnss()) {
|
if (beeConnected.value) {
|
||||||
is ApiResult.Success -> _gnss.value = r.data
|
when (val r = beeClient.getGnss()) {
|
||||||
is ApiResult.Error -> { /* ignore, GPS just won't update */ }
|
is ApiResult.Success -> _gnss.value = r.data
|
||||||
|
is ApiResult.Error -> { /* ignore */ }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
delay(intervalMs)
|
delay(intervalMs)
|
||||||
}
|
}
|
||||||
|
|
@ -101,9 +145,11 @@ class DashboardViewModel(app: Application) : AndroidViewModel(app) {
|
||||||
deviceJob?.cancel()
|
deviceJob?.cancel()
|
||||||
deviceJob = viewModelScope.launch {
|
deviceJob = viewModelScope.launch {
|
||||||
while (true) {
|
while (true) {
|
||||||
when (val r = beeClient.getDeviceInfo()) {
|
if (beeConnected.value) {
|
||||||
is ApiResult.Success -> _deviceInfo.value = r.data
|
when (val r = beeClient.getDeviceInfo()) {
|
||||||
is ApiResult.Error -> { /* ignore */ }
|
is ApiResult.Success -> _deviceInfo.value = r.data
|
||||||
|
is ApiResult.Error -> { /* ignore */ }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
delay(intervalMs)
|
delay(intervalMs)
|
||||||
}
|
}
|
||||||
|
|
@ -114,44 +160,57 @@ class DashboardViewModel(app: Application) : AndroidViewModel(app) {
|
||||||
cameraJob?.cancel()
|
cameraJob?.cancel()
|
||||||
cameraJob = viewModelScope.launch {
|
cameraJob = viewModelScope.launch {
|
||||||
while (true) {
|
while (true) {
|
||||||
val ep = _cameraEndpointWorking.value ?: configuredEndpoint
|
if (beeConnected.value) {
|
||||||
val (foundEp, result) = beeClient.getCameraFrameAuto(ep)
|
val ep = _cameraEndpointWorking.value ?: configuredEndpoint
|
||||||
when (result) {
|
val (foundEp, result) = beeClient.getCameraFrameAuto(ep)
|
||||||
is ApiResult.Success -> {
|
when (result) {
|
||||||
_cameraEndpointWorking.value = foundEp
|
is ApiResult.Success -> {
|
||||||
_cameraBytes.value = result.data
|
_cameraEndpointWorking.value = foundEp
|
||||||
}
|
_cameraBytes.value = result.data
|
||||||
is ApiResult.Error -> {
|
}
|
||||||
// Clear camera bytes when Bee is offline
|
is ApiResult.Error -> {
|
||||||
_cameraBytes.value = null
|
_cameraBytes.value = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
_cameraBytes.value = null
|
||||||
}
|
}
|
||||||
delay(intervalMs)
|
delay(intervalMs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun startForwarding() {
|
// ── Service control ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fun startCollecting() {
|
||||||
val ctx = getApplication<Application>()
|
val ctx = getApplication<Application>()
|
||||||
val intent = Intent(ctx, ForwardingService::class.java).apply {
|
val intent = Intent(ctx, BeeCollectorService::class.java).apply {
|
||||||
action = ForwardingService.ACTION_START
|
action = BeeCollectorService.ACTION_START
|
||||||
}
|
}
|
||||||
ctx.startForegroundService(intent)
|
ctx.startForegroundService(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun stopForwarding() {
|
fun stopCollecting() {
|
||||||
val ctx = getApplication<Application>()
|
val ctx = getApplication<Application>()
|
||||||
val intent = Intent(ctx, ForwardingService::class.java).apply {
|
val intent = Intent(ctx, BeeCollectorService::class.java).apply {
|
||||||
action = ForwardingService.ACTION_STOP
|
action = BeeCollectorService.ACTION_STOP
|
||||||
}
|
}
|
||||||
ctx.startService(intent)
|
ctx.startService(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun toggleForwarding() {
|
fun toggleCollecting() {
|
||||||
if (isForwarding.value) stopForwarding() else startForwarding()
|
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() {
|
fun refreshPolling() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val s = settings.first()
|
val s = settings.first()
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,14 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">Varroa</string>
|
<string name="app_name">Varroa</string>
|
||||||
|
|
||||||
|
<!-- Old forwarding service (deprecated) -->
|
||||||
<string name="forwarding_notification_channel">Varroa Forwarding</string>
|
<string name="forwarding_notification_channel">Varroa Forwarding</string>
|
||||||
<string name="forwarding_notification_title">Varroa — Forwarding Active</string>
|
<string name="forwarding_notification_title">Varroa — Forwarding Active</string>
|
||||||
<string name="forwarding_notification_text">%d detections sent</string>
|
<string name="forwarding_notification_text">%d detections sent</string>
|
||||||
|
|
||||||
|
<!-- New collector service -->
|
||||||
|
<string name="collector_notification_channel">Varroa Collector</string>
|
||||||
|
<string name="collector_notification_title">Varroa — Collecting</string>
|
||||||
|
<string name="collector_notification_text">%d detections collected</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
[versions]
|
[versions]
|
||||||
agp = "8.3.0"
|
agp = "8.3.0"
|
||||||
kotlin = "2.0.0"
|
kotlin = "2.0.0"
|
||||||
|
ksp = "2.0.0-1.0.21"
|
||||||
coreKtx = "1.12.0"
|
coreKtx = "1.12.0"
|
||||||
lifecycleRuntimeKtx = "2.7.0"
|
lifecycleRuntimeKtx = "2.7.0"
|
||||||
activityCompose = "1.8.2"
|
activityCompose = "1.8.2"
|
||||||
|
|
@ -12,6 +13,8 @@ coroutines = "1.8.0"
|
||||||
osmdroid = "6.1.18"
|
osmdroid = "6.1.18"
|
||||||
datastore = "1.0.0"
|
datastore = "1.0.0"
|
||||||
coil = "2.6.0"
|
coil = "2.6.0"
|
||||||
|
room = "2.6.1"
|
||||||
|
work = "2.9.0"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
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" }
|
osmdroid-android = { group = "org.osmdroid", name = "osmdroid-android", version.ref = "osmdroid" }
|
||||||
datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
|
datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
|
||||||
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
|
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]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||||
|
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue