diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 8bd8438..e5999e0 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -102,8 +102,8 @@ dependencies { // Maps — will be used in 28.4 // implementation(libs.maplibre) - // Security — will be used in 28.1.3 - // implementation(libs.security.crypto) + // Security + implementation(libs.security.crypto) // Testing testImplementation("junit:junit:4.13.2") diff --git a/android/app/src/main/java/com/yarisobd/app/DashcamViewModel.kt b/android/app/src/main/java/com/yarisobd/app/DashcamViewModel.kt index 4fa6e0a..563fbaa 100644 --- a/android/app/src/main/java/com/yarisobd/app/DashcamViewModel.kt +++ b/android/app/src/main/java/com/yarisobd/app/DashcamViewModel.kt @@ -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>(emptySet()) val deletingNames: StateFlow> = _deletingNames.asStateFlow() + // Upload state + private val _uploadProgress = MutableStateFlow>(emptyMap()) + val uploadProgress: StateFlow> = _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() + 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() + .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 { diff --git a/android/app/src/main/java/com/yarisobd/app/DownloadWorker.kt b/android/app/src/main/java/com/yarisobd/app/DownloadWorker.kt index b56bfd3..4896b24 100644 --- a/android/app/src/main/java/com/yarisobd/app/DownloadWorker.kt +++ b/android/app/src/main/java/com/yarisobd/app/DownloadWorker.kt @@ -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) { diff --git a/android/app/src/main/java/com/yarisobd/app/DownloadsScreen.kt b/android/app/src/main/java/com/yarisobd/app/DownloadsScreen.kt index b85652a..9cfe7bf 100644 --- a/android/app/src/main/java/com/yarisobd/app/DownloadsScreen.kt +++ b/android/app/src/main/java/com/yarisobd/app/DownloadsScreen.kt @@ -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, activeDownloads: Map, + activeUploads: Map, deletingNames: Set, + 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) } } diff --git a/android/app/src/main/java/com/yarisobd/app/MainActivity.kt b/android/app/src/main/java/com/yarisobd/app/MainActivity.kt index ec4bb0a..b142580 100644 --- a/android/app/src/main/java/com/yarisobd/app/MainActivity.kt +++ b/android/app/src/main/java/com/yarisobd/app/MainActivity.kt @@ -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, ) diff --git a/android/app/src/main/java/com/yarisobd/app/SettingsScreen.kt b/android/app/src/main/java/com/yarisobd/app/SettingsScreen.kt new file mode 100644 index 0000000..ea37dd0 --- /dev/null +++ b/android/app/src/main/java/com/yarisobd/app/SettingsScreen.kt @@ -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") } + } + } + } +} diff --git a/android/app/src/main/java/com/yarisobd/app/UploadWorker.kt b/android/app/src/main/java/com/yarisobd/app/UploadWorker.kt new file mode 100644 index 0000000..6c8caf7 --- /dev/null +++ b/android/app/src/main/java/com/yarisobd/app/UploadWorker.kt @@ -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) } + } +} diff --git a/android/app/src/main/java/com/yarisobd/app/data/CredentialStore.kt b/android/app/src/main/java/com/yarisobd/app/data/CredentialStore.kt new file mode 100644 index 0000000..47446ac --- /dev/null +++ b/android/app/src/main/java/com/yarisobd/app/data/CredentialStore.kt @@ -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() + } +} diff --git a/android/app/src/main/java/com/yarisobd/app/data/remote/ServerApi.kt b/android/app/src/main/java/com/yarisobd/app/data/remote/ServerApi.kt index 37fad21..ece7ab7 100644 --- a/android/app/src/main/java/com/yarisobd/app/data/remote/ServerApi.kt +++ b/android/app/src/main/java/com/yarisobd/app/data/remote/ServerApi.kt @@ -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 } diff --git a/android/app/src/main/java/com/yarisobd/app/data/remote/dto/UploadStatus.kt b/android/app/src/main/java/com/yarisobd/app/data/remote/dto/UploadStatus.kt new file mode 100644 index 0000000..606b8ed --- /dev/null +++ b/android/app/src/main/java/com/yarisobd/app/data/remote/dto/UploadStatus.kt @@ -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? = null, +) + +@Serializable +data class UploadChunkResponse( + val status: String = "", // "already_uploaded", "ok" + val error: String? = null, +)