From b2fefccd1ec34b9d36e6f43b53662e9134778e1c Mon Sep 17 00:00:00 2001 From: Tomek Zebrowski Date: Mon, 6 Apr 2026 20:54:28 +0200 Subject: [PATCH 1/4] feat: add trip logs auto sync --- .../java/org/obd/graphs/activity/Receivers.kt | 7 + .../preferences/trips/TripViewAdapter.kt | 29 +++- .../src/main/java/org/obd/graphs/Constants.kt | 2 + .../obd/graphs/bl/trip/DefaultTripManager.kt | 2 + .../org/obd/graphs/bl/trip/TripDescParser.kt | 8 +- .../java/org/obd/graphs/bl/trip/TripModel.kt | 3 +- .../org/obd/graphs/bl/trip/TripRepository.kt | 2 +- integrations/build.gradle | 3 + .../gcp/authorization/SilentAuthorization.kt | 52 ++++++ .../gcp/gdrive/AbstractDriveManager.kt | 95 ---------- .../gcp/gdrive/DefaultDriveBackupManager.kt | 2 + .../gcp/gdrive/DefaultTripLogDriveManager.kt | 2 + .../gcp/gdrive/DriveHelper.kt | 78 +++++++++ .../gcp/gdrive/TripCloudSyncWorker.kt | 162 ++++++++++++++++++ 14 files changed, 347 insertions(+), 100 deletions(-) create mode 100644 integrations/src/main/java/org/obd/graphs.integrations/gcp/authorization/SilentAuthorization.kt create mode 100644 integrations/src/main/java/org/obd/graphs.integrations/gcp/gdrive/DriveHelper.kt create mode 100644 integrations/src/main/java/org/obd/graphs.integrations/gcp/gdrive/TripCloudSyncWorker.kt diff --git a/app/src/main/java/org/obd/graphs/activity/Receivers.kt b/app/src/main/java/org/obd/graphs/activity/Receivers.kt index b0866b2e3..de5be2fd3 100644 --- a/app/src/main/java/org/obd/graphs/activity/Receivers.kt +++ b/app/src/main/java/org/obd/graphs/activity/Receivers.kt @@ -54,6 +54,7 @@ import org.obd.graphs.ScreenLock import org.obd.graphs.TRIPS_UPLOAD_FAILED import org.obd.graphs.TRIPS_UPLOAD_NO_FILES_SELECTED import org.obd.graphs.TRIPS_UPLOAD_SUCCESSFUL +import org.obd.graphs.TRIP_LOG_WRITE_COMPLETED import org.obd.graphs.bl.datalogger.DATA_LOGGER_ADAPTER_NOT_SET_EVENT import org.obd.graphs.bl.datalogger.DATA_LOGGER_CONNECTED_EVENT import org.obd.graphs.bl.datalogger.DATA_LOGGER_CONNECTING_EVENT @@ -75,6 +76,7 @@ import org.obd.graphs.bl.extra.EVENT_VEHICLE_STATUS_VEHICLE_IDLING import org.obd.graphs.bl.extra.EVENT_VEHICLE_STATUS_VEHICLE_RUNNING import org.obd.graphs.getContext import org.obd.graphs.getSerializableCompat +import org.obd.graphs.integrations.gcp.gdrive.DriveSync import org.obd.graphs.preferences.PREFS_CONNECTION_TYPE_CHANGED_EVENT import org.obd.graphs.preferences.Prefs import org.obd.graphs.preferences.isEnabled @@ -101,6 +103,10 @@ private const val EVENT_VEHICLE_STATUS_CHANGED = "event.vehicle.status.CHANGED" internal fun MainActivity.receive(intent: Intent?) { when (intent?.action) { + TRIP_LOG_WRITE_COMPLETED -> { + DriveSync.start() + } + DATA_LOGGER_SCHEDULED_STOP_EVENT -> { Log.d( LOG_TAG, @@ -388,6 +394,7 @@ internal fun MainActivity.registerReceiver() { it.addAction(NAVIGATION_BUTTONS_VISIBILITY_CHANGED) it.addAction(DATA_LOGGER_SCHEDULED_STOP_EVENT) it.addAction(PROFILE_NAME_CHANGED_EVENT) + it.addAction(TRIP_LOG_WRITE_COMPLETED) } registerReceiver(this, DataLoggerRepository.broadcastReceivers()) { diff --git a/app/src/main/java/org/obd/graphs/preferences/trips/TripViewAdapter.kt b/app/src/main/java/org/obd/graphs/preferences/trips/TripViewAdapter.kt index c17b535c6..779c294bb 100644 --- a/app/src/main/java/org/obd/graphs/preferences/trips/TripViewAdapter.kt +++ b/app/src/main/java/org/obd/graphs/preferences/trips/TripViewAdapter.kt @@ -19,6 +19,9 @@ package org.obd.graphs.preferences.trips import android.content.Context import android.graphics.Color import android.graphics.Typeface +import android.text.Spannable +import android.text.SpannableString +import android.text.style.ForegroundColorSpan import android.util.Log import android.view.LayoutInflater import android.view.View @@ -37,6 +40,7 @@ import org.obd.graphs.ui.common.setText import java.text.SimpleDateFormat import java.util.Date import java.util.Locale +import androidx.core.graphics.toColorInt private const val LOGGER_KEY = "TripsViewAdapter" @@ -80,7 +84,28 @@ class TripViewAdapter internal constructor( startTs = dateFormat.format(Date(it)) } - holder.tripStartDate.setText(startTs, Color.GRAY, Typeface.NORMAL, 0.9f) + source.startTime.toLongOrNull()?.let { + startTs = dateFormat.format(Date(it)) + } + + if (source.isSynced) { + val syncText = " ☁️ Synced" + val fullText = startTs + syncText + + holder.tripStartDate.setText(fullText, Color.GRAY, Typeface.NORMAL, 0.9f) + + val spannable = SpannableString(fullText) + spannable.setSpan( + ForegroundColorSpan("#4CAF50".toColorInt()), // A nice Material Green + startTs.length, + fullText.length, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ) + + holder.tripStartDate.text = spannable + } else { + holder.tripStartDate.setText(startTs, Color.GRAY, Typeface.NORMAL, 0.9f) + } holder.selected.isChecked = checked holder.selected.setOnCheckedChangeListener { buttonView, isChecked -> @@ -90,6 +115,7 @@ class TripViewAdapter internal constructor( } holder.tripTime.let { + val seconds: Int = source.tripTimeSec.toInt() % 60 var hours: Int = source.tripTimeSec.toInt() / 60 val minutes = hours % 60 @@ -99,6 +125,7 @@ class TripViewAdapter internal constructor( }:${seconds.toString().padStart(2, '0')}s" it.setText(text, Color.GRAY, Typeface.BOLD, 0.9f) + } } } diff --git a/common/src/main/java/org/obd/graphs/Constants.kt b/common/src/main/java/org/obd/graphs/Constants.kt index a7191f931..42a086e40 100644 --- a/common/src/main/java/org/obd/graphs/Constants.kt +++ b/common/src/main/java/org/obd/graphs/Constants.kt @@ -16,6 +16,8 @@ */ package org.obd.graphs +const val TRIP_LOG_WRITE_COMPLETED = "trip.log.write.completed.event" + const val LANGUAGE_CHANGE_EVENT = "lang.change.event" const val SCREEN_LOCK_DIALOG_CANCELLED_EVENT = "screen.lock.dialog.cancelled.event" diff --git a/datalogger/src/main/java/org/obd/graphs/bl/trip/DefaultTripManager.kt b/datalogger/src/main/java/org/obd/graphs/bl/trip/DefaultTripManager.kt index dcd1257f2..dc3a4b617 100644 --- a/datalogger/src/main/java/org/obd/graphs/bl/trip/DefaultTripManager.kt +++ b/datalogger/src/main/java/org/obd/graphs/bl/trip/DefaultTripManager.kt @@ -21,6 +21,7 @@ import android.util.Log import org.obd.graphs.SCREEN_LOCK_PROGRESS_EVENT import org.obd.graphs.SCREEN_UNLOCK_PROGRESS_EVENT import org.obd.graphs.ScreenLock +import org.obd.graphs.TRIP_LOG_WRITE_COMPLETED import org.obd.graphs.bl.datalogger.DataLoggerRepository import org.obd.graphs.bl.datalogger.scaleToRange import org.obd.graphs.commons.R @@ -188,6 +189,7 @@ internal class DefaultTripManager : TripManager { activeTripId = null ts = System.currentTimeMillis() - ts + sendBroadcastEvent(TRIP_LOG_WRITE_COMPLETED) Log.i(LOG_TAG, "Trip: $currentTripId is saved. It took $ts ms") } } finally { diff --git a/datalogger/src/main/java/org/obd/graphs/bl/trip/TripDescParser.kt b/datalogger/src/main/java/org/obd/graphs/bl/trip/TripDescParser.kt index dca6c79e4..09ce029ad 100644 --- a/datalogger/src/main/java/org/obd/graphs/bl/trip/TripDescParser.kt +++ b/datalogger/src/main/java/org/obd/graphs/bl/trip/TripDescParser.kt @@ -28,17 +28,21 @@ class TripDescParser { val startTime = if (p.size > 2) p[2] else "" val tripTimeSec = if (p.size > 3) p[3] else "0" + val isSynced = fileName.endsWith(".synced") + return TripFileDesc( fileName = fileName, profileId = profileId, profileLabel = profileLabel, startTime = startTime, - tripTimeSec = tripTimeSec + tripTimeSec = tripTimeSec, + isSynced = isSynced ) } fun decodeTripName(fileName: String): List { - val nameWithoutExtension = fileName.substringBeforeLast(".") + val cleanName = fileName.removeSuffix(".synced") + val nameWithoutExtension = cleanName.substringBeforeLast(".") return nameWithoutExtension.split("-") } } diff --git a/datalogger/src/main/java/org/obd/graphs/bl/trip/TripModel.kt b/datalogger/src/main/java/org/obd/graphs/bl/trip/TripModel.kt index c12b5a04e..bb49529e9 100644 --- a/datalogger/src/main/java/org/obd/graphs/bl/trip/TripModel.kt +++ b/datalogger/src/main/java/org/obd/graphs/bl/trip/TripModel.kt @@ -25,7 +25,8 @@ data class TripFileDesc( val profileId: String, val profileLabel: String, val startTime: String, - val tripTimeSec: String + val tripTimeSec: String, + val isSynced: Boolean = false ) @JsonIgnoreProperties(ignoreUnknown = true) diff --git a/datalogger/src/main/java/org/obd/graphs/bl/trip/TripRepository.kt b/datalogger/src/main/java/org/obd/graphs/bl/trip/TripRepository.kt index a08d140f0..8efed803a 100644 --- a/datalogger/src/main/java/org/obd/graphs/bl/trip/TripRepository.kt +++ b/datalogger/src/main/java/org/obd/graphs/bl/trip/TripRepository.kt @@ -148,7 +148,7 @@ internal class FileTripRepository( .filter { try { parser.decodeTripName(it).size > 3 - } catch (e: Throwable) { + } catch (_ : Throwable) { false } }.mapNotNull { fileName -> diff --git a/integrations/build.gradle b/integrations/build.gradle index 34f6eba40..9b71490fd 100644 --- a/integrations/build.gradle +++ b/integrations/build.gradle @@ -46,6 +46,9 @@ dependencies { implementation project(":datalogger") implementation("com.google.code.gson:gson:2.10.1") + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.7.3" + implementation "androidx.work:work-runtime-ktx:2.9.1" + // This specific library provides lifecycleScope implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.6.2" // (Optional) If you are using it in a ViewModel, you might also need: diff --git a/integrations/src/main/java/org/obd/graphs.integrations/gcp/authorization/SilentAuthorization.kt b/integrations/src/main/java/org/obd/graphs.integrations/gcp/authorization/SilentAuthorization.kt new file mode 100644 index 000000000..2d4501926 --- /dev/null +++ b/integrations/src/main/java/org/obd/graphs.integrations/gcp/authorization/SilentAuthorization.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2019-2026, Tomasz Żebrowski + * + *

Licensed to the Apache Software Foundation (ASF) under one or more contributor license + * agreements. See the NOTICE file distributed with this work for additional information regarding + * copyright ownership. The ASF licenses this file to You under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. You may obtain a + * copy of the License at + * + *

http://www.apache.org/licenses/LICENSE-2.0 + * + *

Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.obd.graphs.integrations.gcp.authorization + +import android.content.Context +import android.util.Log +import com.google.android.gms.auth.api.identity.AuthorizationRequest +import com.google.android.gms.auth.api.identity.Identity +import com.google.android.gms.common.api.Scope +import com.google.api.services.drive.DriveScopes +import kotlinx.coroutines.tasks.await + +private const val TAG = "SilentAuth" + +internal object SilentAuthorization { + + suspend fun getAccessTokenSilently(context: Context): String? { + return try { + val authorizationClient = Identity.getAuthorizationClient(context) + val request = AuthorizationRequest.Builder() + .setRequestedScopes(listOf(Scope(DriveScopes.DRIVE_FILE), Scope(DriveScopes.DRIVE_APPDATA))) + .build() + + val result = authorizationClient.authorize(request).await() + + if (result.hasResolution()) { + Log.w(TAG, "Silent auth failed: UI resolution required.") + null // We cannot show UI in the background + } else { + Log.i(TAG, "Silent auth successful.") + result.accessToken + } + } catch (e: Exception) { + Log.e(TAG, "Silent auth threw an exception", e) + null + } + } +} diff --git a/integrations/src/main/java/org/obd/graphs.integrations/gcp/gdrive/AbstractDriveManager.kt b/integrations/src/main/java/org/obd/graphs.integrations/gcp/gdrive/AbstractDriveManager.kt index 791239cd6..94ae5b29c 100644 --- a/integrations/src/main/java/org/obd/graphs.integrations/gcp/gdrive/AbstractDriveManager.kt +++ b/integrations/src/main/java/org/obd/graphs.integrations/gcp/gdrive/AbstractDriveManager.kt @@ -23,7 +23,6 @@ import com.google.android.gms.auth.GoogleAuthUtil import com.google.android.gms.common.api.Scope import com.google.api.client.googleapis.json.GoogleJsonResponseException import com.google.api.client.http.AbstractInputStreamContent -import com.google.api.client.http.FileContent import com.google.api.client.http.HttpRequestInitializer import com.google.api.client.http.javanet.NetHttpTransport import com.google.api.client.json.gson.GsonFactory @@ -32,10 +31,8 @@ import com.google.api.services.drive.DriveScopes import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.obd.graphs.integrations.gcp.authorization.AuthorizationManager -import java.io.File import java.io.FileNotFoundException import java.io.InputStream -import com.google.api.services.drive.model.File as DriveFile private const val APP_NAME = "MyGiuliaBackup" private const val TAG = "AbstractDriveManager" @@ -108,98 +105,6 @@ internal abstract class AbstractDriveManager( } } - fun Drive.uploadFile( - content: InputStreamContent, - parentFolderId: String - ): DriveFile { - Log.i(TAG, "Uploading file ${content.fileName}") - val metadata = - DriveFile().apply { - name = content.fileName - parents = listOf(parentFolderId) - } - - val uploaded = - this - .files() - .create(metadata, content) - .setFields("id") - .execute() - - Log.i(TAG, "Uploaded ${content.fileName}, ID: ${uploaded.id}") - return uploaded - } - - fun Drive.uploadFile( - localFile: File, - fileName: String, - parentFolderId: String, - mimeType: String = "text/plain" - ): DriveFile { - Log.i(TAG, "Uploading file ${localFile.absolutePath} to $fileName") - val metadata = - DriveFile().apply { - name = fileName - parents = listOf(parentFolderId) - } - val content = FileContent(mimeType, localFile) - - val uploaded = - this - .files() - .create(metadata, content) - .setFields("id") - .execute() - - Log.i(TAG, "Uploaded ${localFile.name}, ID: ${uploaded.id} as $fileName") - return uploaded - } - - protected fun Drive.findFolderIdRecursive(path: String): String { - val folderNames = path.split("/").filter { it.isNotEmpty() } - var currentParentId = "root" - for (folderName in folderNames) { - currentParentId = findOrCreateSingleFolder(this, folderName, currentParentId) - } - return currentParentId - } - - private fun findOrCreateSingleFolder( - drive: Drive, - folderName: String, - parentId: String - ): String { - val query = - "mimeType = 'application/vnd.google-apps.folder' and name = '$folderName' and '$parentId' in parents and trashed = false" - - val files = - drive - .files() - .list() - .setQ(query) - .setSpaces("drive") - .setFields("files(id, name)") - .execute() - .files - - return if (files.isNotEmpty()) { - files.first().id - } else { - val metadata = - DriveFile().apply { - name = folderName - mimeType = "application/vnd.google-apps.folder" - parents = listOf(parentId) - } - drive - .files() - .create(metadata) - .setFields("id") - .execute() - .id - } - } - private fun credentials(accessToken: String): HttpRequestInitializer = HttpRequestInitializer { request -> request.headers.authorization = "Bearer $accessToken" diff --git a/integrations/src/main/java/org/obd/graphs.integrations/gcp/gdrive/DefaultDriveBackupManager.kt b/integrations/src/main/java/org/obd/graphs.integrations/gcp/gdrive/DefaultDriveBackupManager.kt index a18a0990c..18e8151b1 100644 --- a/integrations/src/main/java/org/obd/graphs.integrations/gcp/gdrive/DefaultDriveBackupManager.kt +++ b/integrations/src/main/java/org/obd/graphs.integrations/gcp/gdrive/DefaultDriveBackupManager.kt @@ -24,6 +24,8 @@ import org.obd.graphs.BACKUP_RESTORE_NO_FILES import org.obd.graphs.BACKUP_RESTORE_SUCCESSFUL import org.obd.graphs.BACKUP_SUCCESSFUL import org.obd.graphs.SCREEN_UNLOCK_PROGRESS_EVENT +import org.obd.graphs.integrations.gcp.gdrive.DriveHelper.findFolderIdRecursive +import org.obd.graphs.integrations.gcp.gdrive.DriveHelper.uploadFile import org.obd.graphs.sendBroadcastEvent import java.io.File import java.io.FileOutputStream diff --git a/integrations/src/main/java/org/obd/graphs.integrations/gcp/gdrive/DefaultTripLogDriveManager.kt b/integrations/src/main/java/org/obd/graphs.integrations/gcp/gdrive/DefaultTripLogDriveManager.kt index 4a3e2330b..bb40180e6 100644 --- a/integrations/src/main/java/org/obd/graphs.integrations/gcp/gdrive/DefaultTripLogDriveManager.kt +++ b/integrations/src/main/java/org/obd/graphs.integrations/gcp/gdrive/DefaultTripLogDriveManager.kt @@ -25,6 +25,8 @@ import org.obd.graphs.TRIPS_UPLOAD_SUCCESSFUL import org.obd.graphs.bl.datalogger.DataLoggerRepository import org.obd.graphs.bl.datalogger.scaleToRange import org.obd.graphs.bl.trip.TripDescParser +import org.obd.graphs.integrations.gcp.gdrive.DriveHelper.findFolderIdRecursive +import org.obd.graphs.integrations.gcp.gdrive.DriveHelper.uploadFile import org.obd.graphs.integrations.log.OutputType import org.obd.graphs.integrations.log.TripLog import org.obd.graphs.sendBroadcastEvent diff --git a/integrations/src/main/java/org/obd/graphs.integrations/gcp/gdrive/DriveHelper.kt b/integrations/src/main/java/org/obd/graphs.integrations/gcp/gdrive/DriveHelper.kt new file mode 100644 index 000000000..a37b6bb5b --- /dev/null +++ b/integrations/src/main/java/org/obd/graphs.integrations/gcp/gdrive/DriveHelper.kt @@ -0,0 +1,78 @@ +/* + * Copyright 2019-2026, Tomasz Żebrowski + * + *

Licensed to the Apache Software Foundation (ASF) under one or more contributor license + * agreements. See the NOTICE file distributed with this work for additional information regarding + * copyright ownership. The ASF licenses this file to You under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. You may obtain a + * copy of the License at + * + *

http://www.apache.org/licenses/LICENSE-2.0 + * + *

Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.obd.graphs.integrations.gcp.gdrive + +import android.util.Log +import com.google.api.client.http.FileContent +import com.google.api.services.drive.Drive +import java.io.File +import com.google.api.services.drive.model.File as DriveFile + +private const val TAG = "DriveHelper" + +internal object DriveHelper { + + fun Drive.findFolderIdRecursive(path: String): String { + val folderNames = path.split("/").filter { it.isNotEmpty() } + var currentParentId = "root" + for (folderName in folderNames) { + currentParentId = findOrCreateSingleFolder(folderName, currentParentId) + } + return currentParentId + } + + fun Drive.findOrCreateSingleFolder(folderName: String, parentId: String): String { + val query = "mimeType = 'application/vnd.google-apps.folder' and name = '$folderName' and '$parentId' in parents and trashed = false" + + val files = this.files().list() + .setQ(query) + .setSpaces("drive") + .setFields("files(id, name)") + .execute() + .files + + return if (files.isNotEmpty()) { + files.first().id + } else { + val metadata = DriveFile().apply { + name = folderName + mimeType = "application/vnd.google-apps.folder" + parents = listOf(parentId) + } + this.files().create(metadata).setFields("id").execute().id + } + } + + fun Drive.uploadFile( + localFile: File, + fileName: String, + parentFolderId: String, + mimeType: String = "text/plain" + ): DriveFile { + Log.i(TAG, "Uploading file ${localFile.absolutePath} to $fileName") + val metadata = DriveFile().apply { + name = fileName + parents = listOf(parentFolderId) + } + val content = FileContent(mimeType, localFile) + + val uploaded = this.files().create(metadata, content).setFields("id").execute() + + Log.i(TAG, "Uploaded ${localFile.name}, ID: ${uploaded.id} as $fileName") + return uploaded + } +} diff --git a/integrations/src/main/java/org/obd/graphs.integrations/gcp/gdrive/TripCloudSyncWorker.kt b/integrations/src/main/java/org/obd/graphs.integrations/gcp/gdrive/TripCloudSyncWorker.kt new file mode 100644 index 000000000..07eaf0fb3 --- /dev/null +++ b/integrations/src/main/java/org/obd/graphs.integrations/gcp/gdrive/TripCloudSyncWorker.kt @@ -0,0 +1,162 @@ +/* + * Copyright 2019-2026, Tomasz Żebrowski + * + *

Licensed to the Apache Software Foundation (ASF) under one or more contributor license + * agreements. See the NOTICE file distributed with this work for additional information regarding + * copyright ownership. The ASF licenses this file to You under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. You may obtain a + * copy of the License at + * + *

http://www.apache.org/licenses/LICENSE-2.0 + * + *

Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.obd.graphs.integrations.gcp.gdrive + +import android.content.Context +import android.util.Log +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import com.google.android.gms.auth.GoogleAuthUtil +import com.google.api.client.googleapis.json.GoogleJsonResponseException +import com.google.api.client.http.HttpRequestInitializer +import com.google.api.client.http.javanet.NetHttpTransport +import com.google.api.client.json.gson.GsonFactory +import com.google.api.services.drive.Drive +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.obd.graphs.bl.datalogger.DataLoggerRepository +import org.obd.graphs.bl.datalogger.scaleToRange +import org.obd.graphs.bl.trip.TripDescParser +import org.obd.graphs.bl.trip.tripManager +import org.obd.graphs.getContext +import org.obd.graphs.integrations.gcp.authorization.SilentAuthorization +import org.obd.graphs.integrations.gcp.gdrive.DriveHelper.findFolderIdRecursive +import org.obd.graphs.integrations.gcp.gdrive.DriveHelper.uploadFile +import org.obd.graphs.integrations.log.OutputType +import org.obd.graphs.integrations.log.TripLog +import java.io.File +import java.util.zip.GZIPOutputStream + +private const val LOG_TAG = "TripCloudSyncWorker" + +object DriveSync { + fun start() { + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.UNMETERED) + .setRequiresBatteryNotLow(true) + .build() + + val syncRequest = OneTimeWorkRequestBuilder() + .setConstraints(constraints) + .build() + + WorkManager.getInstance(getContext()!!).enqueue(syncRequest) + } +} + +internal class TripCloudSyncWorker( + appContext: Context, + workerParams: WorkerParameters +) : CoroutineWorker(appContext, workerParams) { + + override suspend fun doWork(): Result = withContext(Dispatchers.IO) { + val context = applicationContext + + val directory = File(tripManager.getTripsDirectory(context)) + val unsyncedFiles = directory.listFiles()?.filter { + it.name.startsWith("trip-") && !it.name.endsWith(".synced") + } ?: emptyList() + + if (unsyncedFiles.isEmpty()) { + return@withContext Result.success() + } + + val token = SilentAuthorization.getAccessTokenSilently(context) + ?: return@withContext Result.retry() // Try again later if auth fails + + try { + val driveService = Drive.Builder( + NetHttpTransport.Builder().build(), + GsonFactory(), + HttpRequestInitializer { request -> request.headers.authorization = "Bearer $token" } + ).setApplicationName("MyGiuliaBackup").build() + + val folderId = driveService.findFolderIdRecursive("mygiulia/trips") + + val definitions = DataLoggerRepository.getPidDefinitionRegistry().findAll() + val signalsMapper = definitions.associate { it.id.toInt() to it.description.replace("\n", " ") } + val pidMap = definitions.associateBy { it.id.toInt() } + + val transformer = TripLog.transformer(OutputType.JSON, signalsMapper) { s, v -> + if (v is Number) { + (pidMap[s]?.scaleToRange(v.toFloat())) ?: v + } else { + v + } + } + + val deviceId = Device.id() + val tripDescParser = TripDescParser() + + unsyncedFiles.forEach { inFile -> + Log.i(LOG_TAG, "Syncing file: ${inFile.name}") + + val metadata = mutableMapOf() + val tripDesc = tripDescParser.getTripDesc(inFile.name) + metadata["trip.duration"] = tripDesc.tripTimeSec + metadata["trip.profileId"] = tripDesc.profileId + metadata["trip.startTime"] = tripDesc.startTime + metadata["trip.profileLabel"] = tripDesc.profileLabel + + val transformedFile = transformer.transform(inFile, metadata) + val tempGzipFile = File(context.cacheDir, "${inFile.name}.gz") + + tempGzipFile.outputStream().use { fos -> + GZIPOutputStream(fos).use { gzipOs -> + transformedFile.inputStream().use { inputStream -> + inputStream.copyTo(gzipOs) + } + } + } + + val originalName = inFile.name.removePrefix("trip-profile_") + val fileName = "$deviceId-$originalName.json.gz" + + driveService.uploadFile(tempGzipFile, fileName, folderId, "application/gzip") + + tempGzipFile.delete() + transformedFile.delete() + + inFile.renameTo(File(inFile.absolutePath + ".synced")) + } + + Log.i(LOG_TAG, "Cloud sync complete.") + Result.success() + } catch (e: GoogleJsonResponseException) { + if (e.statusCode == 401) { + Log.w(LOG_TAG, "Token expired! Clearing cache and retrying...") + + try { + GoogleAuthUtil.clearToken(context, token) + } catch (clearEx: Exception) { + Log.e(LOG_TAG, "Failed to clear dead token", clearEx) + } + + return@withContext Result.retry() + } else { + return@withContext Result.retry() + } + } catch (e: Exception) { + Log.e(LOG_TAG, "Upload failed", e) + Result.retry() + } + } +} From 15e38ce55ce5a5a290929772791fd8bb1a01d51c Mon Sep 17 00:00:00 2001 From: Tomek Zebrowski Date: Tue, 7 Apr 2026 12:04:53 +0200 Subject: [PATCH 2/4] feat: ensure just one worker starts at the time --- .../java/org/obd/graphs/activity/Receivers.kt | 2 +- .../preferences/trips/TripViewAdapter.kt | 4 +- .../org/obd/graphs/bl/trip/TripRepository.kt | 2 +- .../gcp/gdrive/DefaultTripLogDriveManager.kt | 61 +++--------- .../gcp/gdrive/TripCloudSyncWorker.kt | 92 +++++++++---------- .../gcp/gdrive/TripUpload.kt | 86 +++++++++++++++++ 6 files changed, 148 insertions(+), 99 deletions(-) create mode 100644 integrations/src/main/java/org/obd/graphs.integrations/gcp/gdrive/TripUpload.kt diff --git a/app/src/main/java/org/obd/graphs/activity/Receivers.kt b/app/src/main/java/org/obd/graphs/activity/Receivers.kt index de5be2fd3..4ddd14908 100644 --- a/app/src/main/java/org/obd/graphs/activity/Receivers.kt +++ b/app/src/main/java/org/obd/graphs/activity/Receivers.kt @@ -104,7 +104,7 @@ private const val EVENT_VEHICLE_STATUS_CHANGED = "event.vehicle.status.CHANGED" internal fun MainActivity.receive(intent: Intent?) { when (intent?.action) { TRIP_LOG_WRITE_COMPLETED -> { - DriveSync.start() + DriveSync.start(this) } DATA_LOGGER_SCHEDULED_STOP_EVENT -> { diff --git a/app/src/main/java/org/obd/graphs/preferences/trips/TripViewAdapter.kt b/app/src/main/java/org/obd/graphs/preferences/trips/TripViewAdapter.kt index 779c294bb..7e9d15266 100644 --- a/app/src/main/java/org/obd/graphs/preferences/trips/TripViewAdapter.kt +++ b/app/src/main/java/org/obd/graphs/preferences/trips/TripViewAdapter.kt @@ -31,6 +31,7 @@ import android.widget.CheckBox import android.widget.LinearLayout import android.widget.TextView import androidx.appcompat.app.AlertDialog +import androidx.core.graphics.toColorInt import androidx.recyclerview.widget.RecyclerView import org.obd.graphs.R import org.obd.graphs.bl.trip.tripManager @@ -40,7 +41,6 @@ import org.obd.graphs.ui.common.setText import java.text.SimpleDateFormat import java.util.Date import java.util.Locale -import androidx.core.graphics.toColorInt private const val LOGGER_KEY = "TripsViewAdapter" @@ -115,7 +115,6 @@ class TripViewAdapter internal constructor( } holder.tripTime.let { - val seconds: Int = source.tripTimeSec.toInt() % 60 var hours: Int = source.tripTimeSec.toInt() / 60 val minutes = hours % 60 @@ -125,7 +124,6 @@ class TripViewAdapter internal constructor( }:${seconds.toString().padStart(2, '0')}s" it.setText(text, Color.GRAY, Typeface.BOLD, 0.9f) - } } } diff --git a/datalogger/src/main/java/org/obd/graphs/bl/trip/TripRepository.kt b/datalogger/src/main/java/org/obd/graphs/bl/trip/TripRepository.kt index 8efed803a..6ab91cf91 100644 --- a/datalogger/src/main/java/org/obd/graphs/bl/trip/TripRepository.kt +++ b/datalogger/src/main/java/org/obd/graphs/bl/trip/TripRepository.kt @@ -148,7 +148,7 @@ internal class FileTripRepository( .filter { try { parser.decodeTripName(it).size > 3 - } catch (_ : Throwable) { + } catch (_: Throwable) { false } }.mapNotNull { fileName -> diff --git a/integrations/src/main/java/org/obd/graphs.integrations/gcp/gdrive/DefaultTripLogDriveManager.kt b/integrations/src/main/java/org/obd/graphs.integrations/gcp/gdrive/DefaultTripLogDriveManager.kt index bb40180e6..5c65539b2 100644 --- a/integrations/src/main/java/org/obd/graphs.integrations/gcp/gdrive/DefaultTripLogDriveManager.kt +++ b/integrations/src/main/java/org/obd/graphs.integrations/gcp/gdrive/DefaultTripLogDriveManager.kt @@ -22,16 +22,10 @@ import org.obd.graphs.SCREEN_UNLOCK_PROGRESS_EVENT import org.obd.graphs.TRIPS_UPLOAD_FAILED import org.obd.graphs.TRIPS_UPLOAD_NO_FILES_SELECTED import org.obd.graphs.TRIPS_UPLOAD_SUCCESSFUL -import org.obd.graphs.bl.datalogger.DataLoggerRepository -import org.obd.graphs.bl.datalogger.scaleToRange import org.obd.graphs.bl.trip.TripDescParser import org.obd.graphs.integrations.gcp.gdrive.DriveHelper.findFolderIdRecursive -import org.obd.graphs.integrations.gcp.gdrive.DriveHelper.uploadFile -import org.obd.graphs.integrations.log.OutputType -import org.obd.graphs.integrations.log.TripLog import org.obd.graphs.sendBroadcastEvent import java.io.File -import java.util.zip.GZIPOutputStream internal open class DefaultTripLogDriveManager( webClientId: String, @@ -50,53 +44,24 @@ internal open class DefaultTripLogDriveManager( sendBroadcastEvent(TRIPS_UPLOAD_NO_FILES_SELECTED) } else { val folderId = drive.findFolderIdRecursive("mygiulia/trips") - val definitions = DataLoggerRepository.getPidDefinitionRegistry().findAll() - val signalsMapper = definitions.associate { it.id.toInt() to it.description.replace("\n", " ") } - val pidMap = definitions.associateBy { it.id.toInt() } - val transformer = - TripLog.transformer(OutputType.JSON, signalsMapper) { s, v -> - if (v is Number) { - (pidMap[s]?.scaleToRange(v.toFloat())) ?: v - } else { - v - } - } + val transformer = TripUpload.buildTransformer() + val tripDescParser = TripDescParser() val deviceId = Device.id() - files.forEach { inFile -> - - val metadata = mutableMapOf() - val tripDesc = TripDescParser().getTripDesc(inFile.name) - metadata["trip.duration"] = tripDesc.tripTimeSec - metadata["trip.profileId"] = tripDesc.profileId - metadata["trip.startTime"] = tripDesc.startTime - metadata["trip.profileLabel"] = tripDesc.profileLabel - - val transformedFile = transformer.transform(inFile, metadata) - - val tempGzipFile = File(activity.cacheDir, "${inFile.name}.gz") - tempGzipFile.outputStream().use { fos -> - GZIPOutputStream(fos).use { gzipOs -> - transformedFile.inputStream().use { inputStream -> - inputStream.copyTo(gzipOs) - } - } + files.forEach { inFile -> + with(TripUpload) { + drive.transformAndUploadTrip( + inFile = inFile, + cacheDir = activity.cacheDir, + folderId = folderId, + deviceId = deviceId, + transformer = transformer, + tripDescParser = tripDescParser + ) } - - val originalName = inFile.name.removePrefix("trip-profile_") - val fileName = "$deviceId-$originalName.json.gz" - - drive.uploadFile( - localFile = tempGzipFile, - fileName = fileName, - parentFolderId = folderId, - mimeType = "application/gzip" - ) - - tempGzipFile.delete() - transformedFile.delete() } + sendBroadcastEvent(TRIPS_UPLOAD_SUCCESSFUL) } } diff --git a/integrations/src/main/java/org/obd/graphs.integrations/gcp/gdrive/TripCloudSyncWorker.kt b/integrations/src/main/java/org/obd/graphs.integrations/gcp/gdrive/TripCloudSyncWorker.kt index 07eaf0fb3..afe806915 100644 --- a/integrations/src/main/java/org/obd/graphs.integrations/gcp/gdrive/TripCloudSyncWorker.kt +++ b/integrations/src/main/java/org/obd/graphs.integrations/gcp/gdrive/TripCloudSyncWorker.kt @@ -20,6 +20,7 @@ import android.content.Context import android.util.Log import androidx.work.Constraints import androidx.work.CoroutineWorker +import androidx.work.ExistingWorkPolicy import androidx.work.NetworkType import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkManager @@ -36,29 +37,40 @@ import org.obd.graphs.bl.datalogger.DataLoggerRepository import org.obd.graphs.bl.datalogger.scaleToRange import org.obd.graphs.bl.trip.TripDescParser import org.obd.graphs.bl.trip.tripManager -import org.obd.graphs.getContext import org.obd.graphs.integrations.gcp.authorization.SilentAuthorization import org.obd.graphs.integrations.gcp.gdrive.DriveHelper.findFolderIdRecursive -import org.obd.graphs.integrations.gcp.gdrive.DriveHelper.uploadFile import org.obd.graphs.integrations.log.OutputType import org.obd.graphs.integrations.log.TripLog import java.io.File -import java.util.zip.GZIPOutputStream private const val LOG_TAG = "TripCloudSyncWorker" +private const val SYNC_WORK_NAME = "TripCloudSync" object DriveSync { - fun start() { - val constraints = Constraints.Builder() - .setRequiredNetworkType(NetworkType.UNMETERED) - .setRequiresBatteryNotLow(true) - .build() - val syncRequest = OneTimeWorkRequestBuilder() - .setConstraints(constraints) - .build() + fun start(context: Context) { + try { + Log.i(LOG_TAG, "Drive start sync scheduling") + + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.UNMETERED) + .setRequiresBatteryNotLow(true) + .build() + + val syncRequest = OneTimeWorkRequestBuilder() + .setConstraints(constraints) + .build() + + WorkManager.getInstance(context).enqueueUniqueWork( + SYNC_WORK_NAME, + ExistingWorkPolicy.KEEP, + syncRequest + ) - WorkManager.getInstance(getContext()!!).enqueue(syncRequest) + Log.i(LOG_TAG, "Drive sync scheduled") + } catch (e: Exception) { + Log.e(LOG_TAG, "Failed to schedule drive sync", e) + } } } @@ -69,20 +81,23 @@ internal class TripCloudSyncWorker( override suspend fun doWork(): Result = withContext(Dispatchers.IO) { val context = applicationContext + val token = SilentAuthorization.getAccessTokenSilently(context) + ?: return@withContext Result.retry() + + try { + Log.i(LOG_TAG, "Received sync request") - val directory = File(tripManager.getTripsDirectory(context)) - val unsyncedFiles = directory.listFiles()?.filter { - it.name.startsWith("trip-") && !it.name.endsWith(".synced") - } ?: emptyList() + val directory = File(tripManager.getTripsDirectory(context)) + val unsyncedFiles = directory.listFiles()?.filter { + it.name.startsWith("trip-") && !it.name.endsWith(".synced") + } ?: emptyList() - if (unsyncedFiles.isEmpty()) { - return@withContext Result.success() - } + Log.i(LOG_TAG, "Number of files to sync: ${unsyncedFiles.size}") - val token = SilentAuthorization.getAccessTokenSilently(context) - ?: return@withContext Result.retry() // Try again later if auth fails + if (unsyncedFiles.isEmpty()) { + return@withContext Result.success() + } - try { val driveService = Drive.Builder( NetHttpTransport.Builder().build(), GsonFactory(), @@ -109,32 +124,17 @@ internal class TripCloudSyncWorker( unsyncedFiles.forEach { inFile -> Log.i(LOG_TAG, "Syncing file: ${inFile.name}") - val metadata = mutableMapOf() - val tripDesc = tripDescParser.getTripDesc(inFile.name) - metadata["trip.duration"] = tripDesc.tripTimeSec - metadata["trip.profileId"] = tripDesc.profileId - metadata["trip.startTime"] = tripDesc.startTime - metadata["trip.profileLabel"] = tripDesc.profileLabel - - val transformedFile = transformer.transform(inFile, metadata) - val tempGzipFile = File(context.cacheDir, "${inFile.name}.gz") - - tempGzipFile.outputStream().use { fos -> - GZIPOutputStream(fos).use { gzipOs -> - transformedFile.inputStream().use { inputStream -> - inputStream.copyTo(gzipOs) - } - } + with(TripUpload) { + driveService.transformAndUploadTrip( + inFile = inFile, + cacheDir = context.cacheDir, + folderId = folderId, + deviceId = deviceId, + transformer = transformer, + tripDescParser = tripDescParser + ) } - val originalName = inFile.name.removePrefix("trip-profile_") - val fileName = "$deviceId-$originalName.json.gz" - - driveService.uploadFile(tempGzipFile, fileName, folderId, "application/gzip") - - tempGzipFile.delete() - transformedFile.delete() - inFile.renameTo(File(inFile.absolutePath + ".synced")) } diff --git a/integrations/src/main/java/org/obd/graphs.integrations/gcp/gdrive/TripUpload.kt b/integrations/src/main/java/org/obd/graphs.integrations/gcp/gdrive/TripUpload.kt new file mode 100644 index 000000000..82f53f00e --- /dev/null +++ b/integrations/src/main/java/org/obd/graphs.integrations/gcp/gdrive/TripUpload.kt @@ -0,0 +1,86 @@ +/* + * Copyright 2019-2026, Tomasz Żebrowski + * + *

Licensed to the Apache Software Foundation (ASF) under one or more contributor license + * agreements. See the NOTICE file distributed with this work for additional information regarding + * copyright ownership. The ASF licenses this file to You under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. You may obtain a + * copy of the License at + * + *

http://www.apache.org/licenses/LICENSE-2.0 + * + *

Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.obd.graphs.integrations.gcp.gdrive + +import com.google.api.services.drive.Drive +import org.obd.graphs.bl.datalogger.DataLoggerRepository +import org.obd.graphs.bl.datalogger.scaleToRange +import org.obd.graphs.bl.trip.TripDescParser +import org.obd.graphs.integrations.gcp.gdrive.DriveHelper.uploadFile +import org.obd.graphs.integrations.log.OutputType +import org.obd.graphs.integrations.log.TripLog +import org.obd.graphs.integrations.log.TripLogTransformer +import java.io.File +import java.util.zip.GZIPOutputStream + +internal object TripUpload { + + /** + * Builds the unified transformer that scales PID values. + * This is heavy, so it should only be called once per upload batch. + */ + fun buildTransformer(): TripLogTransformer { + val definitions = DataLoggerRepository.getPidDefinitionRegistry().findAll() + val signalsMapper = definitions.associate { it.id.toInt() to it.description.replace("\n", " ") } + val pidMap = definitions.associateBy { it.id.toInt() } + + return TripLog.transformer(OutputType.JSON, signalsMapper) { s, v -> + if (v is Number) { + (pidMap[s]?.scaleToRange(v.toFloat())) ?: v + } else { + v + } + } + } + + /** + * Transforms, compresses, and uploads a single trip file to Google Drive. + */ + fun Drive.transformAndUploadTrip( + inFile: File, + cacheDir: File, + folderId: String, + deviceId: String, + transformer: TripLogTransformer, + tripDescParser: TripDescParser + ) { + val metadata = mutableMapOf() + val tripDesc = tripDescParser.getTripDesc(inFile.name) + metadata["trip.duration"] = tripDesc.tripTimeSec + metadata["trip.profileId"] = tripDesc.profileId + metadata["trip.startTime"] = tripDesc.startTime + metadata["trip.profileLabel"] = tripDesc.profileLabel + + val transformedFile = transformer.transform(inFile, metadata) + val tempGzipFile = File(cacheDir, "${inFile.name}.gz") + + tempGzipFile.outputStream().use { fos -> + GZIPOutputStream(fos).use { gzipOs -> + transformedFile.inputStream().use { inputStream -> + inputStream.copyTo(gzipOs) + } + } + } + + val originalName = inFile.name.removePrefix("trip-profile_") + val fileName = "$deviceId-$originalName.json.gz" + this.uploadFile(tempGzipFile, fileName, folderId, "application/gzip") + + tempGzipFile.delete() + transformedFile.delete() + } +} From 33b8ec2ef9da8d3b706312ecde2cc2c0a0e86b43 Mon Sep 17 00:00:00 2001 From: Tomek Zebrowski Date: Tue, 7 Apr 2026 22:07:44 +0200 Subject: [PATCH 3/4] feat: conditionally enable drive auto-sync --- app/src/main/res/menu/left_nav_menu.xml | 2 +- app/src/main/res/values-pl/strings.xml | 6 ++- app/src/main/res/values/strings.xml | 8 +++- app/src/main/res/xml/preferences.xml | 20 ++++++++-- .../gcp/gdrive/TripCloudSyncWorker.kt | 38 +++++++++++-------- 5 files changed, 52 insertions(+), 22 deletions(-) diff --git a/app/src/main/res/menu/left_nav_menu.xml b/app/src/main/res/menu/left_nav_menu.xml index 74e4ceea7..93e890b7f 100644 --- a/app/src/main/res/menu/left_nav_menu.xml +++ b/app/src/main/res/menu/left_nav_menu.xml @@ -258,7 +258,7 @@ + android:title="@string/pref.trips.title" /> Włącz usunięcie metryki przesunięciem Alarm dla najwyższych wartości Trasy - Nagrane trasy + Nagrane trasy + + Automatyczna synchronizacja z Dyskiem Google + Automatycznie przesyłaj nowe przejazdy na Dysk Google po połączeniu z siecią Wi-Fi. + W tej sekcji możesz dostosować ustawienia związane z widokami Gauge, Wykres i Giulia. Widoki Pasek narzędzi diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index aee17d15c..70bcfe077 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2,6 +2,9 @@ Open Close + + + Gauge label top offset Top margin @@ -423,7 +426,10 @@ Enable swipe to delete metric Alarm for highest values Trips - Recorded trips + Recorded trips + Auto-Sync to Google Drive + Automatically upload new trips to Google Drive when connected to Wi-Fi. + In this section, you can adjust settings related to the Gauge, Graph, and Giulia views. Views Toolbar diff --git a/app/src/main/res/xml/preferences.xml b/app/src/main/res/xml/preferences.xml index 2bb10247b..335806742 100644 --- a/app/src/main/res/xml/preferences.xml +++ b/app/src/main/res/xml/preferences.xml @@ -8,6 +8,16 @@ android:summary="@string/pref.language.summary" android:persistent="false" /> + + + + - + + + - + () - .setConstraints(constraints) - .build() + if (enabled) { + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.UNMETERED) + .setRequiresBatteryNotLow(true) + .build() - WorkManager.getInstance(context).enqueueUniqueWork( - SYNC_WORK_NAME, - ExistingWorkPolicy.KEEP, - syncRequest - ) + val syncRequest = OneTimeWorkRequestBuilder() + .setConstraints(constraints) + .build() - Log.i(LOG_TAG, "Drive sync scheduled") + WorkManager.getInstance(context).enqueueUniqueWork( + SYNC_WORK_NAME, + ExistingWorkPolicy.KEEP, + syncRequest + ) + + Log.i(LOG_TAG, "Drive auto-sync is scheduled") + } else { + Log.i(LOG_TAG, "Skipping Trips Drive auto-sync") + } } catch (e: Exception) { - Log.e(LOG_TAG, "Failed to schedule drive sync", e) + Log.e(LOG_TAG, "Failed to schedule drive auto-sync", e) } } } From ddf6d5ef7d1854b47e87de991915be999ee5df43 Mon Sep 17 00:00:00 2001 From: Tomek Zebrowski Date: Wed, 8 Apr 2026 14:45:36 +0200 Subject: [PATCH 4/4] feat: manual-upload: mark files as synced once uploaded. --- .../obd/graphs/activity/NavigationRouter.kt | 4 +- .../graphs/preferences/PreferencesFragment.kt | 4 +- .../trips/TripLogListDialogFragment.kt | 43 ++++++++++++++++++- ...DriveManager.kt => ManualTripLogUpload.kt} | 8 +++- .../gcp/gdrive/TripLogDriveManager.kt | 4 +- .../gcp/gdrive/TripLogDriveManagerTest.kt | 2 +- 6 files changed, 54 insertions(+), 11 deletions(-) rename integrations/src/main/java/org/obd/graphs.integrations/gcp/gdrive/{DefaultTripLogDriveManager.kt => ManualTripLogUpload.kt} (91%) diff --git a/app/src/main/java/org/obd/graphs/activity/NavigationRouter.kt b/app/src/main/java/org/obd/graphs/activity/NavigationRouter.kt index e5829b2a2..e0c13f304 100644 --- a/app/src/main/java/org/obd/graphs/activity/NavigationRouter.kt +++ b/app/src/main/java/org/obd/graphs/activity/NavigationRouter.kt @@ -33,7 +33,7 @@ import org.obd.graphs.preferences.PREFERENCE_SCREEN_KEY_GRAPH import org.obd.graphs.preferences.PREFERENCE_SCREEN_KEY_PERFORMANCE import org.obd.graphs.preferences.PREFERENCE_SCREEN_KEY_TRIP_INFO import org.obd.graphs.preferences.PREF_GAUGE_TRIPS -import org.obd.graphs.preferences.PREF_LOGS +import org.obd.graphs.preferences.PREF_TRIP_LOGS import org.obd.graphs.preferences.Prefs import org.obd.graphs.preferences.getS import org.obd.graphs.preferences.getStringSet @@ -109,7 +109,7 @@ internal object NavigationRouter { R.id.nav_preferences -> navigateToPreferencesScreen("pref.root") R.id.navigation_adapter_connection -> navigateToPreferencesScreen("pref.adapter.connection") R.id.navigation_adapter_settings -> navigateToPreferencesScreen("pref.adapter") - R.id.navigation_trip_logs -> navigateToPreferencesScreen(PREF_LOGS) + R.id.navigation_trip_logs -> navigateToPreferencesScreen(PREF_TRIP_LOGS) R.id.navigation_giulia -> navigateToScreen(R.id.nav_giulia) R.id.navigation_graph -> navigateToScreen(R.id.nav_graph) diff --git a/app/src/main/java/org/obd/graphs/preferences/PreferencesFragment.kt b/app/src/main/java/org/obd/graphs/preferences/PreferencesFragment.kt index a8d8fae00..fa8adac89 100644 --- a/app/src/main/java/org/obd/graphs/preferences/PreferencesFragment.kt +++ b/app/src/main/java/org/obd/graphs/preferences/PreferencesFragment.kt @@ -60,7 +60,7 @@ const val PREFERENCE_SCREEN_KEY = "preferences.rootKey" const val PREFS_CONNECTION_TYPE_CHANGED_EVENT = "prefs.connection_type.changed.event" const val PREF_GAUGE_TRIPS = "pref.gauge.recordings" -const val PREF_LOGS = "pref.trip_logs" +const val PREF_TRIP_LOGS = "pref.trip_logs" const val PREFERENCE_CONNECTION_TYPE = "pref.adapter.connection.type" private const val LOG_KEY = "Prefs" @@ -278,7 +278,7 @@ class PreferencesFragment : PreferenceFragmentCompat() { private fun openPreferenceDialogFor(preferenceKey: String) { when (preferenceKey) { PREF_GAUGE_TRIPS -> TripLogListDialogFragment(enableUploadCloudButton = false).show(parentFragmentManager, null) - PREF_LOGS -> TripLogListDialogFragment(enableDeleteButtons = false).show(parentFragmentManager, null) + PREF_TRIP_LOGS -> TripLogListDialogFragment(enableDeleteButtons = false).show(parentFragmentManager, null) PREFERENCE_SCREEN_KEY_TRIP_INFO -> openPIDsDialog( diff --git a/app/src/main/java/org/obd/graphs/preferences/trips/TripLogListDialogFragment.kt b/app/src/main/java/org/obd/graphs/preferences/trips/TripLogListDialogFragment.kt index 8ee90a781..603d266a2 100644 --- a/app/src/main/java/org/obd/graphs/preferences/trips/TripLogListDialogFragment.kt +++ b/app/src/main/java/org/obd/graphs/preferences/trips/TripLogListDialogFragment.kt @@ -17,6 +17,9 @@ package org.obd.graphs.preferences.trips import android.annotation.SuppressLint +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent import android.os.Bundle import android.util.Log import android.view.LayoutInflater @@ -33,11 +36,13 @@ import org.obd.graphs.R import org.obd.graphs.SCREEN_LOCK_PROGRESS_EVENT import org.obd.graphs.SCREEN_UNLOCK_PROGRESS_EVENT import org.obd.graphs.TRIPS_UPLOAD_NO_FILES_SELECTED +import org.obd.graphs.TRIPS_UPLOAD_SUCCESSFUL import org.obd.graphs.activity.navigateToScreen import org.obd.graphs.bl.trip.TripFileDesc import org.obd.graphs.bl.trip.tripManager import org.obd.graphs.integrations.gcp.gdrive.TripLogDriveManager import org.obd.graphs.preferences.CoreDialogFragment +import org.obd.graphs.registerReceiver import org.obd.graphs.sendBroadcastEvent import java.io.File @@ -52,6 +57,39 @@ class TripLogListDialogFragment( ) : CoreDialogFragment() { private lateinit var tripLogDriveManager: TripLogDriveManager + private lateinit var adapter: TripViewAdapter + + private var broadcastReceiver = + object : BroadcastReceiver() { + @SuppressLint("NotifyDataSetChanged") + override fun onReceive( + context: Context?, + intent: Intent? + ) { + when (intent?.action) { + TRIPS_UPLOAD_SUCCESSFUL -> { + if (isAdded && isVisible) { + adapter.data = tripManager.findAllTripsBy().map { TripLogDetails(source = it) }.toMutableList() + adapter.notifyDataSetChanged() + } + } + } + } + } + + override fun onPause() { + super.onPause() + requireContext().unregisterReceiver(broadcastReceiver) + } + + override fun onResume() { + super.onResume() + + registerReceiver(activity, broadcastReceiver) { + it.addAction(TRIPS_UPLOAD_SUCCESSFUL) + } + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) tripLogDriveManager = TripLogDriveManager.instance(getString(R.string.ANDROID_WEB_CLIENT_ID), requireActivity(), this) @@ -66,12 +104,13 @@ class TripLogListDialogFragment( requestWindowFeatures() val root = inflater.inflate(R.layout.dialog_trip, container, false) - val adapter = + adapter = TripViewAdapter( context, tripManager.findAllTripsBy().map { TripLogDetails(source = it) }.toMutableList(), enableDeleteButtons ) + val recyclerView: RecyclerView = root.findViewById(R.id.recycler_view) recyclerView.layoutManager = GridLayoutManager(context, 1) recyclerView.adapter = adapter @@ -131,7 +170,7 @@ class TripLogListDialogFragment( sendBroadcastEvent(TRIPS_UPLOAD_NO_FILES_SELECTED) } else { lifecycleScope.launch { - tripLogDriveManager.exportTrips(files) + tripLogDriveManager.uploadTrips(files) } } }.setNegativeButton(no) { dialog, _ -> diff --git a/integrations/src/main/java/org/obd/graphs.integrations/gcp/gdrive/DefaultTripLogDriveManager.kt b/integrations/src/main/java/org/obd/graphs.integrations/gcp/gdrive/ManualTripLogUpload.kt similarity index 91% rename from integrations/src/main/java/org/obd/graphs.integrations/gcp/gdrive/DefaultTripLogDriveManager.kt rename to integrations/src/main/java/org/obd/graphs.integrations/gcp/gdrive/ManualTripLogUpload.kt index 5c65539b2..61ebaad16 100644 --- a/integrations/src/main/java/org/obd/graphs.integrations/gcp/gdrive/DefaultTripLogDriveManager.kt +++ b/integrations/src/main/java/org/obd/graphs.integrations/gcp/gdrive/ManualTripLogUpload.kt @@ -27,13 +27,13 @@ import org.obd.graphs.integrations.gcp.gdrive.DriveHelper.findFolderIdRecursive import org.obd.graphs.sendBroadcastEvent import java.io.File -internal open class DefaultTripLogDriveManager( +internal open class ManualTripLogUpload( webClientId: String, activity: Activity, fragment: Fragment? ) : AbstractDriveManager(webClientId, activity, fragment), TripLogDriveManager { - override suspend fun exportTrips(files: List) = + override suspend fun uploadTrips(files: List) = signInAndExecute("exportTrips") { token -> executeDriveOperation( accessToken = token, @@ -60,6 +60,10 @@ internal open class DefaultTripLogDriveManager( tripDescParser = tripDescParser ) } + + if (!inFile.path.endsWith(".synced")) { + inFile.renameTo(File(inFile.absolutePath + ".synced")) + } } sendBroadcastEvent(TRIPS_UPLOAD_SUCCESSFUL) diff --git a/integrations/src/main/java/org/obd/graphs.integrations/gcp/gdrive/TripLogDriveManager.kt b/integrations/src/main/java/org/obd/graphs.integrations/gcp/gdrive/TripLogDriveManager.kt index 320a3b54b..248fbe3ec 100644 --- a/integrations/src/main/java/org/obd/graphs.integrations/gcp/gdrive/TripLogDriveManager.kt +++ b/integrations/src/main/java/org/obd/graphs.integrations/gcp/gdrive/TripLogDriveManager.kt @@ -21,13 +21,13 @@ import androidx.fragment.app.Fragment import java.io.File interface TripLogDriveManager { - suspend fun exportTrips(files: List) + suspend fun uploadTrips(files: List) companion object { fun instance( webClientId: String, activity: Activity, fragment: Fragment? - ): TripLogDriveManager = DefaultTripLogDriveManager(webClientId, activity, fragment) + ): TripLogDriveManager = ManualTripLogUpload(webClientId, activity, fragment) } } diff --git a/integrations/src/test/java/org/obd/graphs/integrations/gcp/gdrive/TripLogDriveManagerTest.kt b/integrations/src/test/java/org/obd/graphs/integrations/gcp/gdrive/TripLogDriveManagerTest.kt index 3c3e81238..35ee2641e 100644 --- a/integrations/src/test/java/org/obd/graphs/integrations/gcp/gdrive/TripLogDriveManagerTest.kt +++ b/integrations/src/test/java/org/obd/graphs/integrations/gcp/gdrive/TripLogDriveManagerTest.kt @@ -33,7 +33,7 @@ class TripLogDriveManagerTest { private val driveService = mockk(relaxed = true) // Subclass to expose logic wrapped in the executeDriveOperation block - private inner class TestableTripLogManager : DefaultTripLogDriveManager("client", activity, null) { + private inner class TestableTripLogManager : ManualTripLogUpload("client", activity, null) { fun testUploadLogic(files: List) { if (files.isEmpty()) { sendBroadcastEvent(TRIPS_UPLOAD_NO_FILES_SELECTED)