diff --git a/demos/supabase-todolist/README.md b/demos/supabase-todolist/README.md
index 7fbdae32..dfe3e7b2 100644
--- a/demos/supabase-todolist/README.md
+++ b/demos/supabase-todolist/README.md
@@ -58,3 +58,11 @@ SUPABASE_ANON_KEY=foo
## Run the app
Choose a run configuration for the Android or iOS target in Android Studio and run it.
+
+For Android, this demo contains two Android apps:
+
+- [`androidApp/`](androidApp/): This is a regular compose UI app using PowerSync.
+- [`androidBackgroundSync/`](androidBackgroundSync/): This example differs from the regular app in
+ that it uses a foreground service managing the synchronization process. The service is started
+ in the main activity and keeps running even after the app is closed.
+ For more notes on background sync, see [this document](docs/BackgroundSync.md).
diff --git a/demos/supabase-todolist/androidBackgroundSync/.gitignore b/demos/supabase-todolist/androidBackgroundSync/.gitignore
new file mode 100644
index 00000000..42afabfd
--- /dev/null
+++ b/demos/supabase-todolist/androidBackgroundSync/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/demos/supabase-todolist/androidBackgroundSync/build.gradle.kts b/demos/supabase-todolist/androidBackgroundSync/build.gradle.kts
new file mode 100644
index 00000000..c52e9cee
--- /dev/null
+++ b/demos/supabase-todolist/androidBackgroundSync/build.gradle.kts
@@ -0,0 +1,59 @@
+plugins {
+ alias(libs.plugins.androidApplication)
+ alias(libs.plugins.kotlinAndroid)
+ alias(libs.plugins.compose.compiler)
+ id("org.jetbrains.compose")
+ alias(libs.plugins.kotlin.atomicfu)
+}
+
+android {
+ namespace = "com.powersync.demo.backgroundsync"
+ compileSdk = 35
+
+ defaultConfig {
+ applicationId = "com.powersync.demo.backgroundsync"
+ minSdk = 28
+ targetSdk = 35
+ versionCode = 1
+ versionName = "1.0"
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+ }
+ kotlinOptions {
+ jvmTarget = "11"
+ }
+ buildFeatures {
+ compose = true
+ }
+}
+
+dependencies {
+ // When copying this example, replace "latest.release" with the current version available
+ // at: https://central.sonatype.com/artifact/com.powersync/connector-supabase
+ implementation("com.powersync:connector-supabase:latest.release")
+
+ implementation(projects.shared)
+
+ implementation(compose.material)
+ implementation(libs.androidx.core)
+ implementation(libs.androidx.activity.compose)
+ implementation(libs.androidx.lifecycle.service)
+ implementation(libs.compose.lifecycle)
+ implementation(libs.compose.ui.tooling.preview)
+ implementation(libs.koin.android)
+ implementation(libs.koin.compose.viewmodel)
+}
diff --git a/demos/supabase-todolist/androidBackgroundSync/proguard-rules.pro b/demos/supabase-todolist/androidBackgroundSync/proguard-rules.pro
new file mode 100644
index 00000000..f1b42451
--- /dev/null
+++ b/demos/supabase-todolist/androidBackgroundSync/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
diff --git a/demos/supabase-todolist/androidBackgroundSync/src/main/AndroidManifest.xml b/demos/supabase-todolist/androidBackgroundSync/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..8f581b9f
--- /dev/null
+++ b/demos/supabase-todolist/androidBackgroundSync/src/main/AndroidManifest.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/demos/supabase-todolist/androidBackgroundSync/src/main/java/com/powersync/demo/backgroundsync/MainActivity.kt b/demos/supabase-todolist/androidBackgroundSync/src/main/java/com/powersync/demo/backgroundsync/MainActivity.kt
new file mode 100644
index 00000000..58527e8a
--- /dev/null
+++ b/demos/supabase-todolist/androidBackgroundSync/src/main/java/com/powersync/demo/backgroundsync/MainActivity.kt
@@ -0,0 +1,58 @@
+package com.powersync.demo.backgroundsync
+
+import android.content.Intent
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.systemBars
+import androidx.compose.foundation.layout.windowInsetsPadding
+import androidx.compose.material.MaterialTheme
+import androidx.compose.material.Surface
+import androidx.compose.ui.Modifier
+import androidx.lifecycle.lifecycleScope
+import com.powersync.connector.supabase.SupabaseConnector
+import com.powersync.demos.AppContent
+import io.github.jan.supabase.auth.status.SessionStatus
+import kotlinx.coroutines.launch
+import org.koin.android.ext.android.inject
+import org.koin.compose.KoinContext
+
+class MainActivity : ComponentActivity() {
+
+ private val connector: SupabaseConnector by inject()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+
+ lifecycleScope.launch {
+ // Watch the authentication state and start a sync foreground service once the user logs
+ // in.
+ connector.sessionStatus.collect {
+ if (it is SessionStatus.Authenticated) {
+ startForegroundService(Intent().apply {
+ setClass(this@MainActivity, SyncService::class.java)
+ })
+ }
+ }
+ }
+
+ setContent {
+ // We've already started Koin from our application class to be able to use the database
+ // outside of the UI here. So, use KoinContext and AppContent instead of the App()
+ // composable that would set up its own context.
+ KoinContext {
+ MaterialTheme {
+ Surface(color = MaterialTheme.colors.background) {
+ AppContent(
+ modifier=Modifier.fillMaxSize().windowInsetsPadding(WindowInsets.systemBars)
+ )
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/demos/supabase-todolist/androidBackgroundSync/src/main/java/com/powersync/demo/backgroundsync/MainApplication.kt b/demos/supabase-todolist/androidBackgroundSync/src/main/java/com/powersync/demo/backgroundsync/MainApplication.kt
new file mode 100644
index 00000000..563478c8
--- /dev/null
+++ b/demos/supabase-todolist/androidBackgroundSync/src/main/java/com/powersync/demo/backgroundsync/MainApplication.kt
@@ -0,0 +1,27 @@
+package com.powersync.demo.backgroundsync
+
+import android.app.Application
+import com.powersync.DatabaseDriverFactory
+import com.powersync.demos.AuthOptions
+import com.powersync.demos.sharedAppModule
+import org.koin.android.ext.koin.androidContext
+import org.koin.android.ext.koin.androidLogger
+import org.koin.core.context.startKoin
+import org.koin.core.module.dsl.singleOf
+import org.koin.dsl.module
+
+class MainApplication : Application() {
+ override fun onCreate() {
+ super.onCreate()
+
+ startKoin {
+ androidLogger()
+ androidContext(this@MainApplication)
+
+ modules(sharedAppModule, module {
+ single { AuthOptions(connectFromViewModel = false) }
+ singleOf(::DatabaseDriverFactory)
+ })
+ }
+ }
+}
diff --git a/demos/supabase-todolist/androidBackgroundSync/src/main/java/com/powersync/demo/backgroundsync/SyncService.kt b/demos/supabase-todolist/androidBackgroundSync/src/main/java/com/powersync/demo/backgroundsync/SyncService.kt
new file mode 100644
index 00000000..770d75dd
--- /dev/null
+++ b/demos/supabase-todolist/androidBackgroundSync/src/main/java/com/powersync/demo/backgroundsync/SyncService.kt
@@ -0,0 +1,123 @@
+package com.powersync.demo.backgroundsync
+
+import android.app.Notification
+import android.content.Intent
+import android.content.pm.ServiceInfo
+import android.os.Build
+import androidx.core.app.NotificationChannelCompat
+import androidx.core.app.NotificationManagerCompat
+import androidx.core.app.ServiceCompat
+import androidx.lifecycle.LifecycleService
+import androidx.lifecycle.lifecycleScope
+import co.touchlab.kermit.Logger
+import com.powersync.PowerSyncDatabase
+import com.powersync.connector.supabase.SupabaseConnector
+import com.powersync.sync.SyncStatusData
+import io.github.jan.supabase.auth.status.SessionStatus
+import kotlinx.atomicfu.atomic
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.launch
+import org.koin.android.ext.android.inject
+
+class SyncService: LifecycleService() {
+
+ private val connector: SupabaseConnector by inject()
+ private val database: PowerSyncDatabase by inject()
+ private var holdsServiceLock = false
+
+ private val notificationManager get()= NotificationManagerCompat.from(this)
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ super.onStartCommand(intent, flags, startId)
+
+ holdsServiceLock = SERVICE_RUNNING.compareAndSet(false, true)
+ if (!holdsServiceLock) {
+ stopSelf()
+ return START_NOT_STICKY
+ }
+
+ createNotificationChannel()
+ ServiceCompat.startForeground(
+ this,
+ startId,
+ buildNotification(),
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
+ } else {
+ 0
+ }
+ )
+
+ lifecycleScope.launch {
+ database.currentStatus.asFlow().collect {
+ try {
+ Logger.i("Sync service received status $it")
+ notificationManager.notify(startId, buildNotification(it))
+ } catch (e: SecurityException) {
+ Logger.d("Ignoring security exception when updating notification", e)
+ }
+ }
+ }
+
+ lifecycleScope.launch {
+ connector.sessionStatus.collect {
+ when (it) {
+ is SessionStatus.Authenticated -> {
+ database.connect(connector)
+ }
+ is SessionStatus.NotAuthenticated -> {
+ database.disconnectAndClear()
+ Logger.i("Stopping sync service, user logged out")
+ return@collect
+ }
+ else -> {
+ // Ignore
+ }
+ }
+ }
+ }.invokeOnCompletion {
+ if (it !is CancellationException) {
+ this.lifecycle.currentState
+ stopSelf(startId)
+ }
+ }
+
+ return START_NOT_STICKY
+ }
+
+ override fun onTimeout(startId: Int, fgsType: Int) {
+ // Background sync was running for too long without the app ever being open...
+ stopSelf(startId)
+ }
+
+ override fun onDestroy() {
+ if (holdsServiceLock) {
+ SERVICE_RUNNING.value = false
+ }
+
+ super.onDestroy()
+ }
+
+ private fun createNotificationChannel() {
+ val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_LOW)
+ .setName(getString(R.string.background_channel_name))
+ .build()
+ notificationManager.createNotificationChannel(channel)
+ }
+
+ private fun buildNotification(state: SyncStatusData? = null): Notification = Notification.Builder(this, CHANNEL_ID).apply {
+ setContentTitle(getString(R.string.sync_notification_title))
+ setSmallIcon(R.drawable.ic_launcher_foreground)
+
+ if (state != null) {
+ if (state.uploading || state.downloading) {
+ setProgress(0, 0, true)
+ }
+ }
+ }.build()
+
+ private companion object {
+ val CHANNEL_ID = "background_sync"
+ val SERVICE_RUNNING = atomic(false)
+ }
+}
diff --git a/demos/supabase-todolist/androidBackgroundSync/src/main/res/drawable/ic_launcher_background.xml b/demos/supabase-todolist/androidBackgroundSync/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 00000000..07d5da9c
--- /dev/null
+++ b/demos/supabase-todolist/androidBackgroundSync/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,170 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/demos/supabase-todolist/androidBackgroundSync/src/main/res/drawable/ic_launcher_foreground.xml b/demos/supabase-todolist/androidBackgroundSync/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 00000000..2b068d11
--- /dev/null
+++ b/demos/supabase-todolist/androidBackgroundSync/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/demos/supabase-todolist/androidBackgroundSync/src/main/res/mipmap-anydpi/ic_launcher.xml b/demos/supabase-todolist/androidBackgroundSync/src/main/res/mipmap-anydpi/ic_launcher.xml
new file mode 100644
index 00000000..6f3b755b
--- /dev/null
+++ b/demos/supabase-todolist/androidBackgroundSync/src/main/res/mipmap-anydpi/ic_launcher.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/demos/supabase-todolist/androidBackgroundSync/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/demos/supabase-todolist/androidBackgroundSync/src/main/res/mipmap-anydpi/ic_launcher_round.xml
new file mode 100644
index 00000000..6f3b755b
--- /dev/null
+++ b/demos/supabase-todolist/androidBackgroundSync/src/main/res/mipmap-anydpi/ic_launcher_round.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/demos/supabase-todolist/androidBackgroundSync/src/main/res/mipmap-hdpi/ic_launcher.webp b/demos/supabase-todolist/androidBackgroundSync/src/main/res/mipmap-hdpi/ic_launcher.webp
new file mode 100644
index 00000000..c209e78e
Binary files /dev/null and b/demos/supabase-todolist/androidBackgroundSync/src/main/res/mipmap-hdpi/ic_launcher.webp differ
diff --git a/demos/supabase-todolist/androidBackgroundSync/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/demos/supabase-todolist/androidBackgroundSync/src/main/res/mipmap-hdpi/ic_launcher_round.webp
new file mode 100644
index 00000000..b2dfe3d1
Binary files /dev/null and b/demos/supabase-todolist/androidBackgroundSync/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ
diff --git a/demos/supabase-todolist/androidBackgroundSync/src/main/res/mipmap-mdpi/ic_launcher.webp b/demos/supabase-todolist/androidBackgroundSync/src/main/res/mipmap-mdpi/ic_launcher.webp
new file mode 100644
index 00000000..4f0f1d64
Binary files /dev/null and b/demos/supabase-todolist/androidBackgroundSync/src/main/res/mipmap-mdpi/ic_launcher.webp differ
diff --git a/demos/supabase-todolist/androidBackgroundSync/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/demos/supabase-todolist/androidBackgroundSync/src/main/res/mipmap-mdpi/ic_launcher_round.webp
new file mode 100644
index 00000000..62b611da
Binary files /dev/null and b/demos/supabase-todolist/androidBackgroundSync/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ
diff --git a/demos/supabase-todolist/androidBackgroundSync/src/main/res/mipmap-xhdpi/ic_launcher.webp b/demos/supabase-todolist/androidBackgroundSync/src/main/res/mipmap-xhdpi/ic_launcher.webp
new file mode 100644
index 00000000..948a3070
Binary files /dev/null and b/demos/supabase-todolist/androidBackgroundSync/src/main/res/mipmap-xhdpi/ic_launcher.webp differ
diff --git a/demos/supabase-todolist/androidBackgroundSync/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/demos/supabase-todolist/androidBackgroundSync/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
new file mode 100644
index 00000000..1b9a6956
Binary files /dev/null and b/demos/supabase-todolist/androidBackgroundSync/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ
diff --git a/demos/supabase-todolist/androidBackgroundSync/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/demos/supabase-todolist/androidBackgroundSync/src/main/res/mipmap-xxhdpi/ic_launcher.webp
new file mode 100644
index 00000000..28d4b77f
Binary files /dev/null and b/demos/supabase-todolist/androidBackgroundSync/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ
diff --git a/demos/supabase-todolist/androidBackgroundSync/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/demos/supabase-todolist/androidBackgroundSync/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
new file mode 100644
index 00000000..9287f508
Binary files /dev/null and b/demos/supabase-todolist/androidBackgroundSync/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ
diff --git a/demos/supabase-todolist/androidBackgroundSync/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/demos/supabase-todolist/androidBackgroundSync/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
new file mode 100644
index 00000000..aa7d6427
Binary files /dev/null and b/demos/supabase-todolist/androidBackgroundSync/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ
diff --git a/demos/supabase-todolist/androidBackgroundSync/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/demos/supabase-todolist/androidBackgroundSync/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
new file mode 100644
index 00000000..9126ae37
Binary files /dev/null and b/demos/supabase-todolist/androidBackgroundSync/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ
diff --git a/demos/supabase-todolist/androidBackgroundSync/src/main/res/values/colors.xml b/demos/supabase-todolist/androidBackgroundSync/src/main/res/values/colors.xml
new file mode 100644
index 00000000..f8c6127d
--- /dev/null
+++ b/demos/supabase-todolist/androidBackgroundSync/src/main/res/values/colors.xml
@@ -0,0 +1,10 @@
+
+
+ #FFBB86FC
+ #FF6200EE
+ #FF3700B3
+ #FF03DAC5
+ #FF018786
+ #FF000000
+ #FFFFFFFF
+
\ No newline at end of file
diff --git a/demos/supabase-todolist/androidBackgroundSync/src/main/res/values/strings.xml b/demos/supabase-todolist/androidBackgroundSync/src/main/res/values/strings.xml
new file mode 100644
index 00000000..506896fb
--- /dev/null
+++ b/demos/supabase-todolist/androidBackgroundSync/src/main/res/values/strings.xml
@@ -0,0 +1,5 @@
+
+ PowerSync Background Sync
+ Active background sync
+ Background sync in progress
+
\ No newline at end of file
diff --git a/demos/supabase-todolist/androidBackgroundSync/src/main/res/values/themes.xml b/demos/supabase-todolist/androidBackgroundSync/src/main/res/values/themes.xml
new file mode 100644
index 00000000..790b0fb1
--- /dev/null
+++ b/demos/supabase-todolist/androidBackgroundSync/src/main/res/values/themes.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/demos/supabase-todolist/androidBackgroundSync/src/main/res/xml/network_security_config.xml b/demos/supabase-todolist/androidBackgroundSync/src/main/res/xml/network_security_config.xml
new file mode 100644
index 00000000..de61259a
--- /dev/null
+++ b/demos/supabase-todolist/androidBackgroundSync/src/main/res/xml/network_security_config.xml
@@ -0,0 +1,6 @@
+
+
+
+ localhost
+
+
diff --git a/demos/supabase-todolist/build.gradle.kts b/demos/supabase-todolist/build.gradle.kts
index 4f25ebe9..7cc95b1d 100644
--- a/demos/supabase-todolist/build.gradle.kts
+++ b/demos/supabase-todolist/build.gradle.kts
@@ -4,5 +4,7 @@ plugins {
alias(libs.plugins.jetbrainsCompose) apply false
alias(libs.plugins.compose.compiler) apply false
alias(libs.plugins.kotlinMultiplatform) apply false
+ alias(libs.plugins.kotlinAndroid) apply false
alias(libs.plugins.cocoapods) apply false
+ alias(libs.plugins.kotlin.atomicfu) apply false
}
diff --git a/demos/supabase-todolist/docs/BackgroundSync.md b/demos/supabase-todolist/docs/BackgroundSync.md
new file mode 100644
index 00000000..11ded2b6
--- /dev/null
+++ b/demos/supabase-todolist/docs/BackgroundSync.md
@@ -0,0 +1,57 @@
+# Background synchronization with PowerSync on Android
+
+The PowerSync SDK supports background synchronization (i.e., synchronization clients active without
+a visible UI).
+To use background synchronization, make sure that:
+
+1. You only use a single instance of your `PowerSyncDatabase`. Using multiple instances means that
+ multiple write connections are active, which can lead to "database is locked" issues, wasted
+ resources due to multiple sync clients, and `watch()`ed queries not updating due to missing
+ update notifications.
+2. You don't use separate processes for the UI and the background service.
+
+These limitations are not inherent architectural issues, but sharing databases across processes is
+not currently supported in the Android SDK. Please reach out if you need that feature!
+
+PowerSync works by creating a long-lived connection to a synchronization service that pushes
+database changes. This means that PowerSync works best with background services that can stay active
+for longer periods of time.
+At the same time, PowerSync is able to handle interruptions - so you can also connect your database
+to the sync service on a short-lived task like e.g. WorkManager. PowerSync will try to download as
+many operations as possible, and automatically picks up work from where it was previously stopped.
+
+## Case study: Foreground services
+
+The `androidBackgroundSync/` folder of the `supabase-todolist` demo contains a working example that
+keeps synchronization active without a listening UI.
+While it uses foreground services on Android, other APIs that run work in the same process would
+work too.
+
+To set up this type of sync in your app, follow these steps:
+
+1. Set up your app for background sync. In the example, we declare a new service with
+ `android:foregroundServiceType="dataSync"` and add the `FOREGROUND_SERVICE`,
+ `FOREGROUND_SERVICE_DATA_SYNC` and `POST_NOTIFICATIONS` permissions.
+2. Adapt an architecture that lets you share a PowerSync database between your compose UI and these
+ background services. For Kotlin multiplatform support, our example uses Koin. If Android is your
+ only target, you may want to consider Dagger/Hilt instead. As long as you ensure only a single
+ instance of the PowerSync database is created, all is good!
+3. Start the service at an appropriate time. In the `MainActivity` of the background sync example,
+ we wait for the user to be logged in and then start the foreground service.
+4. In the service, obtain an instance of the database in `onStartCommand` and call
+ `database.connect` with your backend connector. If you want the synchronization to be tied to the
+ service's lifecycle, call `database.disconnect()` when the service stops.
+
+With those steps, PowerSync will keep synchronizing your database in the backend. You can try this
+out in the example app by:
+
+1. Starting it.
+2. Closing the app by removing the activity from the recently used tasks.
+3. Changing an entry in the database.
+4. Activating airplane mode before opening the app again.
+5. Despite no internet access while the app is open again, the row should be updated in the client
+ too!
+
+While this example uses foreground services (because it appears to be the only Android API that
+allows us to keep long-running connections active), adopting a different pattern of e.g. using
+Work Manager to schedule a single sync iteration regularly would also work with a similar pattern.
diff --git a/demos/supabase-todolist/gradle/libs.versions.toml b/demos/supabase-todolist/gradle/libs.versions.toml
index df060873..6a5d623d 100644
--- a/demos/supabase-todolist/gradle/libs.versions.toml
+++ b/demos/supabase-todolist/gradle/libs.versions.toml
@@ -4,6 +4,7 @@ android-minSdk = "24"
android-targetSdk = "35"
android-compileSdk = "35"
java = "17"
+atomicfu = "0.27.0"
# Dependencies
kotlin = "2.1.10"
@@ -13,6 +14,7 @@ kotlinx-io = "0.5.4"
ktor = "3.0.1"
uuid = "0.8.2"
buildKonfig = "0.15.1"
+koin-bom = "4.0.2"
junit = "4.13.2"
@@ -57,8 +59,12 @@ androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity-compose" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "androidx-espresso-core" }
androidx-material = { group = "com.google.android.material", name = "material", version.ref = "androidx-material" }
+androidx-lifecycle-service = { module = "androidx.lifecycle:lifecycle-service", version.ref = "lifecycle" }
compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "compose-preview" }
compose-lifecycle = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" }
+koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin-bom" }
+koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel", version.ref = "koin-bom" }
+koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin-bom" }
[plugins]
androidApplication = { id = "com.android.application", version.ref = "android-gradle-plugin" }
@@ -66,7 +72,9 @@ androidLibrary = { id = "com.android.library", version.ref = "android-gradle-plu
jetbrainsCompose = { id = "org.jetbrains.compose", version.ref = "compose" }
cocoapods = { id = "org.jetbrains.kotlin.native.cocoapods", version.ref = "kotlin" }
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
+kotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
buildKonfig = { id = "com.codingfeline.buildkonfig", version.ref = "buildKonfig" }
+kotlin-atomicfu = { id = "org.jetbrains.kotlinx.atomicfu", version.ref = "atomicfu" }
[bundles]
diff --git a/demos/supabase-todolist/iosApp/iosApp.xcodeproj/project.pbxproj b/demos/supabase-todolist/iosApp/iosApp.xcodeproj/project.pbxproj
index 08233b9b..e8559884 100644
--- a/demos/supabase-todolist/iosApp/iosApp.xcodeproj/project.pbxproj
+++ b/demos/supabase-todolist/iosApp/iosApp.xcodeproj/project.pbxproj
@@ -118,6 +118,7 @@
7555FF79242A565900829871 /* Resources */,
F85CB1118929364A9C6EFABC /* Frameworks */,
3C5ACF3A4AAFF294B2A5839B /* [CP] Embed Pods Frameworks */,
+ 1015E800EC39A6B62654C306 /* [CP] Copy Pods Resources */,
);
buildRules = (
);
@@ -191,6 +192,23 @@
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
};
+ 1015E800EC39A6B62654C306 /* [CP] Copy Pods Resources */ = {
+ isa = PBXShellScriptBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputFileListPaths = (
+ "${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-resources-${CONFIGURATION}-input-files.xcfilelist",
+ );
+ name = "[CP] Copy Pods Resources";
+ outputFileListPaths = (
+ "${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-resources-${CONFIGURATION}-output-files.xcfilelist",
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-resources.sh\"\n";
+ showEnvVarsInLog = 0;
+ };
3C5ACF3A4AAFF294B2A5839B /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
diff --git a/demos/supabase-todolist/settings.gradle.kts b/demos/supabase-todolist/settings.gradle.kts
index 0a497510..bcd0af04 100644
--- a/demos/supabase-todolist/settings.gradle.kts
+++ b/demos/supabase-todolist/settings.gradle.kts
@@ -37,6 +37,7 @@ plugins {
rootProject.name = "supabase-todolist"
include(":androidApp")
+include(":androidBackgroundSync")
include(":shared")
include(":desktopApp")
diff --git a/demos/supabase-todolist/shared/build.gradle.kts b/demos/supabase-todolist/shared/build.gradle.kts
index 5c3e0618..abbdbe0e 100644
--- a/demos/supabase-todolist/shared/build.gradle.kts
+++ b/demos/supabase-todolist/shared/build.gradle.kts
@@ -51,6 +51,8 @@ kotlin {
implementation(compose.components.resources)
implementation(compose.materialIconsExtended)
implementation(libs.compose.lifecycle)
+ api(libs.koin.core)
+ implementation(libs.koin.compose.viewmodel)
}
androidMain.dependencies {
api(libs.androidx.activity.compose)
diff --git a/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/App.kt b/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/App.kt
index 765f3ea7..c1a1cb95 100644
--- a/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/App.kt
+++ b/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/App.kt
@@ -15,6 +15,7 @@ import com.powersync.DatabaseDriverFactory
import com.powersync.PowerSyncDatabase
import com.powersync.bucket.BucketPriority
import com.powersync.connector.supabase.SupabaseConnector
+import com.powersync.connectors.PowerSyncBackendConnector
import com.powersync.demos.components.EditDialog
import com.powersync.demos.powersync.ListContent
import com.powersync.demos.powersync.ListItem
@@ -26,21 +27,50 @@ import com.powersync.demos.screens.SignUpScreen
import com.powersync.demos.screens.TodosScreen
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.runBlocking
+import org.koin.compose.KoinApplication
+import org.koin.compose.koinInject
+import org.koin.compose.viewmodel.koinViewModel
+import org.koin.core.KoinApplication
+import org.koin.core.module.dsl.bind
+import org.koin.core.module.dsl.viewModelOf
+import org.koin.core.module.dsl.withOptions
+import org.koin.dsl.module
+
+val sharedAppModule = module {
+ // This is overridden by the androidBackgroundSync example
+ single { AuthOptions(connectFromViewModel = true) }
+ single { PowerSyncDatabase(get(), schema) }
+ single {
+ SupabaseConnector(
+ powerSyncEndpoint = Config.POWERSYNC_URL,
+ supabaseUrl = Config.SUPABASE_URL,
+ supabaseKey = Config.SUPABASE_ANON_KEY,
+ )
+ } withOptions { bind() }
+
+ single { NavController(Screen.Home) }
+ viewModelOf(::AuthViewModel)
+}
@Composable
fun App(
factory: DatabaseDriverFactory,
modifier: Modifier = Modifier,
) {
- val supabase =
- remember {
- SupabaseConnector(
- powerSyncEndpoint = Config.POWERSYNC_URL,
- supabaseUrl = Config.SUPABASE_URL,
- supabaseKey = Config.SUPABASE_ANON_KEY,
- )
- }
- val db = remember { PowerSyncDatabase(factory, schema) }
+ fun KoinApplication.withDatabase() {
+ modules(module { single { factory } }, sharedAppModule)
+ }
+
+ KoinApplication(application = KoinApplication::withDatabase) {
+ AppContent(modifier=modifier)
+ }
+}
+
+@Composable
+fun AppContent(
+ db: PowerSyncDatabase = koinInject(),
+ modifier: Modifier = Modifier,
+) {
// Debouncing the status flow prevents flicker
val status by db.currentStatus
.asFlow()
@@ -55,12 +85,8 @@ fun App(
derivedStateOf { status.statusForPriority(BucketPriority(1)).hasSynced }
}
- val navController = remember { NavController(Screen.Home) }
- val authViewModel =
- remember {
- AuthViewModel(supabase, db, navController)
- }
-
+ val authViewModel = koinViewModel()
+ val navController = koinInject()
val authState by authViewModel.authState.collectAsState()
val currentScreen by navController.currentScreen.collectAsState()
diff --git a/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/Auth.kt b/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/Auth.kt
index 618de02c..846cd531 100644
--- a/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/Auth.kt
+++ b/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/Auth.kt
@@ -11,6 +11,16 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
+data class AuthOptions(
+ /**
+ * Whether the auth view mode is responsible for connecting to PowerSync.
+ * This is a simplification we use in the default example. When starting the
+ * androidBackgroundSync app, this is false because we're connecting from a
+ * foreground service.
+ */
+ val connectFromViewModel: Boolean
+)
+
sealed class AuthState {
data object SignedOut : AuthState()
@@ -21,6 +31,7 @@ internal class AuthViewModel(
private val supabase: SupabaseConnector,
private val db: PowerSyncDatabase,
private val navController: NavController,
+ authOptions: AuthOptions,
) : ViewModel() {
private val _authState = MutableStateFlow(AuthState.SignedOut)
val authState: StateFlow = _authState
@@ -28,13 +39,30 @@ internal class AuthViewModel(
val userId: StateFlow = _userId
init {
+ if (authOptions.connectFromViewModel) {
+ viewModelScope.launch {
+ supabase.sessionStatus.collect {
+ when (it) {
+ is SessionStatus.Authenticated -> {
+ db.connect(supabase)
+ }
+ is SessionStatus.NotAuthenticated -> {
+ db.disconnectAndClear()
+ }
+ else -> {
+ // Ignore
+ }
+ }
+ }
+ }
+ }
+
viewModelScope.launch {
supabase.sessionStatus.collect {
when (it) {
is SessionStatus.Authenticated -> {
_authState.value = AuthState.SignedIn
_userId.value = it.session.user?.id
- db.connect(supabase)
navController.navigate(Screen.Home)
}
@@ -46,7 +74,6 @@ internal class AuthViewModel(
}
}
is SessionStatus.NotAuthenticated -> {
- db.disconnectAndClear()
_authState.value = AuthState.SignedOut
navController.navigate(Screen.SignIn)
}