v7.6: image capture and upload

This commit is contained in:
Kayos 2026-03-12 03:40:17 -07:00
parent 99f76adbaa
commit 5d8cacfacb
12 changed files with 601 additions and 7 deletions

View file

@ -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

View file

@ -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() {

View file

@ -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")
}
}
}

View file

@ -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.

View file

@ -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>
}

View file

@ -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(),

View file

@ -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)
}

View file

@ -7,7 +7,7 @@ import androidx.room.RoomDatabase
@Database(
entities = [DetectionEntity::class],
version = 1,
version = 2,
exportSchema = false
)
abstract class VarroaDatabase : RoomDatabase() {

View file

@ -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")
}
}

View file

@ -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)
}

View file

@ -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
)
}
}
}
}
}

View file

@ -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