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
2cebb4fb5d
commit
964d175454
14 changed files with 1296 additions and 86 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,13 @@
|
|||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- v7: New Bee Collector Service (store-and-forward) -->
|
||||
<service
|
||||
android:name=".service.BeeCollectorService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
|
||||
<!-- Legacy ForwardingService (deprecated, kept for migration) -->
|
||||
<service
|
||||
android:name=".service.ForwardingService"
|
||||
android:exported="false"
|
||||
|
|
|
|||
|
|
@ -1,16 +1,35 @@
|
|||
package com.adamaps.varroa
|
||||
|
||||
import android.app.Application
|
||||
import com.adamaps.varroa.network.NetworkStateMonitor
|
||||
import com.adamaps.varroa.service.AdaMapsUploadWorker
|
||||
import org.osmdroid.config.Configuration
|
||||
|
||||
class VarroaApplication : Application() {
|
||||
|
||||
lateinit var networkMonitor: NetworkStateMonitor
|
||||
private set
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
// osmdroid: set user-agent and tile cache
|
||||
Configuration.getInstance().apply {
|
||||
userAgentValue = "Varroa/1.0 (ADAMaps)"
|
||||
userAgentValue = "Varroa/1.7 (ADAMaps)"
|
||||
osmdroidBasePath = cacheDir
|
||||
osmdroidTileCache = cacheDir
|
||||
}
|
||||
|
||||
// Initialize network state monitoring
|
||||
networkMonitor = NetworkStateMonitor.getInstance(this)
|
||||
networkMonitor.startMonitoring()
|
||||
|
||||
// Schedule background uploads when internet is available
|
||||
AdaMapsUploadWorker.schedule(this)
|
||||
}
|
||||
|
||||
override fun onTerminate() {
|
||||
networkMonitor.stopMonitoring()
|
||||
super.onTerminate()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import android.content.Context
|
|||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.util.Log
|
||||
import com.adamaps.varroa.data.ApiResult
|
||||
import com.adamaps.varroa.data.BeeDetection
|
||||
import com.adamaps.varroa.data.BeeDeviceInfo
|
||||
|
|
@ -22,6 +23,9 @@ class BeeApiClient(
|
|||
private var primaryUrl: String = "http://192.168.0.10:5000",
|
||||
private var altUrl: String = "http://192.168.0.155:5000"
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "BeeApiClient"
|
||||
}
|
||||
|
||||
private var client = buildClient(null)
|
||||
private var fastClient = buildFastClient(null)
|
||||
|
|
@ -41,8 +45,23 @@ class BeeApiClient(
|
|||
activeUrl = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind to a specific network (e.g., unvalidated WiFi for Bee AP).
|
||||
* This is the preferred method when using NetworkStateMonitor.
|
||||
*/
|
||||
fun bindToNetwork(network: Network) {
|
||||
Log.d(TAG, "Binding to network: $network")
|
||||
client = buildClient(network)
|
||||
fastClient = buildFastClient(network)
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy binding method - finds WiFi network automatically.
|
||||
* Prefer bindToNetwork() with explicit network from NetworkStateMonitor.
|
||||
*/
|
||||
fun bindToWifiNetwork(context: Context) {
|
||||
val net = getWifiNetwork(context)
|
||||
Log.d(TAG, "Legacy binding to WiFi: $net")
|
||||
client = buildClient(net)
|
||||
fastClient = buildFastClient(net)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.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() -> {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,10 @@ import com.adamaps.varroa.data.BeeDeviceInfo
|
|||
import com.adamaps.varroa.data.GnssData
|
||||
import com.adamaps.varroa.data.SettingsDataStore
|
||||
import com.adamaps.varroa.data.VarroaSettings
|
||||
import com.adamaps.varroa.service.ForwardingService
|
||||
import com.adamaps.varroa.data.local.VarroaDatabase
|
||||
import com.adamaps.varroa.network.NetworkStateMonitor
|
||||
import com.adamaps.varroa.service.AdaMapsUploadWorker
|
||||
import com.adamaps.varroa.service.BeeCollectorService
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
|
@ -21,10 +24,20 @@ import kotlinx.coroutines.flow.first
|
|||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* ViewModel for the Dashboard screen.
|
||||
*
|
||||
* v7 Store-and-Forward architecture:
|
||||
* - BeeCollectorService handles collection from Bee (stores to local DB)
|
||||
* - AdaMapsUploadWorker handles uploads (when internet is available)
|
||||
* - This ViewModel displays stats from both systems
|
||||
*/
|
||||
class DashboardViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
private val settingsStore = SettingsDataStore(app)
|
||||
private val beeClient = BeeApiClient()
|
||||
private val database = VarroaDatabase.getInstance(app)
|
||||
private val networkMonitor = NetworkStateMonitor.getInstance(app)
|
||||
|
||||
// ── Settings ──────────────────────────────────────────────────────────────
|
||||
val settings: StateFlow<VarroaSettings> = settingsStore.settings
|
||||
|
|
@ -44,18 +57,40 @@ class DashboardViewModel(app: Application) : AndroidViewModel(app) {
|
|||
|
||||
private val _cameraEndpointWorking = MutableStateFlow<String?>(null)
|
||||
|
||||
// ── Forwarding service state (exposed from singleton) ─────────────────────
|
||||
val stats = ForwardingService.stats
|
||||
val isForwarding = ForwardingService.isRunning
|
||||
val beeConnected = ForwardingService.beeConnected
|
||||
val adamapsReachable = ForwardingService.adamapsReachable
|
||||
val lastError = ForwardingService.lastError
|
||||
val beeStatus = ForwardingService.beeStatus
|
||||
// ── Collector service state ───────────────────────────────────────────────
|
||||
val isCollecting = BeeCollectorService.isRunning
|
||||
val beeConnected = BeeCollectorService.beeConnected
|
||||
val beeStatus = BeeCollectorService.beeStatus
|
||||
val beeError = BeeCollectorService.lastError
|
||||
val sessionCollected = BeeCollectorService.sessionCollected
|
||||
|
||||
// ── Error ─────────────────────────────────────────────────────────────────
|
||||
private val _error = MutableStateFlow<String?>(null)
|
||||
val error: StateFlow<String?> = _error.asStateFlow()
|
||||
// ── Uploader state ────────────────────────────────────────────────────────
|
||||
val isUploading = AdaMapsUploadWorker.isUploading
|
||||
val uploadError = AdaMapsUploadWorker.uploadError
|
||||
val totalUploaded = AdaMapsUploadWorker.totalUploaded
|
||||
|
||||
// ── Network state ─────────────────────────────────────────────────────────
|
||||
val hasInternet = networkMonitor.hasValidatedInternet
|
||||
val hasBeeNetwork = networkMonitor.hasBeeNetwork
|
||||
val networkStatus = networkMonitor.networkStatus
|
||||
|
||||
// ── Local database stats (Flow-based, auto-updates) ──────────────────────
|
||||
val localUnsynced: StateFlow<Int> = database.detectionDao().countUnsynced()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0)
|
||||
|
||||
val localTotal: StateFlow<Int> = database.detectionDao().countTotal()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0)
|
||||
|
||||
val localSynced: StateFlow<Int> = database.detectionDao().countSynced()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0)
|
||||
|
||||
// ── Legacy compatibility ──────────────────────────────────────────────────
|
||||
// For UI components that still reference old ForwardingService states
|
||||
val isForwarding = isCollecting
|
||||
val adamapsReachable = hasInternet
|
||||
val lastError = beeError
|
||||
|
||||
// ── Polling jobs ──────────────────────────────────────────────────────────
|
||||
private var gpsJob: Job? = null
|
||||
private var deviceJob: Job? = null
|
||||
private var cameraJob: Job? = null
|
||||
|
|
@ -75,7 +110,14 @@ class DashboardViewModel(app: Application) : AndroidViewModel(app) {
|
|||
|
||||
private fun applySettings(s: VarroaSettings) {
|
||||
beeClient.updateUrls(s.beeApiUrl, s.beeAltApiUrl)
|
||||
beeClient.bindToWifiNetwork(getApplication())
|
||||
|
||||
// Bind to Bee network if available
|
||||
val beeNet = networkMonitor.getBeeNetworkForBinding()
|
||||
if (beeNet != null) {
|
||||
beeClient.bindToNetwork(beeNet)
|
||||
} else {
|
||||
beeClient.bindToWifiNetwork(getApplication())
|
||||
}
|
||||
}
|
||||
|
||||
private fun startPolling(s: VarroaSettings) {
|
||||
|
|
@ -88,9 +130,11 @@ class DashboardViewModel(app: Application) : AndroidViewModel(app) {
|
|||
gpsJob?.cancel()
|
||||
gpsJob = viewModelScope.launch {
|
||||
while (true) {
|
||||
when (val r = beeClient.getGnss()) {
|
||||
is ApiResult.Success -> _gnss.value = r.data
|
||||
is ApiResult.Error -> { /* ignore, GPS just won't update */ }
|
||||
if (beeConnected.value) {
|
||||
when (val r = beeClient.getGnss()) {
|
||||
is ApiResult.Success -> _gnss.value = r.data
|
||||
is ApiResult.Error -> { /* ignore */ }
|
||||
}
|
||||
}
|
||||
delay(intervalMs)
|
||||
}
|
||||
|
|
@ -101,9 +145,11 @@ class DashboardViewModel(app: Application) : AndroidViewModel(app) {
|
|||
deviceJob?.cancel()
|
||||
deviceJob = viewModelScope.launch {
|
||||
while (true) {
|
||||
when (val r = beeClient.getDeviceInfo()) {
|
||||
is ApiResult.Success -> _deviceInfo.value = r.data
|
||||
is ApiResult.Error -> { /* ignore */ }
|
||||
if (beeConnected.value) {
|
||||
when (val r = beeClient.getDeviceInfo()) {
|
||||
is ApiResult.Success -> _deviceInfo.value = r.data
|
||||
is ApiResult.Error -> { /* ignore */ }
|
||||
}
|
||||
}
|
||||
delay(intervalMs)
|
||||
}
|
||||
|
|
@ -114,44 +160,57 @@ class DashboardViewModel(app: Application) : AndroidViewModel(app) {
|
|||
cameraJob?.cancel()
|
||||
cameraJob = viewModelScope.launch {
|
||||
while (true) {
|
||||
val ep = _cameraEndpointWorking.value ?: configuredEndpoint
|
||||
val (foundEp, result) = beeClient.getCameraFrameAuto(ep)
|
||||
when (result) {
|
||||
is ApiResult.Success -> {
|
||||
_cameraEndpointWorking.value = foundEp
|
||||
_cameraBytes.value = result.data
|
||||
}
|
||||
is ApiResult.Error -> {
|
||||
// Clear camera bytes when Bee is offline
|
||||
_cameraBytes.value = null
|
||||
if (beeConnected.value) {
|
||||
val ep = _cameraEndpointWorking.value ?: configuredEndpoint
|
||||
val (foundEp, result) = beeClient.getCameraFrameAuto(ep)
|
||||
when (result) {
|
||||
is ApiResult.Success -> {
|
||||
_cameraEndpointWorking.value = foundEp
|
||||
_cameraBytes.value = result.data
|
||||
}
|
||||
is ApiResult.Error -> {
|
||||
_cameraBytes.value = null
|
||||
}
|
||||
}
|
||||
} else {
|
||||
_cameraBytes.value = null
|
||||
}
|
||||
delay(intervalMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun startForwarding() {
|
||||
// ── Service control ───────────────────────────────────────────────────────
|
||||
|
||||
fun startCollecting() {
|
||||
val ctx = getApplication<Application>()
|
||||
val intent = Intent(ctx, ForwardingService::class.java).apply {
|
||||
action = ForwardingService.ACTION_START
|
||||
val intent = Intent(ctx, BeeCollectorService::class.java).apply {
|
||||
action = BeeCollectorService.ACTION_START
|
||||
}
|
||||
ctx.startForegroundService(intent)
|
||||
}
|
||||
|
||||
fun stopForwarding() {
|
||||
fun stopCollecting() {
|
||||
val ctx = getApplication<Application>()
|
||||
val intent = Intent(ctx, ForwardingService::class.java).apply {
|
||||
action = ForwardingService.ACTION_STOP
|
||||
val intent = Intent(ctx, BeeCollectorService::class.java).apply {
|
||||
action = BeeCollectorService.ACTION_STOP
|
||||
}
|
||||
ctx.startService(intent)
|
||||
}
|
||||
|
||||
fun toggleForwarding() {
|
||||
if (isForwarding.value) stopForwarding() else startForwarding()
|
||||
fun toggleCollecting() {
|
||||
if (isCollecting.value) stopCollecting() else startCollecting()
|
||||
}
|
||||
|
||||
// Restart polling loops when settings change
|
||||
fun triggerUpload() {
|
||||
AdaMapsUploadWorker.triggerNow(getApplication())
|
||||
}
|
||||
|
||||
// Legacy compatibility
|
||||
fun startForwarding() = startCollecting()
|
||||
fun stopForwarding() = stopCollecting()
|
||||
fun toggleForwarding() = toggleCollecting()
|
||||
|
||||
fun refreshPolling() {
|
||||
viewModelScope.launch {
|
||||
val s = settings.first()
|
||||
|
|
|
|||
|
|
@ -1,7 +1,14 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Varroa</string>
|
||||
|
||||
<!-- Old forwarding service (deprecated) -->
|
||||
<string name="forwarding_notification_channel">Varroa Forwarding</string>
|
||||
<string name="forwarding_notification_title">Varroa — Forwarding Active</string>
|
||||
<string name="forwarding_notification_text">%d detections sent</string>
|
||||
|
||||
<!-- New collector service -->
|
||||
<string name="collector_notification_channel">Varroa Collector</string>
|
||||
<string name="collector_notification_title">Varroa — Collecting</string>
|
||||
<string name="collector_notification_text">%d detections collected</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue