Use an 8-digit token for each car so it is easy to share.

This commit is contained in:
RallyBlock committed 2026-10-22 20:54:56 -07:00
1 parent 0224e8f395
commit b95cbefeab
6 files changed
+42 -15

No files matched your search

+1 -1
View File
@@ -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. **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. **Remove from this phone** takes the car off that phone only. The other phone and the server keep it.
@@ -253,7 +253,7 @@ abstract class AppDatabase : RoomDatabase() {
db.execSQL( db.execSQL(
""" """
INSERT INTO cars (carName, raceClass, discipline, token, updatedAt, syncRev, deleted, pending) 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(), """.trimIndent(),
) )
} }
@@ -132,6 +132,15 @@ interface RaceDao {
@Query("SELECT * FROM cars WHERE id = :id") @Query("SELECT * FROM cars WHERE id = :id")
suspend fun carById(id: Long): Car? 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") @Query("SELECT * FROM cars WHERE token = :token LIMIT 1")
suspend fun carByToken(token: String): Car? suspend fun carByToken(token: String): Car?
@@ -1,5 +1,6 @@
package com.racingapp.pitlog.data package com.racingapp.pitlog.data
import java.security.MessageDigest
import java.security.SecureRandom import java.security.SecureRandom
import java.util.UUID import java.util.UUID
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -24,28 +25,39 @@ suspend fun ensureCars(dao: RaceDao, preferences: SyncPreferences) {
preferences.selectedCarId = id preferences.selectedCarId = id
return return
} }
val first = cars.firstOrNull { !it.deleted } ?: cars.first() for (car in cars) {
if (first.token.isBlank()) { if (isEightDigitToken(car.token)) continue
val kept = preferences.token.trim().ifBlank { newCarToken() } val source = car.token.ifBlank { preferences.token.trim() }
val rev = if (kept == preferences.token.trim() && first.syncRev == 0L) preferences.rev else first.syncRev val next = if (source.isBlank()) newCarToken() else eightDigitToken(source)
dao.updateCar(first.copy(token = kept, syncRev = rev, pending = true)) 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 val selected = preferences.selectedCarId
if (selected == 0L || cars.none { it.id == selected && !it.deleted }) { if (selected == 0L || cars.none { it.id == selected && !it.deleted }) {
preferences.selectedCarId = first.id preferences.selectedCarId = first.id
} }
} }
fun newCarToken(): String { fun newCarToken(): String = "%08d".format(SecureRandom().nextInt(100_000_000))
val bytes = ByteArray(32)
SecureRandom().nextBytes(bytes) fun eightDigitToken(current: String): String {
return bytes.joinToString("") { "%02x".format(it) } 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 { fun cleanCarToken(raw: String): String {
val line = raw.trim().lineSequence().firstOrNull().orEmpty().trim() val line = raw.trim().lineSequence().firstOrNull().orEmpty().trim()
val withoutLabel = if (line.startsWith("token:", ignoreCase = true)) line.substringAfter(":").trim() else line val withoutLabel = if (line.startsWith("token:", ignoreCase = true)) line.substringAfter(":").trim() else line
return withoutLabel.lowercase() return withoutLabel.filter { !it.isWhitespace() }.lowercase()
} }
class Repository( class Repository(
@@ -125,7 +137,7 @@ class Repository(
suspend fun joinCar(raw: String): String { suspend fun joinCar(raw: String): String {
val token = cleanCarToken(raw) 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." return "That token doesn't look right."
} }
val existing = dao.carByToken(token) val existing = dao.carByToken(token)
@@ -230,7 +230,7 @@ fun SettingsScreen(onBack: () -> Unit) {
) )
SectionLabel("This car's token") SectionLabel("This car's token")
Text( 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, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
SelectionContainer { SelectionContainer {
+7 -1
View File
@@ -52,6 +52,12 @@ def listen_port():
return 8787 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(): def legacy_token():
inline = os.environ.get("PITLOG_TOKEN", "").strip().lower() inline = os.environ.get("PITLOG_TOKEN", "").strip().lower()
if inline: if inline:
@@ -386,7 +392,7 @@ class Handler(BaseHTTPRequestHandler):
return return
header = self.headers.get("Authorization", "") header = self.headers.get("Authorization", "")
bearer = header[7:].strip().lower() if header.startswith("Bearer ") else "" 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."}) self._send(401, {"error": "The token was refused."})
return return
length = int(self.headers.get("Content-Length", "0")) length = int(self.headers.get("Content-Length", "0"))