package `in`.carecollect.field.work import android.content.Context import androidx.work.* import `in`.carecollect.field.data.FieldDb import `in`.carecollect.field.net.ApiClient import org.json.JSONArray import org.json.JSONObject import java.util.concurrent.TimeUnit /** * Drains the offline queue. * * Two queues, deliberately handled differently: * * - **Actions** (collections, payments, status steps) replay strictly oldest * first, and one failure stops the run. Applying a payment before the * collection it belongs to would be worse than applying it late. * - **Location points** upload as one batch and are best effort. A dropped * breadcrumb costs nothing; blocking a collection upload behind it costs a * lot. * * Every action carries its clientOpId, so a retry after a timeout — where the * server did the work but the response never arrived — is harmless. That case * is common on Indian mobile data and is exactly what the id is there for. */ class SyncWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) { override suspend fun doWork(): Result { val db = FieldDb.get(applicationContext) val api = ApiClient.get(applicationContext) // Housekeeping first, so a phone offline for a week does not try to // upload a week of stale breadcrumbs on reconnect. val dayAgo = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(1) db.ops().prune(dayAgo) db.pings().prune(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(2)) var retryNeeded = false // ---- actions ------------------------------------------------------ for (op in db.ops().due()) { val outcome = runCatching { api.postRaw(op.endpoint, op.payload) } outcome.onSuccess { response -> when { response.code in 200..299 -> db.ops().markSynced(op.clientOpId) // 422 means the server understood and refused: the door // code was wrong, the order was already cancelled. Retrying // will never succeed, so stop and surface it to the person. response.code == 422 -> db.ops().markRejected(op.clientOpId, response.message) response.code == 401 -> { // Token expired. Nothing in the queue can go anywhere // until the person signs in again. ApiClient.clearToken(applicationContext) return Result.retry() } else -> { db.ops().markFailed(op.clientOpId, "HTTP ${response.code}") retryNeeded = true } } }.onFailure { e -> db.ops().markFailed(op.clientOpId, e.message) retryNeeded = true } // Order matters. Do not race ahead past a failure. if (retryNeeded) break } // ---- location points ---------------------------------------------- val pings = db.pings().batch() if (pings.isNotEmpty()) { val points = JSONArray() pings.forEach { p -> points.put( JSONObject().apply { put("lat", p.lat) put("lng", p.lng) put("accuracy", p.accuracy) put("speed", p.speedKmph) put("battery", p.battery) put("mock", p.isMock) p.orderId?.let { put("order_id", it) } put("at", iso(p.recordedAt)) } ) } runCatching { api.postRaw("location/ping", JSONObject().put("points", points).toString()) } .onSuccess { if (it.code in 200..299) db.pings().clear(pings.map { p -> p.id }) } .onFailure { retryNeeded = true } } return if (retryNeeded) Result.retry() else Result.success() } private fun iso(millis: Long): String = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", java.util.Locale.US) .apply { timeZone = java.util.TimeZone.getTimeZone("UTC") } .format(java.util.Date(millis)) } object SyncScheduler { private const val PERIODIC = "cc-sync-periodic" private const val IMMEDIATE = "cc-sync-now" /** Runs whenever there is a connection; WorkManager survives reboots. */ fun schedule(context: Context) { val constraints = Constraints.Builder() .setRequiredNetworkType(NetworkType.CONNECTED) .build() WorkManager.getInstance(context).enqueueUniquePeriodicWork( PERIODIC, ExistingPeriodicWorkPolicy.KEEP, PeriodicWorkRequestBuilder(15, TimeUnit.MINUTES) .setConstraints(constraints) .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS) .build() ) } /** * Called the moment something important happens — a collection, a * payment — so it lands in seconds rather than at the next 15 minute tick. * REPLACE, not APPEND: several taps in a row should collapse into one run. */ fun flushNow(context: Context) { WorkManager.getInstance(context).enqueueUniqueWork( IMMEDIATE, ExistingWorkPolicy.REPLACE, OneTimeWorkRequestBuilder() .setConstraints( Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build() ) .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 10, TimeUnit.SECONDS) .build() ) } }