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