package `in`.carecollect.field.net import android.content.Context import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKey import `in`.carecollect.field.data.* import `in`.carecollect.field.work.SyncScheduler import okhttp3.* import okhttp3.MediaType.Companion.toMediaType import okhttp3.RequestBody.Companion.toRequestBody import org.json.JSONObject import java.util.UUID import java.util.concurrent.TimeUnit data class ApiResponse(val code: Int, val body: String, val message: String?) /** * HTTP for the field app. * * Timeouts are short on purpose. A phlebotomist standing at a door will not * wait fifteen seconds to find out whether a tap worked — the action is * already saved locally, so failing fast and syncing later is the better * behaviour. The screen should never show a spinner tied to the network. * * The bearer token lives in EncryptedSharedPreferences. Shared handsets are * normal in this job, and a token in plain preferences is a token anyone with * the phone can lift. */ class ApiClient private constructor(private val context: Context) { private val http = OkHttpClient.Builder() .connectTimeout(8, TimeUnit.SECONDS) .readTimeout(12, TimeUnit.SECONDS) .writeTimeout(12, TimeUnit.SECONDS) .retryOnConnectionFailure(true) .build() private val json = "application/json; charset=utf-8".toMediaType() fun postRaw(path: String, payload: String): ApiResponse { val request = Request.Builder() .url(baseUrl(context) + path) .post(payload.toRequestBody(json)) .apply { token(context)?.let { header("Authorization", "Bearer $it") } } .build() http.newCall(request).execute().use { r -> val body = r.body?.string().orEmpty() val message = runCatching { JSONObject(body).optJSONObject("error")?.optString("message") }.getOrNull() return ApiResponse(r.code, body, message) } } fun get(path: String): ApiResponse { val request = Request.Builder() .url(baseUrl(context) + path) .apply { token(context)?.let { header("Authorization", "Bearer $it") } } .build() http.newCall(request).execute().use { r -> return ApiResponse(r.code, r.body?.string().orEmpty(), null) } } companion object { @Volatile private var instance: ApiClient? = null fun get(context: Context): ApiClient = instance ?: synchronized(this) { instance ?: ApiClient(context.applicationContext).also { instance = it } } fun baseUrl(context: Context) = prefs(context).getString("base_url", "https://api.carecollect.in/api/v1/")!! fun token(context: Context): String? = prefs(context).getString("token", null) fun saveToken(context: Context, token: String) = prefs(context).edit().putString("token", token).apply() fun clearToken(context: Context) = prefs(context).edit().remove("token").apply() private fun prefs(context: Context) = EncryptedSharedPreferences.create( context, "cc_secure", MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build(), EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM ) } } /** * Everything the UI talks to. * * Note what these methods do NOT do: none of them wait for the server. An * action is written to the queue, the local cache is updated so the screen * moves immediately, and the sync worker is nudged. If the network is there * the round trip finishes in a second and the person never notices; if it is * not, the work is still done and uploads later. */ class FieldRepository(private val context: Context) { private val db get() = FieldDb.get(context) suspend fun stopsFor(date: String): List = db.stops().forDate(date) suspend fun pendingUploads(): Int = db.ops().pendingCount() /** Pulls the day's route and replaces the cache for that date. */ suspend fun refreshRoute(date: String): Boolean { val res = runCatching { ApiClient.get(context).get("route?date=$date") }.getOrNull() ?: return false if (res.code !in 200..299) return false val stops = JSONObject(res.body).getJSONArray("stops") val list = (0 until stops.length()).map { i -> val o = stops.getJSONObject(i) CachedStop( orderId = o.getLong("id"), orderNo = o.getString("order_no"), slotDate = o.getString("slot_date"), slotStart = o.getString("slot_start"), slotEnd = o.getString("slot_end"), status = o.getString("status"), priority = o.getString("priority"), fasting = o.getBoolean("fasting"), patientName = o.getString("patient_name"), mobile = o.optString("mobile"), address = o.optString("address"), lat = if (o.isNull("latitude")) null else o.getDouble("latitude"), lng = if (o.isNull("longitude")) null else o.getDouble("longitude"), amount = o.getDouble("amount"), paymentStatus = o.getString("payment_status"), detailJson = null ) } db.stops().upsertAll(list) return true } suspend fun step(orderId: Long, to: String, lat: Double?, lng: Double?) = enqueue("orders/$orderId/step", JSONObject().apply { put("to", to) lat?.let { put("lat", it) } lng?.let { put("lng", it) } }, localStatus = orderId to to) suspend fun verifyOtp(orderId: Long, otp: String) = enqueue("orders/$orderId/verify-otp", JSONObject().put("otp", otp)) suspend fun collect(orderId: Long, vials: List>, lat: Double?, lng: Double?) = enqueue("orders/$orderId/collect", JSONObject().apply { put("vials", org.json.JSONArray().apply { vials.forEach { (type, count) -> put(JSONObject().put("sample_type", type).put("count", count)) } }) lat?.let { put("lat", it) } lng?.let { put("lng", it) } // Device time travels with the action so the office can see how // long it actually sat in the queue. The server keeps its own // clock as the record of truth. put("device_at", isoNow()) put("offline", true) }, localStatus = orderId to "collected") suspend fun fail(orderId: Long, reasonCode: String, note: String?) = enqueue("orders/$orderId/fail", JSONObject().apply { put("reason_code", reasonCode) note?.let { put("note", it) } }, localStatus = orderId to "failed") suspend fun payment(orderId: Long, amount: Double, mode: String, ref: String?) = enqueue("orders/$orderId/payment", JSONObject().apply { put("amount", amount) put("mode", mode) ref?.let { put("txn_ref", it) } }) suspend fun handover(barcodes: List, branchId: Long?) = enqueue("handover", JSONObject().apply { put("barcodes", org.json.JSONArray(barcodes)) branchId?.let { put("branch_id", it) } }) /** * The one path every field action goes through: assign an id, save it, * update the screen, then ask the network to catch up. */ private suspend fun enqueue( endpoint: String, body: JSONObject, localStatus: Pair? = null ): String { val opId = UUID.randomUUID().toString() body.put("client_op_id", opId) db.ops().enqueue(PendingOp(clientOpId = opId, endpoint = endpoint, payload = body.toString())) localStatus?.let { (orderId, status) -> db.stops().setStatus(orderId, status) } SyncScheduler.flushNow(context) return opId } private fun isoNow(): 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()) }