Put Pit Log under version control so the app and sync server can live on git.
This commit is contained in:
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()
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user