Skip to content

Commit b2fefcc

Browse files
committed
feat: add trip logs auto sync
1 parent 5200908 commit b2fefcc

14 files changed

Lines changed: 347 additions & 100 deletions

File tree

app/src/main/java/org/obd/graphs/activity/Receivers.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import org.obd.graphs.ScreenLock
5454
import org.obd.graphs.TRIPS_UPLOAD_FAILED
5555
import org.obd.graphs.TRIPS_UPLOAD_NO_FILES_SELECTED
5656
import org.obd.graphs.TRIPS_UPLOAD_SUCCESSFUL
57+
import org.obd.graphs.TRIP_LOG_WRITE_COMPLETED
5758
import org.obd.graphs.bl.datalogger.DATA_LOGGER_ADAPTER_NOT_SET_EVENT
5859
import org.obd.graphs.bl.datalogger.DATA_LOGGER_CONNECTED_EVENT
5960
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
7576
import org.obd.graphs.bl.extra.EVENT_VEHICLE_STATUS_VEHICLE_RUNNING
7677
import org.obd.graphs.getContext
7778
import org.obd.graphs.getSerializableCompat
79+
import org.obd.graphs.integrations.gcp.gdrive.DriveSync
7880
import org.obd.graphs.preferences.PREFS_CONNECTION_TYPE_CHANGED_EVENT
7981
import org.obd.graphs.preferences.Prefs
8082
import org.obd.graphs.preferences.isEnabled
@@ -101,6 +103,10 @@ private const val EVENT_VEHICLE_STATUS_CHANGED = "event.vehicle.status.CHANGED"
101103

102104
internal fun MainActivity.receive(intent: Intent?) {
103105
when (intent?.action) {
106+
TRIP_LOG_WRITE_COMPLETED -> {
107+
DriveSync.start()
108+
}
109+
104110
DATA_LOGGER_SCHEDULED_STOP_EVENT -> {
105111
Log.d(
106112
LOG_TAG,
@@ -388,6 +394,7 @@ internal fun MainActivity.registerReceiver() {
388394
it.addAction(NAVIGATION_BUTTONS_VISIBILITY_CHANGED)
389395
it.addAction(DATA_LOGGER_SCHEDULED_STOP_EVENT)
390396
it.addAction(PROFILE_NAME_CHANGED_EVENT)
397+
it.addAction(TRIP_LOG_WRITE_COMPLETED)
391398
}
392399

393400
registerReceiver(this, DataLoggerRepository.broadcastReceivers()) {

app/src/main/java/org/obd/graphs/preferences/trips/TripViewAdapter.kt

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ package org.obd.graphs.preferences.trips
1919
import android.content.Context
2020
import android.graphics.Color
2121
import android.graphics.Typeface
22+
import android.text.Spannable
23+
import android.text.SpannableString
24+
import android.text.style.ForegroundColorSpan
2225
import android.util.Log
2326
import android.view.LayoutInflater
2427
import android.view.View
@@ -37,6 +40,7 @@ import org.obd.graphs.ui.common.setText
3740
import java.text.SimpleDateFormat
3841
import java.util.Date
3942
import java.util.Locale
43+
import androidx.core.graphics.toColorInt
4044

4145
private const val LOGGER_KEY = "TripsViewAdapter"
4246

@@ -80,7 +84,28 @@ class TripViewAdapter internal constructor(
8084
startTs = dateFormat.format(Date(it))
8185
}
8286

83-
holder.tripStartDate.setText(startTs, Color.GRAY, Typeface.NORMAL, 0.9f)
87+
source.startTime.toLongOrNull()?.let {
88+
startTs = dateFormat.format(Date(it))
89+
}
90+
91+
if (source.isSynced) {
92+
val syncText = " ☁️ Synced"
93+
val fullText = startTs + syncText
94+
95+
holder.tripStartDate.setText(fullText, Color.GRAY, Typeface.NORMAL, 0.9f)
96+
97+
val spannable = SpannableString(fullText)
98+
spannable.setSpan(
99+
ForegroundColorSpan("#4CAF50".toColorInt()), // A nice Material Green
100+
startTs.length,
101+
fullText.length,
102+
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
103+
)
104+
105+
holder.tripStartDate.text = spannable
106+
} else {
107+
holder.tripStartDate.setText(startTs, Color.GRAY, Typeface.NORMAL, 0.9f)
108+
}
84109

85110
holder.selected.isChecked = checked
86111
holder.selected.setOnCheckedChangeListener { buttonView, isChecked ->
@@ -90,6 +115,7 @@ class TripViewAdapter internal constructor(
90115
}
91116

92117
holder.tripTime.let {
118+
93119
val seconds: Int = source.tripTimeSec.toInt() % 60
94120
var hours: Int = source.tripTimeSec.toInt() / 60
95121
val minutes = hours % 60
@@ -99,6 +125,7 @@ class TripViewAdapter internal constructor(
99125
}:${seconds.toString().padStart(2, '0')}s"
100126

101127
it.setText(text, Color.GRAY, Typeface.BOLD, 0.9f)
128+
102129
}
103130
}
104131
}

common/src/main/java/org/obd/graphs/Constants.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
*/
1717
package org.obd.graphs
1818

19+
const val TRIP_LOG_WRITE_COMPLETED = "trip.log.write.completed.event"
20+
1921
const val LANGUAGE_CHANGE_EVENT = "lang.change.event"
2022

2123
const val SCREEN_LOCK_DIALOG_CANCELLED_EVENT = "screen.lock.dialog.cancelled.event"

datalogger/src/main/java/org/obd/graphs/bl/trip/DefaultTripManager.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import android.util.Log
2121
import org.obd.graphs.SCREEN_LOCK_PROGRESS_EVENT
2222
import org.obd.graphs.SCREEN_UNLOCK_PROGRESS_EVENT
2323
import org.obd.graphs.ScreenLock
24+
import org.obd.graphs.TRIP_LOG_WRITE_COMPLETED
2425
import org.obd.graphs.bl.datalogger.DataLoggerRepository
2526
import org.obd.graphs.bl.datalogger.scaleToRange
2627
import org.obd.graphs.commons.R
@@ -188,6 +189,7 @@ internal class DefaultTripManager : TripManager {
188189

189190
activeTripId = null
190191
ts = System.currentTimeMillis() - ts
192+
sendBroadcastEvent(TRIP_LOG_WRITE_COMPLETED)
191193
Log.i(LOG_TAG, "Trip: $currentTripId is saved. It took $ts ms")
192194
}
193195
} finally {

datalogger/src/main/java/org/obd/graphs/bl/trip/TripDescParser.kt

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,17 +28,21 @@ class TripDescParser {
2828
val startTime = if (p.size > 2) p[2] else ""
2929
val tripTimeSec = if (p.size > 3) p[3] else "0"
3030

31+
val isSynced = fileName.endsWith(".synced")
32+
3133
return TripFileDesc(
3234
fileName = fileName,
3335
profileId = profileId,
3436
profileLabel = profileLabel,
3537
startTime = startTime,
36-
tripTimeSec = tripTimeSec
38+
tripTimeSec = tripTimeSec,
39+
isSynced = isSynced
3740
)
3841
}
3942

4043
fun decodeTripName(fileName: String): List<String> {
41-
val nameWithoutExtension = fileName.substringBeforeLast(".")
44+
val cleanName = fileName.removeSuffix(".synced")
45+
val nameWithoutExtension = cleanName.substringBeforeLast(".")
4246
return nameWithoutExtension.split("-")
4347
}
4448
}

datalogger/src/main/java/org/obd/graphs/bl/trip/TripModel.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ data class TripFileDesc(
2525
val profileId: String,
2626
val profileLabel: String,
2727
val startTime: String,
28-
val tripTimeSec: String
28+
val tripTimeSec: String,
29+
val isSynced: Boolean = false
2930
)
3031

3132
@JsonIgnoreProperties(ignoreUnknown = true)

datalogger/src/main/java/org/obd/graphs/bl/trip/TripRepository.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ internal class FileTripRepository(
148148
.filter {
149149
try {
150150
parser.decodeTripName(it).size > 3
151-
} catch (e: Throwable) {
151+
} catch (_ : Throwable) {
152152
false
153153
}
154154
}.mapNotNull { fileName ->

integrations/build.gradle

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ dependencies {
4646
implementation project(":datalogger")
4747
implementation("com.google.code.gson:gson:2.10.1")
4848

49+
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.7.3"
50+
implementation "androidx.work:work-runtime-ktx:2.9.1"
51+
4952
// This specific library provides lifecycleScope
5053
implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.6.2"
5154
// (Optional) If you are using it in a ViewModel, you might also need:
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/*
2+
* Copyright 2019-2026, Tomasz Żebrowski
3+
*
4+
* <p>Licensed to the Apache Software Foundation (ASF) under one or more contributor license
5+
* agreements. See the NOTICE file distributed with this work for additional information regarding
6+
* copyright ownership. The ASF licenses this file to You under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance with the License. You may obtain a
8+
* copy of the License at
9+
*
10+
* <p>http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* <p>Unless required by applicable law or agreed to in writing, software distributed under the
13+
* License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
14+
* express or implied. See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
package org.obd.graphs.integrations.gcp.authorization
18+
19+
import android.content.Context
20+
import android.util.Log
21+
import com.google.android.gms.auth.api.identity.AuthorizationRequest
22+
import com.google.android.gms.auth.api.identity.Identity
23+
import com.google.android.gms.common.api.Scope
24+
import com.google.api.services.drive.DriveScopes
25+
import kotlinx.coroutines.tasks.await
26+
27+
private const val TAG = "SilentAuth"
28+
29+
internal object SilentAuthorization {
30+
31+
suspend fun getAccessTokenSilently(context: Context): String? {
32+
return try {
33+
val authorizationClient = Identity.getAuthorizationClient(context)
34+
val request = AuthorizationRequest.Builder()
35+
.setRequestedScopes(listOf(Scope(DriveScopes.DRIVE_FILE), Scope(DriveScopes.DRIVE_APPDATA)))
36+
.build()
37+
38+
val result = authorizationClient.authorize(request).await()
39+
40+
if (result.hasResolution()) {
41+
Log.w(TAG, "Silent auth failed: UI resolution required.")
42+
null // We cannot show UI in the background
43+
} else {
44+
Log.i(TAG, "Silent auth successful.")
45+
result.accessToken
46+
}
47+
} catch (e: Exception) {
48+
Log.e(TAG, "Silent auth threw an exception", e)
49+
null
50+
}
51+
}
52+
}

integrations/src/main/java/org/obd/graphs.integrations/gcp/gdrive/AbstractDriveManager.kt

Lines changed: 0 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ import com.google.android.gms.auth.GoogleAuthUtil
2323
import com.google.android.gms.common.api.Scope
2424
import com.google.api.client.googleapis.json.GoogleJsonResponseException
2525
import com.google.api.client.http.AbstractInputStreamContent
26-
import com.google.api.client.http.FileContent
2726
import com.google.api.client.http.HttpRequestInitializer
2827
import com.google.api.client.http.javanet.NetHttpTransport
2928
import com.google.api.client.json.gson.GsonFactory
@@ -32,10 +31,8 @@ import com.google.api.services.drive.DriveScopes
3231
import kotlinx.coroutines.Dispatchers
3332
import kotlinx.coroutines.withContext
3433
import org.obd.graphs.integrations.gcp.authorization.AuthorizationManager
35-
import java.io.File
3634
import java.io.FileNotFoundException
3735
import java.io.InputStream
38-
import com.google.api.services.drive.model.File as DriveFile
3936

4037
private const val APP_NAME = "MyGiuliaBackup"
4138
private const val TAG = "AbstractDriveManager"
@@ -108,98 +105,6 @@ internal abstract class AbstractDriveManager(
108105
}
109106
}
110107

111-
fun Drive.uploadFile(
112-
content: InputStreamContent,
113-
parentFolderId: String
114-
): DriveFile {
115-
Log.i(TAG, "Uploading file ${content.fileName}")
116-
val metadata =
117-
DriveFile().apply {
118-
name = content.fileName
119-
parents = listOf(parentFolderId)
120-
}
121-
122-
val uploaded =
123-
this
124-
.files()
125-
.create(metadata, content)
126-
.setFields("id")
127-
.execute()
128-
129-
Log.i(TAG, "Uploaded ${content.fileName}, ID: ${uploaded.id}")
130-
return uploaded
131-
}
132-
133-
fun Drive.uploadFile(
134-
localFile: File,
135-
fileName: String,
136-
parentFolderId: String,
137-
mimeType: String = "text/plain"
138-
): DriveFile {
139-
Log.i(TAG, "Uploading file ${localFile.absolutePath} to $fileName")
140-
val metadata =
141-
DriveFile().apply {
142-
name = fileName
143-
parents = listOf(parentFolderId)
144-
}
145-
val content = FileContent(mimeType, localFile)
146-
147-
val uploaded =
148-
this
149-
.files()
150-
.create(metadata, content)
151-
.setFields("id")
152-
.execute()
153-
154-
Log.i(TAG, "Uploaded ${localFile.name}, ID: ${uploaded.id} as $fileName")
155-
return uploaded
156-
}
157-
158-
protected fun Drive.findFolderIdRecursive(path: String): String {
159-
val folderNames = path.split("/").filter { it.isNotEmpty() }
160-
var currentParentId = "root"
161-
for (folderName in folderNames) {
162-
currentParentId = findOrCreateSingleFolder(this, folderName, currentParentId)
163-
}
164-
return currentParentId
165-
}
166-
167-
private fun findOrCreateSingleFolder(
168-
drive: Drive,
169-
folderName: String,
170-
parentId: String
171-
): String {
172-
val query =
173-
"mimeType = 'application/vnd.google-apps.folder' and name = '$folderName' and '$parentId' in parents and trashed = false"
174-
175-
val files =
176-
drive
177-
.files()
178-
.list()
179-
.setQ(query)
180-
.setSpaces("drive")
181-
.setFields("files(id, name)")
182-
.execute()
183-
.files
184-
185-
return if (files.isNotEmpty()) {
186-
files.first().id
187-
} else {
188-
val metadata =
189-
DriveFile().apply {
190-
name = folderName
191-
mimeType = "application/vnd.google-apps.folder"
192-
parents = listOf(parentId)
193-
}
194-
drive
195-
.files()
196-
.create(metadata)
197-
.setFields("id")
198-
.execute()
199-
.id
200-
}
201-
}
202-
203108
private fun credentials(accessToken: String): HttpRequestInitializer =
204109
HttpRequestInitializer { request ->
205110
request.headers.authorization = "Bearer $accessToken"

0 commit comments

Comments
 (0)