Skip to content

Commit bca578f

Browse files
authored
Merge pull request #143 from powersync-ja/background-sync-example
Demo: Add example using a foreground service for synchronization
2 parents 953c3b4 + 9991199 commit bca578f

34 files changed

+725
-17
lines changed

demos/supabase-todolist/README.md

+8
Original file line numberDiff line numberDiff line change
@@ -58,3 +58,11 @@ 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.
68+
For more notes on background sync, see [this document](docs/BackgroundSync.md).
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/build
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
plugins {
2+
alias(libs.plugins.androidApplication)
3+
alias(libs.plugins.kotlinAndroid)
4+
alias(libs.plugins.compose.compiler)
5+
id("org.jetbrains.compose")
6+
alias(libs.plugins.kotlin.atomicfu)
7+
}
8+
9+
android {
10+
namespace = "com.powersync.demo.backgroundsync"
11+
compileSdk = 35
12+
13+
defaultConfig {
14+
applicationId = "com.powersync.demo.backgroundsync"
15+
minSdk = 28
16+
targetSdk = 35
17+
versionCode = 1
18+
versionName = "1.0"
19+
20+
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
21+
}
22+
23+
buildTypes {
24+
release {
25+
isMinifyEnabled = false
26+
proguardFiles(
27+
getDefaultProguardFile("proguard-android-optimize.txt"),
28+
"proguard-rules.pro"
29+
)
30+
}
31+
}
32+
compileOptions {
33+
sourceCompatibility = JavaVersion.VERSION_11
34+
targetCompatibility = JavaVersion.VERSION_11
35+
}
36+
kotlinOptions {
37+
jvmTarget = "11"
38+
}
39+
buildFeatures {
40+
compose = true
41+
}
42+
}
43+
44+
dependencies {
45+
// When copying this example, replace "latest.release" with the current version available
46+
// at: https://central.sonatype.com/artifact/com.powersync/connector-supabase
47+
implementation("com.powersync:connector-supabase:latest.release")
48+
49+
implementation(projects.shared)
50+
51+
implementation(compose.material)
52+
implementation(libs.androidx.core)
53+
implementation(libs.androidx.activity.compose)
54+
implementation(libs.androidx.lifecycle.service)
55+
implementation(libs.compose.lifecycle)
56+
implementation(libs.compose.ui.tooling.preview)
57+
implementation(libs.koin.android)
58+
implementation(libs.koin.compose.viewmodel)
59+
}
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
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>
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+
}
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+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
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.atomicfu.atomic
18+
import kotlinx.coroutines.CancellationException
19+
import kotlinx.coroutines.launch
20+
import org.koin.android.ext.android.inject
21+
22+
class SyncService: LifecycleService() {
23+
24+
private val connector: SupabaseConnector by inject()
25+
private val database: PowerSyncDatabase by inject()
26+
private var holdsServiceLock = false
27+
28+
private val notificationManager get()= NotificationManagerCompat.from(this)
29+
30+
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
31+
super.onStartCommand(intent, flags, startId)
32+
33+
holdsServiceLock = SERVICE_RUNNING.compareAndSet(false, true)
34+
if (!holdsServiceLock) {
35+
stopSelf()
36+
return START_NOT_STICKY
37+
}
38+
39+
createNotificationChannel()
40+
ServiceCompat.startForeground(
41+
this,
42+
startId,
43+
buildNotification(),
44+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
45+
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
46+
} else {
47+
0
48+
}
49+
)
50+
51+
lifecycleScope.launch {
52+
database.currentStatus.asFlow().collect {
53+
try {
54+
Logger.i("Sync service received status $it")
55+
notificationManager.notify(startId, buildNotification(it))
56+
} catch (e: SecurityException) {
57+
Logger.d("Ignoring security exception when updating notification", e)
58+
}
59+
}
60+
}
61+
62+
lifecycleScope.launch {
63+
connector.sessionStatus.collect {
64+
when (it) {
65+
is SessionStatus.Authenticated -> {
66+
database.connect(connector)
67+
}
68+
is SessionStatus.NotAuthenticated -> {
69+
database.disconnectAndClear()
70+
Logger.i("Stopping sync service, user logged out")
71+
return@collect
72+
}
73+
else -> {
74+
// Ignore
75+
}
76+
}
77+
}
78+
}.invokeOnCompletion {
79+
if (it !is CancellationException) {
80+
this.lifecycle.currentState
81+
stopSelf(startId)
82+
}
83+
}
84+
85+
return START_NOT_STICKY
86+
}
87+
88+
override fun onTimeout(startId: Int, fgsType: Int) {
89+
// Background sync was running for too long without the app ever being open...
90+
stopSelf(startId)
91+
}
92+
93+
override fun onDestroy() {
94+
if (holdsServiceLock) {
95+
SERVICE_RUNNING.value = false
96+
}
97+
98+
super.onDestroy()
99+
}
100+
101+
private fun createNotificationChannel() {
102+
val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_LOW)
103+
.setName(getString(R.string.background_channel_name))
104+
.build()
105+
notificationManager.createNotificationChannel(channel)
106+
}
107+
108+
private fun buildNotification(state: SyncStatusData? = null): Notification = Notification.Builder(this, CHANNEL_ID).apply {
109+
setContentTitle(getString(R.string.sync_notification_title))
110+
setSmallIcon(R.drawable.ic_launcher_foreground)
111+
112+
if (state != null) {
113+
if (state.uploading || state.downloading) {
114+
setProgress(0, 0, true)
115+
}
116+
}
117+
}.build()
118+
119+
private companion object {
120+
val CHANNEL_ID = "background_sync"
121+
val SERVICE_RUNNING = atomic(false)
122+
}
123+
}

0 commit comments

Comments
 (0)