From b95cbefeab2b7ce0aca934b59b0782cd0a36449c Mon Sep 17 00:00:00 2001 From: RallyBlock <50393307+RallyBlock@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:54:56 -0700 Subject: [PATCH] Use an 8-digit token for each car so it is easy to share. --- README.md | 2 +- .../com/racingapp/pitlog/data/AppDatabase.kt | 2 +- .../java/com/racingapp/pitlog/data/RaceDao.kt | 9 +++++ .../com/racingapp/pitlog/data/Repository.kt | 34 +++++++++++++------ .../pitlog/ui/settings/SettingsScreen.kt | 2 +- sync/pitlog_sync.py | 8 ++++- 6 files changed, 42 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 31d2ffb..de9125b 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ The gear icon opens **Car**. A new install already has the server address filled **Sync on its own** sends a save right away and checks about every 30 seconds while the app is open. It stays quiet while the app is closed. **Sync now** sends every car on the phone. -Each car has its own token, shown on the **Car** screen. **Share token** sends that string to another phone. On the other phone, paste it under **Add a car from a token**. That phone then syncs that car. A different car gets a different token. +Each car has its own 8-digit token, shown on the **Car** screen. **Share token** sends those digits to another phone. On the other phone, paste it under **Add a car from a token**. That phone then syncs that car. A different car gets a different token. **Remove from this phone** takes the car off that phone only. The other phone and the server keep it. diff --git a/app/src/main/java/com/racingapp/pitlog/data/AppDatabase.kt b/app/src/main/java/com/racingapp/pitlog/data/AppDatabase.kt index 4e9ae3b..01f4e64 100644 --- a/app/src/main/java/com/racingapp/pitlog/data/AppDatabase.kt +++ b/app/src/main/java/com/racingapp/pitlog/data/AppDatabase.kt @@ -253,7 +253,7 @@ abstract class AppDatabase : RoomDatabase() { 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) + VALUES ('$DEFAULT_CAR', '$DEFAULT_CLASS', '$DEFAULT_DISCIPLINE', printf('%08d', abs(random()) % 100000000), 1, 0, 0, 1) """.trimIndent(), ) } diff --git a/app/src/main/java/com/racingapp/pitlog/data/RaceDao.kt b/app/src/main/java/com/racingapp/pitlog/data/RaceDao.kt index 31cab38..39ae11a 100644 --- a/app/src/main/java/com/racingapp/pitlog/data/RaceDao.kt +++ b/app/src/main/java/com/racingapp/pitlog/data/RaceDao.kt @@ -132,6 +132,15 @@ interface RaceDao { @Query("SELECT * FROM cars WHERE id = :id") suspend fun carById(id: Long): Car? + @Query("UPDATE race_nights SET pending = 1 WHERE carId = :carId") + suspend fun markNightsPending(carId: Long) + + @Query("UPDATE runs SET pending = 1 WHERE nightId IN (SELECT id FROM race_nights WHERE carId = :carId)") + suspend fun markRunsPending(carId: Long) + + @Query("UPDATE setup_sheets SET pending = 1 WHERE carId = :carId") + suspend fun markSheetsPending(carId: Long) + @Query("SELECT * FROM cars WHERE token = :token LIMIT 1") suspend fun carByToken(token: String): Car? diff --git a/app/src/main/java/com/racingapp/pitlog/data/Repository.kt b/app/src/main/java/com/racingapp/pitlog/data/Repository.kt index 0977b1b..f285ce3 100644 --- a/app/src/main/java/com/racingapp/pitlog/data/Repository.kt +++ b/app/src/main/java/com/racingapp/pitlog/data/Repository.kt @@ -1,5 +1,6 @@ package com.racingapp.pitlog.data +import java.security.MessageDigest import java.security.SecureRandom import java.util.UUID import kotlinx.coroutines.flow.Flow @@ -24,28 +25,39 @@ suspend fun ensureCars(dao: RaceDao, preferences: SyncPreferences) { 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)) + for (car in cars) { + if (isEightDigitToken(car.token)) continue + val source = car.token.ifBlank { preferences.token.trim() } + val next = if (source.isBlank()) newCarToken() else eightDigitToken(source) + dao.updateCar(car.copy(token = next, syncRev = 0, pending = true)) + dao.markNightsPending(car.id) + dao.markRunsPending(car.id) + dao.markSheetsPending(car.id) } + val first = cars.firstOrNull { !it.deleted } ?: cars.first() 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 newCarToken(): String = "%08d".format(SecureRandom().nextInt(100_000_000)) + +fun eightDigitToken(current: String): String { + val digest = MessageDigest.getInstance("SHA-256").digest(current.toByteArray(Charsets.UTF_8)) + var value = 0L + for (index in 0 until 4) { + value = (value shl 8) or (digest[index].toLong() and 0xff) + } + return "%08d".format(value % 100_000_000L) } +fun isEightDigitToken(token: String): Boolean = token.length == 8 && token.all { it.isDigit() } + 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() + return withoutLabel.filter { !it.isWhitespace() }.lowercase() } class Repository( @@ -125,7 +137,7 @@ class Repository( suspend fun joinCar(raw: String): String { val token = cleanCarToken(raw) - if (token.length < 16 || token.any { it !in '0'..'9' && it !in 'a'..'f' }) { + if (!isEightDigitToken(token)) { return "That token doesn't look right." } val existing = dao.carByToken(token) diff --git a/app/src/main/java/com/racingapp/pitlog/ui/settings/SettingsScreen.kt b/app/src/main/java/com/racingapp/pitlog/ui/settings/SettingsScreen.kt index 49e4279..4c90035 100644 --- a/app/src/main/java/com/racingapp/pitlog/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/racingapp/pitlog/ui/settings/SettingsScreen.kt @@ -230,7 +230,7 @@ fun SettingsScreen(onBack: () -> Unit) { ) SectionLabel("This car's token") Text( - "Share this token with another phone, then on that phone use Add a car from a token. That phone syncs this car. A different car gets a different token, so it can sync with different phones.", + "This token is 8 digits. Share it with another phone, then on that phone use Add a car from a token. That phone syncs this car. A different car gets a different token.", color = MaterialTheme.colorScheme.onSurfaceVariant, ) SelectionContainer { diff --git a/sync/pitlog_sync.py b/sync/pitlog_sync.py index 51b24f3..923ce5d 100644 --- a/sync/pitlog_sync.py +++ b/sync/pitlog_sync.py @@ -52,6 +52,12 @@ def listen_port(): return 8787 +def token_ok(bearer): + if len(bearer) == 8 and bearer.isdigit(): + return True + return len(bearer) >= 16 and all(character in "0123456789abcdef" for character in bearer) + + def legacy_token(): inline = os.environ.get("PITLOG_TOKEN", "").strip().lower() if inline: @@ -386,7 +392,7 @@ class Handler(BaseHTTPRequestHandler): return header = self.headers.get("Authorization", "") bearer = header[7:].strip().lower() if header.startswith("Bearer ") else "" - if len(bearer) < 16 or any(character not in "0123456789abcdef" for character in bearer): + if not token_ok(bearer): self._send(401, {"error": "The token was refused."}) return length = int(self.headers.get("Content-Length", "0"))