Put Pit Log under version control so the app and sync server can live on git.

This commit is contained in:
RallyBlock committed 2026-10-22 13:50:38 -07:00
commit 1030bac79e
79 files changed
+8463

No files matched your search

@@ -0,0 +1,265 @@
package com.racingapp.pitlog.data
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
private val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE race_nights ADD COLUMN crossPercent REAL")
db.execSQL("ALTER TABLE race_nights ADD COLUMN springLf INTEGER")
db.execSQL("ALTER TABLE race_nights ADD COLUMN springRf INTEGER")
db.execSQL("ALTER TABLE race_nights ADD COLUMN springLr INTEGER")
db.execSQL("ALTER TABLE race_nights ADD COLUMN springRr INTEGER")
db.execSQL("ALTER TABLE race_nights ADD COLUMN camberLf REAL")
db.execSQL("ALTER TABLE race_nights ADD COLUMN camberRf REAL")
db.execSQL("ALTER TABLE race_nights ADD COLUMN casterLf REAL")
db.execSQL("ALTER TABLE race_nights ADD COLUMN casterRf REAL")
}
}
private val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE race_nights ADD COLUMN swayBarHole INTEGER")
}
}
private val MIGRATION_3_4 = object : Migration(3, 4) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE race_nights ADD COLUMN blockLr REAL")
db.execSQL("ALTER TABLE race_nights ADD COLUMN blockRr REAL")
}
}
private val MIGRATION_4_5 = object : Migration(4, 5) {
override fun migrate(db: SupportSQLiteDatabase) {
db.setForeignKeyConstraintsEnabled(false)
db.execSQL(
"""
CREATE TABLE race_nights_new (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
dateEpochDay INTEGER NOT NULL,
trackName TEXT NOT NULL,
seriesName TEXT NOT NULL,
carName TEXT NOT NULL,
raceClass TEXT NOT NULL,
discipline TEXT NOT NULL,
airTempF INTEGER,
notes TEXT NOT NULL,
createdAt INTEGER NOT NULL,
crossPercent REAL,
springLf INTEGER,
springRf INTEGER,
springLr INTEGER,
springRr INTEGER,
camberLf REAL,
camberRf REAL,
casterLf REAL,
casterRf REAL,
swayBarHole INTEGER,
blockLr REAL,
blockRr REAL
)
""".trimIndent(),
)
db.execSQL(
"""
INSERT INTO race_nights_new (
id, dateEpochDay, trackName, seriesName, carName, raceClass, discipline,
airTempF, notes, createdAt, crossPercent, springLf, springRf, springLr, springRr,
camberLf, camberRf, casterLf, casterRf, swayBarHole, blockLr, blockRr
)
SELECT
id, dateEpochDay, trackName, seriesName, carName, raceClass, discipline,
airTempF, notes, createdAt, crossPercent, springLf, springRf, springLr, springRr,
camberLf, camberRf, casterLf, casterRf, swayBarHole, blockLr, blockRr
FROM race_nights
""".trimIndent(),
)
db.execSQL("DROP TABLE race_nights")
db.execSQL("ALTER TABLE race_nights_new RENAME TO race_nights")
db.setForeignKeyConstraintsEnabled(true)
}
}
private val MIGRATION_5_6 = object : Migration(5, 6) {
override fun migrate(db: SupportSQLiteDatabase) {
db.setForeignKeyConstraintsEnabled(false)
db.execSQL(
"""
CREATE TABLE race_nights_new (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
dateEpochDay INTEGER NOT NULL,
trackName TEXT NOT NULL,
carName TEXT NOT NULL,
raceClass TEXT NOT NULL,
discipline TEXT NOT NULL,
airTempF INTEGER,
notes TEXT NOT NULL,
createdAt INTEGER NOT NULL,
crossPercent REAL,
springLf INTEGER,
springRf INTEGER,
springLr INTEGER,
springRr INTEGER,
camberLf REAL,
camberRf REAL,
casterLf REAL,
casterRf REAL,
swayBarHole INTEGER,
blockLr REAL,
blockRr REAL
)
""".trimIndent(),
)
db.execSQL(
"""
INSERT INTO race_nights_new (
id, dateEpochDay, trackName, carName, raceClass, discipline,
airTempF, notes, createdAt, crossPercent, springLf, springRf, springLr, springRr,
camberLf, camberRf, casterLf, casterRf, swayBarHole, blockLr, blockRr
)
SELECT
id, dateEpochDay, trackName, carName, raceClass, discipline,
airTempF, notes, createdAt, crossPercent, springLf, springRf, springLr, springRr,
camberLf, camberRf, casterLf, casterRf, swayBarHole, blockLr, blockRr
FROM race_nights
""".trimIndent(),
)
db.execSQL("DROP TABLE race_nights")
db.execSQL("ALTER TABLE race_nights_new RENAME TO race_nights")
db.setForeignKeyConstraintsEnabled(true)
}
}
private val MIGRATION_6_7 = object : Migration(6, 7) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS setup_sheets (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
recordedAt INTEGER NOT NULL,
note TEXT NOT NULL,
crossPercent REAL,
springLf INTEGER,
springRf INTEGER,
springLr INTEGER,
springRr INTEGER,
camberLf REAL,
camberRf REAL,
casterLf REAL,
casterRf REAL,
swayBarHole INTEGER,
blockLr REAL,
blockRr REAL,
toeFront REAL,
toeRear REAL
)
""".trimIndent(),
)
}
}
internal val MIGRATION_7_8 = object : Migration(7, 8) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE car_profile ADD COLUMN updatedAt INTEGER NOT NULL DEFAULT 0")
db.execSQL(
"""
UPDATE car_profile SET updatedAt = 1
WHERE carName != '1976 Chevy Camaro'
OR raceClass != 'Hobby Stock'
OR discipline != 'Oval'
""".trimIndent(),
)
addSyncColumns(db, "race_nights")
db.execSQL("UPDATE race_nights SET syncId = lower(hex(randomblob(16))) WHERE syncId = ''")
db.execSQL("UPDATE race_nights SET updatedAt = createdAt WHERE updatedAt = 0 AND createdAt != 0")
addSyncColumns(db, "setup_sheets")
db.execSQL("UPDATE setup_sheets SET syncId = lower(hex(randomblob(16))) WHERE syncId = ''")
db.execSQL("UPDATE setup_sheets SET updatedAt = recordedAt WHERE updatedAt = 0 AND recordedAt != 0")
db.execSQL("ALTER TABLE runs ADD COLUMN syncId TEXT NOT NULL DEFAULT ''")
db.execSQL("ALTER TABLE runs ADD COLUMN nightSyncId TEXT NOT NULL DEFAULT ''")
db.execSQL("ALTER TABLE runs ADD COLUMN updatedAt INTEGER NOT NULL DEFAULT 0")
db.execSQL("ALTER TABLE runs ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0")
db.execSQL("ALTER TABLE runs ADD COLUMN pending INTEGER NOT NULL DEFAULT 1")
db.execSQL("UPDATE runs SET syncId = lower(hex(randomblob(16))) WHERE syncId = ''")
db.execSQL(
"""
UPDATE runs SET nightSyncId = (
SELECT race_nights.syncId FROM race_nights WHERE race_nights.id = runs.nightId
) WHERE nightSyncId = ''
""".trimIndent(),
)
db.execSQL("UPDATE runs SET updatedAt = recordedAt WHERE updatedAt = 0 AND recordedAt != 0")
}
private fun addSyncColumns(db: SupportSQLiteDatabase, table: String) {
db.execSQL("ALTER TABLE $table ADD COLUMN syncId TEXT NOT NULL DEFAULT ''")
db.execSQL("ALTER TABLE $table ADD COLUMN updatedAt INTEGER NOT NULL DEFAULT 0")
db.execSQL("ALTER TABLE $table ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0")
db.execSQL("ALTER TABLE $table ADD COLUMN pending INTEGER NOT NULL DEFAULT 1")
}
}
internal val MIGRATION_8_9 = object : Migration(8, 9) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS cars (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
carName TEXT NOT NULL,
raceClass TEXT NOT NULL,
discipline TEXT NOT NULL,
token TEXT NOT NULL DEFAULT '',
updatedAt INTEGER NOT NULL DEFAULT 0,
syncRev INTEGER NOT NULL DEFAULT 0,
deleted INTEGER NOT NULL DEFAULT 0,
pending INTEGER NOT NULL DEFAULT 1
)
""".trimIndent(),
)
db.execSQL(
"""
INSERT INTO cars (carName, raceClass, discipline, token, updatedAt, syncRev, deleted, pending)
SELECT carName, raceClass, discipline, '', updatedAt, 0, 0, 1 FROM car_profile WHERE id = 1
""".trimIndent(),
)
db.execSQL("ALTER TABLE race_nights ADD COLUMN carId INTEGER NOT NULL DEFAULT 0")
db.execSQL("ALTER TABLE setup_sheets ADD COLUMN carId INTEGER NOT NULL DEFAULT 0")
db.execSQL("UPDATE race_nights SET carId = (SELECT id FROM cars ORDER BY id LIMIT 1) WHERE carId = 0")
db.execSQL("UPDATE setup_sheets SET carId = (SELECT id FROM cars ORDER BY id LIMIT 1) WHERE carId = 0")
}
}
@Database(
entities = [RaceNight::class, RunLog::class, Car::class, SetupSheet::class],
version = 9,
exportSchema = false,
)
abstract class AppDatabase : RoomDatabase() {
abstract fun raceDao(): RaceDao
companion object {
fun create(context: Context): AppDatabase {
return Room.databaseBuilder(context, AppDatabase::class.java, "pitlog.db")
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9)
.addCallback(
object : Callback() {
override fun onCreate(db: SupportSQLiteDatabase) {
super.onCreate(db)
db.execSQL(
"""
INSERT INTO cars (carName, raceClass, discipline, token, updatedAt, syncRev, deleted, pending)
VALUES ('$DEFAULT_CAR', '$DEFAULT_CLASS', '$DEFAULT_DISCIPLINE', lower(hex(randomblob(32))), 1, 0, 0, 1)
""".trimIndent(),
)
}
},
)
.build()
}
}
}
@@ -0,0 +1,5 @@
package com.racingapp.pitlog.data
const val DEFAULT_CAR = "1976 Chevy Camaro"
const val DEFAULT_CLASS = "Hobby Stock"
const val DEFAULT_DISCIPLINE = "Oval"
@@ -0,0 +1,135 @@
package com.racingapp.pitlog.data
import androidx.room.ColumnInfo
import androidx.room.Embedded
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
import androidx.room.Relation
@Entity(tableName = "cars")
data class Car(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val carName: String = DEFAULT_CAR,
val raceClass: String = DEFAULT_CLASS,
val discipline: String = DEFAULT_DISCIPLINE,
@ColumnInfo(defaultValue = "''") val token: String = "",
@ColumnInfo(defaultValue = "0") val updatedAt: Long = 0,
@ColumnInfo(defaultValue = "0") val syncRev: Long = 0,
@ColumnInfo(defaultValue = "0") val deleted: Boolean = false,
@ColumnInfo(defaultValue = "1") val pending: Boolean = true,
)
@Entity(tableName = "setup_sheets")
data class SetupSheet(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val recordedAt: Long,
val note: String = "",
val crossPercent: Double? = null,
val springLf: Int? = null,
val springRf: Int? = null,
val springLr: Int? = null,
val springRr: Int? = null,
val camberLf: Double? = null,
val camberRf: Double? = null,
val casterLf: Double? = null,
val casterRf: Double? = null,
val swayBarHole: Int? = null,
val blockLr: Double? = null,
val blockRr: Double? = null,
val toeFront: Double? = null,
val toeRear: Double? = null,
@ColumnInfo(defaultValue = "''") val syncId: String = "",
@ColumnInfo(defaultValue = "0") val updatedAt: Long = 0,
@ColumnInfo(defaultValue = "0") val deleted: Boolean = false,
@ColumnInfo(defaultValue = "1") val pending: Boolean = true,
@ColumnInfo(defaultValue = "0") val carId: Long = 0,
)
@Entity(tableName = "race_nights")
data class RaceNight(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val dateEpochDay: Long,
val trackName: String,
val carName: String = DEFAULT_CAR,
val raceClass: String = DEFAULT_CLASS,
val discipline: String = DEFAULT_DISCIPLINE,
val airTempF: Int? = null,
val notes: String = "",
val createdAt: Long = 0,
val crossPercent: Double? = null,
val springLf: Int? = null,
val springRf: Int? = null,
val springLr: Int? = null,
val springRr: Int? = null,
val camberLf: Double? = null,
val camberRf: Double? = null,
val casterLf: Double? = null,
val casterRf: Double? = null,
val swayBarHole: Int? = null,
val blockLr: Double? = null,
val blockRr: Double? = null,
@ColumnInfo(defaultValue = "''") val syncId: String = "",
@ColumnInfo(defaultValue = "0") val updatedAt: Long = 0,
@ColumnInfo(defaultValue = "0") val deleted: Boolean = false,
@ColumnInfo(defaultValue = "1") val pending: Boolean = true,
@ColumnInfo(defaultValue = "0") val carId: Long = 0,
)
@Entity(
tableName = "runs",
foreignKeys = [
ForeignKey(
entity = RaceNight::class,
parentColumns = ["id"],
childColumns = ["nightId"],
onDelete = ForeignKey.CASCADE,
),
],
indices = [Index("nightId")],
)
data class RunLog(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val nightId: Long,
val session: String,
val tireSet: String = "",
val laps: Int? = null,
val finish: Int? = null,
val staggerInches: Double? = null,
val coldLf: Double? = null,
val coldRf: Double? = null,
val coldLr: Double? = null,
val coldRr: Double? = null,
val hotLf: Double? = null,
val hotRf: Double? = null,
val hotLr: Double? = null,
val hotRr: Double? = null,
val lfInside: Int? = null,
val lfMiddle: Int? = null,
val lfOutside: Int? = null,
val rfInside: Int? = null,
val rfMiddle: Int? = null,
val rfOutside: Int? = null,
val lrInside: Int? = null,
val lrMiddle: Int? = null,
val lrOutside: Int? = null,
val rrInside: Int? = null,
val rrMiddle: Int? = null,
val rrOutside: Int? = null,
val handling: String = "",
val handlingWhere: String = "",
val notes: String = "",
val recordedAt: Long = 0,
@ColumnInfo(defaultValue = "''") val syncId: String = "",
@ColumnInfo(defaultValue = "''") val nightSyncId: String = "",
@ColumnInfo(defaultValue = "0") val updatedAt: Long = 0,
@ColumnInfo(defaultValue = "0") val deleted: Boolean = false,
@ColumnInfo(defaultValue = "1") val pending: Boolean = true,
)
data class NightWithRuns(
@Embedded val night: RaceNight,
@Relation(parentColumn = "id", entityColumn = "nightId")
val runs: List<RunLog>,
)
@@ -0,0 +1,253 @@
package com.racingapp.pitlog.data
import android.content.Context
import androidx.work.Constraints
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import java.net.HttpURLConnection
import java.net.URL
import java.time.LocalTime
import java.time.format.DateTimeFormatter
import java.util.UUID
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.json.JSONObject
class SyncException(message: String) : Exception(message)
private const val WORK_NAME = "pitlog-sync"
class PitSync(
private val appContext: Context,
private val dao: RaceDao,
private val preferences: SyncPreferences,
) {
val address: String get() = preferences.address
val status: String get() = preferences.status
val automatic: Boolean get() = preferences.automatic
private val gate = Mutex()
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
fun saveAddress(address: String) {
preferences.address = address.trim().trimEnd('/')
schedule()
}
fun setAutomatic(enabled: Boolean) {
preferences.automatic = enabled
schedule()
}
fun schedule() {
try {
val manager = WorkManager.getInstance(appContext)
if (!preferences.automatic || preferences.address.isBlank()) {
manager.cancelUniqueWork(WORK_NAME)
return
}
val request = PeriodicWorkRequestBuilder<SyncWorker>(15, TimeUnit.MINUTES)
.setConstraints(
Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build(),
)
.build()
manager.enqueueUniquePeriodicWork(WORK_NAME, ExistingPeriodicWorkPolicy.UPDATE, request)
} catch (_: Exception) {
// Unit tests do not start WorkManager.
}
}
fun launchAutomatic() {
scope.launch { syncAutomatic() }
}
suspend fun syncAutomatic() {
if (!preferences.automatic) return
if (preferences.address.isBlank()) return
try {
sync()
} catch (error: SyncException) {
preferences.status = error.message ?: "Couldn't sync."
} catch (_: Exception) {
preferences.status = "Couldn't reach the server."
}
}
suspend fun run(): String = try {
sync()
} catch (error: SyncException) {
preferences.status = error.message ?: "Couldn't sync."
preferences.status
} catch (_: Exception) {
preferences.status = "Couldn't reach the server."
preferences.status
}
private suspend fun sync(): String = gate.withLock { withContext(Dispatchers.IO) {
val base = preferences.address
if (!base.startsWith("http://") && !base.startsWith("https://")) {
throw SyncException("The address needs to start with http:// or https://.")
}
preferences.lastAttempt = System.currentTimeMillis()
val cars = dao.activeCars()
if (cars.isEmpty()) throw SyncException("Add a car first.")
val errors = mutableListOf<String>()
var synced = 0
cars.forEach { car ->
try {
syncCar(base, car)
synced += 1
} catch (error: SyncException) {
errors.add("${car.carName}: ${error.message}")
}
}
val clock = DateTimeFormatter.ofPattern("h:mm a").format(LocalTime.now())
val message = when {
errors.isEmpty() -> "Synced at $clock."
synced == 0 -> errors.first()
else -> "Synced $synced cars at $clock. ${errors.first()}"
}
preferences.status = message
if (synced == 0) throw SyncException(message)
message
} }
private suspend fun syncCar(base: String, car: Car) {
val token = car.token.ifBlank { newCarToken().also { dao.updateCar(car.copy(token = it)) } }
val current = dao.carById(car.id) ?: car
ensureIds(current.id)
val nights = dao.pendingNights(current.id)
val runs = dao.pendingRuns(current.id)
val sheets = dao.pendingSheets(current.id)
val body = encodeSync(current.syncRev, current, nights, runs, sheets)
val response = decodeSync(post(base, token, body))
apply(current, response)
clearAccepted(response.accepted, nights, runs, sheets)
val after = dao.carById(current.id) ?: current
dao.updateCar(after.copy(syncRev = response.rev))
}
private suspend fun ensureIds(carId: Long) {
dao.nightsMissingSyncId(carId).forEach { night ->
dao.updateNight(night.copy(syncId = newSyncId(), pending = true))
}
dao.sheetsMissingSyncId(carId).forEach { sheet ->
dao.updateSetupSheet(sheet.copy(syncId = newSyncId(), pending = true))
}
dao.runsMissingSyncId(carId).forEach { run ->
val night = dao.nightById(run.nightId)
dao.updateRun(
run.copy(
syncId = run.syncId.ifBlank { newSyncId() },
nightSyncId = run.nightSyncId.ifBlank { night?.syncId.orEmpty() },
pending = true,
),
)
}
}
private suspend fun apply(car: Car, response: SyncResponse) {
val localCar = dao.carById(car.id) ?: car
val remoteProfile = response.profile
if (remoteProfile != null && remoteProfile.updatedAt >= localCar.updatedAt) {
dao.updateCar(
localCar.copy(
carName = remoteProfile.carName.ifBlank { localCar.carName },
raceClass = remoteProfile.raceClass.ifBlank { localCar.raceClass },
discipline = remoteProfile.discipline.ifBlank { localCar.discipline },
updatedAt = remoteProfile.updatedAt,
pending = false,
),
)
}
response.nights.forEach { remote ->
val local = dao.nightBySyncId(remote.syncId, car.id)
val placed = remote.copy(id = local?.id ?: 0, carId = car.id, pending = false)
when {
local == null -> dao.insertNight(placed)
remote.updatedAt >= local.updatedAt -> dao.updateNight(placed)
!local.pending -> dao.updateNight(local.copy(pending = true))
}
}
response.runs.forEach { remote ->
val night = dao.nightBySyncId(remote.nightSyncId, car.id)
?: throw SyncException("A run came in without its race night. Sync again.")
val local = dao.runBySyncId(remote.syncId)
val placed = remote.copy(id = local?.id ?: 0, nightId = night.id, pending = false)
when {
local == null -> dao.insertRun(placed)
remote.updatedAt >= local.updatedAt -> dao.updateRun(placed)
!local.pending -> dao.updateRun(local.copy(pending = true))
}
}
response.sheets.forEach { remote ->
val local = dao.sheetBySyncId(remote.syncId, car.id)
val placed = remote.copy(id = local?.id ?: 0, carId = car.id, pending = false)
when {
local == null -> dao.insertSetupSheet(placed)
remote.updatedAt >= local.updatedAt -> dao.updateSetupSheet(placed)
!local.pending -> dao.updateSetupSheet(local.copy(pending = true))
}
}
}
private suspend fun clearAccepted(
accepted: Set<String>,
nights: List<RaceNight>,
runs: List<RunLog>,
sheets: List<SetupSheet>,
) {
nights.filter { it.syncId in accepted }.forEach { sent ->
val current = dao.nightBySyncId(sent.syncId, sent.carId) ?: return@forEach
if (current.pending && current.updatedAt == sent.updatedAt) {
dao.updateNight(current.copy(pending = false))
}
}
runs.filter { it.syncId in accepted }.forEach { sent ->
val current = dao.runBySyncId(sent.syncId) ?: return@forEach
if (current.pending && current.updatedAt == sent.updatedAt) {
dao.updateRun(current.copy(pending = false))
}
}
sheets.filter { it.syncId in accepted }.forEach { sent ->
val current = dao.sheetBySyncId(sent.syncId, sent.carId) ?: return@forEach
if (current.pending && current.updatedAt == sent.updatedAt) {
dao.updateSetupSheet(current.copy(pending = false))
}
}
}
private fun post(base: String, token: String, body: String): String {
val connection = (URL("$base/v1/sync").openConnection() as HttpURLConnection).apply {
requestMethod = "POST"
connectTimeout = 20_000
readTimeout = 20_000
doOutput = true
setRequestProperty("Content-Type", "application/json; charset=utf-8")
setRequestProperty("Authorization", "Bearer $token")
setRequestProperty("User-Agent", "PitLog")
}
val bytes = body.toByteArray(Charsets.UTF_8)
connection.setFixedLengthStreamingMode(bytes.size)
connection.outputStream.use { it.write(bytes) }
val stream = if (connection.responseCode in 200..299) connection.inputStream else connection.errorStream
val text = stream?.bufferedReader()?.use { it.readText() }.orEmpty()
connection.disconnect()
if (connection.responseCode == 401) throw SyncException("The token was refused.")
if (connection.responseCode !in 200..299) {
val message = runCatching { JSONObject(text).optString("error") }.getOrNull().orEmpty()
throw SyncException(message.ifBlank { "The server couldn't save that sync." })
}
return text
}
private fun newSyncId(): String = UUID.randomUUID().toString()
}
@@ -0,0 +1,143 @@
package com.racingapp.pitlog.data
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Update
import kotlinx.coroutines.flow.Flow
@Dao
interface RaceDao {
@Transaction
@Query("SELECT * FROM race_nights WHERE deleted = 0 AND carId = :carId ORDER BY dateEpochDay DESC, id DESC")
fun observeNightsWithRuns(carId: Long): Flow<List<NightWithRuns>>
@Query("SELECT * FROM race_nights WHERE id = :id AND deleted = 0")
fun observeNight(id: Long): Flow<RaceNight?>
@Query("SELECT * FROM race_nights WHERE id = :id AND deleted = 0")
suspend fun getNight(id: Long): RaceNight?
@Query("SELECT * FROM race_nights WHERE id = :id")
suspend fun nightById(id: Long): RaceNight?
@Query("SELECT * FROM race_nights WHERE syncId = :syncId AND carId = :carId LIMIT 1")
suspend fun nightBySyncId(syncId: String, carId: Long): RaceNight?
@Query("SELECT * FROM race_nights WHERE pending = 1 AND carId = :carId")
suspend fun pendingNights(carId: Long): List<RaceNight>
@Query("SELECT * FROM race_nights WHERE syncId = '' AND carId = :carId")
suspend fun nightsMissingSyncId(carId: Long): List<RaceNight>
@Query(
"""
SELECT trackName FROM race_nights
WHERE deleted = 0 AND carId = :carId AND TRIM(trackName) != ''
GROUP BY trackName COLLATE NOCASE
ORDER BY MAX(dateEpochDay) DESC, trackName COLLATE NOCASE
""",
)
fun observeTrackNames(carId: Long): Flow<List<String>>
@Insert
suspend fun insertNight(night: RaceNight): Long
@Update
suspend fun updateNight(night: RaceNight)
@Query("SELECT * FROM runs WHERE nightId = :nightId AND deleted = 0 ORDER BY recordedAt DESC")
fun observeRuns(nightId: Long): Flow<List<RunLog>>
@Query("SELECT * FROM runs WHERE nightId = :nightId")
suspend fun runsForNight(nightId: Long): List<RunLog>
@Query("SELECT * FROM runs WHERE id = :id AND deleted = 0")
fun observeRun(id: Long): Flow<RunLog?>
@Query("SELECT * FROM runs WHERE id = :id AND deleted = 0")
suspend fun getRun(id: Long): RunLog?
@Query("SELECT * FROM runs WHERE id = :id")
suspend fun runById(id: Long): RunLog?
@Query("SELECT * FROM runs WHERE syncId = :syncId LIMIT 1")
suspend fun runBySyncId(syncId: String): RunLog?
@Query(
"""
SELECT * FROM runs
WHERE pending = 1 AND nightId IN (SELECT id FROM race_nights WHERE carId = :carId)
""",
)
suspend fun pendingRuns(carId: Long): List<RunLog>
@Query(
"""
SELECT * FROM runs
WHERE (syncId = '' OR nightSyncId = '')
AND nightId IN (SELECT id FROM race_nights WHERE carId = :carId)
""",
)
suspend fun runsMissingSyncId(carId: Long): List<RunLog>
@Query("SELECT * FROM runs WHERE nightId = :nightId AND deleted = 0 ORDER BY recordedAt DESC LIMIT 1")
suspend fun latestRun(nightId: Long): RunLog?
@Query(
"SELECT * FROM runs WHERE nightId = :nightId AND deleted = 0 AND recordedAt < :before ORDER BY recordedAt DESC LIMIT 1",
)
suspend fun previousRun(nightId: Long, before: Long): RunLog?
@Insert
suspend fun insertRun(run: RunLog): Long
@Update
suspend fun updateRun(run: RunLog)
@Query("SELECT * FROM setup_sheets WHERE deleted = 0 AND carId = :carId ORDER BY recordedAt ASC, id ASC")
fun observeSetupSheets(carId: Long): Flow<List<SetupSheet>>
@Query("SELECT * FROM setup_sheets WHERE id = :id")
suspend fun sheetById(id: Long): SetupSheet?
@Query("SELECT * FROM setup_sheets WHERE syncId = :syncId AND carId = :carId LIMIT 1")
suspend fun sheetBySyncId(syncId: String, carId: Long): SetupSheet?
@Query("SELECT * FROM setup_sheets WHERE pending = 1 AND carId = :carId")
suspend fun pendingSheets(carId: Long): List<SetupSheet>
@Query("SELECT * FROM setup_sheets WHERE syncId = '' AND carId = :carId")
suspend fun sheetsMissingSyncId(carId: Long): List<SetupSheet>
@Insert
suspend fun insertSetupSheet(sheet: SetupSheet): Long
@Update
suspend fun updateSetupSheet(sheet: SetupSheet)
@Query("SELECT * FROM cars WHERE deleted = 0 ORDER BY id ASC")
fun observeCars(): Flow<List<Car>>
@Query("SELECT * FROM cars ORDER BY id ASC")
suspend fun everyCar(): List<Car>
@Query("SELECT * FROM cars WHERE deleted = 0 ORDER BY id ASC")
suspend fun activeCars(): List<Car>
@Query("SELECT * FROM cars WHERE id = :id")
fun observeCar(id: Long): Flow<Car?>
@Query("SELECT * FROM cars WHERE id = :id")
suspend fun carById(id: Long): Car?
@Query("SELECT * FROM cars WHERE token = :token LIMIT 1")
suspend fun carByToken(token: String): Car?
@Insert
suspend fun insertCar(car: Car): Long
@Update
suspend fun updateCar(car: Car)
}
@@ -0,0 +1,270 @@
package com.racingapp.pitlog.data
import java.security.SecureRandom
import java.util.UUID
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
suspend fun ensureCars(dao: RaceDao, preferences: SyncPreferences) {
val cars = dao.everyCar()
if (cars.isEmpty()) {
val id = dao.insertCar(
Car(
carName = DEFAULT_CAR,
raceClass = DEFAULT_CLASS,
discipline = DEFAULT_DISCIPLINE,
token = newCarToken(),
updatedAt = 1,
pending = true,
),
)
preferences.selectedCarId = id
return
}
val first = cars.firstOrNull { !it.deleted } ?: cars.first()
if (first.token.isBlank()) {
val kept = preferences.token.trim().ifBlank { newCarToken() }
val rev = if (kept == preferences.token.trim() && first.syncRev == 0L) preferences.rev else first.syncRev
dao.updateCar(first.copy(token = kept, syncRev = rev, pending = true))
}
val selected = preferences.selectedCarId
if (selected == 0L || cars.none { it.id == selected && !it.deleted }) {
preferences.selectedCarId = first.id
}
}
fun newCarToken(): String {
val bytes = ByteArray(32)
SecureRandom().nextBytes(bytes)
return bytes.joinToString("") { "%02x".format(it) }
}
fun cleanCarToken(raw: String): String {
val line = raw.trim().lineSequence().firstOrNull().orEmpty().trim()
val withoutLabel = if (line.startsWith("token:", ignoreCase = true)) line.substringAfter(":").trim() else line
return withoutLabel.lowercase()
}
class Repository(
private val dao: RaceDao,
private val sync: PitSync? = null,
private val preferences: SyncPreferences? = null,
) {
private val selected = MutableStateFlow(preferences?.selectedCarId ?: 0L)
fun observeCars(): Flow<List<Car>> = dao.observeCars()
fun observeSelectedCar(): Flow<Car?> = selected.flatMapLatest { id ->
if (id == 0L) flowOf(null) else dao.observeCar(id)
}.map { car -> car?.takeUnless { it.deleted } }
fun observeNightsWithRuns(): Flow<List<NightWithRuns>> = selected.flatMapLatest { carId ->
dao.observeNightsWithRuns(carId).map { nights ->
nights.map { item -> item.copy(runs = item.runs.filter { !it.deleted }) }
}
}
fun observeNight(id: Long): Flow<RaceNight?> = dao.observeNight(id)
fun observeRuns(nightId: Long): Flow<List<RunLog>> = dao.observeRuns(nightId)
fun observeRun(id: Long): Flow<RunLog?> = dao.observeRun(id)
fun observeSetupSheets(): Flow<List<SetupSheet>> = selected.flatMapLatest { carId ->
dao.observeSetupSheets(carId)
}
suspend fun saveOriginalSetup(sheet: SetupSheet) {
val stamped = stampSheet(sheet)
if (stamped.id == 0L) dao.insertSetupSheet(stamped) else dao.updateSetupSheet(stamped)
sync?.launchAutomatic()
}
suspend fun logSetupChange(sheet: SetupSheet) {
dao.insertSetupSheet(stampSheet(sheet))
sync?.launchAutomatic()
}
fun observeTrackNames(): Flow<List<String>> = selected.flatMapLatest { carId ->
dao.observeTrackNames(carId)
}
suspend fun getNight(id: Long): RaceNight? = dao.getNight(id)
suspend fun getRun(id: Long): RunLog? = dao.getRun(id)
suspend fun latestRun(nightId: Long): RunLog? = dao.latestRun(nightId)
suspend fun previousRun(nightId: Long, before: Long): RunLog? = dao.previousRun(nightId, before)
fun selectCar(id: Long) {
selected.value = id
preferences?.selectedCarId = id
}
suspend fun selectedCar(): Car? = dao.carById(selected.value)?.takeUnless { it.deleted }
suspend fun addCar(): Car {
val now = System.currentTimeMillis()
val car = Car(
carName = "New car",
raceClass = DEFAULT_CLASS,
discipline = DEFAULT_DISCIPLINE,
token = newCarToken(),
updatedAt = now,
pending = true,
)
val id = dao.insertCar(car)
selectCar(id)
sync?.launchAutomatic()
return car.copy(id = id)
}
suspend fun joinCar(raw: String): String {
val token = cleanCarToken(raw)
if (token.length < 16 || token.any { it !in '0'..'9' && it !in 'a'..'f' }) {
return "That token doesn't look right."
}
val existing = dao.carByToken(token)
if (existing != null) {
if (existing.deleted) dao.updateCar(existing.copy(deleted = false))
selectCar(existing.id)
return ""
}
val id = dao.insertCar(
Car(
carName = "Shared car",
raceClass = DEFAULT_CLASS,
discipline = DEFAULT_DISCIPLINE,
token = token,
updatedAt = 0,
pending = false,
),
)
selectCar(id)
return ""
}
suspend fun removeCar(id: Long) {
val car = dao.carById(id) ?: return
dao.updateCar(car.copy(deleted = true))
if (selected.value == id) {
val next = dao.activeCars().firstOrNull { it.id != id } ?: return
selectCar(next.id)
}
}
suspend fun saveNight(night: RaceNight): Long {
val stamped = stampNight(night)
return if (stamped.id == 0L) {
dao.insertNight(stamped)
} else {
dao.updateNight(stamped)
stamped.id
}.also { sync?.launchAutomatic() }
}
suspend fun deleteNight(id: Long) {
val night = dao.nightById(id) ?: return
val now = System.currentTimeMillis()
dao.updateNight(night.copy(deleted = true, pending = true, updatedAt = now))
dao.runsForNight(id).forEach { run ->
dao.updateRun(run.copy(deleted = true, pending = true, updatedAt = now))
}
sync?.launchAutomatic()
}
suspend fun saveRun(run: RunLog): Long {
val stamped = stampRun(run)
return if (stamped.id == 0L) {
dao.insertRun(stamped)
} else {
dao.updateRun(stamped)
stamped.id
}.also { sync?.launchAutomatic() }
}
suspend fun deleteRun(id: Long) {
val run = dao.runById(id) ?: return
dao.updateRun(run.copy(deleted = true, pending = true, updatedAt = System.currentTimeMillis()))
sync?.launchAutomatic()
}
suspend fun saveCar(car: Car) {
val existing = dao.carById(car.id) ?: return
dao.updateCar(
existing.copy(
carName = car.carName,
raceClass = car.raceClass,
discipline = car.discipline,
updatedAt = System.currentTimeMillis(),
pending = true,
),
)
sync?.launchAutomatic()
}
fun syncAddress(): String = sync?.address.orEmpty()
fun syncStatus(): String = sync?.status.orEmpty()
fun saveAddress(address: String) {
sync?.saveAddress(address)
}
fun syncAutomaticEnabled(): Boolean = sync?.automatic ?: true
fun setSyncAutomatic(enabled: Boolean) {
sync?.setAutomatic(enabled)
}
suspend fun syncNow(): String = sync?.run() ?: "Sync isn't available on this phone."
suspend fun syncAutomatic() {
sync?.syncAutomatic()
}
private suspend fun stampNight(night: RaceNight): RaceNight {
val existing = if (night.id != 0L) dao.nightById(night.id) else null
val carId = night.carId.takeIf { it != 0L } ?: existing?.carId?.takeIf { it != 0L } ?: selected.value
return night.copy(
carId = carId,
syncId = night.syncId.ifBlank { existing?.syncId?.ifBlank { null } ?: newSyncId() },
updatedAt = System.currentTimeMillis(),
pending = true,
)
}
private suspend fun stampRun(run: RunLog): RunLog {
val existing = if (run.id != 0L) dao.runById(run.id) else null
val night = dao.nightById(run.nightId)
var nightSyncId = run.nightSyncId.ifBlank { existing?.nightSyncId?.ifBlank { null } ?: night?.syncId.orEmpty() }
if (nightSyncId.isBlank() && night != null) {
val fixed = night.copy(syncId = newSyncId(), pending = true, updatedAt = System.currentTimeMillis())
dao.updateNight(fixed)
nightSyncId = fixed.syncId
}
return run.copy(
syncId = run.syncId.ifBlank { existing?.syncId?.ifBlank { null } ?: newSyncId() },
nightSyncId = nightSyncId,
updatedAt = System.currentTimeMillis(),
pending = true,
)
}
private suspend fun stampSheet(sheet: SetupSheet): SetupSheet {
val existing = if (sheet.id != 0L) dao.sheetById(sheet.id) else null
val carId = sheet.carId.takeIf { it != 0L } ?: existing?.carId?.takeIf { it != 0L } ?: selected.value
return sheet.copy(
carId = carId,
syncId = sheet.syncId.ifBlank { existing?.syncId?.ifBlank { null } ?: newSyncId() },
updatedAt = System.currentTimeMillis(),
pending = true,
)
}
private fun newSyncId(): String = UUID.randomUUID().toString()
}
@@ -0,0 +1,241 @@
package com.racingapp.pitlog.data
import org.json.JSONArray
import org.json.JSONObject
data class SyncResponse(
val rev: Long,
val accepted: Set<String>,
val profile: Car?,
val nights: List<RaceNight>,
val runs: List<RunLog>,
val sheets: List<SetupSheet>,
)
fun encodeSync(
rev: Long,
profile: Car,
nights: List<RaceNight>,
runs: List<RunLog>,
sheets: List<SetupSheet>,
): String = JSONObject()
.put("rev", rev)
.put("profile", profileJson(profile))
.put("nights", JSONArray().apply { nights.forEach { put(nightJson(it)) } })
.put("runs", JSONArray().apply { runs.forEach { put(runJson(it)) } })
.put("sheets", JSONArray().apply { sheets.forEach { put(sheetJson(it)) } })
.toString()
fun decodeSync(body: String): SyncResponse {
val json = JSONObject(body)
val accepted = buildSet {
val array = json.optJSONArray("accepted") ?: JSONArray()
for (index in 0 until array.length()) add(array.optString(index))
}
return SyncResponse(
rev = json.optLong("rev"),
accepted = accepted,
profile = if (json.isNull("profile")) null else profileFrom(json.getJSONObject("profile")),
nights = json.optJSONArray("nights").toList { nightFrom(it) },
runs = json.optJSONArray("runs").toList { runFrom(it) },
sheets = json.optJSONArray("sheets").toList { sheetFrom(it) },
)
}
private fun profileJson(profile: Car) = JSONObject()
.put("carName", profile.carName)
.put("raceClass", profile.raceClass)
.put("discipline", profile.discipline)
.put("updatedAt", profile.updatedAt)
private fun profileFrom(json: JSONObject) = Car(
carName = json.optString("carName"),
raceClass = json.optString("raceClass"),
discipline = json.optString("discipline"),
updatedAt = json.optLong("updatedAt"),
)
private fun nightJson(night: RaceNight) = JSONObject()
.put("syncId", night.syncId)
.put("updatedAt", night.updatedAt)
.put("deleted", night.deleted)
.put("dateEpochDay", night.dateEpochDay)
.put("trackName", night.trackName)
.put("carName", night.carName)
.put("raceClass", night.raceClass)
.put("discipline", night.discipline)
.putNullable("airTempF", night.airTempF)
.put("notes", night.notes)
.put("createdAt", night.createdAt)
.putNullable("crossPercent", night.crossPercent)
.putNullable("springLf", night.springLf)
.putNullable("springRf", night.springRf)
.putNullable("springLr", night.springLr)
.putNullable("springRr", night.springRr)
.putNullable("camberLf", night.camberLf)
.putNullable("camberRf", night.camberRf)
.putNullable("casterLf", night.casterLf)
.putNullable("casterRf", night.casterRf)
.putNullable("swayBarHole", night.swayBarHole)
.putNullable("blockLr", night.blockLr)
.putNullable("blockRr", night.blockRr)
private fun nightFrom(json: JSONObject) = RaceNight(
dateEpochDay = json.optLong("dateEpochDay"),
trackName = json.optString("trackName"),
carName = json.optString("carName"),
raceClass = json.optString("raceClass"),
discipline = json.optString("discipline"),
airTempF = json.optionalInt("airTempF"),
notes = json.optString("notes"),
createdAt = json.optLong("createdAt"),
crossPercent = json.optionalDouble("crossPercent"),
springLf = json.optionalInt("springLf"),
springRf = json.optionalInt("springRf"),
springLr = json.optionalInt("springLr"),
springRr = json.optionalInt("springRr"),
camberLf = json.optionalDouble("camberLf"),
camberRf = json.optionalDouble("camberRf"),
casterLf = json.optionalDouble("casterLf"),
casterRf = json.optionalDouble("casterRf"),
swayBarHole = json.optionalInt("swayBarHole"),
blockLr = json.optionalDouble("blockLr"),
blockRr = json.optionalDouble("blockRr"),
syncId = json.optString("syncId"),
updatedAt = json.optLong("updatedAt"),
deleted = json.optBoolean("deleted"),
pending = false,
)
private fun runJson(run: RunLog) = JSONObject()
.put("syncId", run.syncId)
.put("nightSyncId", run.nightSyncId)
.put("updatedAt", run.updatedAt)
.put("deleted", run.deleted)
.put("session", run.session)
.put("tireSet", run.tireSet)
.putNullable("laps", run.laps)
.putNullable("finish", run.finish)
.putNullable("staggerInches", run.staggerInches)
.putNullable("coldLf", run.coldLf)
.putNullable("coldRf", run.coldRf)
.putNullable("coldLr", run.coldLr)
.putNullable("coldRr", run.coldRr)
.putNullable("hotLf", run.hotLf)
.putNullable("hotRf", run.hotRf)
.putNullable("hotLr", run.hotLr)
.putNullable("hotRr", run.hotRr)
.putNullable("lfInside", run.lfInside)
.putNullable("lfMiddle", run.lfMiddle)
.putNullable("lfOutside", run.lfOutside)
.putNullable("rfInside", run.rfInside)
.putNullable("rfMiddle", run.rfMiddle)
.putNullable("rfOutside", run.rfOutside)
.putNullable("lrInside", run.lrInside)
.putNullable("lrMiddle", run.lrMiddle)
.putNullable("lrOutside", run.lrOutside)
.putNullable("rrInside", run.rrInside)
.putNullable("rrMiddle", run.rrMiddle)
.putNullable("rrOutside", run.rrOutside)
.put("handling", run.handling)
.put("handlingWhere", run.handlingWhere)
.put("notes", run.notes)
.put("recordedAt", run.recordedAt)
private fun runFrom(json: JSONObject) = RunLog(
nightId = 0,
session = json.optString("session"),
tireSet = json.optString("tireSet"),
laps = json.optionalInt("laps"),
finish = json.optionalInt("finish"),
staggerInches = json.optionalDouble("staggerInches"),
coldLf = json.optionalDouble("coldLf"),
coldRf = json.optionalDouble("coldRf"),
coldLr = json.optionalDouble("coldLr"),
coldRr = json.optionalDouble("coldRr"),
hotLf = json.optionalDouble("hotLf"),
hotRf = json.optionalDouble("hotRf"),
hotLr = json.optionalDouble("hotLr"),
hotRr = json.optionalDouble("hotRr"),
lfInside = json.optionalInt("lfInside"),
lfMiddle = json.optionalInt("lfMiddle"),
lfOutside = json.optionalInt("lfOutside"),
rfInside = json.optionalInt("rfInside"),
rfMiddle = json.optionalInt("rfMiddle"),
rfOutside = json.optionalInt("rfOutside"),
lrInside = json.optionalInt("lrInside"),
lrMiddle = json.optionalInt("lrMiddle"),
lrOutside = json.optionalInt("lrOutside"),
rrInside = json.optionalInt("rrInside"),
rrMiddle = json.optionalInt("rrMiddle"),
rrOutside = json.optionalInt("rrOutside"),
handling = json.optString("handling"),
handlingWhere = json.optString("handlingWhere"),
notes = json.optString("notes"),
recordedAt = json.optLong("recordedAt"),
syncId = json.optString("syncId"),
nightSyncId = json.optString("nightSyncId"),
updatedAt = json.optLong("updatedAt"),
deleted = json.optBoolean("deleted"),
pending = false,
)
private fun sheetJson(sheet: SetupSheet) = JSONObject()
.put("syncId", sheet.syncId)
.put("updatedAt", sheet.updatedAt)
.put("deleted", sheet.deleted)
.put("recordedAt", sheet.recordedAt)
.put("note", sheet.note)
.putNullable("crossPercent", sheet.crossPercent)
.putNullable("springLf", sheet.springLf)
.putNullable("springRf", sheet.springRf)
.putNullable("springLr", sheet.springLr)
.putNullable("springRr", sheet.springRr)
.putNullable("camberLf", sheet.camberLf)
.putNullable("camberRf", sheet.camberRf)
.putNullable("casterLf", sheet.casterLf)
.putNullable("casterRf", sheet.casterRf)
.putNullable("swayBarHole", sheet.swayBarHole)
.putNullable("blockLr", sheet.blockLr)
.putNullable("blockRr", sheet.blockRr)
.putNullable("toeFront", sheet.toeFront)
.putNullable("toeRear", sheet.toeRear)
private fun sheetFrom(json: JSONObject) = SetupSheet(
recordedAt = json.optLong("recordedAt"),
note = json.optString("note"),
crossPercent = json.optionalDouble("crossPercent"),
springLf = json.optionalInt("springLf"),
springRf = json.optionalInt("springRf"),
springLr = json.optionalInt("springLr"),
springRr = json.optionalInt("springRr"),
camberLf = json.optionalDouble("camberLf"),
camberRf = json.optionalDouble("camberRf"),
casterLf = json.optionalDouble("casterLf"),
casterRf = json.optionalDouble("casterRf"),
swayBarHole = json.optionalInt("swayBarHole"),
blockLr = json.optionalDouble("blockLr"),
blockRr = json.optionalDouble("blockRr"),
toeFront = json.optionalDouble("toeFront"),
toeRear = json.optionalDouble("toeRear"),
syncId = json.optString("syncId"),
updatedAt = json.optLong("updatedAt"),
deleted = json.optBoolean("deleted"),
pending = false,
)
private fun JSONObject.putNullable(key: String, value: Any?): JSONObject =
put(key, value ?: JSONObject.NULL)
private fun JSONObject.optionalInt(key: String): Int? =
if (isNull(key)) null else optInt(key)
private fun JSONObject.optionalDouble(key: String): Double? =
if (isNull(key)) null else optDouble(key)
private fun <T> JSONArray?.toList(read: (JSONObject) -> T): List<T> {
if (this == null) return emptyList()
return buildList {
for (index in 0 until length()) add(read(getJSONObject(index)))
}
}
@@ -0,0 +1,45 @@
package com.racingapp.pitlog.data
import android.content.Context
class SyncPreferences(context: Context) {
private val prefs = context.getSharedPreferences("pitlog-sync", Context.MODE_PRIVATE)
var address: String
get() = prefs.getString(ADDRESS, "").orEmpty()
set(value) = prefs.edit().putString(ADDRESS, value).apply()
var token: String
get() = prefs.getString(TOKEN, "").orEmpty()
set(value) = prefs.edit().putString(TOKEN, value).apply()
var status: String
get() = prefs.getString(STATUS, "").orEmpty()
set(value) = prefs.edit().putString(STATUS, value).apply()
var rev: Long
get() = prefs.getLong(REV, 0L)
set(value) = prefs.edit().putLong(REV, value).apply()
var lastAttempt: Long
get() = prefs.getLong(ATTEMPT, 0L)
set(value) = prefs.edit().putLong(ATTEMPT, value).apply()
var automatic: Boolean
get() = prefs.getBoolean(AUTOMATIC, true)
set(value) = prefs.edit().putBoolean(AUTOMATIC, value).apply()
var selectedCarId: Long
get() = prefs.getLong(SELECTED, 0L)
set(value) = prefs.edit().putLong(SELECTED, value).apply()
private companion object {
const val ADDRESS = "address"
const val TOKEN = "token"
const val STATUS = "status"
const val REV = "rev"
const val ATTEMPT = "attempt"
const val AUTOMATIC = "automatic"
const val SELECTED = "selectedCar"
}
}
@@ -0,0 +1,17 @@
package com.racingapp.pitlog.data
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.racingapp.pitlog.RacingApp
class SyncWorker(
context: Context,
params: WorkerParameters,
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
val app = applicationContext as? RacingApp ?: return Result.success()
app.repository.syncAutomatic()
return Result.success()
}
}