feat: Phase 28.3 — cellular upload relay + Settings tab
- UploadWorker: 40MB chunked upload to yaris.ceaf.dev (SHA256 check, /status resume, WorkManager foreground notification, progress) - ServerApi extended: uploadStatus + uploadChunk endpoints - CredentialStore: EncryptedSharedPreferences for server URL/device name/API key - Settings tab: credential entry (server URL, device name, API key) - Downloads tab: ↑ Upload button per file, upload progress with cancel - Upload only shown when credentials configured
This commit is contained in:
@@ -8,6 +8,7 @@ import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.workDataOf
|
||||
import com.yarisobd.app.data.CredentialStore
|
||||
import com.yarisobd.app.data.remote.PiApi
|
||||
import com.yarisobd.app.data.remote.dto.VideoFile
|
||||
import com.yarisobd.app.data.remote.dto.VideoListResponse
|
||||
@@ -87,6 +88,10 @@ class DashcamViewModel(application: Application) : AndroidViewModel(application)
|
||||
private val _deletingNames = MutableStateFlow<Set<String>>(emptySet())
|
||||
val deletingNames: StateFlow<Set<String>> = _deletingNames.asStateFlow()
|
||||
|
||||
// Upload state
|
||||
private val _uploadProgress = MutableStateFlow<Map<String, ActiveDownload>>(emptyMap())
|
||||
val uploadProgress: StateFlow<Map<String, ActiveDownload>> = _uploadProgress.asStateFlow()
|
||||
|
||||
private fun getDownloadDir(): File {
|
||||
// Public Movies/yaris-obd/ — visible in file managers
|
||||
val dir = File(
|
||||
@@ -219,6 +224,68 @@ class DashcamViewModel(application: Application) : AndroidViewModel(application)
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
fun uploadFile(file: DownloadedFile) {
|
||||
val app = getApplication<YarisApp>()
|
||||
val ctx = app.applicationContext
|
||||
|
||||
if (!CredentialStore.isConfigured(ctx)) {
|
||||
viewModelScope.launch { _downloadEvents.emit("✗ Server credentials not configured") }
|
||||
return
|
||||
}
|
||||
|
||||
val serverUrl = CredentialStore.getServerUrl(ctx)
|
||||
val deviceName = CredentialStore.getDeviceName(ctx)
|
||||
val apiKey = CredentialStore.getApiKey(ctx)
|
||||
|
||||
val request = OneTimeWorkRequestBuilder<UploadWorker>()
|
||||
.setInputData(workDataOf(
|
||||
UploadWorker.KEY_FILENAME to file.name,
|
||||
UploadWorker.KEY_SERVER_URL to serverUrl,
|
||||
UploadWorker.KEY_DEVICE_NAME to deviceName,
|
||||
UploadWorker.KEY_API_KEY to apiKey,
|
||||
))
|
||||
.addTag("upload_${file.name}")
|
||||
.build()
|
||||
|
||||
val wm = WorkManager.getInstance(app)
|
||||
val uuid = request.id
|
||||
val workerId = uuid.toString()
|
||||
wm.enqueue(request)
|
||||
|
||||
_uploadProgress.value = _uploadProgress.value + (file.name to ActiveDownload(workerId, file.name, 0, false))
|
||||
|
||||
wm.getWorkInfoByIdLiveData(uuid).observeForever { info ->
|
||||
if (info == null) return@observeForever
|
||||
val progress = info.progress.getInt("pct", 0)
|
||||
val done = info.state == WorkInfo.State.SUCCEEDED || info.state == WorkInfo.State.FAILED
|
||||
|
||||
_uploadProgress.value = _uploadProgress.value + (file.name to ActiveDownload(workerId, file.name, progress, done))
|
||||
|
||||
when (info.state) {
|
||||
WorkInfo.State.SUCCEEDED -> {
|
||||
viewModelScope.launch { _downloadEvents.emit("✓ Uploaded: ${file.name}") }
|
||||
_uploadProgress.value = _uploadProgress.value - file.name
|
||||
}
|
||||
WorkInfo.State.FAILED -> {
|
||||
val err = info.outputData.getString("error") ?: "Unknown error"
|
||||
viewModelScope.launch { _downloadEvents.emit("✗ Upload failed: $err") }
|
||||
_uploadProgress.value = _uploadProgress.value - file.name
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch { _downloadEvents.emit("⏳ Uploading: ${file.name}") }
|
||||
}
|
||||
|
||||
fun cancelUpload(dl: ActiveDownload) {
|
||||
try {
|
||||
val uuid = UUID.fromString(dl.workerId)
|
||||
WorkManager.getInstance(getApplication()).cancelWorkById(uuid)
|
||||
_uploadProgress.value = _uploadProgress.value - dl.filename
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
fun deleteVideo(video: VideoFile) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
|
||||
@@ -59,6 +59,24 @@ class DownloadWorker(
|
||||
nm.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
fun createForegroundNotification(context: Context, title: String, progress: Int): ForegroundInfo {
|
||||
val intent = Intent(context, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||
}
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
context, 0, intent,
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||
)
|
||||
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setContentTitle(title)
|
||||
.setSmallIcon(android.R.drawable.stat_sys_download)
|
||||
.setOngoing(true)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setContentText("$progress%")
|
||||
.setProgress(100, progress, progress == 0)
|
||||
return ForegroundInfo(NOTIFICATION_ID_BASE, builder.build())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
|
||||
|
||||
@@ -28,8 +28,18 @@ import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import com.yarisobd.app.data.CredentialStore
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -43,9 +53,13 @@ import androidx.core.content.FileProvider
|
||||
fun DownloadsScreen(
|
||||
files: List<DownloadedFile>,
|
||||
activeDownloads: Map<String, ActiveDownload>,
|
||||
activeUploads: Map<String, ActiveDownload>,
|
||||
deletingNames: Set<String>,
|
||||
credentialsConfigured: Boolean,
|
||||
onDelete: (DownloadedFile) -> Unit,
|
||||
onCancel: (ActiveDownload) -> Unit,
|
||||
onUpload: (DownloadedFile) -> Unit,
|
||||
onCancelUpload: (ActiveDownload) -> Unit,
|
||||
onRefresh: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
@@ -63,6 +77,53 @@ fun DownloadsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Active uploads
|
||||
AnimatedVisibility(visible = activeUploads.isNotEmpty(), enter = fadeIn(), exit = fadeOut()) {
|
||||
Column(modifier = Modifier.padding(horizontal = 16.dp)) {
|
||||
activeUploads.entries.forEach { (filename, dl) ->
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.3f))
|
||||
) {
|
||||
Column(modifier = Modifier.padding(14.dp)) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) {
|
||||
if (dl.done) {
|
||||
Text("✓", color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.bodyLarge)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(filename, style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
} else {
|
||||
CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.tertiary)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Column {
|
||||
Text("↑ $filename", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text("${dl.progress}%", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!dl.done) Button(
|
||||
onClick = { onCancelUpload(dl) },
|
||||
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error),
|
||||
modifier = Modifier.height(30.dp), shape = RoundedCornerShape(8.dp),
|
||||
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 0.dp)
|
||||
) { Text("Cancel", style = MaterialTheme.typography.bodySmall) }
|
||||
}
|
||||
if (!dl.done) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
LinearProgressIndicator(
|
||||
progress = { dl.progress / 100f },
|
||||
modifier = Modifier.fillMaxWidth().height(6.dp).clip(RoundedCornerShape(3.dp)),
|
||||
color = MaterialTheme.colorScheme.tertiary,
|
||||
trackColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Active downloads with progress
|
||||
AnimatedVisibility(visible = activeDownloads.isNotEmpty(), enter = fadeIn(), exit = fadeOut()) {
|
||||
Column(modifier = Modifier.padding(horizontal = 16.dp)) {
|
||||
@@ -152,11 +213,19 @@ fun DownloadsScreen(
|
||||
if (isDeleting) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.error)
|
||||
} else {
|
||||
if (credentialsConfigured) {
|
||||
Button(
|
||||
onClick = { onUpload(file) },
|
||||
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.tertiary),
|
||||
modifier = Modifier.height(32.dp), shape = RoundedCornerShape(8.dp),
|
||||
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 0.dp)
|
||||
) { Text("↑", style = MaterialTheme.typography.bodySmall) }
|
||||
Spacer(Modifier.width(4.dp))
|
||||
}
|
||||
Button(
|
||||
onClick = { onDelete(file) },
|
||||
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error),
|
||||
modifier = Modifier.height(32.dp),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
modifier = Modifier.height(32.dp), shape = RoundedCornerShape(8.dp),
|
||||
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 0.dp)
|
||||
) { Text("✕", style = MaterialTheme.typography.bodySmall) }
|
||||
}
|
||||
|
||||
@@ -56,6 +56,9 @@ fun YarisMainScreen(client: OkHttpClient) {
|
||||
val downloadedFiles by dashcamVm.downloadedFiles.collectAsState()
|
||||
val activeDownloads by dashcamVm.activeDownloads.collectAsState()
|
||||
val deletingNames by dashcamVm.deletingNames.collectAsState()
|
||||
val uploadProgress by dashcamVm.uploadProgress.collectAsState()
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
val credentialsConfigured = com.yarisobd.app.data.CredentialStore.isConfigured(context)
|
||||
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
var selectedTab by remember { mutableIntStateOf(0) }
|
||||
@@ -85,8 +88,12 @@ fun YarisMainScreen(client: OkHttpClient) {
|
||||
)
|
||||
NavigationBarItem(
|
||||
selected = selectedTab == 2,
|
||||
onClick = { selectedTab = 2 },
|
||||
icon = { Text("�🔌") },
|
||||
onClick = { selectedTab = 2 }, icon = { Text("⚙️") },
|
||||
label = { Text("Settings") }
|
||||
)
|
||||
NavigationBarItem(
|
||||
selected = selectedTab == 3,
|
||||
onClick = { selectedTab = 3 }, icon = { Text("�🔌") },
|
||||
label = { Text("Test") }
|
||||
)
|
||||
}
|
||||
@@ -107,13 +114,18 @@ fun YarisMainScreen(client: OkHttpClient) {
|
||||
)
|
||||
1 -> DownloadsScreen(
|
||||
files = downloadedFiles,
|
||||
deletingNames = deletingNames,
|
||||
activeDownloads = activeDownloads,
|
||||
activeUploads = uploadProgress,
|
||||
deletingNames = deletingNames,
|
||||
credentialsConfigured = credentialsConfigured,
|
||||
onDelete = { dashcamVm.deleteDownloaded(it) },
|
||||
onCancel = { dashcamVm.cancelDownload(it) },
|
||||
onUpload = { dashcamVm.uploadFile(it) },
|
||||
onCancelUpload = { dashcamVm.cancelUpload(it) },
|
||||
onRefresh = { dashcamVm.scanDownloads() },
|
||||
)
|
||||
2 -> DualNetworkTestScreen(
|
||||
2 -> SettingsScreen()
|
||||
3 -> DualNetworkTestScreen(
|
||||
piClient = client,
|
||||
serverClient = client,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.yarisobd.app
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.yarisobd.app.data.CredentialStore
|
||||
|
||||
@Composable
|
||||
fun SettingsScreen() {
|
||||
val context = LocalContext.current
|
||||
var serverUrl by remember { mutableStateOf(CredentialStore.getServerUrl(context)) }
|
||||
var deviceName by remember { mutableStateOf(CredentialStore.getDeviceName(context)) }
|
||||
var apiKey by remember { mutableStateOf("") }
|
||||
var saved by remember { mutableStateOf(false) }
|
||||
val configured = CredentialStore.isConfigured(context)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)
|
||||
) {
|
||||
Text("Settings", style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.primary)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// Server credentials
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text("Remote Server", style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
if (configured) "✓ Configured" else "Credentials needed for cellular upload",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (configured) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
OutlinedTextField(
|
||||
value = serverUrl, onValueChange = { serverUrl = it; saved = false },
|
||||
label = { Text("Server URL") }, singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textStyle = MaterialTheme.typography.bodySmall,
|
||||
placeholder = { Text("https://yaris.ceaf.dev") }
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = deviceName, onValueChange = { deviceName = it; saved = false },
|
||||
label = { Text("Device Name") }, singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textStyle = MaterialTheme.typography.bodySmall,
|
||||
placeholder = { Text("yaris-2013") }
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = apiKey, onValueChange = { apiKey = it; saved = false },
|
||||
label = { Text("API Key") }, singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textStyle = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Button(
|
||||
onClick = {
|
||||
CredentialStore.save(context, serverUrl, deviceName, apiKey)
|
||||
saved = true
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) { Text(if (saved) "✓ Saved — restart app to apply" else "Save") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package com.yarisobd.app
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Environment
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.ForegroundInfo
|
||||
import androidx.work.WorkerParameters
|
||||
import androidx.work.workDataOf
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.asRequestBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okio.buffer
|
||||
import okio.source
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.security.MessageDigest
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class UploadWorker(
|
||||
context: Context,
|
||||
params: WorkerParameters,
|
||||
) : CoroutineWorker(context, params) {
|
||||
|
||||
companion object {
|
||||
const val KEY_FILENAME = "filename"
|
||||
const val KEY_SERVER_URL = "server_url"
|
||||
const val KEY_DEVICE_NAME = "device_name"
|
||||
const val KEY_API_KEY = "api_key"
|
||||
const val CHUNK_SIZE = 40L * 1024 * 1024 // 40MB
|
||||
}
|
||||
|
||||
private val okHttp = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(5, TimeUnit.MINUTES)
|
||||
.writeTimeout(5, TimeUnit.MINUTES)
|
||||
.build()
|
||||
|
||||
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
|
||||
val filename = inputData.getString(KEY_FILENAME) ?: return@withContext Result.failure()
|
||||
val serverUrl = inputData.getString(KEY_SERVER_URL) ?: return@withContext Result.failure()
|
||||
val deviceName = inputData.getString(KEY_DEVICE_NAME) ?: return@withContext Result.failure()
|
||||
val apiKey = inputData.getString(KEY_API_KEY) ?: return@withContext Result.failure()
|
||||
|
||||
val file = File(
|
||||
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES),
|
||||
"yaris-obd/$filename"
|
||||
)
|
||||
if (!file.exists()) return@withContext Result.failure(workDataOf("error" to "File not found"))
|
||||
|
||||
try {
|
||||
// Compute SHA256
|
||||
val sha256 = computeSha256(file)
|
||||
val relativePath = filename // Using filename as relative_path since flat dir
|
||||
|
||||
// Check status
|
||||
val statusUrl = "$serverUrl/api/v1/dashcam/upload/status?relative_path=$relativePath&sha256=$sha256"
|
||||
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() ?: ""
|
||||
val statusJson = JSONObject(statusBody)
|
||||
|
||||
if (statusJson.optString("status") == "already_uploaded") {
|
||||
return@withContext Result.success(workDataOf("filename" to filename))
|
||||
}
|
||||
|
||||
val receivedIndices = statusJson.optJSONArray("received_chunk_indices")
|
||||
?.let { arr -> (0 until arr.length()).map { arr.getInt(it) } }
|
||||
?.toSet() ?: emptySet()
|
||||
|
||||
val totalChunks = ((file.length() + CHUNK_SIZE - 1) / CHUNK_SIZE).toInt()
|
||||
setForeground(DownloadWorker.createForegroundNotification(applicationContext, "Uploading $filename", 0))
|
||||
|
||||
// Upload missing chunks
|
||||
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())
|
||||
FileInputStream(file).use { fis ->
|
||||
fis.skip(offset)
|
||||
fis.read(chunkBytes)
|
||||
}
|
||||
|
||||
val chunkBody = chunkBytes.toRequestBody("application/octet-stream".toMediaType())
|
||||
val multipart = okhttp3.MultipartBody.Builder()
|
||||
.setType(okhttp3.MultipartBody.FORM)
|
||||
.addFormDataPart("file", filename, chunkBody)
|
||||
.addFormDataPart("relative_path", relativePath)
|
||||
.addFormDataPart("sha256", sha256)
|
||||
.addFormDataPart("chunk_index", i.toString())
|
||||
.addFormDataPart("total_chunks", totalChunks.toString())
|
||||
.build()
|
||||
|
||||
val uploadReq = Request.Builder()
|
||||
.url("$serverUrl/api/v1/dashcam/upload/chunk")
|
||||
.addHeader("X-Device-Name", deviceName)
|
||||
.addHeader("X-Api-Key", apiKey)
|
||||
.post(multipart)
|
||||
.build()
|
||||
|
||||
val uploadRes = okHttp.newCall(uploadReq).execute()
|
||||
if (!uploadRes.isSuccessful) {
|
||||
return@withContext Result.failure(workDataOf("error" to "Chunk $i: HTTP ${uploadRes.code}"))
|
||||
}
|
||||
|
||||
val pct = ((i + 1) * 100) / totalChunks
|
||||
setForeground(DownloadWorker.createForegroundNotification(applicationContext, "Uploading $filename", pct))
|
||||
setProgressAsync(workDataOf("pct" to pct))
|
||||
}
|
||||
|
||||
return@withContext Result.success(workDataOf("filename" to filename))
|
||||
} catch (e: Exception) {
|
||||
return@withContext Result.failure(workDataOf("error" to (e.message ?: "Unknown error")))
|
||||
}
|
||||
}
|
||||
|
||||
private fun computeSha256(file: File): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
FileInputStream(file).use { fis ->
|
||||
val buffer = ByteArray(8192)
|
||||
var read: Int
|
||||
while (fis.read(buffer).also { read = it } != -1) {
|
||||
digest.update(buffer, 0, read)
|
||||
}
|
||||
}
|
||||
return digest.digest().joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.yarisobd.app.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
|
||||
object CredentialStore {
|
||||
private const val PREFS = "yaris_creds"
|
||||
private const val KEY_SERVER_URL = "server_url"
|
||||
private const val KEY_DEVICE_NAME = "device_name"
|
||||
private const val KEY_API_KEY = "api_key"
|
||||
|
||||
private fun prefs(context: Context) = EncryptedSharedPreferences.create(
|
||||
context, PREFS,
|
||||
MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build(),
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||
)
|
||||
|
||||
fun getServerUrl(context: Context): String = prefs(context).getString(KEY_SERVER_URL, "") ?: ""
|
||||
fun getDeviceName(context: Context): String = prefs(context).getString(KEY_DEVICE_NAME, "") ?: ""
|
||||
fun getApiKey(context: Context): String = prefs(context).getString(KEY_API_KEY, "") ?: ""
|
||||
|
||||
fun isConfigured(context: Context): Boolean =
|
||||
getServerUrl(context).isNotBlank() && getDeviceName(context).isNotBlank() && getApiKey(context).isNotBlank()
|
||||
|
||||
fun save(context: Context, serverUrl: String, deviceName: String, apiKey: String) {
|
||||
prefs(context).edit()
|
||||
.putString(KEY_SERVER_URL, serverUrl)
|
||||
.putString(KEY_DEVICE_NAME, deviceName)
|
||||
.putString(KEY_API_KEY, apiKey)
|
||||
.apply()
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,40 @@
|
||||
package com.yarisobd.app.data.remote
|
||||
|
||||
import com.yarisobd.app.data.remote.dto.HealthResponse
|
||||
import com.yarisobd.app.data.remote.dto.UploadStatus
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.RequestBody
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Header
|
||||
import retrofit2.http.Multipart
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Part
|
||||
import retrofit2.http.Query
|
||||
|
||||
/**
|
||||
* REST API for the remote server (https://yaris.ceaf.dev over cellular/internet).
|
||||
* Only the endpoints the Android app needs are declared here.
|
||||
*/
|
||||
interface ServerApi {
|
||||
|
||||
/** Health check — Phase 9 (unauthenticated) */
|
||||
@GET("/health")
|
||||
suspend fun health(): HealthResponse
|
||||
|
||||
// Future: add endpoints from android/plan.md as each sub-phase needs them:
|
||||
// @GET("/api/v1/dashcam/upload/status") — Phase 10 chunked upload status
|
||||
// @POST("/api/v1/dashcam/upload/chunk") — Phase 10 multipart chunk upload
|
||||
// @GET("/api/v1/portal/trips") — Phase 24 portal trips (needs auth cookie)
|
||||
// @GET("/api/v1/portal/trip/{id}") — Phase 24
|
||||
// @GET("/api/v1/portal/dashcam") — Phase 24
|
||||
// @GET("/api/v1/portal/dashcam/stream/{id}") — Phase 24 streaming
|
||||
/** Check which chunks already received */
|
||||
@GET("/api/v1/dashcam/upload/status")
|
||||
suspend fun uploadStatus(
|
||||
@Query("relative_path") path: String,
|
||||
@Query("sha256") sha256: String,
|
||||
@Header("X-Device-Name") deviceName: String,
|
||||
@Header("X-Api-Key") apiKey: String,
|
||||
): UploadStatus
|
||||
|
||||
/** Upload one 40MB chunk */
|
||||
@Multipart
|
||||
@POST("/api/v1/dashcam/upload/chunk")
|
||||
suspend fun uploadChunk(
|
||||
@Part file: MultipartBody.Part,
|
||||
@Part("relative_path") path: RequestBody,
|
||||
@Part("sha256") sha256: RequestBody,
|
||||
@Part("chunk_index") chunkIndex: RequestBody,
|
||||
@Part("total_chunks") totalChunks: RequestBody,
|
||||
@Header("X-Device-Name") deviceName: String,
|
||||
@Header("X-Api-Key") apiKey: String,
|
||||
): UploadStatus
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.yarisobd.app.data.remote.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class UploadStatus(
|
||||
val status: String = "", // "already_uploaded", "in_progress", "not_started"
|
||||
val received_chunk_indices: List<Int>? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class UploadChunkResponse(
|
||||
val status: String = "", // "already_uploaded", "ok"
|
||||
val error: String? = null,
|
||||
)
|
||||
Reference in New Issue
Block a user