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 @@ + + + +