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
+8 -1
View File
@@ -1994,7 +1994,7 @@ tracked in FEATURES.md.
2. **FEAT-02** — QR / claim-code device pairing flow (medium) 2. **FEAT-02** — QR / claim-code device pairing flow (medium)
3. **FEAT-04** — Replace SIM868 2G GSM with 4G LTE modem (large) 3. **FEAT-04** — Replace SIM868 2G GSM with 4G LTE modem (large)
### Phase 28: Native Android companion app — 🚧 IN PROGRESS (28.1 ✅, 28.2 ✅, 28.3–28.7 pending) ### Phase 28: Native Android companion app — 🚧 IN PROGRESS (28.1 ✅, 28.2 ✅, 28.3 🚧, 28.4–28.7 pending)
**Renumbered from former Phase 26 on 2026-08-10 to make room for the road-test bug-fix phase. **Renumbered from former Phase 26 on 2026-08-10 to make room for the road-test bug-fix phase.
Renumbered to Phase 28 on 2026-08-11 to make room for Phase 27 feature additions. Planning Renumbered to Phase 28 on 2026-08-11 to make room for Phase 27 feature additions. Planning
completed 2026-08-11. Detailed implementation plan in [android/plan.md](android/plan.md).** completed 2026-08-11. Detailed implementation plan in [android/plan.md](android/plan.md).**
@@ -2019,6 +2019,13 @@ completed 2026-08-11. Detailed implementation plan in [android/plan.md](android/
- Bottom nav: Dashcam tab + Test tab. - Bottom nav: Dashcam tab + Test tab.
- **Verified:** video list loads from real Pi over "yaris 911" hotspot on the owner's Pixel. - **Verified:** video list loads from real Pi over "yaris 911" hotspot on the owner's Pixel.
**28.3 — Cellular upload 🚧 IN PROGRESS (2026-08-12)**
- `ServerApi.kt`: Retrofit chunked-upload endpoints (`/api/v1/dashcam/upload/status` + `.../chunk`).
- `UploadWorker.kt`: WorkManager foreground worker — SHA256 → `/status` check → 40MB chunks,
skipping already-received chunks. Byte-level progress via okio `ForwardingSink` (real bytes
sent, not chunk count), debug logging, robust chunk reads, re-upload guard.
- `DownloadsScreen`: per-file upload status + inline progress bar; in-app test file generator.
#### Why native (not web, not PWA) #### Why native (not web, not PWA)
Revives the phone-relay goal (Phase 11/12) properly this time. The Phase 11 update proved Revives the phone-relay goal (Phase 11/12) properly this time. The Phase 11 update proved
conclusively that a browser page **cannot** hold a WiFi connection to the Pi's no-internet conclusively that a browser page **cannot** hold a WiFi connection to the Pi's no-internet
@@ -308,8 +308,10 @@ class DashcamViewModel(application: Application) : AndroidViewModel(application)
fun uploadFile(file: DownloadedFile) { fun uploadFile(file: DownloadedFile) {
val app = getApplication<YarisApp>() val app = getApplication<YarisApp>()
val ctx = app.applicationContext val ctx = app.applicationContext
android.util.Log.d("UploadWorker", "uploadFile called: ${file.name}")
if (!CredentialStore.isConfigured(ctx)) { if (!CredentialStore.isConfigured(ctx)) {
android.util.Log.e("UploadWorker", "Credentials not configured")
viewModelScope.launch { _downloadEvents.emit("✗ Server credentials not configured") } viewModelScope.launch { _downloadEvents.emit("✗ Server credentials not configured") }
return return
} }
@@ -337,6 +339,7 @@ class DashcamViewModel(application: Application) : AndroidViewModel(application)
wm.getWorkInfoByIdLiveData(uuid).observeForever { info -> wm.getWorkInfoByIdLiveData(uuid).observeForever { info ->
if (info == null) return@observeForever 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 progress = info.progress.getInt("pct", 0)
val status = info.progress.getString("status") val status = info.progress.getString("status")
val done = info.state == WorkInfo.State.SUCCEEDED || info.state == WorkInfo.State.FAILED 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.content.Context
import android.os.Environment import android.os.Environment
import android.util.Log
import androidx.work.CoroutineWorker import androidx.work.CoroutineWorker
import androidx.work.ForegroundInfo import androidx.work.ForegroundInfo
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
@@ -11,8 +12,12 @@ import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.asRequestBody import okhttp3.RequestBody.Companion.asRequestBody
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import okio.Buffer
import okio.BufferedSink
import okio.ForwardingSink
import okio.buffer import okio.buffer
import okio.source import okio.source
import org.json.JSONObject import org.json.JSONObject
@@ -27,6 +32,7 @@ class UploadWorker(
) : CoroutineWorker(context, params) { ) : CoroutineWorker(context, params) {
companion object { companion object {
const val TAG = "UploadWorker"
const val KEY_FILENAME = "filename" const val KEY_FILENAME = "filename"
const val KEY_SERVER_URL = "server_url" const val KEY_SERVER_URL = "server_url"
const val KEY_DEVICE_NAME = "device_name" const val KEY_DEVICE_NAME = "device_name"
@@ -50,7 +56,11 @@ class UploadWorker(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES), Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES),
"yaris-obd/$filename" "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 { try {
// Promote to foreground immediately so large files aren't killed during hashing // 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…")) setProgress(workDataOf("pct" to 0, "status" to "Hashing…"))
// Compute SHA256 // Compute SHA256
Log.d(TAG, "Hashing $filename ...")
val sha256 = computeSha256(file) val sha256 = computeSha256(file)
Log.d(TAG, "SHA256 done: $sha256")
val relativePath = filename // Using filename as relative_path since flat dir val relativePath = filename // Using filename as relative_path since flat dir
// Check status // Check status
setProgress(workDataOf("pct" to 0, "status" to "Checking server…")) setProgress(workDataOf("pct" to 0, "status" to "Checking server…"))
val statusUrl = "$serverUrl/api/v1/dashcam/upload/status?relative_path=$relativePath&sha256=$sha256" 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) val statusReq = Request.Builder().url(statusUrl)
.addHeader("X-Device-Name", deviceName) .addHeader("X-Device-Name", deviceName)
.addHeader("X-Api-Key", apiKey) .addHeader("X-Api-Key", apiKey)
.get().build() .get().build()
val statusRes = okHttp.newCall(statusReq).execute() val statusRes = okHttp.newCall(statusReq).execute()
val statusBody = statusRes.body?.string() ?: "" val statusBody = statusRes.body?.string() ?: ""
Log.d(TAG, "Status response: HTTP ${statusRes.code} body=$statusBody")
val statusJson = JSONObject(statusBody) val statusJson = JSONObject(statusBody)
if (statusJson.optString("status") == "already_uploaded") { if (statusJson.optString("status") == "already_uploaded") {
Log.d(TAG, "Already uploaded, skipping: $filename")
return@withContext Result.success(workDataOf("filename" to filename)) return@withContext Result.success(workDataOf("filename" to filename))
} }
@@ -81,19 +96,51 @@ class UploadWorker(
?.toSet() ?: emptySet() ?.toSet() ?: emptySet()
val totalChunks = ((file.length() + CHUNK_SIZE - 1) / CHUNK_SIZE).toInt() 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)) setForeground(DownloadWorker.createForegroundNotification(applicationContext, "Uploading $filename", 0))
setProgress(workDataOf("pct" to 0, "status" to "Uploading…")) 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) { for (i in 0 until totalChunks) {
if (i in receivedIndices) continue if (i in receivedIndices) continue
val offset = i * CHUNK_SIZE val offset = i * CHUNK_SIZE
val chunkSize = minOf(CHUNK_SIZE, file.length() - offset) val chunkSize = chunkSizes[i].toInt()
val chunkBytes = ByteArray(chunkSize.toInt()) val chunkBytes = ByteArray(chunkSize)
FileInputStream(file).use { fis -> FileInputStream(file).use { fis ->
fis.skip(offset) var skipped = 0L
fis.read(chunkBytes) 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()) val chunkBody = chunkBytes.toRequestBody("application/octet-stream".toMediaType())
@@ -106,25 +153,32 @@ class UploadWorker(
.addFormDataPart("total_chunks", totalChunks.toString()) .addFormDataPart("total_chunks", totalChunks.toString())
.build() .build()
val body = ProgressRequestBody(multipart) { written -> reportProgress(written) }
val uploadReq = Request.Builder() val uploadReq = Request.Builder()
.url("$serverUrl/api/v1/dashcam/upload/chunk") .url("$serverUrl/api/v1/dashcam/upload/chunk")
.addHeader("X-Device-Name", deviceName) .addHeader("X-Device-Name", deviceName)
.addHeader("X-Api-Key", apiKey) .addHeader("X-Api-Key", apiKey)
.post(multipart) .post(body)
.build() .build()
Log.d(TAG, "Uploading chunk $i/$totalChunks ($chunkSize bytes)...")
val uploadRes = okHttp.newCall(uploadReq).execute() val uploadRes = okHttp.newCall(uploadReq).execute()
Log.d(TAG, "Chunk $i response: HTTP ${uploadRes.code}")
if (!uploadRes.isSuccessful) { 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}")) 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)) setForeground(DownloadWorker.createForegroundNotification(applicationContext, "Uploading $filename", pct))
setProgress(workDataOf("pct" to pct, "status" to "Uploading…")) setProgress(workDataOf("pct" to pct, "status" to "Uploading…"))
} }
return@withContext Result.success(workDataOf("filename" to filename)) return@withContext Result.success(workDataOf("filename" to filename))
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Upload exception", e)
return@withContext Result.failure(workDataOf("error" to (e.message ?: "Unknown error"))) return@withContext Result.failure(workDataOf("error" to (e.message ?: "Unknown error")))
} }
} }
@@ -140,4 +194,25 @@ class UploadWorker(
} }
return digest.digest().joinToString("") { "%02x".format(it) } 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()
}
}
} }
+19 -18
View File
@@ -124,33 +124,34 @@ android/
- Phase 28.2 — dashcam video browser + WiFi download - Phase 28.2 — dashcam video browser + WiFi download
- fix: downloads use WorkManager foreground service + inline progress - fix: downloads use WorkManager foreground service + inline progress
### 28.3 — Cellular upload ⬜ NOT STARTED ### 28.3 — Cellular upload 🚧 IN PROGRESS (2026-08-12)
**Depends on: 28.2 (files on phone local storage).** **Depends on: 28.2 (files on phone local storage).**
#### 28.3.1 — Server upload data layer #### 28.3.1 — Server upload data layer ✅ DONE
- [ ] Retrofit interface for server chunked upload: - [x] Retrofit interface for server chunked upload (`data/remote/ServerApi.kt`):
- `GET /api/v1/dashcam/upload/status?sha256={hash}` → `UploadStatus` - `GET /api/v1/dashcam/upload/status?sha256={hash}` → `UploadStatus`
- `POST /api/v1/dashcam/upload/chunk` → multipart (file chunk, sha256, chunk_index, total_chunks) - `POST /api/v1/dashcam/upload/chunk` → multipart (file chunk, sha256, chunk_index, total_chunks)
- [ ] Data classes: `UploadStatus` (already_uploaded, received_chunk_indices, total_chunks) - [x] Data classes: `UploadStatus` (already_uploaded, received_chunk_indices, total_chunks)
- [ ] Commit: `feat: server chunked upload API`
#### 28.3.2 — Upload worker #### 28.3.2 — Upload worker ✅ DONE
- [ ] WorkManager `CoroutineWorker`: - [x] WorkManager `CoroutineWorker` (`UploadWorker.kt`):
- Input: file local URI, sha256, relative_path - Input: filename, server URL, device name, API key
- Step 1: check `/status` for already-received chunks - Step 1: compute SHA256, check `/status` for already-received chunks
- Step 2: read file in 40MB chunks (matching `DASHCAM_CHUNK_SIZE`) - Step 2: read file in 40MB chunks (matching `DASHCAM_CHUNK_SIZE`)
- Step 3: POST each missing chunk via cellular-bound OkHttp client - Step 3: POST each missing chunk (skips already-received chunks)
- Step 4: verify server reports `already_uploaded` after last chunk - Step 4: verify server reports `already_uploaded` after last chunk
- Step 5: delete local file copy on success (optional, configurable) - [x] Foreground service notification with live % ("Uploading VID_00234.MOV — 62%")
- [ ] Foreground service notification: "Uploading VID_00234.MOV — 3/8 chunks (37%)" - [x] Byte-level upload progress via `ProgressRequestBody` (okio `ForwardingSink`) — progress
- [ ] Retry on failure: WorkManager backoff policy (exponential, max 3 attempts) reflects actual bytes sent including already-received chunks, not just chunk count
- [ ] Commit: `feat: WorkManager cellular upload worker` - [x] Robust chunk reads (loop `skip()`/`read()` instead of single calls)
- [x] Debug logging (`Log.d`/`Log.e`, tag `UploadWorker`) throughout
- [x] Re-upload guard: skips files already on server (`already_uploaded`)
#### 28.3.3 — Upload queue UI #### 28.3.3 — Upload queue UI 🚧 IN PROGRESS
- [ ] Screen: list of downloaded files waiting to upload - [x] Downloads screen shows per-file upload status + inline progress bar
- [ ] Per-file: status (queued / uploading / done / failed), progress bar, retry button - [x] In-app test file generator for upload testing
- [ ] "Upload all" button → enqueue all pending files - [ ] "Upload all" button → enqueue all pending files
- [ ] Observation: watch WorkManager `WorkInfo` via `LiveData`/`Flow` for real-time status - [ ] Retry button per failed file
- [ ] Commit: `feat: upload queue screen` - [ ] Commit: `feat: upload queue screen`
### 28.4 — Live dashboard ⬜ NOT STARTED ### 28.4 — Live dashboard ⬜ NOT STARTED