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