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:
Mathieu Moran
2026-08-12 04:00:45 -04:00
parent c8e26dde26
commit 19942dc114
10 changed files with 478 additions and 20 deletions
+2 -2
View File
@@ -102,8 +102,8 @@ dependencies {
// Maps — will be used in 28.4 // Maps — will be used in 28.4
// implementation(libs.maplibre) // implementation(libs.maplibre)
// Security — will be used in 28.1.3 // Security
// implementation(libs.security.crypto) implementation(libs.security.crypto)
// Testing // Testing
testImplementation("junit:junit:4.13.2") testImplementation("junit:junit:4.13.2")
@@ -8,6 +8,7 @@ import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkInfo import androidx.work.WorkInfo
import androidx.work.WorkManager import androidx.work.WorkManager
import androidx.work.workDataOf import androidx.work.workDataOf
import com.yarisobd.app.data.CredentialStore
import com.yarisobd.app.data.remote.PiApi import com.yarisobd.app.data.remote.PiApi
import com.yarisobd.app.data.remote.dto.VideoFile import com.yarisobd.app.data.remote.dto.VideoFile
import com.yarisobd.app.data.remote.dto.VideoListResponse 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()) private val _deletingNames = MutableStateFlow<Set<String>>(emptySet())
val deletingNames: StateFlow<Set<String>> = _deletingNames.asStateFlow() 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 { private fun getDownloadDir(): File {
// Public Movies/yaris-obd/ — visible in file managers // Public Movies/yaris-obd/ — visible in file managers
val dir = File( val dir = File(
@@ -219,6 +224,68 @@ class DashcamViewModel(application: Application) : AndroidViewModel(application)
} catch (_: Exception) {} } 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) { fun deleteVideo(video: VideoFile) {
viewModelScope.launch { viewModelScope.launch {
try { try {
@@ -59,6 +59,24 @@ class DownloadWorker(
nm.createNotificationChannel(channel) 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) { 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.CircularProgressIndicator
import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import com.yarisobd.app.data.CredentialStore
import androidx.compose.runtime.Composable 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@@ -43,9 +53,13 @@ import androidx.core.content.FileProvider
fun DownloadsScreen( fun DownloadsScreen(
files: List<DownloadedFile>, files: List<DownloadedFile>,
activeDownloads: Map<String, ActiveDownload>, activeDownloads: Map<String, ActiveDownload>,
activeUploads: Map<String, ActiveDownload>,
deletingNames: Set<String>, deletingNames: Set<String>,
credentialsConfigured: Boolean,
onDelete: (DownloadedFile) -> Unit, onDelete: (DownloadedFile) -> Unit,
onCancel: (ActiveDownload) -> Unit, onCancel: (ActiveDownload) -> Unit,
onUpload: (DownloadedFile) -> Unit,
onCancelUpload: (ActiveDownload) -> Unit,
onRefresh: () -> Unit, onRefresh: () -> Unit,
) { ) {
val context = LocalContext.current 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 // Active downloads with progress
AnimatedVisibility(visible = activeDownloads.isNotEmpty(), enter = fadeIn(), exit = fadeOut()) { AnimatedVisibility(visible = activeDownloads.isNotEmpty(), enter = fadeIn(), exit = fadeOut()) {
Column(modifier = Modifier.padding(horizontal = 16.dp)) { Column(modifier = Modifier.padding(horizontal = 16.dp)) {
@@ -152,11 +213,19 @@ fun DownloadsScreen(
if (isDeleting) { if (isDeleting) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.error) CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.error)
} else { } 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( Button(
onClick = { onDelete(file) }, onClick = { onDelete(file) },
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error), colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error),
modifier = Modifier.height(32.dp), modifier = Modifier.height(32.dp), shape = RoundedCornerShape(8.dp),
shape = RoundedCornerShape(8.dp),
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 0.dp) contentPadding = PaddingValues(horizontal = 10.dp, vertical = 0.dp)
) { Text("✕", style = MaterialTheme.typography.bodySmall) } ) { Text("✕", style = MaterialTheme.typography.bodySmall) }
} }
@@ -56,6 +56,9 @@ fun YarisMainScreen(client: OkHttpClient) {
val downloadedFiles by dashcamVm.downloadedFiles.collectAsState() val downloadedFiles by dashcamVm.downloadedFiles.collectAsState()
val activeDownloads by dashcamVm.activeDownloads.collectAsState() val activeDownloads by dashcamVm.activeDownloads.collectAsState()
val deletingNames by dashcamVm.deletingNames.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() } val snackbarHostState = remember { SnackbarHostState() }
var selectedTab by remember { mutableIntStateOf(0) } var selectedTab by remember { mutableIntStateOf(0) }
@@ -85,8 +88,12 @@ fun YarisMainScreen(client: OkHttpClient) {
) )
NavigationBarItem( NavigationBarItem(
selected = selectedTab == 2, selected = selectedTab == 2,
onClick = { selectedTab = 2 }, onClick = { selectedTab = 2 }, icon = { Text("⚙️") },
icon = { Text("�🔌") }, label = { Text("Settings") }
)
NavigationBarItem(
selected = selectedTab == 3,
onClick = { selectedTab = 3 }, icon = { Text("�🔌") },
label = { Text("Test") } label = { Text("Test") }
) )
} }
@@ -107,13 +114,18 @@ fun YarisMainScreen(client: OkHttpClient) {
) )
1 -> DownloadsScreen( 1 -> DownloadsScreen(
files = downloadedFiles, files = downloadedFiles,
deletingNames = deletingNames,
activeDownloads = activeDownloads, activeDownloads = activeDownloads,
activeUploads = uploadProgress,
deletingNames = deletingNames,
credentialsConfigured = credentialsConfigured,
onDelete = { dashcamVm.deleteDownloaded(it) }, onDelete = { dashcamVm.deleteDownloaded(it) },
onCancel = { dashcamVm.cancelDownload(it) }, onCancel = { dashcamVm.cancelDownload(it) },
onUpload = { dashcamVm.uploadFile(it) },
onCancelUpload = { dashcamVm.cancelUpload(it) },
onRefresh = { dashcamVm.scanDownloads() }, onRefresh = { dashcamVm.scanDownloads() },
) )
2 -> DualNetworkTestScreen( 2 -> SettingsScreen()
3 -> DualNetworkTestScreen(
piClient = client, piClient = client,
serverClient = 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 package com.yarisobd.app.data.remote
import com.yarisobd.app.data.remote.dto.HealthResponse 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.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 { interface ServerApi {
/** Health check — Phase 9 (unauthenticated) */
@GET("/health") @GET("/health")
suspend fun health(): HealthResponse suspend fun health(): HealthResponse
// Future: add endpoints from android/plan.md as each sub-phase needs them: /** Check which chunks already received */
// @GET("/api/v1/dashcam/upload/status") — Phase 10 chunked upload status @GET("/api/v1/dashcam/upload/status")
// @POST("/api/v1/dashcam/upload/chunk") — Phase 10 multipart chunk upload suspend fun uploadStatus(
// @GET("/api/v1/portal/trips") — Phase 24 portal trips (needs auth cookie) @Query("relative_path") path: String,
// @GET("/api/v1/portal/trip/{id}") — Phase 24 @Query("sha256") sha256: String,
// @GET("/api/v1/portal/dashcam") — Phase 24 @Header("X-Device-Name") deviceName: String,
// @GET("/api/v1/portal/dashcam/stream/{id}") — Phase 24 streaming @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,
)