Skip to content

Commit b808c1b

Browse files
committed
Add background sync example
1 parent 676622e commit b808c1b

32 files changed

+588
-8
lines changed

demos/supabase-todolist/README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,3 +58,10 @@ SUPABASE_ANON_KEY=foo
5858
## Run the app
5959

6060
Choose a run configuration for the Android or iOS target in Android Studio and run it.
61+
62+
For Android, this demo contains two Android apps:
63+
64+
- [`androidApp/`](androidApp/): This is a regular compose UI app using PowerSync.
65+
- [`androidBackgroundSync/`](androidBackgroundSync/): This example differs from the regular app in
66+
that it uses a foreground service managing the synchronization process. The service is started
67+
in the main activity and keeps running even after the app is closed.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/build
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
plugins {
2+
alias(libs.plugins.androidApplication)
3+
alias(libs.plugins.kotlinAndroid)
4+
alias(libs.plugins.compose.compiler)
5+
id("org.jetbrains.compose")
6+
}
7+
8+
android {
9+
namespace = "com.powersync.demo.backgroundsync"
10+
compileSdk = 35
11+
12+
defaultConfig {
13+
applicationId = "com.powersync.demo.backgroundsync"
14+
minSdk = 28
15+
targetSdk = 35
16+
versionCode = 1
17+
versionName = "1.0"
18+
19+
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
20+
}
21+
22+
buildTypes {
23+
release {
24+
isMinifyEnabled = false
25+
proguardFiles(
26+
getDefaultProguardFile("proguard-android-optimize.txt"),
27+
"proguard-rules.pro"
28+
)
29+
}
30+
}
31+
compileOptions {
32+
sourceCompatibility = JavaVersion.VERSION_11
33+
targetCompatibility = JavaVersion.VERSION_11
34+
}
35+
kotlinOptions {
36+
jvmTarget = "11"
37+
}
38+
buildFeatures {
39+
compose = true
40+
}
41+
}
42+
43+
dependencies {
44+
// When copying this example, replace "latest.release" with the current version available
45+
// at: https://central.sonatype.com/artifact/com.powersync/connector-supabase
46+
implementation("com.powersync:connector-supabase:latest.release")
47+
48+
implementation(projects.shared)
49+
50+
implementation(compose.material)
51+
implementation(libs.androidx.core)
52+
implementation(libs.androidx.activity.compose)
53+
implementation(libs.androidx.lifecycle.service)
54+
implementation(libs.compose.lifecycle)
55+
implementation(libs.compose.ui.tooling.preview)
56+
implementation(libs.koin.android)
57+
implementation(libs.koin.compose.viewmodel)
58+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Add project specific ProGuard rules here.
2+
# You can control the set of applied configuration files using the
3+
# proguardFiles setting in build.gradle.
4+
#
5+
# For more details, see
6+
# http://developer.android.com/guide/developing/tools/proguard.html
7+
8+
# If your project uses WebView with JS, uncomment the following
9+
# and specify the fully qualified class name to the JavaScript interface
10+
# class:
11+
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12+
# public *;
13+
#}
14+
15+
# Uncomment this to preserve the line number information for
16+
# debugging stack traces.
17+
#-keepattributes SourceFile,LineNumberTable
18+
19+
# If you keep the line number information, uncomment this to
20+
# hide the original source file name.
21+
#-renamesourcefileattribute SourceFile
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
3+
4+
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
5+
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
6+
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
7+
<uses-permission android:name="android.permission.INTERNET" />
8+
9+
<application
10+
android:allowBackup="true"
11+
android:icon="@mipmap/ic_launcher"
12+
android:label="@string/app_name"
13+
android:roundIcon="@mipmap/ic_launcher_round"
14+
android:supportsRtl="true"
15+
android:networkSecurityConfig="@xml/network_security_config"
16+
android:name=".MainApplication"
17+
android:theme="@style/Theme.Supabasetodolist">
18+
<activity
19+
android:name=".MainActivity"
20+
android:exported="true"
21+
android:label="@string/app_name"
22+
android:theme="@style/Theme.Supabasetodolist">
23+
<intent-filter>
24+
<action android:name="android.intent.action.MAIN" />
25+
26+
<category android:name="android.intent.category.LAUNCHER" />
27+
</intent-filter>
28+
</activity>
29+
30+
<service android:name=".SyncService" android:exported="false" android:foregroundServiceType="dataSync" />
31+
</application>
32+
</manifest>
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package com.powersync.demo.backgroundsync
2+
3+
import android.content.Intent
4+
import android.os.Bundle
5+
import androidx.activity.ComponentActivity
6+
import androidx.activity.compose.setContent
7+
import androidx.activity.enableEdgeToEdge
8+
import androidx.compose.foundation.layout.WindowInsets
9+
import androidx.compose.foundation.layout.fillMaxSize
10+
import androidx.compose.foundation.layout.systemBars
11+
import androidx.compose.foundation.layout.windowInsetsPadding
12+
import androidx.compose.material.MaterialTheme
13+
import androidx.compose.material.Surface
14+
import androidx.compose.ui.Modifier
15+
import androidx.lifecycle.lifecycleScope
16+
import com.powersync.connector.supabase.SupabaseConnector
17+
import com.powersync.demos.AppContent
18+
import io.github.jan.supabase.auth.status.SessionStatus
19+
import kotlinx.coroutines.launch
20+
import org.koin.android.ext.android.inject
21+
import org.koin.compose.KoinContext
22+
23+
class MainActivity : ComponentActivity() {
24+
25+
private val connector: SupabaseConnector by inject()
26+
27+
override fun onCreate(savedInstanceState: Bundle?) {
28+
super.onCreate(savedInstanceState)
29+
enableEdgeToEdge()
30+
31+
lifecycleScope.launch {
32+
// Watch the authentication state and start a sync foreground service once the user logs
33+
// in.
34+
connector.sessionStatus.collect {
35+
if (it is SessionStatus.Authenticated) {
36+
startForegroundService(Intent().apply {
37+
setClass(this@MainActivity, SyncService::class.java)
38+
})
39+
}
40+
}
41+
}
42+
43+
setContent {
44+
// We've already started Koin from our application class to be able to use the database
45+
// outside of the UI here. So, use KoinContext and AppContent instead of the App()
46+
// composable that would set up its own context.
47+
KoinContext {
48+
MaterialTheme {
49+
Surface(color = MaterialTheme.colors.background) {
50+
AppContent(
51+
modifier=Modifier.fillMaxSize().windowInsetsPadding(WindowInsets.systemBars)
52+
)
53+
}
54+
}
55+
}
56+
}
57+
}
58+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package com.powersync.demo.backgroundsync
2+
3+
import android.app.Application
4+
import com.powersync.DatabaseDriverFactory
5+
import com.powersync.demos.AuthOptions
6+
import com.powersync.demos.sharedAppModule
7+
import org.koin.android.ext.koin.androidContext
8+
import org.koin.android.ext.koin.androidLogger
9+
import org.koin.core.context.startKoin
10+
import org.koin.core.module.dsl.singleOf
11+
import org.koin.dsl.module
12+
13+
class MainApplication : Application() {
14+
override fun onCreate() {
15+
super.onCreate()
16+
17+
startKoin {
18+
androidLogger()
19+
androidContext(this@MainApplication)
20+
21+
modules(sharedAppModule, module {
22+
single { AuthOptions(connectFromViewModel = false) }
23+
singleOf(::DatabaseDriverFactory)
24+
})
25+
}
26+
}
27+
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
package com.powersync.demo.backgroundsync
2+
3+
import android.app.Notification
4+
import android.content.Intent
5+
import android.content.pm.ServiceInfo
6+
import android.os.Build
7+
import androidx.core.app.NotificationChannelCompat
8+
import androidx.core.app.NotificationManagerCompat
9+
import androidx.core.app.ServiceCompat
10+
import androidx.lifecycle.LifecycleService
11+
import androidx.lifecycle.lifecycleScope
12+
import co.touchlab.kermit.Logger
13+
import com.powersync.PowerSyncDatabase
14+
import com.powersync.connector.supabase.SupabaseConnector
15+
import com.powersync.sync.SyncStatusData
16+
import io.github.jan.supabase.auth.status.SessionStatus
17+
import kotlinx.coroutines.CancellationException
18+
import kotlinx.coroutines.launch
19+
import org.koin.android.ext.android.inject
20+
21+
class SyncService: LifecycleService() {
22+
23+
private val connector: SupabaseConnector by inject()
24+
private val database: PowerSyncDatabase by inject()
25+
26+
private val notificationManager get()= NotificationManagerCompat.from(this)
27+
28+
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
29+
super.onStartCommand(intent, flags, startId)
30+
31+
createNotificationChannel()
32+
33+
ServiceCompat.startForeground(
34+
this,
35+
startId,
36+
buildNotification(),
37+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
38+
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
39+
} else {
40+
0
41+
}
42+
)
43+
44+
lifecycleScope.launch {
45+
database.currentStatus.asFlow().collect {
46+
try {
47+
notificationManager.notify(startId, buildNotification(it))
48+
} catch (e: SecurityException) {
49+
Logger.d("Ignoring security exception when updating notification", e)
50+
}
51+
}
52+
}
53+
54+
lifecycleScope.launch {
55+
connector.sessionStatus.collect {
56+
when (it) {
57+
is SessionStatus.Authenticated -> {
58+
database.connect(connector)
59+
}
60+
is SessionStatus.NotAuthenticated -> {
61+
database.disconnectAndClear()
62+
Logger.i("Stopping sync service, user logged out")
63+
return@collect
64+
}
65+
else -> {
66+
// Ignore
67+
}
68+
}
69+
}
70+
}.invokeOnCompletion {
71+
if (it !is CancellationException) {
72+
this.lifecycle.currentState
73+
stopSelf(startId)
74+
}
75+
}
76+
77+
return START_NOT_STICKY
78+
}
79+
80+
override fun onTimeout(startId: Int, fgsType: Int) {
81+
// Background sync was running for too long without the app ever being open...
82+
stopSelf(startId)
83+
}
84+
85+
private fun createNotificationChannel() {
86+
val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_LOW)
87+
.setName(getString(R.string.background_channel_name))
88+
.build()
89+
notificationManager.createNotificationChannel(channel)
90+
}
91+
92+
private fun buildNotification(state: SyncStatusData? = null): Notification = Notification.Builder(this, CHANNEL_ID).apply {
93+
setContentTitle(getString(R.string.sync_notification_title))
94+
setSmallIcon(R.drawable.ic_launcher_foreground)
95+
96+
if (state != null) {
97+
if (state.uploading || state.downloading) {
98+
setProgress(0, 0, true)
99+
}
100+
}
101+
}.build()
102+
103+
private companion object {
104+
private val CHANNEL_ID = "background_sync"
105+
}
106+
}

0 commit comments

Comments
 (0)