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 e5829b2a..e0c13f30 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/activity/Receivers.kt b/app/src/main/java/org/obd/graphs/activity/Receivers.kt index b0866b2e..4ddd1490 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(this) + } + 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/PreferencesFragment.kt b/app/src/main/java/org/obd/graphs/preferences/PreferencesFragment.kt index a8d8fae0..fa8adac8 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 8ee90a78..603d266a 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/app/src/main/java/org/obd/graphs/preferences/trips/TripViewAdapter.kt b/app/src/main/java/org/obd/graphs/preferences/trips/TripViewAdapter.kt index c17b535c..7e9d1526 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 @@ -28,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 @@ -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 -> diff --git a/app/src/main/res/menu/left_nav_menu.xml b/app/src/main/res/menu/left_nav_menu.xml index 74e4ceea..93e890b7 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 aee17d15..70bcfe07 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 2bb10247..33580674 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" /> + + + + - + + + - + 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 c12b5a04..bb49529e 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 a08d140f..6ab91cf9 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 34f6eba4..9b71490f 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 00000000..2d450192 --- /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 791239cd..94ae5b29 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 a18a0990..18e8151b 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 deleted file mode 100644 index 4a3e2330..00000000 --- a/integrations/src/main/java/org/obd/graphs.integrations/gcp/gdrive/DefaultTripLogDriveManager.kt +++ /dev/null @@ -1,102 +0,0 @@ -/* - * 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.app.Activity -import androidx.fragment.app.Fragment -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.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, - activity: Activity, - fragment: Fragment? -) : AbstractDriveManager(webClientId, activity, fragment), - TripLogDriveManager { - override suspend fun exportTrips(files: List) = - signInAndExecute("exportTrips") { token -> - executeDriveOperation( - accessToken = token, - onFailure = { sendBroadcastEvent(TRIPS_UPLOAD_FAILED) }, - onFinally = { sendBroadcastEvent(SCREEN_UNLOCK_PROGRESS_EVENT) } - ) { drive -> - if (files.isEmpty()) { - 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 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) - } - } - } - - 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/DriveHelper.kt b/integrations/src/main/java/org/obd/graphs.integrations/gcp/gdrive/DriveHelper.kt new file mode 100644 index 00000000..a37b6bb5 --- /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/ManualTripLogUpload.kt b/integrations/src/main/java/org/obd/graphs.integrations/gcp/gdrive/ManualTripLogUpload.kt new file mode 100644 index 00000000..61ebaad1 --- /dev/null +++ b/integrations/src/main/java/org/obd/graphs.integrations/gcp/gdrive/ManualTripLogUpload.kt @@ -0,0 +1,73 @@ +/* + * 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.app.Activity +import androidx.fragment.app.Fragment +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.trip.TripDescParser +import org.obd.graphs.integrations.gcp.gdrive.DriveHelper.findFolderIdRecursive +import org.obd.graphs.sendBroadcastEvent +import java.io.File + +internal open class ManualTripLogUpload( + webClientId: String, + activity: Activity, + fragment: Fragment? +) : AbstractDriveManager(webClientId, activity, fragment), + TripLogDriveManager { + override suspend fun uploadTrips(files: List) = + signInAndExecute("exportTrips") { token -> + executeDriveOperation( + accessToken = token, + onFailure = { sendBroadcastEvent(TRIPS_UPLOAD_FAILED) }, + onFinally = { sendBroadcastEvent(SCREEN_UNLOCK_PROGRESS_EVENT) } + ) { drive -> + if (files.isEmpty()) { + sendBroadcastEvent(TRIPS_UPLOAD_NO_FILES_SELECTED) + } else { + val folderId = drive.findFolderIdRecursive("mygiulia/trips") + + val transformer = TripUpload.buildTransformer() + val tripDescParser = TripDescParser() + val deviceId = Device.id() + + files.forEach { inFile -> + with(TripUpload) { + drive.transformAndUploadTrip( + inFile = inFile, + cacheDir = activity.cacheDir, + folderId = folderId, + deviceId = deviceId, + transformer = transformer, + 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/TripCloudSyncWorker.kt b/integrations/src/main/java/org/obd/graphs.integrations/gcp/gdrive/TripCloudSyncWorker.kt new file mode 100644 index 00000000..5396553d --- /dev/null +++ b/integrations/src/main/java/org/obd/graphs.integrations/gcp/gdrive/TripCloudSyncWorker.kt @@ -0,0 +1,170 @@ +/* + * 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.ExistingWorkPolicy +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.integrations.gcp.authorization.SilentAuthorization +import org.obd.graphs.integrations.gcp.gdrive.DriveHelper.findFolderIdRecursive +import org.obd.graphs.integrations.log.OutputType +import org.obd.graphs.integrations.log.TripLog +import org.obd.graphs.preferences.Prefs +import org.obd.graphs.preferences.isEnabled +import java.io.File + +private const val LOG_TAG = "TripCloudSyncWorker" +private const val SYNC_WORK_NAME = "TripCloudSync" + +object DriveSync { + + fun start(context: Context) { + try { + val enabled = Prefs.isEnabled("pref.trips.drive.auto_sync") + + Log.i(LOG_TAG, "Received trips drive auto-sync request, sync enabled=$enabled") + + if (enabled) { + 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 + ) + + 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 auto-sync", e) + } + } +} + +internal class TripCloudSyncWorker( + appContext: Context, + workerParams: WorkerParameters +) : CoroutineWorker(appContext, workerParams) { + + 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() + + Log.i(LOG_TAG, "Number of files to sync: ${unsyncedFiles.size}") + + if (unsyncedFiles.isEmpty()) { + return@withContext Result.success() + } + + 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}") + + with(TripUpload) { + driveService.transformAndUploadTrip( + inFile = inFile, + cacheDir = context.cacheDir, + folderId = folderId, + deviceId = deviceId, + transformer = transformer, + tripDescParser = tripDescParser + ) + } + + 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() + } + } +} 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 320a3b54..248fbe3e 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/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 00000000..82f53f00 --- /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() + } +} 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 3c3e8123..35ee2641 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)