v7.6: image capture and upload
This commit is contained in:
parent
99f76adbaa
commit
5d8cacfacb
12 changed files with 601 additions and 7 deletions
|
|
@ -13,8 +13,8 @@ android {
|
|||
applicationId = "com.adamaps.varroa"
|
||||
minSdk = 26
|
||||
targetSdk = 34
|
||||
versionCode = 11
|
||||
versionName = "1.7.5"
|
||||
versionCode = 12
|
||||
versionName = "1.7.6"
|
||||
|
||||
vectorDrawables {
|
||||
useSupportLibrary = true
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package com.adamaps.varroa
|
|||
import android.app.Application
|
||||
import com.adamaps.varroa.network.NetworkStateMonitor
|
||||
import com.adamaps.varroa.service.AdaMapsUploadWorker
|
||||
import com.adamaps.varroa.service.ImageCollectorService
|
||||
import org.osmdroid.config.Configuration
|
||||
|
||||
class VarroaApplication : Application() {
|
||||
|
|
@ -26,6 +27,9 @@ class VarroaApplication : Application() {
|
|||
|
||||
// Schedule background uploads when internet is available
|
||||
AdaMapsUploadWorker.schedule(this)
|
||||
|
||||
// Schedule background image collection from Bee device
|
||||
ImageCollectorService.schedule(this)
|
||||
}
|
||||
|
||||
override fun onTerminate() {
|
||||
|
|
|
|||
|
|
@ -8,9 +8,12 @@ import kotlinx.coroutines.Dispatchers
|
|||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.Dns
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.asRequestBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.io.File
|
||||
import java.net.InetAddress
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
|
|
@ -109,4 +112,61 @@ class AdaMapsApiClient(
|
|||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload detection image to ADAMaps API.
|
||||
*
|
||||
* @param detectionId The detection ID to link the image to
|
||||
* @param imageFile The image file to upload
|
||||
* @param deviceId The device ID for reference
|
||||
* @return ApiResult indicating success/failure
|
||||
*/
|
||||
suspend fun uploadImage(
|
||||
detectionId: Long,
|
||||
imageFile: File,
|
||||
deviceId: String
|
||||
): ApiResult<String> = withContext(Dispatchers.IO) {
|
||||
val uploadUrl = "$apiUrl/api/images"
|
||||
Log.d(TAG, "POST request to: $uploadUrl for detection $detectionId")
|
||||
|
||||
try {
|
||||
if (!imageFile.exists()) {
|
||||
return@withContext ApiResult.Error("Image file does not exist: ${imageFile.absolutePath}")
|
||||
}
|
||||
|
||||
val requestBody = MultipartBody.Builder()
|
||||
.setType(MultipartBody.FORM)
|
||||
.addFormDataPart("detection_id", detectionId.toString())
|
||||
.addFormDataPart("device_id", deviceId)
|
||||
.addFormDataPart(
|
||||
"image",
|
||||
imageFile.name,
|
||||
imageFile.asRequestBody("image/jpeg".toMediaType())
|
||||
)
|
||||
.build()
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(uploadUrl)
|
||||
.addHeader("X-MapNet-Key", apiKey)
|
||||
.post(requestBody)
|
||||
.build()
|
||||
|
||||
Log.d(TAG, "Uploading image for detection $detectionId: ${imageFile.length()} bytes")
|
||||
client.newCall(request).execute().use { resp ->
|
||||
val respBody = resp.body?.string() ?: ""
|
||||
Log.d(TAG, "Image upload HTTP ${resp.code} ${resp.message} - response: ${respBody.take(200)}")
|
||||
|
||||
if (resp.isSuccessful) {
|
||||
Log.i(TAG, "Image upload successful for detection $detectionId")
|
||||
ApiResult.Success(respBody)
|
||||
} else {
|
||||
Log.e(TAG, "Image upload failed for detection $detectionId - HTTP ${resp.code}")
|
||||
ApiResult.Error("HTTP ${resp.code}: ${resp.message}", resp.code)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Image upload request failed for detection $detectionId", e)
|
||||
ApiResult.Error(e.message ?: "Network error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -325,6 +325,29 @@ class BeeApiClient(
|
|||
return configured to ApiResult.Error("No camera endpoint responded")
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch detection image from Bee device.
|
||||
*
|
||||
* @param detectionId The landmark/detection ID from the Bee API
|
||||
* @return ApiResult containing image bytes (JPEG) or error
|
||||
*/
|
||||
suspend fun getDetectionImage(detectionId: Long): ApiResult<ByteArray> = withContext(Dispatchers.IO) {
|
||||
val endpoint = "/api/1/landmarks/images/$detectionId"
|
||||
Log.d(TAG, "Fetching detection image for ID: $detectionId")
|
||||
|
||||
val result = getBytes(endpoint)
|
||||
when (result) {
|
||||
is ApiResult.Success -> {
|
||||
Log.d(TAG, "Detection image fetched successfully: ${result.data.size} bytes")
|
||||
result
|
||||
}
|
||||
is ApiResult.Error -> {
|
||||
Log.w(TAG, "Failed to fetch detection image for ID $detectionId: ${result.message}")
|
||||
result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to delete landmarks from Bee device after successful upload.
|
||||
* This tries various potential DELETE endpoints.
|
||||
|
|
|
|||
|
|
@ -72,4 +72,35 @@ interface DetectionDao {
|
|||
*/
|
||||
@Query("SELECT EXISTS(SELECT 1 FROM detections WHERE beeDetectionId = :beeId AND timestamp = :ts)")
|
||||
suspend fun exists(beeId: Long, ts: Long?): Boolean
|
||||
|
||||
/**
|
||||
* Update image information for a detection.
|
||||
*/
|
||||
@Query("UPDATE detections SET hasImage = :hasImage, localImagePath = :localImagePath WHERE id = :id")
|
||||
suspend fun updateImageInfo(id: Long, hasImage: Boolean, localImagePath: String?)
|
||||
|
||||
/**
|
||||
* Get detections that have images but haven't been uploaded yet.
|
||||
*/
|
||||
@Query("SELECT * FROM detections WHERE hasImage = 1 AND imageSynced = 0 ORDER BY createdAt ASC LIMIT :limit")
|
||||
suspend fun getUnsyncedImagesBatch(limit: Int = 20): List<DetectionEntity>
|
||||
|
||||
/**
|
||||
* Mark images as synced after successful upload.
|
||||
*/
|
||||
@Query("UPDATE detections SET imageSynced = 1 WHERE id IN (:ids)")
|
||||
suspend fun markImagesSynced(ids: List<Long>)
|
||||
|
||||
/**
|
||||
* Count detections with images available.
|
||||
*/
|
||||
@Query("SELECT COUNT(*) FROM detections WHERE hasImage = 1")
|
||||
fun countWithImages(): Flow<Int>
|
||||
|
||||
/**
|
||||
* Get detections that don't have images yet.
|
||||
* Prioritizes recent detections for image collection.
|
||||
*/
|
||||
@Query("SELECT * FROM detections WHERE hasImage = 0 ORDER BY createdAt DESC LIMIT :limit")
|
||||
suspend fun getDetectionsNeedingImages(limit: Int = 10): List<DetectionEntity>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,11 @@ data class DetectionEntity(
|
|||
val posConfidence: Double?,
|
||||
val azimuth: Double?,
|
||||
|
||||
// Image state
|
||||
val hasImage: Boolean = false,
|
||||
val localImagePath: String? = null,
|
||||
val imageSynced: Boolean = false,
|
||||
|
||||
// Sync state
|
||||
val synced: Boolean = false,
|
||||
val createdAt: Long = System.currentTimeMillis(),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,197 @@
|
|||
package com.adamaps.varroa.data.local
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
|
||||
/**
|
||||
* Manages local storage of detection images.
|
||||
* Images are stored in the app's internal storage for security and automatic cleanup.
|
||||
*/
|
||||
class ImageStorageManager(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ImageStorage"
|
||||
private const val IMAGES_DIR = "detection_images"
|
||||
}
|
||||
|
||||
private val imagesDir: File by lazy {
|
||||
File(context.filesDir, IMAGES_DIR).apply {
|
||||
if (!exists()) {
|
||||
mkdirs()
|
||||
Log.d(TAG, "Created images directory: $absolutePath")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save image bytes to local storage.
|
||||
*
|
||||
* @param detectionId The detection ID to use as filename
|
||||
* @param imageData The image bytes (JPEG)
|
||||
* @return File path if successful, null if failed
|
||||
*/
|
||||
fun saveImage(detectionId: Long, imageData: ByteArray): String? {
|
||||
if (imageData.isEmpty()) {
|
||||
Log.w(TAG, "Cannot save empty image data for detection $detectionId")
|
||||
return null
|
||||
}
|
||||
|
||||
return try {
|
||||
val filename = "detection_${detectionId}.jpg"
|
||||
val file = File(imagesDir, filename)
|
||||
|
||||
// Don't overwrite existing files
|
||||
if (file.exists()) {
|
||||
Log.d(TAG, "Image already exists for detection $detectionId")
|
||||
return file.absolutePath
|
||||
}
|
||||
|
||||
Log.d(TAG, "Saving image for detection $detectionId: ${imageData.size} bytes to $filename")
|
||||
|
||||
FileOutputStream(file).use { output ->
|
||||
output.write(imageData)
|
||||
output.flush()
|
||||
}
|
||||
|
||||
Log.i(TAG, "Image saved successfully for detection $detectionId: ${file.absolutePath}")
|
||||
file.absolutePath
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to save image for detection $detectionId", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if image exists locally.
|
||||
*/
|
||||
fun hasImage(detectionId: Long): Boolean {
|
||||
val filename = "detection_${detectionId}.jpg"
|
||||
val file = File(imagesDir, filename)
|
||||
return file.exists()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get image file for a detection.
|
||||
*/
|
||||
fun getImageFile(detectionId: Long): File? {
|
||||
val filename = "detection_${detectionId}.jpg"
|
||||
val file = File(imagesDir, filename)
|
||||
return if (file.exists()) file else null
|
||||
}
|
||||
|
||||
/**
|
||||
* Read image bytes from storage.
|
||||
*/
|
||||
fun readImage(localImagePath: String): ByteArray? {
|
||||
return try {
|
||||
val file = File(localImagePath)
|
||||
if (file.exists()) {
|
||||
file.readBytes()
|
||||
} else {
|
||||
Log.w(TAG, "Image file not found: $localImagePath")
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to read image from $localImagePath", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete image file.
|
||||
*/
|
||||
fun deleteImage(localImagePath: String): Boolean {
|
||||
return try {
|
||||
val file = File(localImagePath)
|
||||
val deleted = file.delete()
|
||||
if (deleted) {
|
||||
Log.d(TAG, "Deleted image file: $localImagePath")
|
||||
} else {
|
||||
Log.w(TAG, "Failed to delete image file: $localImagePath")
|
||||
}
|
||||
deleted
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Exception deleting image: $localImagePath", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup old images that are no longer referenced by the database.
|
||||
* Call this periodically to free up storage space.
|
||||
*/
|
||||
suspend fun cleanupOrphanedImages(database: VarroaDatabase) {
|
||||
try {
|
||||
Log.d(TAG, "Starting orphaned image cleanup...")
|
||||
|
||||
val imageFiles = imagesDir.listFiles { _, name ->
|
||||
name.endsWith(".jpg") && name.startsWith("detection_")
|
||||
} ?: emptyArray()
|
||||
|
||||
var deletedCount = 0
|
||||
for (file in imageFiles) {
|
||||
// Extract detection ID from filename
|
||||
val detectionId = file.name
|
||||
.removePrefix("detection_")
|
||||
.removeSuffix(".jpg")
|
||||
.toLongOrNull()
|
||||
|
||||
if (detectionId != null) {
|
||||
// Check if this detection still exists in database
|
||||
val exists = database.detectionDao().exists(detectionId, null)
|
||||
if (!exists) {
|
||||
if (file.delete()) {
|
||||
deletedCount++
|
||||
Log.d(TAG, "Deleted orphaned image: ${file.name}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (deletedCount > 0) {
|
||||
Log.i(TAG, "Cleanup complete: deleted $deletedCount orphaned images")
|
||||
} else {
|
||||
Log.d(TAG, "No orphaned images found")
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error during image cleanup", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get storage statistics.
|
||||
*/
|
||||
fun getStorageInfo(): ImageStorageInfo {
|
||||
return try {
|
||||
val files = imagesDir.listFiles { _, name ->
|
||||
name.endsWith(".jpg") && name.startsWith("detection_")
|
||||
} ?: emptyArray()
|
||||
|
||||
val totalSize = files.sumOf { it.length() }
|
||||
|
||||
ImageStorageInfo(
|
||||
imageCount = files.size,
|
||||
totalSizeBytes = totalSize,
|
||||
storageDir = imagesDir.absolutePath
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error getting storage info", e)
|
||||
ImageStorageInfo(0, 0, imagesDir.absolutePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage statistics for UI display.
|
||||
*/
|
||||
data class ImageStorageInfo(
|
||||
val imageCount: Int,
|
||||
val totalSizeBytes: Long,
|
||||
val storageDir: String
|
||||
) {
|
||||
val totalSizeMB: Double get() = totalSizeBytes / (1024.0 * 1024.0)
|
||||
}
|
||||
|
|
@ -7,7 +7,7 @@ import androidx.room.RoomDatabase
|
|||
|
||||
@Database(
|
||||
entities = [DetectionEntity::class],
|
||||
version = 1,
|
||||
version = 2,
|
||||
exportSchema = false
|
||||
)
|
||||
abstract class VarroaDatabase : RoomDatabase() {
|
||||
|
|
|
|||
|
|
@ -16,7 +16,9 @@ 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.ImageStorageManager
|
||||
import com.adamaps.varroa.data.local.VarroaDatabase
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
|
@ -111,6 +113,7 @@ class AdaMapsUploadWorker(
|
|||
private val adamapsClient = AdaMapsApiClient()
|
||||
private val beeClient = BeeApiClient()
|
||||
private val settingsStore = SettingsDataStore(applicationContext)
|
||||
private val imageStorage = ImageStorageManager(applicationContext)
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
Log.i(TAG, "AdaMapsUploadWorker starting - checking for unsynced detections")
|
||||
|
|
@ -143,7 +146,7 @@ class AdaMapsUploadWorker(
|
|||
|
||||
when (val result = uploadBatch(batch)) {
|
||||
is Result.Success -> {
|
||||
// Mark as synced
|
||||
// Mark detection metadata as synced
|
||||
val ids = batch.map { it.id }
|
||||
Log.d(TAG, "Marking ${ids.size} records as synced in database...")
|
||||
database.detectionDao().markSynced(ids)
|
||||
|
|
@ -151,6 +154,9 @@ class AdaMapsUploadWorker(
|
|||
_totalUploaded.value += batch.size
|
||||
Log.i(TAG, "Batch #$batchNum uploaded successfully - running total: $totalUploaded")
|
||||
|
||||
// Upload images for detections that have them
|
||||
uploadImagesForBatch(batch)
|
||||
|
||||
// Attempt cleanup from Bee device after successful upload
|
||||
val beeDetectionIds = batch.map { it.beeDetectionId }
|
||||
Log.d(TAG, "Attempting to cleanup ${beeDetectionIds.size} landmarks from Bee device...")
|
||||
|
|
@ -260,4 +266,53 @@ class AdaMapsUploadWorker(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload images for detections that have them.
|
||||
* This runs after successful metadata upload.
|
||||
*/
|
||||
private suspend fun uploadImagesForBatch(batch: List<DetectionEntity>) {
|
||||
val detectionsWithImages = batch.filter { it.hasImage && !it.imageSynced && it.localImagePath != null }
|
||||
if (detectionsWithImages.isEmpty()) {
|
||||
Log.d(TAG, "No images to upload for this batch")
|
||||
return
|
||||
}
|
||||
|
||||
Log.i(TAG, "Uploading images for ${detectionsWithImages.size} detections...")
|
||||
|
||||
var uploadedCount = 0
|
||||
for (detection in detectionsWithImages) {
|
||||
try {
|
||||
val imagePath = detection.localImagePath!!
|
||||
val imageFile = File(imagePath)
|
||||
|
||||
if (!imageFile.exists()) {
|
||||
Log.w(TAG, "Image file not found for detection ${detection.beeDetectionId}: $imagePath")
|
||||
continue
|
||||
}
|
||||
|
||||
Log.d(TAG, "Uploading image for detection ${detection.beeDetectionId}")
|
||||
when (val result = adamapsClient.uploadImage(
|
||||
detection.beeDetectionId,
|
||||
imageFile,
|
||||
detection.deviceId
|
||||
)) {
|
||||
is ApiResult.Success -> {
|
||||
// Mark image as synced
|
||||
database.detectionDao().markImagesSynced(listOf(detection.id))
|
||||
uploadedCount++
|
||||
Log.i(TAG, "Image uploaded successfully for detection ${detection.beeDetectionId}")
|
||||
}
|
||||
is ApiResult.Error -> {
|
||||
Log.w(TAG, "Image upload failed for detection ${detection.beeDetectionId}: ${result.message}")
|
||||
// Don't fail the entire batch for image upload failures
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Exception uploading image for detection ${detection.beeDetectionId}", e)
|
||||
}
|
||||
}
|
||||
|
||||
Log.i(TAG, "Image upload batch complete: $uploadedCount/${detectionsWithImages.size} images uploaded")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,192 @@
|
|||
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.BeeApiClient
|
||||
import com.adamaps.varroa.data.ApiResult
|
||||
import com.adamaps.varroa.data.SettingsDataStore
|
||||
import com.adamaps.varroa.data.local.ImageStorageManager
|
||||
import com.adamaps.varroa.data.local.VarroaDatabase
|
||||
import kotlinx.coroutines.flow.first
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* ImageCollectorService - Downloads detection images from Bee device and stores them locally.
|
||||
*
|
||||
* This runs when connected to the Bee AP network and:
|
||||
* - Finds detections that don't have images yet
|
||||
* - Downloads images from Bee device using /api/1/landmarks/images/{id}
|
||||
* - Stores images in local storage
|
||||
* - Updates database to mark which detections have images
|
||||
*/
|
||||
class ImageCollectorService(
|
||||
context: Context,
|
||||
params: WorkerParameters
|
||||
) : CoroutineWorker(context, params) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ImageCollector"
|
||||
private const val WORK_NAME = "image_collector"
|
||||
private const val BATCH_SIZE = 10 // Smaller batches for images
|
||||
|
||||
/**
|
||||
* Schedule periodic image collection when connected to Bee network.
|
||||
*/
|
||||
fun schedule(context: Context) {
|
||||
Log.d(TAG, "Scheduling periodic image collector...")
|
||||
val constraints = Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build()
|
||||
|
||||
val collectRequest = PeriodicWorkRequestBuilder<ImageCollectorService>(
|
||||
20, TimeUnit.MINUTES, // Run every 20 minutes
|
||||
5, TimeUnit.MINUTES // Flex interval
|
||||
)
|
||||
.setConstraints(constraints)
|
||||
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 60, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
|
||||
WORK_NAME,
|
||||
ExistingPeriodicWorkPolicy.KEEP,
|
||||
collectRequest
|
||||
)
|
||||
Log.i(TAG, "Image collector scheduled - 20min interval")
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel scheduled image collection.
|
||||
*/
|
||||
fun cancel(context: Context) {
|
||||
WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME)
|
||||
Log.d(TAG, "Image collector cancelled")
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger immediate image collection.
|
||||
*/
|
||||
fun triggerNow(context: Context) {
|
||||
val constraints = Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build()
|
||||
|
||||
val collectRequest = androidx.work.OneTimeWorkRequestBuilder<ImageCollectorService>()
|
||||
.setConstraints(constraints)
|
||||
.build()
|
||||
|
||||
WorkManager.getInstance(context).enqueue(collectRequest)
|
||||
Log.d(TAG, "Immediate image collection triggered")
|
||||
}
|
||||
}
|
||||
|
||||
private val database = VarroaDatabase.getInstance(applicationContext)
|
||||
private val beeClient = BeeApiClient()
|
||||
private val settingsStore = SettingsDataStore(applicationContext)
|
||||
private val imageStorage = ImageStorageManager(applicationContext)
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
Log.i(TAG, "ImageCollectorService starting - checking for detections without images")
|
||||
|
||||
try {
|
||||
// Load settings
|
||||
val settings = settingsStore.settings.first()
|
||||
beeClient.updateUrl(settings.beeApiUrl)
|
||||
Log.d(TAG, "BeeApiClient configured with URL: ${settings.beeApiUrl}")
|
||||
|
||||
// Check if we can reach the Bee device
|
||||
if (!beeClient.ping()) {
|
||||
Log.w(TAG, "Cannot reach Bee device - skipping image collection")
|
||||
return Result.retry()
|
||||
}
|
||||
|
||||
// Process batches of detections without images
|
||||
var totalCollected = 0
|
||||
var batchNum = 0
|
||||
|
||||
while (true) {
|
||||
// Get detections that don't have images yet
|
||||
val batch = getDetectionsNeedingImages(BATCH_SIZE)
|
||||
if (batch.isEmpty()) {
|
||||
Log.i(TAG, "No more detections need images - collection complete")
|
||||
break
|
||||
}
|
||||
|
||||
batchNum++
|
||||
Log.i(TAG, "Processing image batch #$batchNum with ${batch.size} detections")
|
||||
|
||||
var batchCollected = 0
|
||||
for (detection in batch) {
|
||||
try {
|
||||
Log.d(TAG, "Fetching image for detection ${detection.beeDetectionId}")
|
||||
|
||||
when (val result = beeClient.getDetectionImage(detection.beeDetectionId)) {
|
||||
is ApiResult.Success -> {
|
||||
val imagePath = imageStorage.saveImage(
|
||||
detection.beeDetectionId,
|
||||
result.data
|
||||
)
|
||||
|
||||
if (imagePath != null) {
|
||||
// Update database to mark this detection as having an image
|
||||
database.detectionDao().updateImageInfo(
|
||||
detection.id,
|
||||
hasImage = true,
|
||||
localImagePath = imagePath
|
||||
)
|
||||
batchCollected++
|
||||
Log.i(TAG, "Image saved for detection ${detection.beeDetectionId}: $imagePath")
|
||||
} else {
|
||||
Log.w(TAG, "Failed to save image for detection ${detection.beeDetectionId}")
|
||||
}
|
||||
}
|
||||
is ApiResult.Error -> {
|
||||
Log.w(TAG, "Failed to fetch image for detection ${detection.beeDetectionId}: ${result.message}")
|
||||
// Continue with next detection - image may not exist for this detection
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Exception processing detection ${detection.beeDetectionId}", e)
|
||||
}
|
||||
}
|
||||
|
||||
totalCollected += batchCollected
|
||||
Log.i(TAG, "Batch #$batchNum complete: $batchCollected/$${batch.size} images collected")
|
||||
|
||||
// If we didn't collect any images in this batch, the Bee device might not have
|
||||
// images for these detections, so we can stop
|
||||
if (batchCollected == 0) {
|
||||
Log.i(TAG, "No images available for current detections - stopping collection")
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup orphaned images periodically
|
||||
if (totalCollected > 0) {
|
||||
Log.d(TAG, "Running orphaned image cleanup...")
|
||||
imageStorage.cleanupOrphanedImages(database)
|
||||
}
|
||||
|
||||
Log.i(TAG, "Image collection completed: $totalCollected images collected in $batchNum batches")
|
||||
return Result.success()
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Image collector encountered unexpected error", e)
|
||||
return Result.retry()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detections that need images downloaded.
|
||||
* Prioritizes recent detections that don't have images yet.
|
||||
*/
|
||||
private suspend fun getDetectionsNeedingImages(limit: Int) =
|
||||
database.detectionDao().getDetectionsNeedingImages(limit)
|
||||
}
|
||||
|
|
@ -63,6 +63,7 @@ fun DashboardScreen(
|
|||
val localUnsynced by vm.localUnsynced.collectAsState()
|
||||
val localTotal by vm.localTotal.collectAsState()
|
||||
val localSynced by vm.localSynced.collectAsState()
|
||||
val localWithImages by vm.localWithImages.collectAsState()
|
||||
|
||||
// Network status
|
||||
val networkStatus by vm.networkStatus.collectAsState()
|
||||
|
|
@ -138,7 +139,8 @@ fun DashboardScreen(
|
|||
sessionCollected = sessionCollected,
|
||||
localUnsynced = localUnsynced,
|
||||
totalUploaded = totalUploaded,
|
||||
localTotal = localTotal
|
||||
localTotal = localTotal,
|
||||
localWithImages = localWithImages
|
||||
)
|
||||
|
||||
// Camera snapshot
|
||||
|
|
@ -357,7 +359,8 @@ private fun SessionStatsCardV7(
|
|||
sessionCollected: Int,
|
||||
localUnsynced: Int,
|
||||
totalUploaded: Int,
|
||||
localTotal: Int
|
||||
localTotal: Int,
|
||||
localWithImages: Int
|
||||
) {
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(containerColor = Surface),
|
||||
|
|
@ -383,7 +386,7 @@ private fun SessionStatsCardV7(
|
|||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = "Total stored: $localTotal detections",
|
||||
|
|
@ -391,6 +394,26 @@ private fun SessionStatsCardV7(
|
|||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 10.sp
|
||||
)
|
||||
|
||||
if (localWithImages > 0) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.PhotoCamera,
|
||||
contentDescription = "Images available",
|
||||
tint = Amber,
|
||||
modifier = Modifier.size(12.dp)
|
||||
)
|
||||
Text(
|
||||
text = "$localWithImages images",
|
||||
color = Amber,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 10.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -83,6 +83,10 @@ class DashboardViewModel(app: Application) : AndroidViewModel(app) {
|
|||
|
||||
val localSynced: StateFlow<Int> = database.detectionDao().countSynced()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0)
|
||||
|
||||
// ── Image statistics ─────────────────────────────────────────────────────
|
||||
val localWithImages: StateFlow<Int> = database.detectionDao().countWithImages()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0)
|
||||
|
||||
// ── Legacy compatibility ──────────────────────────────────────────────────
|
||||
// For UI components that still reference old ForwardingService states
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue