android: byte-level upload progress + debug logging; document 28.3

- UploadWorker: ProgressRequestBody (okio ForwardingSink) tracks real bytes
  sent including already-received chunks, instead of chunk count
- Robust chunk reads (loop skip/read) + Log.d/Log.e debug logging
- DashcamViewModel: upload state/progress debug logging
- android/plan.md + PLAN.md: 28.3 marked in progress with built items
This commit is contained in:
Mathieu Moran
2026-08-12 19:00:00 -04:00
parent 6398baaa6b
commit 2e8e42a165
4 changed files with 113 additions and 27 deletions
@@ -308,8 +308,10 @@ class DashcamViewModel(application: Application) : AndroidViewModel(application)
fun uploadFile(file: DownloadedFile) {
val app = getApplication<YarisApp>()
val ctx = app.applicationContext
android.util.Log.d("UploadWorker", "uploadFile called: ${file.name}")
if (!CredentialStore.isConfigured(ctx)) {
android.util.Log.e("UploadWorker", "Credentials not configured")
viewModelScope.launch { _downloadEvents.emit("✗ Server credentials not configured") }
return
}
@@ -337,6 +339,7 @@ class DashcamViewModel(application: Application) : AndroidViewModel(application)
wm.getWorkInfoByIdLiveData(uuid).observeForever { info ->
if (info == null) return@observeForever
android.util.Log.d("UploadWorker", "State: ${info.state} pct=${info.progress.getInt("pct", -1)}")
val progress = info.progress.getInt("pct", 0)
val status = info.progress.getString("status")
val done = info.state == WorkInfo.State.SUCCEEDED || info.state == WorkInfo.State.FAILED
@@ -2,6 +2,7 @@ package com.yarisobd.app
import android.content.Context
import android.os.Environment
import android.util.Log
import androidx.work.CoroutineWorker
import androidx.work.ForegroundInfo
import androidx.work.WorkerParameters
@@ -11,8 +12,12 @@ import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.asRequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import okio.Buffer
import okio.BufferedSink
import okio.ForwardingSink
import okio.buffer
import okio.source
import org.json.JSONObject
@@ -27,6 +32,7 @@ class UploadWorker(
) : CoroutineWorker(context, params) {
companion object {
const val TAG = "UploadWorker"
const val KEY_FILENAME = "filename"
const val KEY_SERVER_URL = "server_url"
const val KEY_DEVICE_NAME = "device_name"
@@ -50,7 +56,11 @@ class UploadWorker(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES),
"yaris-obd/$filename"
)
if (!file.exists()) return@withContext Result.failure(workDataOf("error" to "File not found"))
Log.d(TAG, "doWork start: $filename size=${file.length()} server=$serverUrl device=$deviceName")
if (!file.exists()) {
Log.e(TAG, "File not found: ${file.absolutePath}")
return@withContext Result.failure(workDataOf("error" to "File not found"))
}
try {
// Promote to foreground immediately so large files aren't killed during hashing
@@ -58,21 +68,26 @@ class UploadWorker(
setProgress(workDataOf("pct" to 0, "status" to "Hashing…"))
// Compute SHA256
Log.d(TAG, "Hashing $filename ...")
val sha256 = computeSha256(file)
Log.d(TAG, "SHA256 done: $sha256")
val relativePath = filename // Using filename as relative_path since flat dir
// Check status
setProgress(workDataOf("pct" to 0, "status" to "Checking server…"))
val statusUrl = "$serverUrl/api/v1/dashcam/upload/status?relative_path=$relativePath&sha256=$sha256"
Log.d(TAG, "Status check: $statusUrl")
val statusReq = Request.Builder().url(statusUrl)
.addHeader("X-Device-Name", deviceName)
.addHeader("X-Api-Key", apiKey)
.get().build()
val statusRes = okHttp.newCall(statusReq).execute()
val statusBody = statusRes.body?.string() ?: ""
Log.d(TAG, "Status response: HTTP ${statusRes.code} body=$statusBody")
val statusJson = JSONObject(statusBody)
if (statusJson.optString("status") == "already_uploaded") {
Log.d(TAG, "Already uploaded, skipping: $filename")
return@withContext Result.success(workDataOf("filename" to filename))
}
@@ -81,19 +96,51 @@ class UploadWorker(
?.toSet() ?: emptySet()
val totalChunks = ((file.length() + CHUNK_SIZE - 1) / CHUNK_SIZE).toInt()
Log.d(TAG, "Uploading: totalChunks=$totalChunks received=$receivedIndices")
setForeground(DownloadWorker.createForegroundNotification(applicationContext, "Uploading $filename", 0))
setProgress(workDataOf("pct" to 0, "status" to "Uploading…"))
// Upload missing chunks
// Upload missing chunks with byte-level progress
val fileSize = file.length()
val chunkSizes = LongArray(totalChunks) { i -> minOf(CHUNK_SIZE, fileSize - i * CHUNK_SIZE) }
var baseBytes = receivedIndices.sumOf { chunkSizes[it] }
var lastProgressMs = 0L
var lastNotifyMs = 0L
fun reportProgress(bytesWritten: Long) {
val now = System.currentTimeMillis()
val totalBytes = baseBytes + bytesWritten
val pct = ((totalBytes * 100) / fileSize).toInt().coerceIn(0, 100)
if (now - lastProgressMs >= 250) {
lastProgressMs = now
Log.d(TAG, "Progress: $pct% ($totalBytes/$fileSize bytes)")
setProgressAsync(workDataOf("pct" to pct, "status" to "Uploading…"))
}
if (now - lastNotifyMs >= 1000) {
lastNotifyMs = now
setForegroundAsync(DownloadWorker.createForegroundNotification(applicationContext, "Uploading $filename", pct))
}
}
for (i in 0 until totalChunks) {
if (i in receivedIndices) continue
val offset = i * CHUNK_SIZE
val chunkSize = minOf(CHUNK_SIZE, file.length() - offset)
val chunkBytes = ByteArray(chunkSize.toInt())
val chunkSize = chunkSizes[i].toInt()
val chunkBytes = ByteArray(chunkSize)
FileInputStream(file).use { fis ->
fis.skip(offset)
fis.read(chunkBytes)
var skipped = 0L
while (skipped < offset) {
val s = fis.skip(offset - skipped)
if (s <= 0) break
skipped += s
}
var read = 0
while (read < chunkSize) {
val n = fis.read(chunkBytes, read, chunkSize - read)
if (n < 0) break
read += n
}
}
val chunkBody = chunkBytes.toRequestBody("application/octet-stream".toMediaType())
@@ -106,25 +153,32 @@ class UploadWorker(
.addFormDataPart("total_chunks", totalChunks.toString())
.build()
val body = ProgressRequestBody(multipart) { written -> reportProgress(written) }
val uploadReq = Request.Builder()
.url("$serverUrl/api/v1/dashcam/upload/chunk")
.addHeader("X-Device-Name", deviceName)
.addHeader("X-Api-Key", apiKey)
.post(multipart)
.post(body)
.build()
Log.d(TAG, "Uploading chunk $i/$totalChunks ($chunkSize bytes)...")
val uploadRes = okHttp.newCall(uploadReq).execute()
Log.d(TAG, "Chunk $i response: HTTP ${uploadRes.code}")
if (!uploadRes.isSuccessful) {
Log.e(TAG, "Chunk $i failed: HTTP ${uploadRes.code} body=${uploadRes.body?.string()}")
return@withContext Result.failure(workDataOf("error" to "Chunk $i: HTTP ${uploadRes.code}"))
}
val pct = ((i + 1) * 100) / totalChunks
baseBytes += chunkSizes[i]
val pct = ((baseBytes * 100) / fileSize).toInt().coerceIn(0, 100)
setForeground(DownloadWorker.createForegroundNotification(applicationContext, "Uploading $filename", pct))
setProgress(workDataOf("pct" to pct, "status" to "Uploading…"))
}
return@withContext Result.success(workDataOf("filename" to filename))
} catch (e: Exception) {
Log.e(TAG, "Upload exception", e)
return@withContext Result.failure(workDataOf("error" to (e.message ?: "Unknown error")))
}
}
@@ -140,4 +194,25 @@ class UploadWorker(
}
return digest.digest().joinToString("") { "%02x".format(it) }
}
private class ProgressRequestBody(
private val delegate: RequestBody,
private val onBytesWritten: (Long) -> Unit,
) : RequestBody() {
override fun contentType() = delegate.contentType()
override fun contentLength() = delegate.contentLength()
override fun writeTo(sink: BufferedSink) {
var written = 0L
val countingSink = object : ForwardingSink(sink) {
override fun write(source: Buffer, byteCount: Long) {
super.write(source, byteCount)
written += byteCount
onBytesWritten(written)
}
}
val buffered = countingSink.buffer()
delegate.writeTo(buffered)
buffered.flush()
}
}
}