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:
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user