Project setup¶
Every step from a Kotlin Multiplatform project to a first offline read and write. The library is a
supabase-kt plugin, so most of this is supabase-kt's own setup with one more install.
Requirements¶
| Kotlin | 2.4 or later. The library is built with Kotlin 2.4.20 and needs kotlin-stdlib 2.4.20 at run time |
| supabase-kt | 3.6. The library brings supabase-kt's BOM at 3.6.0, with PostgREST, Storage and Auth; with another minor, the plugin logs a warning when it is installed (why 3.6) |
| Android | API 23 (Android 6.0) or later for the library. The sample app needs API 24 |
| iOS | 15 or later, for the iosArm64 and iosSimulatorArm64 targets of a Kotlin Multiplatform module |
There are no JVM, JS or Wasm targets. A native Swift app uses the Swift package instead: iOS apps in Swift.
Add the dependency¶
The library is on Maven Central, which needs no credentials. Add it to the shared module's
commonMain:
It brings supabase-kt's PostgREST, Storage and Auth modules and its BOM. Take any other supabase-kt module, such as Realtime, from the same BOM, so every module stays on one version:
commonMain.dependencies {
implementation(project.dependencies.platform("io.github.jan-tennert.supabase:bom:3.6.0"))
implementation("io.github.jan-tennert.supabase:realtime-kt")
}
Before 1.0, pin the minor you have tested: a minor release may break your build. See Compatibility.
Add a Ktor engine per target¶
supabase-kt sends its requests through Ktor: give each platform an engine, at the Ktor version supabase-kt 3.6.0 was released with, 3.4.3, or later:
kotlin {
sourceSets {
androidMain.dependencies {
implementation("io.ktor:ktor-client-okhttp:3.4.3")
}
iosMain.dependencies {
implementation("io.ktor:ktor-client-darwin:3.4.3")
}
}
}
Create the client¶
Build the supabase-kt client as you do today, and install the plugin last:
From the samples: SampleSupabaseClient.kt · AppModel.swift
return createSupabaseClient(supabaseUrl = url, supabaseKey = publishableKey) {
// Warnings and errors only: request logs name filter values.
defaultLogLevel = LogLevel.WARNING
install(Auth) // the anonymous session; the cache belongs to one user at a time
install(Postgrest) // required
install(Storage) // for offline.storage
install(Realtime) // for Live updates
// Last, so no plugin after it can fail once it has claimed its database.
install(Offline) { writeEverySetting(networkAvailability, debugBuild) }
}
// The one client the app has, made at launch and shared: it learns who the user is
// from the token alone, and follows the app's own connectivity signal.
let client = SupabaseOfflineClient(
supabaseURL: url,
supabaseKey: publishableKey,
config: ClientSettings.config(
accessToken: { await session.accessToken() },
networkAvailability: ClientSettings.pathMonitorSignal
)
)
Import io.github.nesfeder.supabase.offline.offline to reach it as supabase.offline, as
supabase.postgrest is reached. PostgREST is required (from throws IllegalStateException
without it); Storage is needed only for supabase.offline.storage; Auth is optional, and
with it the cache belongs to one user at a time and each user's queued writes are sent with that
user's token (Signing out and switching users). The settings go in the
install(Offline) { … } block, each with a default (Configuration).
- Install the plugin last. It opens its database when the client is created, on the calling
thread (build it off the main thread when that matters); when a plugin installed after it fails
to set up,
createSupabaseClientthrows and the database stays claimed until the process ends. - One client per process. A second client over the same database throws
IllegalStateExceptionuntil the first is closed: build it once, in yourApplicationon Android or at app start on iOS, and share it. - No
Contexton Android. The plugin takes the application context from androidx.startup; if your manifest removes itsInitializationProvider,createSupabaseClientthrows, naming it.
First read¶
From the samples: OfflineTaskRepository.kt · TaskRepository.swift
/**
* Pull to refresh: the server's answer when it can be reached, else the device's copy if it is
* a day old at most; the answer says which it was.
*/
override suspend fun refreshTasks(query: TaskQuery): ReadProvenance = read {
offline
.from(Tables.TASKS)
.select(readStrategy = ReadStrategy.NetworkFirst(maxAge = REFRESH_MAX_AGE)) {
tasksMatching(query, recentWindow(clock.now())) // the shown list's own query
}
.toReadProvenance()
}
/// Asks the server for `query`'s tasks again, as pull to refresh does: the same query as the
/// list's live read, so its answer replaces that query's cached rows and every reader of it
/// sees them. When the server cannot be reached, the saved rows answer, unless they are older
/// than ``refreshMaxAge``; either way the answer says where it came from.
func refreshTasks(_ query: TaskQuery) async throws -> ListProvenance {
let request = client.from(Tables.tasks).select(readStrategy: .networkFirst(maxAge: Self.refreshMaxAge))
let response = try await query.applied(to: request)
.order("created_at", ascending: false)
.execute([TaskRow].self)
return ListProvenance(response)
}
The call is supabase-kt's own. NetworkFirst, the default, is named only to give it a maxAge:
the server's answer is kept on the device, and when the server cannot be reached the kept rows are
served, with your queued writes laid over them, unless they are older than maxAge. Before the
first read reaches the server nothing is kept (One-shot reads).
First live read¶
selectAsFlow emits the rows as they change: when you write through the plugin, when a refresh
lands, and when a Realtime record is applied; it needs no Realtime channel.
From the samples: OfflineTaskRepository.kt · TaskRepository.swift · TasksModel.swift
/** The user's tasks that [query] selects, as a live read mapped to the app's snapshot. */
private fun taskList(
query: TaskQuery,
strategy: FlowReadStrategy,
shown: (List<Task>) -> List<Task>,
): Flow<TaskListSnapshot> {
val recent = recentWindow(clock.now()) // read once: the request must not read the clock
return whileSignedIn(before = nothingYet()) {
combine(
offline.from(Tables.TASKS).selectAsFlow(readStrategy = strategy) {
tasksMatching(query, recent)
},
pendingRowIds(Tables.TASKS),
) { snapshot, pending ->
snapshot.toRowsSnapshot { result ->
// A queued task is listed after the server's rows; show it where it sorts.
shown(result.decodeList<TaskRow>().map { it.toTask(pending) }.newestFirst())
}
}
}
}
/// Every task of the user, newest first, as the device holds it and as the server refreshes
/// it: the list the other screens share. The task screen picks its task out of it, because
/// each query is cached on its own, and offline a query never fetched has nothing to show.
func taskSnapshots(readStrategy: SequenceReadStrategy = .cacheFirstThenNetwork) -> QuerySnapshotSequence<[TaskRow]> {
client.from(Tables.tasks)
.selectAsSequence(as: [TaskRow].self, readStrategy: readStrategy)
.order("created_at", ascending: false)
}
/// Follows every task of the signed-in user, the list the other screens share, until the
/// calling task is canceled. Before there is a user it asks nothing: the schema refuses every
/// request without one.
func observeTasks() async {
rows = nil
guard repository.session.userID != nil else { return }
while !Task.isCancelled {
do {
for try await snapshot in repository.taskSnapshots() {
rows = snapshot.result
}
return
} catch is CancellationError {
return
} catch {
// The shown list reports a failure; both read the same rows. Read again when it is
// worth it, so the list does not stop.
await untilWorthReadingAgain()
}
}
}
Each emission is a QuerySnapshot: the rows (null while nothing is known yet), whether they are
the device's copy, when they were fetched, whether a queued write changed them, and how the
refresh behind them stands (Reactive reads).
First write¶
From the samples: OfflineTaskRepository.kt · TaskRepository.swift
/** Adds a task; [needsConnection] picks the write's strategy. */
override suspend fun addTask(
title: String,
notes: String?,
needsConnection: Boolean,
): NewTaskOutcome {
val row = newTaskRow(title, notes)
val outcome =
write(Tables.TASKS, TaskMutationOperation.Insert, title) {
offline.from(Tables.TASKS).insert(row, mutationStrategyFor(needsConnection)) {
select() // the stored row back, with the times the server set
}
}
return when (outcome) {
is MutationOutcome.Applied -> {
val stored = outcome.result.decodeSingle<TaskRecord>()
// An online-only write is never patched into the cache: the stored row goes there.
if (needsConnection) offline.applyServerRecord(Tables.TASKS, stored)
NewTaskOutcome.Saved(outcome.result.decodeSingle<TaskRow>().toTask(emptySet()))
}
is MutationOutcome.Queued -> NewTaskOutcome.SavedOnDevice(row.id) // sent when it can be
}
}
/// Adds task `id` (a new one unless given) for the signed-in user, with the device's clock as
/// its creation time, and says what happened: the server stored it, at the `updated_at` it set
/// and gives back, or it waits in the queue on this device.
func addTask(id: String = DeviceID.make(), title: String, notes: String?, needsConnection: Bool = false) async throws -> NewTaskResult {
let row = NewTaskRow(
id: id,
userID: try signedInUserID(),
title: title,
notes: notes,
done: false,
createdAt: .now
)
let outcome = try await write(taskTitle: title) {
try await client.from(Tables.tasks)
.insert(row, mutationStrategy: Self.mutationStrategy(needsConnection: needsConnection))
.select()
.single()
.execute(StoredTaskRow.self)
}
switch outcome {
case .applied(let result):
// A write sent only straight to the server is never put into the device's lists:
// the app puts in the row the server stored, as it would a row Realtime reports.
if needsConnection { try await client.applyServerRecord(table: Tables.tasks, record: result.value) }
return .saved(storedAt: result.value.updatedAt)
case .queued(let pendingMutation):
return .savedOnDevice(pendingMutationID: pendingMutation.id)
}
}
Make the row's id on the device: a write made offline can then be shown, opened and edited
before it reaches the server, and one sent again after its answer was lost is not inserted twice.
A write returns MutationOutcome.Applied, with the server's answer, or Queued, and every cached
read shows it at once either way; one sent at once that the server refuses throws supabase-kt's
exception, as supabase.from(…) does (Writes and the offline queue).
Connectivity and sync¶
Nothing to wire up: the plugin watches the platform's connectivity and sends queued writes by
itself when the network comes back and when a user's session settles. syncStatus is what a sync
banner reads, and syncPendingMutations() is what a "Sync now" button calls
(Replaying the queue).
Sign-out¶
Call supabase.offline.clearLocalData() when a user signs out for good, before auth.signOut() or
in a finally around it: offline, signOut() throws, and a clear after it never runs. It deletes
the cached rows, the downloaded files and every write still queued; with Auth the cache already
passes from one user to the next, but queued writes stay with their user until you clear them.
With your own token provider and no Auth, clear only once it has stopped returning the outgoing
user's token: a live read refreshes right after the clear with whatever token it gets
(Signing out and switching users).
Logging¶
The plugin logs through supabase-kt's own logging, tagged Supabase-Offline, at the client's
defaultLogLevel unless you give it a level of its own:
From the samples: SampleSupabaseClient.kt · ClientSettings.swift
Messages at INFO and above carry no rows, filter values, tokens or object paths; DEBUG carries
request bodies and responses, so keep it out of release builds
(Security and privacy).
Android¶
The manifest¶
The library's manifest declares android.permission.ACCESS_NETWORK_STATE, for its connectivity
monitor, so it is in your app's merged manifest and its store listing's permissions, even when
install(Offline) { networkAvailability = … } gives your own signal and the monitor is not used.
Backups¶
The database and the file cache hold one user's data, all of which can be fetched again. Android's
Auto Backup includes both by default, so a restore onto a new device would bring them back under
whoever signs in next: leave them out. For Android 12 and later (android:dataExtractionRules),
with the default data directory:
<data-extraction-rules>
<cloud-backup>
<exclude domain="database" path="supabase_offline.db" />
<exclude domain="database" path="supabase_offline.db-wal" />
<exclude domain="database" path="supabase_offline.db-shm" />
<exclude domain="file" path="supabase_offline/" />
</cloud-backup>
<device-transfer>
<exclude domain="database" path="supabase_offline.db" />
<exclude domain="database" path="supabase_offline.db-wal" />
<exclude domain="database" path="supabase_offline.db-shm" />
<exclude domain="file" path="supabase_offline/" />
</device-transfer>
</data-extraction-rules>
For Android 11 and earlier (android:fullBackupContent), put the same four exclude lines in a
<full-backup-content> file. On iOS the plugin excludes its files from backups itself.
Or keep everything in a directory Android never backs up, and a new install needs no rules:
val supabase =
createSupabaseClient(supabaseUrl, supabaseKey) {
install(Auth)
install(Postgrest)
install(Offline) {
// The database and the file cache; Android never backs up noBackupFilesDir
dataDirectory = context.noBackupFilesDir.resolve("supabase_offline").path
}
}
A data directory, and a second process¶
When dataDirectory names a directory that holds no database yet, and the default location holds
one, the first client moves the database there, with its unsent writes, and then the downloaded
files; a file that cannot move is deleted, to be downloaded again, so none is left where
clearLocalData() no longer reaches it. When the database cannot move, the client keeps using the
default one and logs a warning. The claim on the database is per process: do not install the
plugin in a second process (a service or a WorkManager worker declared with android:process), as
two processes over one database can send the same queued write twice, and the move can happen
while the other process has the database open.
Next steps¶
- Coming from supabase-kt: moving existing calls.
- Testing your app: the in-memory client for unit tests.
- Behaviors: what each call does offline.
- Configuration: every setting.