Skip to content

Kotlin / Android

dependencies {
implementation("fit.trainstar:synchro:0.1.0")
}
dependencies {
implementation 'fit.trainstar:synchro:0.1.0'
}

Android: minSdk 24, compileSdk 34

Dependencies: OkHttp 4.12+, Kotlinx Serialization 1.6+, Kotlinx Coroutines 1.8+

val config = SynchroConfig(
dbPath = "synchro.db",
serverURL = "https://api.example.com",
authProvider = { getToken() },
clientID = Settings.Secure.getString(contentResolver, Settings.Secure.ANDROID_ID),
appVersion = "1.0.0"
)
val client = SynchroClient(config, context)
ParameterTypeDefaultDescription
dbPathStringRequiredSQLite database file name or path
serverURLStringRequiredSync server base URL
authProvidersuspend () -> StringRequiredReturns a JWT token for authentication
clientIDStringRequiredUnique device identifier
platformString"android"Platform name sent during registration
appVersionStringRequiredSemantic version of the app
syncIntervalDouble30.0Seconds between sync cycles
pushDebounceDouble0.5Seconds after a write before triggering push
maxRetryAttemptsInt5Maximum retry count before entering error state
pullPageSizeInt100Rows per pull page (validated 1—1000)
pushBatchSizeInt100Pending changes per push batch (validated 1—1000)
snapshotPageSizeInt100Rows per snapshot page (validated 1—1000)
// Fetch multiple rows
val rows = client.query(
"SELECT * FROM tasks WHERE user_id = ?",
params = arrayOf(userId)
)
// Fetch a single row
val task = client.queryOne(
"SELECT * FROM tasks WHERE id = ?",
params = arrayOf(id)
)

Row is a type alias for Map<String, Any?>. Column values are returned as their SQLite-native types: Long for integers, Double for floats, String for text, ByteArray for blobs, and null for NULL.

val result = client.execute(
"INSERT INTO tasks (id, title, user_id) VALUES (?, ?, ?)",
params = arrayOf(UUID.randomUUID().toString(), "Review proposal", userId)
)
// result.rowsAffected == 1
val affected = client.executeBatch(listOf(
SQLStatement(
sql = "INSERT INTO tasks (id, title, user_id) VALUES (?, ?, ?)",
params = arrayOf(UUID.randomUUID().toString(), "Write report", userId)
),
SQLStatement(
sql = "INSERT INTO tasks (id, title, user_id) VALUES (?, ?, ?)",
params = arrayOf(UUID.randomUUID().toString(), "Update docs", userId)
),
))
// affected == 2
// Write transaction: multiple operations atomically
client.writeTransaction { db ->
db.execSQL(
"INSERT INTO tasks (id, title, user_id) VALUES (?, ?, ?)",
arrayOf(UUID.randomUUID().toString(), "Review proposal", userId)
)
db.execSQL(
"INSERT INTO comments (id, task_id, body, status) VALUES (?, ?, ?, ?)",
arrayOf(UUID.randomUUID().toString(), taskId, "Looks good", "open")
)
}
// Read transaction: consistent snapshot
val count = client.readTransaction { db ->
db.rawQuery("SELECT COUNT(*) FROM tasks", null).use { cursor ->
if (cursor.moveToFirst()) cursor.getInt(0) else 0
}
}
val cancellable = client.onChange(listOf("tasks")) {
println("tasks table changed")
}
// Later: stop observing
cancellable.cancel()
val cancellable = client.watch(
"SELECT * FROM tasks ORDER BY created_at DESC",
tables = listOf("tasks")
) { rows ->
this.tasks = rows
}

The callback fires immediately with the current result set, then again whenever the observed tables change. The SDK uses a SQL-parsing heuristic to detect which tables are affected by each write.

// Start sync: register, fetch schema, begin sync loop
client.start()
// Trigger an immediate sync cycle
client.syncNow()
// Stop the sync loop
client.stop()
// Close the database and HTTP connections
client.close()
val cancellable = client.onStatusChange { status ->
when (status) {
is SyncStatus.Idle -> println("Idle")
is SyncStatus.Syncing -> println("Syncing...")
is SyncStatus.Error -> {
val retryAt = status.retryAt
if (retryAt != null) {
println("Error, retrying at $retryAt")
} else {
println("Error, no retry scheduled")
}
}
is SyncStatus.Stopped -> println("Stopped")
}
}

SyncStatus is a sealed class:

sealed class SyncStatus {
data object Idle : SyncStatus()
data object Syncing : SyncStatus()
data class Error(val retryAt: java.time.Instant?) : SyncStatus()
data object Stopped : SyncStatus()
}
val cancellable = client.onConflict { event ->
println("Conflict on ${event.table} record ${event.recordID}")
println("Client data: ${event.clientData}")
println("Server data: ${event.serverData}")
}
val cancellable = client.onSnapshotRequired {
// Prompt the user or decide programmatically
// Return true to proceed with snapshot, false to abort
promptUser("Full resync needed. Continue?")
}

All errors are represented by the SynchroError sealed class:

sealed class SynchroError(message: String, cause: Throwable? = null)
: Exception(message, cause) {
class NotConnected : SynchroError(...)
class SchemaNotLoaded : SynchroError(...)
class TableNotSynced(val table: String) : SynchroError(...)
class UpgradeRequired(
val currentVersion: String,
val minimumVersion: String
) : SynchroError(...)
class SchemaMismatch(
val serverVersion: Long,
val serverHash: String
) : SynchroError(...)
class SnapshotRequired : SynchroError(...)
class PushRejected(val results: List<PushResult>) : SynchroError(...)
class NetworkError(val underlying: Throwable) : SynchroError(...)
class ServerError(
val status: Int,
val serverMessage: String
) : SynchroError(...)
class DatabaseError(val underlying: Throwable) : SynchroError(...)
class InvalidResponse(val details: String) : SynchroError(...)
class AlreadyStarted : SynchroError(...)
class NotStarted : SynchroError(...)
}
ClassWhen It Occurs
NotConnectedOperation attempted before start() completes registration
SchemaNotLoadedSchema has not been fetched from server yet
TableNotSyncedWrite attempted on a table not in the server schema
UpgradeRequiredServer rejected the client’s app version (HTTP 426)
SchemaMismatchClient schema hash does not match server (HTTP 409)
SnapshotRequiredServer indicates a full snapshot is needed
PushRejectedOne or more push records were rejected by the server
NetworkErrorNetwork connectivity failure
ServerErrorServer returned a non-200 HTTP status
DatabaseErrorSQLite operation failed
InvalidResponseServer response could not be decoded
AlreadyStartedstart() called when sync is already running
NotStartedsyncNow() called before start()

Errors follow standard Kotlin exception handling:

try {
client.start()
} catch (e: SynchroError.UpgradeRequired) {
showUpgradeDialog(e.minimumVersion)
} catch (e: SynchroError.NetworkError) {
showOfflineBanner()
} catch (e: SynchroError) {
log.error("Sync error", e)
}