v7.6: image capture and upload
This commit is contained in:
parent
ccd2e01008
commit
afefd01053
12 changed files with 601 additions and 7 deletions
|
|
@ -13,8 +13,8 @@ android {
|
||||||
applicationId = "com.adamaps.varroa"
|
applicationId = "com.adamaps.varroa"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 34
|
targetSdk = 34
|
||||||
versionCode = 11
|
versionCode = 12
|
||||||
versionName = "1.7.5"
|
versionName = "1.7.6"
|
||||||
|
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
useSupportLibrary = true
|
useSupportLibrary = true
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package com.adamaps.varroa
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import com.adamaps.varroa.network.NetworkStateMonitor
|
import com.adamaps.varroa.network.NetworkStateMonitor
|
||||||
import com.adamaps.varroa.service.AdaMapsUploadWorker
|
import com.adamaps.varroa.service.AdaMapsUploadWorker
|
||||||
|
import com.adamaps.varroa.service.ImageCollectorService
|
||||||
import org.osmdroid.config.Configuration
|
import org.osmdroid.config.Configuration
|
||||||
|
|
||||||
class VarroaApplication : Application() {
|
class VarroaApplication : Application() {
|
||||||
|
|
@ -26,6 +27,9 @@ class VarroaApplication : Application() {
|
||||||
|
|
||||||
// Schedule background uploads when internet is available
|
// Schedule background uploads when internet is available
|
||||||
AdaMapsUploadWorker.schedule(this)
|
AdaMapsUploadWorker.schedule(this)
|
||||||
|
|
||||||
|
// Schedule background image collection from Bee device
|
||||||
|
ImageCollectorService.schedule(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onTerminate() {
|
override fun onTerminate() {
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,12 @@ import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import okhttp3.Dns
|
import okhttp3.Dns
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.MultipartBody
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.asRequestBody
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import java.io.File
|
||||||
import java.net.InetAddress
|
import java.net.InetAddress
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
|
@ -109,4 +112,61 @@ class AdaMapsApiClient(
|
||||||
false
|
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")
|
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.
|
* Attempt to delete landmarks from Bee device after successful upload.
|
||||||
* This tries various potential DELETE endpoints.
|
* 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)")
|
@Query("SELECT EXISTS(SELECT 1 FROM detections WHERE beeDetectionId = :beeId AND timestamp = :ts)")
|
||||||
suspend fun exists(beeId: Long, ts: Long?): Boolean
|
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 posConfidence: Double?,
|
||||||
val azimuth: Double?,
|
val azimuth: Double?,
|
||||||
|
|
||||||
|
// Image state
|
||||||
|
val hasImage: Boolean = false,
|
||||||
|
val localImagePath: String? = null,
|
||||||
|
val imageSynced: Boolean = false,
|
||||||
|
|
||||||
// Sync state
|
// Sync state
|
||||||
val synced: Boolean = false,
|
val synced: Boolean = false,
|
||||||
val createdAt: Long = System.currentTimeMillis(),
|
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(
|
@Database(
|
||||||
entities = [DetectionEntity::class],
|
entities = [DetectionEntity::class],
|
||||||
version = 1,
|
version = 2,
|
||||||
exportSchema = false
|
exportSchema = false
|
||||||
)
|
)
|
||||||
abstract class VarroaDatabase : RoomDatabase() {
|
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.ApiResult
|
||||||
import com.adamaps.varroa.data.SettingsDataStore
|
import com.adamaps.varroa.data.SettingsDataStore
|
||||||
import com.adamaps.varroa.data.local.DetectionEntity
|
import com.adamaps.varroa.data.local.DetectionEntity
|
||||||
|
import com.adamaps.varroa.data.local.ImageStorageManager
|
||||||
import com.adamaps.varroa.data.local.VarroaDatabase
|
import com.adamaps.varroa.data.local.VarroaDatabase
|
||||||
|
import java.io.File
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
|
@ -111,6 +113,7 @@ class AdaMapsUploadWorker(
|
||||||
private val adamapsClient = AdaMapsApiClient()
|
private val adamapsClient = AdaMapsApiClient()
|
||||||
private val beeClient = BeeApiClient()
|
private val beeClient = BeeApiClient()
|
||||||
private val settingsStore = SettingsDataStore(applicationContext)
|
private val settingsStore = SettingsDataStore(applicationContext)
|
||||||
|
private val imageStorage = ImageStorageManager(applicationContext)
|
||||||
|
|
||||||
override suspend fun doWork(): Result {
|
override suspend fun doWork(): Result {
|
||||||
Log.i(TAG, "AdaMapsUploadWorker starting - checking for unsynced detections")
|
Log.i(TAG, "AdaMapsUploadWorker starting - checking for unsynced detections")
|
||||||
|
|
@ -143,7 +146,7 @@ class AdaMapsUploadWorker(
|
||||||
|
|
||||||
when (val result = uploadBatch(batch)) {
|
when (val result = uploadBatch(batch)) {
|
||||||
is Result.Success -> {
|
is Result.Success -> {
|
||||||
// Mark as synced
|
// Mark detection metadata as synced
|
||||||
val ids = batch.map { it.id }
|
val ids = batch.map { it.id }
|
||||||
Log.d(TAG, "Marking ${ids.size} records as synced in database...")
|
Log.d(TAG, "Marking ${ids.size} records as synced in database...")
|
||||||
database.detectionDao().markSynced(ids)
|
database.detectionDao().markSynced(ids)
|
||||||
|
|
@ -151,6 +154,9 @@ class AdaMapsUploadWorker(
|
||||||
_totalUploaded.value += batch.size
|
_totalUploaded.value += batch.size
|
||||||
Log.i(TAG, "Batch #$batchNum uploaded successfully - running total: $totalUploaded")
|
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
|
// Attempt cleanup from Bee device after successful upload
|
||||||
val beeDetectionIds = batch.map { it.beeDetectionId }
|
val beeDetectionIds = batch.map { it.beeDetectionId }
|
||||||
Log.d(TAG, "Attempting to cleanup ${beeDetectionIds.size} landmarks from Bee device...")
|
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 localUnsynced by vm.localUnsynced.collectAsState()
|
||||||
val localTotal by vm.localTotal.collectAsState()
|
val localTotal by vm.localTotal.collectAsState()
|
||||||
val localSynced by vm.localSynced.collectAsState()
|
val localSynced by vm.localSynced.collectAsState()
|
||||||
|
val localWithImages by vm.localWithImages.collectAsState()
|
||||||
|
|
||||||
// Network status
|
// Network status
|
||||||
val networkStatus by vm.networkStatus.collectAsState()
|
val networkStatus by vm.networkStatus.collectAsState()
|
||||||
|
|
@ -138,7 +139,8 @@ fun DashboardScreen(
|
||||||
sessionCollected = sessionCollected,
|
sessionCollected = sessionCollected,
|
||||||
localUnsynced = localUnsynced,
|
localUnsynced = localUnsynced,
|
||||||
totalUploaded = totalUploaded,
|
totalUploaded = totalUploaded,
|
||||||
localTotal = localTotal
|
localTotal = localTotal,
|
||||||
|
localWithImages = localWithImages
|
||||||
)
|
)
|
||||||
|
|
||||||
// Camera snapshot
|
// Camera snapshot
|
||||||
|
|
@ -357,7 +359,8 @@ private fun SessionStatsCardV7(
|
||||||
sessionCollected: Int,
|
sessionCollected: Int,
|
||||||
localUnsynced: Int,
|
localUnsynced: Int,
|
||||||
totalUploaded: Int,
|
totalUploaded: Int,
|
||||||
localTotal: Int
|
localTotal: Int,
|
||||||
|
localWithImages: Int
|
||||||
) {
|
) {
|
||||||
Card(
|
Card(
|
||||||
colors = CardDefaults.cardColors(containerColor = Surface),
|
colors = CardDefaults.cardColors(containerColor = Surface),
|
||||||
|
|
@ -383,7 +386,7 @@ private fun SessionStatsCardV7(
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.Center
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Total stored: $localTotal detections",
|
text = "Total stored: $localTotal detections",
|
||||||
|
|
@ -391,6 +394,26 @@ private fun SessionStatsCardV7(
|
||||||
fontFamily = FontFamily.Monospace,
|
fontFamily = FontFamily.Monospace,
|
||||||
fontSize = 10.sp
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,10 @@ class DashboardViewModel(app: Application) : AndroidViewModel(app) {
|
||||||
val localSynced: StateFlow<Int> = database.detectionDao().countSynced()
|
val localSynced: StateFlow<Int> = database.detectionDao().countSynced()
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0)
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0)
|
||||||
|
|
||||||
|
// ── Image statistics ─────────────────────────────────────────────────────
|
||||||
|
val localWithImages: StateFlow<Int> = database.detectionDao().countWithImages()
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0)
|
||||||
|
|
||||||
// ── Legacy compatibility ──────────────────────────────────────────────────
|
// ── Legacy compatibility ──────────────────────────────────────────────────
|
||||||
// For UI components that still reference old ForwardingService states
|
// For UI components that still reference old ForwardingService states
|
||||||
val isForwarding = isCollecting
|
val isForwarding = isCollecting
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue