diff --git a/.github/workflows/generate-apk-aab-debug-release.yml b/.github/workflows/generate-apk-aab-debug-release.yml index e3115fd..54e9720 100644 --- a/.github/workflows/generate-apk-aab-debug-release.yml +++ b/.github/workflows/generate-apk-aab-debug-release.yml @@ -45,6 +45,9 @@ jobs: - name: Ensure gradlew is executable run: chmod +x ./gradlew + - name: Decode google-services.json + run: echo "${{ secrets.GOOGLE_SERVICES_JSON }}" | openssl base64 -d > modules/lyrics-maker/google-services.json + - name: Build APK for module ${{ matrix.module }} # Using assembleRelease so you get APKs in build/outputs; if you prefer debug builds (always unsigned), # change to :${{ matrix.module }}:assembleDebug @@ -71,7 +74,7 @@ jobs: shell: bash - name: Upload APK artifact for ${{ matrix.module }} - if: always() # still run to ensure artifact (even if none found, action will create empty artifact) + if: always() # still run to ensure artifact (even if none found, action will create empty artifact) uses: actions/upload-artifact@v4 with: # name artifact per module so each appears separately in Actions UI diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6df05e1..04a2c06 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,9 @@ [versions] +firebaseBomVersion = "34.9.0" +supabaseBomVersion = "3.4.0" constraintlayoutVersion = "2.2.1" contourVersion = "1.1.0" -filamentAndroidVersion = "1.69.2" +filamentAndroidVersion = "1.69.4" jcodecVersion = "0.2.5" jsoupVersion = "1.22.1" kotlinxCoroutinesCoreVersion = "1.10.2" @@ -28,8 +30,10 @@ activityComposeVersion = "1.12.4" composeBomVersion = "2026.02.00" navigationComposeVersion = "2.9.7" ktlint = "14.0.1" -runtimeVersion = "1.10.3" +runtimeVersion = "1.10.4" composeCompilerVersion = "1.5.14" +crashlyticsVersion = "3.0.6" +googleServicesVersion = "4.4.4" [libraries] androidx-constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "constraintlayoutVersion" } @@ -39,12 +43,14 @@ androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-view contour = { module = "app.cash.contour:contour", version.ref = "contourVersion" } filament-android = { module = "com.google.android.filament:filament-android", version.ref = "filamentAndroidVersion" } filament-utils-android = { module = "com.google.android.filament:filament-utils-android", version.ref = "filamentAndroidVersion" } +firebase-crashlytics = { module = "com.google.firebase:firebase-crashlytics" } gltfio-android = { module = "com.google.android.filament:gltfio-android", version.ref = "filamentAndroidVersion" } google-android-material = { group = "com.google.android.material", name = "material", version.ref = "material_version" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle_runtime_ktx" } androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work_version" } +google-firebase-analytics = { module = "com.google.firebase:firebase-analytics" } jcodec = { module = "org.jcodec:jcodec", version.ref = "jcodecVersion" } jcodec-android = { module = "org.jcodec:jcodec-android", version.ref = "jcodecVersion" } jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoupVersion" } @@ -81,6 +87,16 @@ androidx-navigation-compose = { group = "androidx.navigation", name = "navigatio androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime", version.ref = "runtimeVersion" } compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" } +supabase-bom = { module = "io.github.jan-tennert.supabase:bom", version.ref = "supabaseBomVersion" } +supabase-auth-kt = { module = "io.github.jan-tennert.supabase:auth-kt" } +supabase-postgrest-kt = { module = "io.github.jan-tennert.supabase:postgrest-kt" } +supabase-realtime-kt = { module = "io.github.jan-tennert.supabase:realtime-kt" } + +firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebaseBomVersion" } +firebase-analytics = { module = "com.google.firebase:firebase-analytics" } +firebase-auth = { module = "com.google.firebase:firebase-auth" } +firebase-firestore = { module = "com.google.firebase:firebase-firestore" } + [plugins] android-application = { id = "com.android.application", version.ref = "agpVersion" } android-library = { id = "com.android.library", version.ref = "agpVersion" } @@ -88,6 +104,8 @@ kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", versi kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlinVersion" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlinVersion" } ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" } +google-services = { id = "com.google.gms.google-services", version.ref = "googleServicesVersion" } +crashlytics = { id = "com.google.firebase.crashlytics", version.ref = "crashlyticsVersion" } [bundles] androidx = [ @@ -141,4 +159,16 @@ compose = [ "androidx-ui-tooling-preview", "androidx-material3", "androidx-compose-runtime" +] +supabase = [ + "supabase-auth-kt", + "supabase-postgrest-kt", + "supabase-realtime-kt", + "ktor-client-cio", +] +firebase = [ + "firebase-auth", + "firebase-firestore", + "firebase-crashlytics", + "google-firebase-analytics" ] \ No newline at end of file diff --git a/modules/lyrics-maker/build.gradle b/modules/lyrics-maker/build.gradle index 5fdd6dc..674dae6 100644 --- a/modules/lyrics-maker/build.gradle +++ b/modules/lyrics-maker/build.gradle @@ -3,6 +3,8 @@ plugins { alias(libs.plugins.kotlin.serialization) alias(libs.plugins.kotlin.parcelize) alias(libs.plugins.kotlin.compose) + alias(libs.plugins.google.services) + alias(libs.plugins.crashlytics) } android { @@ -64,6 +66,9 @@ dependencies { implementation libs.androidx.navigation.compose implementation(libs.compose.material.icons.extended) + implementation platform(libs.firebase.bom) + implementation libs.bundles.firebase + testImplementation libs.junit androidTestImplementation libs.androidx.test.ext.junit androidTestImplementation libs.androidx.test.espresso.core diff --git a/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/LyricsApp.kt b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/LyricsApp.kt index e6948f2..46fd756 100644 --- a/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/LyricsApp.kt +++ b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/LyricsApp.kt @@ -2,12 +2,55 @@ package com.tejpratapsingh.lyricsmaker import android.app.Application import android.content.Context +import androidx.work.Configuration +import com.google.firebase.FirebaseApp +import com.tejpratapsingh.motionstore.dao.DownloadedTrackerDao import com.tejpratapsingh.motionstore.dao.MotionProjectDao import com.tejpratapsingh.motionstore.infra.DatabaseManager +import com.tejpratapsingh.motionstore.infra.FirebaseAdapter +import com.tejpratapsingh.motionstore.infra.SyncManager +import com.tejpratapsingh.motionstore.worker.SyncWorker +import com.tejpratapsingh.motionstore.worker.SyncWorkerFactory -class LyricsApp : Application() { +class LyricsApp : + Application(), + Configuration.Provider { val database by lazy { DatabaseManager.init(this) } - val motionStore by lazy { MotionProjectDao(database) } + val motionStoreDao by lazy { MotionProjectDao(database) } + + val syncManager by lazy { + SyncManager( + backend = FirebaseAdapter(), + downloadedTracker = DownloadedTrackerDao(database), + ) + } + + override fun onCreate() { + super.onCreate() + + FirebaseApp.initializeApp(this) + SyncWorker.schedulePeriodic(this) + } + + /** + * Provides a custom WorkManager configuration injecting [SyncManager] + * and DAOs into [SyncWorker] via [SyncWorkerFactory]. + * + * Implementing [Configuration.Provider] here replaces the default + * WorkManager auto-init. Remove the WorkManagerInitializer + * entry from AndroidManifest.xml (see SyncWorker KDoc for the snippet). + */ + + override val workManagerConfiguration: Configuration + get() = + Configuration + .Builder() + .setWorkerFactory( + SyncWorkerFactory( + syncManager = syncManager, + daos = listOf(motionStoreDao), + ), + ).build() } fun Context.asLyricsApp(): LyricsApp = applicationContext as LyricsApp diff --git a/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/activity/SearchActivity.kt b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/activity/SearchActivity.kt index 53daef6..70ff075 100644 --- a/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/activity/SearchActivity.kt +++ b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/activity/SearchActivity.kt @@ -44,7 +44,7 @@ import java.net.URLConnection class SearchActivity : ComponentActivity() { private val projectsViewModel: ProjectsViewModel by viewModels { ProjectsViewModelFactory( - applicationContext.asLyricsApp().motionStore, + applicationContext.asLyricsApp().motionStoreDao, ) } diff --git a/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/ProjectsScreenCompose.kt b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/ProjectsScreenCompose.kt index 04c5003..6d6cb31 100644 --- a/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/ProjectsScreenCompose.kt +++ b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/ProjectsScreenCompose.kt @@ -1,6 +1,5 @@ package com.tejpratapsingh.lyricsmaker.presentation.compose -import android.content.Intent import android.graphics.Bitmap import android.media.MediaMetadataRetriever import androidx.compose.foundation.BorderStroke @@ -24,6 +23,8 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Add +import androidx.compose.material.icons.rounded.CloudDone +import androidx.compose.material.icons.rounded.CloudOff import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.PlayCircle import androidx.compose.material.icons.rounded.Share @@ -41,7 +42,6 @@ import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -233,7 +233,8 @@ private fun ProjectCard( var showDeleteDialog by remember { mutableStateOf(false) } val context = LocalContext.current - val thumbnail: Bitmap? = remember(project.id) { extractFirstFrame(context.createProjectFile(project).path) } + val thumbnail: Bitmap? = + remember(project.id) { extractFirstFrame(context.createProjectFile(project).path) } if (showDeleteDialog) { DeleteConfirmationDialog( @@ -286,24 +287,38 @@ private fun ProjectCard( .padding(14.dp), verticalArrangement = Arrangement.SpaceBetween, ) { - Box( - modifier = - Modifier - .size(40.dp) - .background( - color = MaterialTheme.colorScheme.secondary.copy(alpha = 0.15f), - shape = RoundedCornerShape(10.dp), - ), - contentAlignment = Alignment.Center, + Row( + modifier = Modifier.fillMaxWidth(), // Ensure the row takes up full width + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, ) { + // Left Item + Box( + modifier = + Modifier + .size(40.dp) + .background( + color = MaterialTheme.colorScheme.secondary.copy(alpha = 0.15f), + shape = RoundedCornerShape(10.dp), + ), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Rounded.PlayCircle, + contentDescription = null, + tint = MaterialTheme.colorScheme.secondary, + modifier = Modifier.size(22.dp), + ) + } + + // Right Item Icon( - imageVector = Icons.Rounded.PlayCircle, + imageVector = if (project.syncTracker.isDirty) Icons.Rounded.CloudOff else Icons.Rounded.CloudDone, contentDescription = null, tint = MaterialTheme.colorScheme.secondary, modifier = Modifier.size(22.dp), ) } - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { Text( text = project.name, diff --git a/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/worker/LyricsMotionWorker.kt b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/worker/LyricsMotionWorker.kt index 8d2e1a9..a4014f6 100644 --- a/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/worker/LyricsMotionWorker.kt +++ b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/worker/LyricsMotionWorker.kt @@ -144,7 +144,7 @@ class LyricsMotionWorker( val motionProject = provideCurrentProject() Log.i(TAG, "onCompleted: $motionProject") - applicationContext.asLyricsApp().motionStore.upsert(motionProject) + applicationContext.asLyricsApp().motionStoreDao.upsert(motionProject) // Cancel the progress notification notificationManager.cancel(progressNotificationId) diff --git a/modules/metadata-extractor/src/main/java/com/tejpratapsingh/motion/metadataextractor/presentation/ShareReceiverActivity.kt b/modules/metadata-extractor/src/main/java/com/tejpratapsingh/motion/metadataextractor/presentation/ShareReceiverActivity.kt index 54e8c51..4c2994b 100644 --- a/modules/metadata-extractor/src/main/java/com/tejpratapsingh/motion/metadataextractor/presentation/ShareReceiverActivity.kt +++ b/modules/metadata-extractor/src/main/java/com/tejpratapsingh/motion/metadataextractor/presentation/ShareReceiverActivity.kt @@ -1,7 +1,6 @@ package com.tejpratapsingh.motion.metadataextractor.presentation import android.content.Intent -import android.os.Build import android.os.Bundle import android.util.Log import android.view.inputmethod.EditorInfo @@ -9,6 +8,7 @@ import android.widget.Toast import androidx.activity.enableEdgeToEdge import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.IntentCompat import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.isVisible @@ -26,11 +26,7 @@ class ShareReceiverActivity : AppCompatActivity() { const val ACTIVITY_INTENT_ACTION = "com.tejpratapsingh.motion.metadataextractor.action.OPEN" fun readMetadataFromIntent(intent: Intent): SocialMeta? = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - intent.getParcelableExtra(EXTRA_METADATA, SocialMeta::class.java) - } else { - intent.getParcelableExtra(EXTRA_METADATA) as SocialMeta? - } + IntentCompat.getParcelableExtra(intent, EXTRA_METADATA, SocialMeta::class.java) } private lateinit var binding: ActivityShareReceiverBinding @@ -112,8 +108,7 @@ class ShareReceiverActivity : AppCompatActivity() { Intent(ACTIVITY_INTENT_ACTION).apply { putExtra( EXTRA_METADATA, - result.metaData - .copy(title = binding.tvTitle.text.toString()), + result.metaData.copy(title = binding.tvTitle.text.toString()), ) }, ) @@ -134,9 +129,7 @@ class ShareReceiverActivity : AppCompatActivity() { is MetaDataResult.Error -> { Log.e(TAG, "Received error", result.error) - Toast - .makeText(this, "Failed to fetch metadata", Toast.LENGTH_SHORT) - .show() + Toast.makeText(this, "Failed to fetch metadata", Toast.LENGTH_SHORT).show() } } } diff --git a/modules/motion-store/build.gradle b/modules/motion-store/build.gradle index 0c3a0c1..4d3ba96 100644 --- a/modules/motion-store/build.gradle +++ b/modules/motion-store/build.gradle @@ -43,6 +43,14 @@ dependencies { implementation libs.androidx.core.ktx implementation libs.androidx.appcompat + implementation platform(libs.supabase.bom) + implementation libs.bundles.supabase + + implementation platform(libs.firebase.bom) + implementation libs.bundles.firebase + + implementation libs.androidx.work.runtime.ktx + implementation project(":modules:core") testImplementation libs.junit diff --git a/modules/motion-store/src/main/AndroidManifest.xml b/modules/motion-store/src/main/AndroidManifest.xml index a5918e6..32b6fb0 100644 --- a/modules/motion-store/src/main/AndroidManifest.xml +++ b/modules/motion-store/src/main/AndroidManifest.xml @@ -1,4 +1,9 @@ - + + + \ No newline at end of file diff --git a/modules/motion-store/src/main/java/com/tejpratapsingh/motionstore/dao/BaseDao.kt b/modules/motion-store/src/main/java/com/tejpratapsingh/motionstore/dao/BaseDao.kt index a4e4099..07a5c73 100644 --- a/modules/motion-store/src/main/java/com/tejpratapsingh/motionstore/dao/BaseDao.kt +++ b/modules/motion-store/src/main/java/com/tejpratapsingh/motionstore/dao/BaseDao.kt @@ -105,13 +105,13 @@ abstract class BaseDao( // ── Read ───────────────────────────────────────────────────────────────── /** Fetch a single entity by primary key, or null if not found. */ - fun findById(id: Long): T? = + fun findById(id: String): T? = db .query( tableName, null, "$primaryKey = ?", - arrayOf(id.toString()), + arrayOf(id), null, null, null, @@ -150,7 +150,7 @@ abstract class BaseDao( /** Update the row with [id]. Returns number of rows affected. */ fun update( - id: Long, + id: String, entity: T, ): Int = db.update(tableName, toContentValues(entity), "$primaryKey = ?", arrayOf(id.toString())) diff --git a/modules/motion-store/src/main/java/com/tejpratapsingh/motionstore/dao/DownloadedTrackerDao.kt b/modules/motion-store/src/main/java/com/tejpratapsingh/motionstore/dao/DownloadedTrackerDao.kt new file mode 100644 index 0000000..d0e8da5 --- /dev/null +++ b/modules/motion-store/src/main/java/com/tejpratapsingh/motionstore/dao/DownloadedTrackerDao.kt @@ -0,0 +1,110 @@ +package com.tejpratapsingh.motionstore.dao + +import android.content.ContentValues +import android.database.Cursor +import android.database.sqlite.SQLiteDatabase +import com.tejpratapsingh.motionstore.infra.DatabaseManager +import com.tejpratapsingh.motionstore.tables.DownloadedTracker + +/** + * Tracks the high-water mark of downloaded server data per table. + * + * One row per table. The [downloadedTill] value is the highest [SyncTracker.uploadedAt] + * seen from the server so far. On the next sync cycle, we ask the server for all rows + * where uploadedAt > downloadedTill, giving us a clean incremental download cursor. + * extending [BaseDao] — it is framework-internal and has no sync metadata of its own. + */ +class DownloadedTrackerDao( + val dbManager: DatabaseManager, +) : BaseDao(dbManager) { + companion object { + const val TABLE_NAME = "downloaded_tracker" + + const val COL_TABLE_NAME = "table_name" + const val COL_DOWNLOADED_TILL = "downloaded_till" + + val SCHEMA = + """ + CREATE TABLE IF NOT EXISTS $TABLE_NAME ( + $COL_TABLE_NAME TEXT PRIMARY KEY, + $COL_DOWNLOADED_TILL INTEGER NOT NULL DEFAULT 0 + ) + """.trimIndent() + } + + override val tableName: String + get() = TABLE_NAME + + override val primaryKey: String + get() = COL_TABLE_NAME + + override fun fromCursor(cursor: Cursor): DownloadedTracker = + DownloadedTracker( + tableName = cursor.getString(cursor.getColumnIndexOrThrow(COL_TABLE_NAME)), + downloadedTill = cursor.getLong(cursor.getColumnIndexOrThrow(COL_DOWNLOADED_TILL)), + ) + + override fun toContentValues(entity: DownloadedTracker): ContentValues = + ContentValues().apply { + put(COL_TABLE_NAME, entity.tableName) + put(COL_DOWNLOADED_TILL, entity.downloadedTill) + } + + /** + * Returns the epoch-ms timestamp up to which [tableName] data has been downloaded. + * Returns 0 if no download has occurred yet (meaning: fetch everything from the server). + */ + fun getDownloadedTill(tableName: String): Long = + dbManager + .getDb() + .query( + TABLE_NAME, + arrayOf(COL_DOWNLOADED_TILL), + "$COL_TABLE_NAME = ?", + arrayOf(tableName), + null, + null, + null, + ).use { cursor -> + if (cursor.moveToFirst()) { + cursor.getLong(cursor.getColumnIndexOrThrow(COL_DOWNLOADED_TILL)) + } else { + 0L + } + } + + /** + * Upsert the cursor for [tableName] to [timestamp]. + * Only advances the cursor — never moves it backwards, since + * a lower timestamp would cause re-downloading already-synced rows. + */ + fun setDownloadedTill( + tableName: String, + timestamp: Long, + ) { + val current = getDownloadedTill(tableName) + if (timestamp <= current) return + + val values = + ContentValues().apply { + put(COL_TABLE_NAME, tableName) + put(COL_DOWNLOADED_TILL, timestamp) + } + dbManager + .getDb() + .insertWithOnConflict(TABLE_NAME, null, values, SQLiteDatabase.CONFLICT_REPLACE) + } + + /** + * Reset the cursor for [tableName] to zero (re-download everything on next sync). + * Useful during logout or full re-sync scenarios. + */ + fun reset(tableName: String) { + dbManager.getDb().delete(TABLE_NAME, "$COL_TABLE_NAME = ?", arrayOf(tableName)) + } + + /** Reset all download cursors. */ + fun resetAll() { + dbManager.getDb().delete(TABLE_NAME, null, null) + } +} diff --git a/modules/motion-store/src/main/java/com/tejpratapsingh/motionstore/dao/MotionProjectDao.kt b/modules/motion-store/src/main/java/com/tejpratapsingh/motionstore/dao/MotionProjectDao.kt index ef63325..4dcdd6e 100644 --- a/modules/motion-store/src/main/java/com/tejpratapsingh/motionstore/dao/MotionProjectDao.kt +++ b/modules/motion-store/src/main/java/com/tejpratapsingh/motionstore/dao/MotionProjectDao.kt @@ -5,10 +5,11 @@ import android.database.Cursor import android.database.sqlite.SQLiteDatabase import com.tejpratapsingh.motionstore.infra.DatabaseManager import com.tejpratapsingh.motionstore.tables.MotionProject +import com.tejpratapsingh.motionstore.tables.SyncTracker class MotionProjectDao( dbManager: DatabaseManager, -) : BaseDao(dbManager) { +) : SyncableDao(dbManager) { companion object { const val TABLE = "MotionProject" @@ -28,7 +29,8 @@ class MotionProjectDao( $COL_SDUI TEXT, $COL_METADATA TEXT, $COL_CREATED INTEGER NOT NULL, - $COL_UPDATED INTEGER NOT NULL + $COL_UPDATED INTEGER NOT NULL, + ${SyncTracker.COLUMNS_SQL} ) """ } @@ -47,6 +49,7 @@ class MotionProjectDao( metadata = cursor.getString(cursor.getColumnIndexOrThrow(COL_METADATA)), created = cursor.getLong(cursor.getColumnIndexOrThrow(COL_CREATED)), updated = cursor.getLong(cursor.getColumnIndexOrThrow(COL_UPDATED)), + syncTracker = cursor.readSyncTracker(), ) override fun toContentValues(entity: MotionProject): ContentValues = @@ -58,5 +61,28 @@ class MotionProjectDao( put(COL_METADATA, entity.metadata) put(COL_CREATED, entity.created) put(COL_UPDATED, entity.updated) + putSyncTracker(entity.syncTracker) } + + override fun fromServerRow( + row: Map, + localId: String, + ) = MotionProject( + id = localId, + name = row[COL_NAME] as String, + path = row[COL_PATH] as String, + sdui = row[COL_SDUI] as? String, + metadata = row[COL_METADATA] as? String, + created = (row[COL_CREATED] as Number).toLong(), + updated = (row[COL_UPDATED] as Number).toLong(), + syncTracker = + SyncTracker( + isDirty = false, + updatedBy = row[SyncTracker.COL_UPDATED_BY] as String, + createdOn = (row[SyncTracker.COL_CREATED_ON] as Number).toLong(), + updatedOn = (row[SyncTracker.COL_UPDATED_ON] as Number).toLong(), + serverId = row[SyncTracker.COL_SERVER_ID] as? String, + uploadedAt = (row[SyncTracker.COL_UPLOADED_AT] as? Number)?.toLong(), + ), + ) } diff --git a/modules/motion-store/src/main/java/com/tejpratapsingh/motionstore/dao/SyncableDao.kt b/modules/motion-store/src/main/java/com/tejpratapsingh/motionstore/dao/SyncableDao.kt new file mode 100644 index 0000000..893d8d0 --- /dev/null +++ b/modules/motion-store/src/main/java/com/tejpratapsingh/motionstore/dao/SyncableDao.kt @@ -0,0 +1,222 @@ +package com.tejpratapsingh.motionstore.dao + +import android.content.ContentValues +import android.database.Cursor +import com.tejpratapsingh.motionstore.domain.SyncableEntity +import com.tejpratapsingh.motionstore.infra.DatabaseManager +import com.tejpratapsingh.motionstore.tables.SyncTracker +import java.util.UUID + +/** + * Extends [BaseDao] for entities that implement [SyncableEntity]. + * + * Adds sync-specific persistence operations used internally by [SyncManager]: + * - Reading and writing [SyncTracker] columns alongside entity columns. + * - Querying dirty rows for upload. + * - Applying server acknowledgement (serverId + uploadedAt) after a successful upload. + * - Conflict resolution on download. + * + * Concrete DAOs extend this instead of [BaseDao] and additionally implement + * [syncTrackerFromCursor] to extract the tracker from a cursor row. + * + * ── Example ────────────────────────────────────────────────────────────────── + * + * class UserDao(dbManager: DatabaseManager) : SyncableDao(dbManager) { + * + * companion object { const val SCHEMA = "CREATE TABLE IF NOT EXISTS users ( ... )" } + * + * override val tableName = "users" + * + * override fun toContentValues(entity: User) = ContentValues().apply { + * put("name", entity.name) + * put("email", entity.email) + * putSyncTracker(entity.syncTracker) // ← call this to persist tracker fields + * } + * + * override fun fromCursor(cursor: Cursor) = User( + * id = cursor.getLong(cursor.getColumnIndexOrThrow("id")), + * name = cursor.getString(cursor.getColumnIndexOrThrow("name")), + * email = cursor.getString(cursor.getColumnIndexOrThrow("email")), + * syncTracker = cursor.readSyncTracker(), + * ) + * } + */ +abstract class SyncableDao( + dbManager: DatabaseManager, +) : BaseDao(dbManager) { + // ── Dirty row queries ───────────────────────────────────────────────────── + + /** + * Returns all rows where [SyncTracker.COL_IS_DIRTY] = 1. + * Called by [SyncManager] during the upload phase. + */ + fun findDirty(): List = + findWhere( + whereClause = "${SyncTracker.COL_IS_DIRTY} = 1", + whereArgs = emptyArray(), + ) + + // ── Sync-aware local insert/update ──────────────────────────────────────── + + /** + * Insert a new entity and mark it dirty. The returned ID is the SQLite rowid. + * The SyncTracker defaults (isDirty=true, serverId=null) represent a + * locally-created row not yet seen by the server. + */ + fun insertLocal(entity: T): Long = insert(entity) + + /** + * Update an existing entity and mark it dirty so it gets picked up on + * the next upload cycle. + */ + fun updateLocal(entity: T): Int { + val dirtyTracker = + entity.syncTracker.copy( + isDirty = true, + updatedOn = System.currentTimeMillis(), + ) + @Suppress("UNCHECKED_CAST") + return update(entity.id, entity.withSyncTracker(dirtyTracker) as T) + } + + // ── Server acknowledgement ──────────────────────────────────────────────── + + /** + * After a successful server CREATE, stamp the local row with the [serverId] + * and [uploadedAt] returned by the server, and clear the dirty flag. + */ + fun markUploaded( + localId: String, + serverId: String, + uploadedAt: Long, + ) { + val entity = findById(localId) ?: return + val updatedTracker = + entity.syncTracker.copy( + isDirty = false, + serverId = serverId, + uploadedAt = uploadedAt, + ) + @Suppress("UNCHECKED_CAST") + update(localId, entity.withSyncTracker(updatedTracker) as T) + } + + /** + * After a successful server UPDATE, stamp the local row with the new + * [uploadedAt] and clear the dirty flag. + */ + fun markSynced( + localId: String, + uploadedAt: Long, + ) { + val entity = findById(localId) ?: return + val updatedTracker = + entity.syncTracker.copy( + isDirty = false, + uploadedAt = uploadedAt, + ) + @Suppress("UNCHECKED_CAST") + update(localId, entity.withSyncTracker(updatedTracker) as T) + } + + // ── Download conflict resolution ────────────────────────────────────────── + + /** + * Find a local row that shares the same [serverId], or null if this is + * a new row from the server that we haven't seen before. + */ + fun findByServerId(serverId: String): T? = + findWhere( + whereClause = "${SyncTracker.COL_SERVER_ID} = ?", + whereArgs = arrayOf(serverId), + ).firstOrNull() + + // ── ContentValues helpers (call from toContentValues) ───────────────────── + + /** + * Extension on [ContentValues] to write all [SyncTracker] fields. + * Call this inside your [toContentValues] implementation. + * + * override fun toContentValues(entity: User) = ContentValues().apply { + * put("name", entity.name) + * putSyncTracker(entity.syncTracker) + * } + */ + protected fun ContentValues.putSyncTracker(tracker: SyncTracker) { + put(SyncTracker.COL_IS_DIRTY, if (tracker.isDirty) 1 else 0) + put(SyncTracker.COL_UPDATED_BY, tracker.updatedBy) + put(SyncTracker.COL_CREATED_ON, tracker.createdOn) + put(SyncTracker.COL_UPDATED_ON, tracker.updatedOn) + if (tracker.serverId != null) put(SyncTracker.COL_SERVER_ID, tracker.serverId) + if (tracker.uploadedAt != null) put(SyncTracker.COL_UPLOADED_AT, tracker.uploadedAt) + } + + /** + * Extension on [Cursor] to reconstruct a [SyncTracker] from the current row. + * Call this inside your [fromCursor] implementation. + * + * override fun fromCursor(cursor: Cursor) = User( + * ... + * syncTracker = cursor.readSyncTracker(), + * ) + */ + protected fun Cursor.readSyncTracker(): SyncTracker { + fun idx(col: String) = getColumnIndexOrThrow(col) + return SyncTracker( + isDirty = getInt(idx(SyncTracker.COL_IS_DIRTY)) == 1, + updatedBy = getString(idx(SyncTracker.COL_UPDATED_BY)), + createdOn = getLong(idx(SyncTracker.COL_CREATED_ON)), + updatedOn = getLong(idx(SyncTracker.COL_UPDATED_ON)), + serverId = getString(idx(SyncTracker.COL_SERVER_ID)), + uploadedAt = + if (isNull(idx(SyncTracker.COL_UPLOADED_AT))) { + null + } else { + getLong(idx(SyncTracker.COL_UPLOADED_AT)) + }, + ) + } + + // ── Entity → server map ─────────────────────────────────────────────────── + + /** + * Serialize [entity] into a flat [Map] suitable for the [BackendAdapter]. + * The default implementation converts [ContentValues] to a map. + * Override this if you need custom server field names. + */ + open fun toServerMap(entity: T): Map = + toContentValues(entity).let { cv -> + cv.keySet().associateWith { cv.get(it) } + } + + // ── Server map → entity ─────────────────────────────────────────────────── + + /** + * Deserialize a raw server field map into an entity [T]. + * Called by [SyncManager] during the download phase for both inserts and + * conflict-resolution updates. + * + * [localId] is the existing SQLite rowid when updating a conflicting row, + * or -1 when inserting a brand-new row from the server. + * + * ── Example ────────────────────────────────────────────────────────────── + * + * override fun fromServerRow(row: Map, localId: Long) = User( + * id = localId, + * name = row["name"] as String, + * email = row["email"] as String, + * syncTracker = SyncTracker( + * isDirty = false, + * updatedBy = row[SyncTracker.COL_UPDATED_BY] as String, + * createdOn = (row[SyncTracker.COL_CREATED_ON] as Number).toLong(), + * updatedOn = (row[SyncTracker.COL_UPDATED_ON] as Number).toLong(), + * serverId = row[SyncTracker.COL_SERVER_ID] as? String, + * uploadedAt = (row[SyncTracker.COL_UPLOADED_AT] as? Number)?.toLong(), + * ), + * ) + */ + abstract fun fromServerRow( + row: Map, + localId: String = UUID.randomUUID().toString(), + ): T +} diff --git a/modules/motion-store/src/main/java/com/tejpratapsingh/motionstore/domain/BackendAdapter.kt b/modules/motion-store/src/main/java/com/tejpratapsingh/motionstore/domain/BackendAdapter.kt new file mode 100644 index 0000000..8535333 --- /dev/null +++ b/modules/motion-store/src/main/java/com/tejpratapsingh/motionstore/domain/BackendAdapter.kt @@ -0,0 +1,75 @@ +package com.tejpratapsingh.motionstore.domain + +/** + * Abstraction layer over the remote backend. + * + * Implement this interface once per backend (Firebase, Supabase, REST). + * [SyncManager] only ever calls these four methods — swapping backends + * means swapping the [BackendAdapter] implementation, nothing else. + * + * All methods are suspend functions; call them from a coroutine scope. + * + * ── Type parameter ─────────────────────────────────────────────────────────── + * [T] is the DTO / map type that the backend speaks. Each concrete adapter + * converts between [T] and [Map] internally, so [SyncManager] + * always works with plain maps. + */ +interface BackendAdapter { + /** + * Fetch rows from [tableName] on the server where `uploadedAt > since`. + * The server must exclude rows whose `updatedBy == deviceId` so a device + * never downloads its own uploads back. + * + * @param tableName Remote collection / table name. + * @param since Epoch-ms cursor. Pass 0 to fetch everything. + * @param deviceId The calling device's ID. Server must filter this out. + * @return List of rows as raw field maps, including all sync fields. + */ + suspend fun fetchSince( + tableName: String, + since: Long, + deviceId: String, + ): List> + + /** + * Create a new row on the server. + * The server generates and returns a [serverId] and an [uploadedAt] timestamp. + * + * @param tableName Remote collection / table name. + * @param data Entity fields + sync tracker fields (without serverId / uploadedAt). + * @return The server's response map, which must include + * [SyncTracker.COL_SERVER_ID] and [SyncTracker.COL_UPLOADED_AT]. + */ + suspend fun create( + tableName: String, + data: Map, + ): Map + + /** + * Update an existing row on the server identified by [serverId]. + * The server updates and returns the new [uploadedAt] timestamp. + * + * @param tableName Remote collection / table name. + * @param serverId The server-side row identifier. + * @param data Updated entity fields + sync tracker fields. + * @return The server's response map, which must include + * [SyncTracker.COL_UPLOADED_AT]. + */ + suspend fun update( + tableName: String, + serverId: String, + data: Map, + ): Map + + /** + * Delete a row on the server. Optional — implement as a no-op if your + * backend uses soft deletes instead. + * + * @param tableName Remote collection / table name. + * @param serverId The server-side row identifier. + */ + suspend fun delete( + tableName: String, + serverId: String, + ) +} diff --git a/modules/motion-store/src/main/java/com/tejpratapsingh/motionstore/domain/SyncException.kt b/modules/motion-store/src/main/java/com/tejpratapsingh/motionstore/domain/SyncException.kt new file mode 100644 index 0000000..a389ceb --- /dev/null +++ b/modules/motion-store/src/main/java/com/tejpratapsingh/motionstore/domain/SyncException.kt @@ -0,0 +1,34 @@ +package com.tejpratapsingh.motionstore.domain + +/** + * Typed exceptions thrown by the sync framework. + * Catch these in your ViewModel / use-case layer to show appropriate UI. + */ +sealed class SyncException( + message: String, + cause: Throwable? = null, +) : Exception(message, cause) { + /** A network call to the backend failed (non-2xx or connection error). */ + class NetworkError( + message: String, + cause: Throwable? = null, + ) : SyncException(message, cause) + + /** A row downloaded from the server could not be parsed into a local entity. */ + class ParseError( + tableName: String, + row: Map, + cause: Throwable? = null, + ) : SyncException("Failed to parse row from '$tableName': $row", cause) + + /** A local write (insert/update) failed during the sync process. */ + class LocalWriteError( + message: String, + cause: Throwable? = null, + ) : SyncException(message, cause) + + /** The adapter threw an unexpected error. */ + class UnknownError( + cause: Throwable, + ) : SyncException(cause.message ?: "Unknown sync error", cause) +} diff --git a/modules/motion-store/src/main/java/com/tejpratapsingh/motionstore/domain/SyncResult.kt b/modules/motion-store/src/main/java/com/tejpratapsingh/motionstore/domain/SyncResult.kt new file mode 100644 index 0000000..7488d84 --- /dev/null +++ b/modules/motion-store/src/main/java/com/tejpratapsingh/motionstore/domain/SyncResult.kt @@ -0,0 +1,37 @@ +package com.tejpratapsingh.motionstore.domain + +/** + * Summary of a completed sync cycle returned by [SyncManager.sync]. + */ +data class SyncResult( + val tableName: String, + val downloaded: Int = 0, + val conflicts: Int = 0, + val skipped: Int = 0, + val uploaded: Int = 0, + val uploadFailed: Int = 0, + val error: SyncException? = null, +) { + val hasError: Boolean get() = error != null + + override fun toString(): String = + "[$tableName] ↓$downloaded conflicts=$conflicts skipped=$skipped ↑$uploaded failed=$uploadFailed" + + if (hasError) " ERROR=${error!!.message}" else "" +} + +/** Emitted by [SyncManager] so observers can react to sync lifecycle events. */ +sealed class SyncStatus { + object Idle : SyncStatus() + + data class Running( + val tableName: String, + ) : SyncStatus() + + data class Completed( + val results: List, + ) : SyncStatus() + + data class Failed( + val error: SyncException, + ) : SyncStatus() +} diff --git a/modules/motion-store/src/main/java/com/tejpratapsingh/motionstore/domain/SyncableEntity.kt b/modules/motion-store/src/main/java/com/tejpratapsingh/motionstore/domain/SyncableEntity.kt new file mode 100644 index 0000000..7e84e33 --- /dev/null +++ b/modules/motion-store/src/main/java/com/tejpratapsingh/motionstore/domain/SyncableEntity.kt @@ -0,0 +1,36 @@ +package com.tejpratapsingh.motionstore.domain + +import android.content.ContentValues +import com.tejpratapsingh.motionstore.tables.SyncTracker + +/** + * Contract that every entity participating in sync must satisfy. + * + * Each entity owns its local [id] (SQLite rowid) and carries a [syncTracker] + * that holds all sync metadata. The framework only ever touches [syncTracker]; + * it never inspects the entity's own domain fields. + * + * ── Example implementation ─────────────────────────────────────────────────── + * + * data class User( + * val id: Long = -1, + * val name: String, + * val email: String, + * override val syncTracker: SyncTracker = SyncTracker(updatedBy = DeviceInfo.id), + * ) : SyncableEntity { + * override fun withSyncTracker(tracker: SyncTracker) = copy(syncTracker = tracker) + * } + */ +interface SyncableEntity { + /** Local SQLite primary key. -1 means not yet persisted. */ + val id: String + + /** Sync metadata attached to this row. */ + val syncTracker: SyncTracker + + /** + * Return a copy of this entity with [syncTracker] replaced. + * Implemented via data class [copy] in every concrete entity. + */ + fun withSyncTracker(tracker: SyncTracker): SyncableEntity +} diff --git a/modules/motion-store/src/main/java/com/tejpratapsingh/motionstore/infra/DatabaseManager.kt b/modules/motion-store/src/main/java/com/tejpratapsingh/motionstore/infra/DatabaseManager.kt index 1806ea2..5ab9047 100644 --- a/modules/motion-store/src/main/java/com/tejpratapsingh/motionstore/infra/DatabaseManager.kt +++ b/modules/motion-store/src/main/java/com/tejpratapsingh/motionstore/infra/DatabaseManager.kt @@ -3,6 +3,7 @@ package com.tejpratapsingh.motionstore.infra import android.content.Context import android.database.sqlite.SQLiteDatabase import android.database.sqlite.SQLiteOpenHelper +import com.tejpratapsingh.motionstore.dao.DownloadedTrackerDao import com.tejpratapsingh.motionstore.dao.MotionProjectDao /** @@ -90,7 +91,7 @@ class DatabaseManager private constructor( context: Context, databaseName: String = "app.db", version: Int = 1, - schemas: List = listOf(MotionProjectDao.SCHEMA), + schemas: List = listOf(MotionProjectDao.SCHEMA, DownloadedTrackerDao.SCHEMA), onUpgrade: ((db: SQLiteDatabase, oldVersion: Int, newVersion: Int) -> Unit)? = null, ): DatabaseManager = instance ?: synchronized(this) { diff --git a/modules/motion-store/src/main/java/com/tejpratapsingh/motionstore/infra/DeviceInfo.kt b/modules/motion-store/src/main/java/com/tejpratapsingh/motionstore/infra/DeviceInfo.kt new file mode 100644 index 0000000..4f31cb3 --- /dev/null +++ b/modules/motion-store/src/main/java/com/tejpratapsingh/motionstore/infra/DeviceInfo.kt @@ -0,0 +1,59 @@ +package com.tejpratapsingh.motionstore.infra + +import android.content.Context +import android.content.SharedPreferences +import androidx.core.content.edit +import java.util.UUID + +/** + * Provides a stable, unique device identifier that persists across app launches. + * + * Uses explicit initialization via a [ContentProvider] that runs automatically + * at app startup — no manual init() call required anywhere in your code. + * + * The [ContentProvider] trick is the same mechanism used by WorkManager, Firebase, + * and other zero-init libraries. Android guarantees it runs before any Activity, + * Service, or BroadcastReceiver starts. + * + * ── Setup ──────────────────────────────────────────────────────────────────── + * + * Add DeviceInfoInitializer to your AndroidManifest.xml: + * + * + * + * That's it. No Application.onCreate() call needed. + * + * ── Usage ──────────────────────────────────────────────────────────────────── + * + * val deviceId = DeviceInfo.id // works from Activity, Service, anywhere + */ +object DeviceInfo { + private const val PREFS_FILE = "device_info_prefs" + private const val KEY_DEVICE_ID = "device_id" + + private lateinit var prefs: SharedPreferences + + /** + * Stable device UUID. Generated once and persisted to SharedPreferences. + * Accessing this before the ContentProvider has run will throw — but that + * cannot happen in normal app flow since the provider runs before everything. + */ + val id: String by lazy { + check(::prefs.isInitialized) { + "DeviceInfo not initialized. Make sure DeviceInfoInitializer is declared in AndroidManifest.xml." + } + prefs.getString(KEY_DEVICE_ID, null) ?: UUID.randomUUID().toString().also { newId -> + prefs.edit(true) { putString(KEY_DEVICE_ID, newId) } + } + } + + /** Called exclusively by [DeviceInfoInitializer]. Not part of the public API. */ + internal fun initialize(context: Context) { + prefs = + context.applicationContext + .getSharedPreferences(PREFS_FILE, Context.MODE_PRIVATE) + } +} diff --git a/modules/motion-store/src/main/java/com/tejpratapsingh/motionstore/infra/DeviceInfoInitializer.kt b/modules/motion-store/src/main/java/com/tejpratapsingh/motionstore/infra/DeviceInfoInitializer.kt new file mode 100644 index 0000000..5879086 --- /dev/null +++ b/modules/motion-store/src/main/java/com/tejpratapsingh/motionstore/infra/DeviceInfoInitializer.kt @@ -0,0 +1,57 @@ +package com.tejpratapsingh.motionstore.infra + +import android.content.ContentProvider +import android.content.ContentValues +import android.database.Cursor +import android.net.Uri + +/** + * A no-op [ContentProvider] whose sole purpose is to initialize [DeviceInfo] + * automatically at app startup — before any Activity, Service, or BroadcastReceiver. + * + * Android instantiates all declared ContentProviders and calls their [onCreate] + * during the app launch sequence, providing a [Context] with no manual wiring needed. + * + * Declare in AndroidManifest.xml: + * + * + */ +class DeviceInfoInitializer : ContentProvider() { + override fun onCreate(): Boolean { + DeviceInfo.initialize(context!!) + return true + } + + // ── Unused ContentProvider methods ──────────────────────────────────────── + + override fun query( + uri: Uri, + projection: Array?, + selection: String?, + selectionArgs: Array?, + sortOrder: String?, + ): Cursor? = null + + override fun getType(uri: Uri): String? = null + + override fun insert( + uri: Uri, + values: ContentValues?, + ): Uri? = null + + override fun delete( + uri: Uri, + selection: String?, + selectionArgs: Array?, + ): Int = 0 + + override fun update( + uri: Uri, + values: ContentValues?, + selection: String?, + selectionArgs: Array?, + ): Int = 0 +} diff --git a/modules/motion-store/src/main/java/com/tejpratapsingh/motionstore/infra/FirebaseAdapter.kt b/modules/motion-store/src/main/java/com/tejpratapsingh/motionstore/infra/FirebaseAdapter.kt new file mode 100644 index 0000000..1114e61 --- /dev/null +++ b/modules/motion-store/src/main/java/com/tejpratapsingh/motionstore/infra/FirebaseAdapter.kt @@ -0,0 +1,100 @@ +package com.tejpratapsingh.motionstore.infra + +import com.google.firebase.firestore.FieldValue +import com.google.firebase.firestore.FirebaseFirestore +import com.google.firebase.firestore.Query +import com.tejpratapsingh.motionstore.domain.BackendAdapter +import com.tejpratapsingh.motionstore.tables.SyncTracker +import kotlinx.coroutines.tasks.await + +/** + * [BackendAdapter] backed by Cloud Firestore. + * + * Each [tableName] maps to a Firestore collection of the same name. + * Server-side filtering (exclude own deviceId, filter by uploadedAt) is + * done via Firestore query operators — no extra Cloud Functions needed. + * + * Requires: com.google.firebase:firebase-firestore-ktx + * org.jetbrains.kotlinx:kotlinx-coroutines-play-services + * + * Note: Firestore does not support `uploadedAt` being set server-side + * automatically unless you use a Cloud Function trigger. If you want true + * server timestamps, replace the client-supplied uploadedAt with + * FieldValue.serverTimestamp() in [create] and [update] and read it back + * via a subsequent get(). The simpler client-timestamp approach is used + * here for portability. + */ +class FirebaseAdapter( + private val firestore: FirebaseFirestore = FirebaseFirestore.getInstance(), +) : BackendAdapter { + override suspend fun fetchSince( + tableName: String, + since: Long, + deviceId: String, + ): List> { + val snapshot = + firestore + .collection(tableName) + .whereGreaterThan(SyncTracker.COL_UPLOADED_AT, since) + .whereNotEqualTo(SyncTracker.COL_UPDATED_BY, deviceId) + .orderBy(SyncTracker.COL_UPLOADED_AT, Query.Direction.ASCENDING) + .get() + .await() + + return snapshot.documents.map { doc -> + doc.data.orEmpty().toMutableMap().apply { + // Expose Firestore document ID as serverId for consistency + putIfAbsent(SyncTracker.COL_SERVER_ID, doc.id) + } + } + } + + override suspend fun create( + tableName: String, + data: Map, + ): Map { + val payload = data.toMutableMap().apply { put(SyncTracker.COL_UPLOADED_AT, FieldValue.serverTimestamp()) } + + val docRef = firestore.collection(tableName).add(payload).await() + + // Read the created doc back so we return the full server state + val created = docRef.get().await() + return created.data.orEmpty().toMutableMap().apply { + put(SyncTracker.COL_SERVER_ID, docRef.id) + put(SyncTracker.COL_UPLOADED_AT, created.getLong(SyncTracker.COL_UPLOADED_AT)) + } + } + + override suspend fun update( + tableName: String, + serverId: String, + data: Map, + ): Map { + val payload = data.toMutableMap().apply { put(SyncTracker.COL_UPLOADED_AT, FieldValue.serverTimestamp()) } + + firestore + .collection(tableName) + .document(serverId) + .set(payload) + .await() + + val docRef = firestore.collection(tableName).document(serverId) + val updatedDoc = docRef.get().await() + + return payload.apply { + put(SyncTracker.COL_SERVER_ID, serverId) + put(SyncTracker.COL_UPLOADED_AT, updatedDoc.getLong(SyncTracker.COL_UPLOADED_AT)) + } + } + + override suspend fun delete( + tableName: String, + serverId: String, + ) { + firestore + .collection(tableName) + .document(serverId) + .delete() + .await() + } +} diff --git a/modules/motion-store/src/main/java/com/tejpratapsingh/motionstore/infra/RestAdapter.kt b/modules/motion-store/src/main/java/com/tejpratapsingh/motionstore/infra/RestAdapter.kt new file mode 100644 index 0000000..7f9478b --- /dev/null +++ b/modules/motion-store/src/main/java/com/tejpratapsingh/motionstore/infra/RestAdapter.kt @@ -0,0 +1,127 @@ +package com.tejpratapsingh.motionstore.infra + +import com.tejpratapsingh.motionstore.domain.BackendAdapter +import com.tejpratapsingh.motionstore.domain.SyncException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONArray +import org.json.JSONObject +import java.io.OutputStreamWriter +import java.net.HttpURLConnection +import java.net.URL + +/** + * [BackendAdapter] backed by a custom REST API. + * + * Assumes a JSON REST API following these conventions: + * + * GET /sync/{table}?since=&deviceId= → JSON array of rows + * POST /sync/{table} → created row (with serverId + uploadedAt) + * PUT /sync/{table}/{serverId} → updated row (with uploadedAt) + * DELETE /sync/{table}/{serverId} → 204 No Content + * + * Swap [baseUrl] and [authTokenProvider] to point at any REST backend. + * For more complex needs (OAuth, certificate pinning, etc.) replace the + * [HttpURLConnection] calls with OkHttp or Ktor. + * + * @param baseUrl Root URL, e.g. "https://api.example.com" + * @param authTokenProvider Lambda that returns a Bearer token (or null for + * unauthenticated APIs). Called on every request so + * token refresh is handled transparently. + */ +class RestAdapter( + private val baseUrl: String, + private val authTokenProvider: suspend () -> String? = { null }, +) : BackendAdapter { + override suspend fun fetchSince( + tableName: String, + since: Long, + deviceId: String, + ): List> { + val url = "$baseUrl/sync/$tableName?since=$since&deviceId=${deviceId.encode()}" + val response = request("GET", url) + return JSONArray(response).toListOfMaps() + } + + override suspend fun create( + tableName: String, + data: Map, + ): Map { + val url = "$baseUrl/sync/$tableName" + val response = request("POST", url, body = data.toJsonObject()) + return JSONObject(response).toMap() + } + + override suspend fun update( + tableName: String, + serverId: String, + data: Map, + ): Map { + val url = "$baseUrl/sync/$tableName/$serverId" + val response = request("PUT", url, body = data.toJsonObject()) + return JSONObject(response).toMap() + } + + override suspend fun delete( + tableName: String, + serverId: String, + ) { + val url = "$baseUrl/sync/$tableName/$serverId" + request("DELETE", url) + } + + // ── HTTP ───────────────────────────────────────────────────────────────── + + private suspend fun request( + method: String, + url: String, + body: JSONObject? = null, + ): String = + withContext(Dispatchers.IO) { + val connection = + (URL(url).openConnection() as HttpURLConnection).apply { + requestMethod = method + setRequestProperty("Content-Type", "application/json") + setRequestProperty("Accept", "application/json") + authTokenProvider()?.let { setRequestProperty("Authorization", "Bearer $it") } + connectTimeout = 15_000 + readTimeout = 15_000 + if (body != null) { + doOutput = true + OutputStreamWriter(outputStream).use { it.write(body.toString()) } + } + } + + val code = connection.responseCode + val stream = if (code in 200..299) connection.inputStream else connection.errorStream + val responseText = stream.bufferedReader().use { it.readText() } + + if (code !in 200..299) { + throw SyncException.NetworkError( + "HTTP $code on $method $url — $responseText", + ) + } + responseText + } + + // ── Serialization helpers ───────────────────────────────────────────────── + + private fun String.encode() = java.net.URLEncoder.encode(this, "UTF-8") + + private fun Map.toJsonObject(): JSONObject = + JSONObject().also { json -> + forEach { (k, v) -> json.put(k, v ?: JSONObject.NULL) } + } + + private fun JSONArray.toListOfMaps(): List> = (0 until length()).map { getJSONObject(it).toMap() } + + private fun JSONObject.toMap(): Map = + keys().asSequence().associateWith { key -> + when (val v = get(key)) { + JSONObject.NULL -> null + is JSONObject -> v.toMap() + is JSONArray -> v.toListOfMaps() + else -> v + } + } +} diff --git a/modules/motion-store/src/main/java/com/tejpratapsingh/motionstore/infra/SupabaseAdapter.kt b/modules/motion-store/src/main/java/com/tejpratapsingh/motionstore/infra/SupabaseAdapter.kt new file mode 100644 index 0000000..0cf7c4a --- /dev/null +++ b/modules/motion-store/src/main/java/com/tejpratapsingh/motionstore/infra/SupabaseAdapter.kt @@ -0,0 +1,183 @@ +package com.tejpratapsingh.motionstore.infra + +import com.tejpratapsingh.motionstore.domain.BackendAdapter +import com.tejpratapsingh.motionstore.tables.SyncTracker +import io.github.jan.supabase.SupabaseClient +import io.github.jan.supabase.postgrest.postgrest +import io.github.jan.supabase.postgrest.query.Columns +import io.github.jan.supabase.postgrest.query.Order +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.double +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.long +import kotlinx.serialization.json.longOrNull + +/** + * [BackendAdapter] backed by Supabase (PostgREST). + * + * Each [tableName] maps to a Supabase / Postgres table of the same name. + * Filtering is done via PostgREST query parameters — the server handles + * the `uploadedAt > since` and `updatedBy != deviceId` predicates natively. + * + * Requires: io.github.jan-tennert.supabase:postgrest-kt + * + * Your Postgres tables must have RLS policies that allow the client to + * insert / update rows. A recommended pattern is to enable RLS and add + * a permissive policy for authenticated users. + * + * The `uploaded_at` column should be set server-side via a trigger: + * CREATE OR REPLACE FUNCTION set_uploaded_at() + * RETURNS TRIGGER AS $$ + * BEGIN NEW.uploaded_at = (EXTRACT(EPOCH FROM NOW()) * 1000)::BIGINT; RETURN NEW; END; + * $$ LANGUAGE plpgsql; + * + * CREATE TRIGGER trg_uploaded_at BEFORE INSERT OR UPDATE ON + * FOR EACH ROW EXECUTE FUNCTION set_uploaded_at(); + */ +class SupabaseAdapter( + private val client: SupabaseClient, +) : BackendAdapter { + override suspend fun fetchSince( + tableName: String, + since: Long, + deviceId: String, + ): List> { + val result = + client.postgrest[tableName] + .select(Columns.ALL) { + filter { + gt(SyncTracker.COL_UPLOADED_AT, since) + neq(SyncTracker.COL_UPDATED_BY, deviceId) + } + order(SyncTracker.COL_UPLOADED_AT, Order.ASCENDING) + } + + return result.data.parseJsonToListOfMaps() + } + + override suspend fun create( + tableName: String, + data: Map, + ): Map { + val result = + client.postgrest[tableName] + .insert(data.toJsonObject()) { select() } + + return result.data.parseJsonToListOfMaps().firstOrNull() + ?: throw IllegalStateException("Server returned empty response after insert on '$tableName'") + } + + override suspend fun update( + tableName: String, + serverId: String, + data: Map, + ): Map { + val result = + client.postgrest[tableName] + .update(data.toJsonObject()) { + filter { eq(SyncTracker.COL_SERVER_ID, serverId) } + select() + } + + return result.data.parseJsonToListOfMaps().firstOrNull() + ?: throw IllegalStateException("Supabase did not return updated row for '$tableName'") + } + + override suspend fun delete( + tableName: String, + serverId: String, + ) { + client.postgrest[tableName].delete { + filter { eq(SyncTracker.COL_SERVER_ID, serverId) } + } + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + private fun Map.toJsonObject(): JsonObject = + buildJsonObject { + forEach { (key, value) -> + put(key, value.toJsonElement()) + } + } + + private fun Any?.toJsonElement(): JsonElement = + when (this) { + null -> { + JsonNull + } + + is Boolean -> { + JsonPrimitive(this) + } + + is Number -> { + JsonPrimitive(this) + } + + is String -> { + JsonPrimitive(this) + } + + is Map<*, *> -> { + buildJsonObject { + @Suppress("UNCHECKED_CAST") + (this@toJsonElement as Map).forEach { (k, v) -> + put( + k, + v.toJsonElement(), + ) + } + } + } + + is List<*> -> { + buildJsonArray { forEach { add(it.toJsonElement()) } } + } + + else -> { + JsonPrimitive(toString()) + } + } + + private fun String.parseJsonToListOfMaps(): List> { + val array = Json.parseToJsonElement(this).jsonArray + return array.map { element -> + element.jsonObject.entries.associate { (k, v) -> k to v.toAny() } + } + } + + private fun JsonElement.toAny(): Any? = + when (this) { + is JsonNull -> { + null + } + + is JsonPrimitive -> { + when { + isString -> content + booleanOrNull != null -> boolean + longOrNull != null -> long + else -> double + } + } + + is JsonObject -> { + entries.associate { (k, v) -> k to v.toAny() } + } + + is JsonArray -> { + map { it.toAny() } + } + } +} diff --git a/modules/motion-store/src/main/java/com/tejpratapsingh/motionstore/infra/SyncManager.kt b/modules/motion-store/src/main/java/com/tejpratapsingh/motionstore/infra/SyncManager.kt new file mode 100644 index 0000000..4ea4e28 --- /dev/null +++ b/modules/motion-store/src/main/java/com/tejpratapsingh/motionstore/infra/SyncManager.kt @@ -0,0 +1,250 @@ +package com.tejpratapsingh.motionstore.infra + +import com.tejpratapsingh.motionstore.dao.DownloadedTrackerDao +import com.tejpratapsingh.motionstore.dao.SyncableDao +import com.tejpratapsingh.motionstore.domain.BackendAdapter +import com.tejpratapsingh.motionstore.domain.SyncException +import com.tejpratapsingh.motionstore.domain.SyncResult +import com.tejpratapsingh.motionstore.domain.SyncStatus +import com.tejpratapsingh.motionstore.domain.SyncableEntity +import com.tejpratapsingh.motionstore.tables.SyncTracker +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +/** + * Orchestrates the full bidirectional sync cycle for one or more tables. + * + * Each call to [sync] (or [syncTable]) executes the following steps: + * + * DOWNLOAD PHASE + * ───────────── + * 1. Read `downloadedTill` cursor from [DownloadedTrackerDao] for this table. + * 2. Fetch all server rows with `uploadedAt > downloadedTill`, + * excluding rows from this device (`updatedBy == deviceId`). + * 3. For each fetched row: + * a. Look up a local row with the same `serverId`. + * b. If no local row exists → INSERT (new data from server). + * c. If local row exists and server's `updatedOn` ≥ local `updatedOn` → UPDATE. + * d. If local row exists and local `updatedOn` is newer → SKIP (local wins). + * 4. After all rows are saved, advance `downloadedTill` to the highest + * `uploadedAt` seen in this batch. + * + * UPLOAD PHASE + * ───────────── + * 5. Query all local rows where `isDirty = 1`. + * 6. For each dirty row: + * a. If `serverId == null` → POST (CREATE) to the server. + * b. If `serverId != null` → PUT (UPDATE) to the server. + * 7. On success, write the returned `serverId` (creates only) and `uploadedAt` + * back to the local row and clear `isDirty`. + * + * @param backend The [BackendAdapter] implementation to use. + * @param downloadedTracker DAO for persisting the download cursor. + * @param scope CoroutineScope in which sync jobs run. + * Defaults to a SupervisorJob so one table failure + * does not cancel others. + */ +class SyncManager( + private val backend: BackendAdapter, + private val downloadedTracker: DownloadedTrackerDao, + private val scope: CoroutineScope = + CoroutineScope( + SupervisorJob() + Dispatchers.IO + CoroutineName("SyncManager"), + ), +) { + private val _status = MutableStateFlow(SyncStatus.Idle) + + /** Observable sync status. Collect in your ViewModel to drive UI. */ + val status: StateFlow = _status + + // ── Public API ──────────────────────────────────────────────────────────── + + /** + * Run a full sync cycle across all [daos] concurrently. + * Each table runs in its own coroutine under a [SupervisorJob], so a + * failure in one table does not block the others. + * + * @return List of [SyncResult], one per DAO/table. + */ + suspend fun sync(daos: List>): List { + _status.value = SyncStatus.Running(daos.joinToString { it.tableName }) + val results = + daos + .map { dao -> + scope.async { syncTable(dao) } + }.awaitAll() + _status.value = SyncStatus.Completed(results) + return results + } + + /** + * Sync a single table. Safe to call directly for targeted syncs. + */ + suspend fun syncTable(dao: SyncableDao): SyncResult { + _status.value = SyncStatus.Running(dao.tableName) + return try { + val downloadResult = download(dao) + val uploadResult = upload(dao) + SyncResult( + tableName = dao.tableName, + downloaded = downloadResult.saved, + conflicts = downloadResult.conflicts, + skipped = downloadResult.skipped, + uploaded = uploadResult.uploaded, + uploadFailed = uploadResult.failed, + ) + } catch (e: SyncException) { + SyncResult(tableName = dao.tableName, error = e) + } catch (e: Exception) { + SyncResult(tableName = dao.tableName, error = SyncException.UnknownError(e)) + } + } + + // ── Download ────────────────────────────────────────────────────────────── + + private suspend fun download(dao: SyncableDao): DownloadStats { + val since = downloadedTracker.getDownloadedTill(dao.tableName) + + val serverRows = + try { + backend.fetchSince(dao.tableName, since, DeviceInfo.id) + } catch (e: Exception) { + throw SyncException.NetworkError("Download failed for '${dao.tableName}'", e) + } + + var saved = 0 + var conflicts = 0 + var skipped = 0 + var highWaterMark = since + + for (row in serverRows) { + val serverId = row[SyncTracker.COL_SERVER_ID] as? String ?: continue + val uploadedAt = (row[SyncTracker.COL_UPLOADED_AT] as? Number)?.toLong() ?: 0L + + val serverUpdatedOn = (row[SyncTracker.COL_UPDATED_ON] as? Number)?.toLong() ?: 0L + + val localEntity = dao.findByServerId(serverId) + + when { + // ── New row from server — never seen locally ────────────────── + localEntity == null -> { + val entity = + try { + dao.fromServerRow(row) + } catch (e: Exception) { + throw SyncException.ParseError(dao.tableName, row, e) + } + dao.insert(entity) + saved++ + } + + // ── Conflict: server wins (server is newer or equal) ────────── + serverUpdatedOn >= localEntity.syncTracker.updatedOn -> { + val entity = + try { + dao.fromServerRow(row, localId = localEntity.id) + } catch (e: Exception) { + throw SyncException.ParseError(dao.tableName, row, e) + } + dao.update(localEntity.id, entity) + conflicts++ + saved++ + } + + // ── Conflict: local wins (local is newer) — skip ────────────── + else -> { + skipped++ + } + } + + if (uploadedAt > highWaterMark) highWaterMark = uploadedAt + } + + // Advance cursor only after all rows are safely written + if (highWaterMark > since) { + downloadedTracker.setDownloadedTill(dao.tableName, highWaterMark) + } + + return DownloadStats(saved, conflicts, skipped) + } + + // ── Upload ──────────────────────────────────────────────────────────────── + + private suspend fun upload(dao: SyncableDao): UploadStats { + val dirtyRows = dao.findDirty() + var uploaded = 0 + var failed = 0 + + for (entity in dirtyRows) { + try { + val payload = + dao.toServerMap(entity).toMutableMap().apply { + put(SyncTracker.COL_UPDATED_BY, DeviceInfo.id) + } + + if (entity.syncTracker.serverId == null) { + // ── CREATE ──────────────────────────────────────────────── + val response = backend.create(dao.tableName, payload) + val serverId = + response[SyncTracker.COL_SERVER_ID] as? String + ?: throw SyncException.NetworkError( + "Server did not return '${SyncTracker.COL_SERVER_ID}' after create on '${dao.tableName}'", + ) + val uploadedAt = + (response[SyncTracker.COL_UPLOADED_AT] as? Number)?.toLong() + ?: System.currentTimeMillis() + dao.markUploaded(entity.id, serverId, uploadedAt) + } else { + // ── UPDATE ──────────────────────────────────────────────── + val response = + backend.update( + dao.tableName, + entity.syncTracker.serverId!!, + payload, + ) + val uploadedAt = + (response[SyncTracker.COL_UPLOADED_AT] as? Number)?.toLong() + ?: System.currentTimeMillis() + dao.markSynced(entity.id, uploadedAt) + } + + uploaded++ + } catch (e: SyncException) { + // Log and continue — a single row failure must not abort the whole batch + failed++ + } catch (e: Exception) { + failed++ + } + } + + return UploadStats(uploaded, failed) + } + + // ── Internal data holders ───────────────────────────────────────────────── + + private data class DownloadStats( + val saved: Int, + val conflicts: Int, + val skipped: Int, + ) + + private data class UploadStats( + val uploaded: Int, + val failed: Int, + ) + + // ── Lifecycle ───────────────────────────────────────────────────────────── + + /** Cancel the internal coroutine scope (call from Application.onTerminate or similar). */ + fun cancel() { + scope.cancel() + _status.value = SyncStatus.Idle + } +} diff --git a/modules/motion-store/src/main/java/com/tejpratapsingh/motionstore/tables/DownloadedTracker.kt b/modules/motion-store/src/main/java/com/tejpratapsingh/motionstore/tables/DownloadedTracker.kt new file mode 100644 index 0000000..8444bb3 --- /dev/null +++ b/modules/motion-store/src/main/java/com/tejpratapsingh/motionstore/tables/DownloadedTracker.kt @@ -0,0 +1,6 @@ +package com.tejpratapsingh.motionstore.tables + +data class DownloadedTracker( + val tableName: String, + val downloadedTill: Long, +) diff --git a/modules/motion-store/src/main/java/com/tejpratapsingh/motionstore/tables/MotionProject.kt b/modules/motion-store/src/main/java/com/tejpratapsingh/motionstore/tables/MotionProject.kt index 39ee2a0..9144002 100644 --- a/modules/motion-store/src/main/java/com/tejpratapsingh/motionstore/tables/MotionProject.kt +++ b/modules/motion-store/src/main/java/com/tejpratapsingh/motionstore/tables/MotionProject.kt @@ -1,16 +1,21 @@ package com.tejpratapsingh.motionstore.tables +import com.tejpratapsingh.motionstore.domain.SyncableEntity +import com.tejpratapsingh.motionstore.infra.DeviceInfo import java.util.UUID data class MotionProject( - val id: String, + override val id: String, val name: String, val path: String, val sdui: String? = null, val metadata: String? = null, val created: Long = System.currentTimeMillis(), val updated: Long = System.currentTimeMillis(), -) + override val syncTracker: SyncTracker = SyncTracker(updatedBy = DeviceInfo.id), +) : SyncableEntity { + override fun withSyncTracker(tracker: SyncTracker) = copy(syncTracker = tracker) +} private object ProjectStore { @Volatile diff --git a/modules/motion-store/src/main/java/com/tejpratapsingh/motionstore/tables/SyncTracker.kt b/modules/motion-store/src/main/java/com/tejpratapsingh/motionstore/tables/SyncTracker.kt new file mode 100644 index 0000000..0d317f8 --- /dev/null +++ b/modules/motion-store/src/main/java/com/tejpratapsingh/motionstore/tables/SyncTracker.kt @@ -0,0 +1,58 @@ +package com.tejpratapsingh.motionstore.tables + +/** + * Sync metadata attached to every syncable row. + * + * Stored as extra columns on the entity's own table — NOT a separate table — + * so reads are cheap and atomic with the entity itself. + * + * Column names are exposed as constants so DAOs can reference them + * without magic strings. + */ +data class SyncTracker( + /** True whenever the local row is ahead of the server. Set on every local write. */ + val isDirty: Boolean = true, + /** Device ID of the device that last modified this row locally. */ + val updatedBy: String, + /** Local timestamp (epoch ms) when the row was first created on this device. */ + val createdOn: Long = System.currentTimeMillis(), + /** Local timestamp (epoch ms) of the last local modification. */ + val updatedOn: Long = System.currentTimeMillis(), + /** + * ID assigned by the server when the row was first uploaded. + * Null until the server has acknowledged the creation. + * Used to distinguish CREATE vs UPDATE when uploading dirty rows. + */ + val serverId: String? = null, + /** + * Server-side timestamp (epoch ms) written by the server on every write. + * Used as the cursor for incremental downloads. + * Null until the server has written the row at least once. + */ + val uploadedAt: Long? = null, +) { + companion object { + // ── Column name constants ──────────────────────────────────────────── + const val COL_IS_DIRTY = "is_dirty" + const val COL_UPDATED_BY = "updated_by" + const val COL_CREATED_ON = "created_on" + const val COL_UPDATED_ON = "updated_on" + const val COL_SERVER_ID = "server_id" + const val COL_UPLOADED_AT = "uploaded_at" + + /** + * SQL fragment to append to any CREATE TABLE statement. + * + * Usage: + * "CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT, ${SyncTracker.COLUMNS_SQL})" + */ + const val COLUMNS_SQL = """ + $COL_IS_DIRTY INTEGER NOT NULL DEFAULT 1, + $COL_UPDATED_BY TEXT NOT NULL, + $COL_CREATED_ON INTEGER NOT NULL, + $COL_UPDATED_ON INTEGER NOT NULL, + $COL_SERVER_ID TEXT, + $COL_UPLOADED_AT INTEGER + """ + } +} diff --git a/modules/motion-store/src/main/java/com/tejpratapsingh/motionstore/worker/SyncWorker.kt b/modules/motion-store/src/main/java/com/tejpratapsingh/motionstore/worker/SyncWorker.kt new file mode 100644 index 0000000..11433a9 --- /dev/null +++ b/modules/motion-store/src/main/java/com/tejpratapsingh/motionstore/worker/SyncWorker.kt @@ -0,0 +1,263 @@ +package com.tejpratapsingh.motionstore.worker + +import android.content.Context +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.Data +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ExistingWorkPolicy +import androidx.work.ListenableWorker +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerFactory +import androidx.work.WorkerParameters +import androidx.work.workDataOf +import com.tejpratapsingh.motionstore.dao.SyncableDao +import com.tejpratapsingh.motionstore.domain.SyncException +import com.tejpratapsingh.motionstore.infra.SyncManager +import com.tejpratapsingh.motionstore.worker.SyncWorker.Companion.KEY_ERROR +import com.tejpratapsingh.motionstore.worker.SyncWorker.Companion.MIN_BACKOFF_SECONDS +import com.tejpratapsingh.motionstore.worker.SyncWorker.Companion.scheduleImmediate +import com.tejpratapsingh.motionstore.worker.SyncWorker.Companion.schedulePeriodic +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.util.concurrent.TimeUnit + +/** + * A [CoroutineWorker] that drives a full [SyncManager] sync cycle. + * + * Designed to be scheduled via [WorkManager] for both periodic background + * sync and on-demand sync (e.g. after a local write or on network reconnect). + * + * ── Scheduling ─────────────────────────────────────────────────────────────── + * + * Call [SyncWorker.schedulePeriodic] once from Application.onCreate() to set + * up a repeating sync, and [SyncWorker.scheduleImmediate] anywhere you want + * to trigger an out-of-band sync (e.g. after the user saves a record). + * + * ── Dependency injection ───────────────────────────────────────────────────── + * + * [SyncWorker] obtains its [SyncManager] and DAO list via [SyncWorkerFactory]. + * Register the factory with WorkManager in your Application class — see the + * companion object docs below for the full setup snippet. + * + * ── Retry & back-off ───────────────────────────────────────────────────────── + * + * On network failure the worker returns [Result.retry()], triggering + * WorkManager's exponential back-off (starting at [MIN_BACKOFF_SECONDS]). + * On a non-retryable error it returns [Result.failure] with the error + * message stored in the output [Data] under key [KEY_ERROR]. + */ +class SyncWorker( + context: Context, + params: WorkerParameters, + private val syncManager: SyncManager, + private val daos: List>, +) : CoroutineWorker(context, params) { + override suspend fun doWork(): Result = + withContext(Dispatchers.IO) { + try { + val results = syncManager.sync(daos) + + val anyNetworkFailure = + results.any { result -> + result.error is SyncException.NetworkError + } + + if (anyNetworkFailure && runAttemptCount < MAX_ATTEMPTS) { + // Transient network error — let WorkManager retry with back-off + return@withContext Result.retry() + } + + // Build output data summarising the sync so observers / chained + // workers can inspect results without reading the database. + val output = + workDataOf( + KEY_DOWNLOADED to results.sumOf { it.downloaded }, + KEY_UPLOADED to results.sumOf { it.uploaded }, + KEY_CONFLICTS to results.sumOf { it.conflicts }, + KEY_UPLOAD_FAILS to results.sumOf { it.uploadFailed }, + KEY_TABLES to results.joinToString(",") { it.tableName }, + ) + + Result.success(output) + } catch (e: SyncException.NetworkError) { + if (runAttemptCount < MAX_ATTEMPTS) { + Result.retry() + } else { + Result.failure(workDataOf(KEY_ERROR to e.message)) + } + } catch (e: Exception) { + Result.failure(workDataOf(KEY_ERROR to (e.message ?: "Unknown error"))) + } + } + + companion object { + // ── Work tags & keys ───────────────────────────────────────────────── + + const val TAG_PERIODIC = "sync_periodic" + const val TAG_IMMEDIATE = "sync_immediate" + + const val KEY_DOWNLOADED = "downloaded" + const val KEY_UPLOADED = "uploaded" + const val KEY_CONFLICTS = "conflicts" + const val KEY_UPLOAD_FAILS = "upload_fails" + const val KEY_TABLES = "tables" + const val KEY_ERROR = "error" + + private const val MAX_ATTEMPTS = 3 + private const val MIN_BACKOFF_SECONDS = 30L + + // ── Constraints ────────────────────────────────────────────────────── + + /** + * Default constraints: require network connectivity. + * Override by passing custom constraints to [schedulePeriodic] / + * [scheduleImmediate]. + */ + val defaultConstraints: Constraints = + Constraints + .Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + + // ── Scheduling helpers ──────────────────────────────────────────────── + + /** + * Enqueue a periodic sync that runs every [intervalMinutes]. + * Uses [ExistingPeriodicWorkPolicy.KEEP] so rescheduling on every + * app launch does not reset the timer. + * + * Minimum interval enforced by WorkManager is 15 minutes. + * + * Usage (in Application.onCreate): + * SyncWorker.schedulePeriodic(context) + */ + fun schedulePeriodic( + context: Context, + intervalMinutes: Long = 30, + constraints: Constraints = defaultConstraints, + ) { + val request = + PeriodicWorkRequestBuilder( + repeatInterval = intervalMinutes, + repeatIntervalTimeUnit = TimeUnit.MINUTES, + ).setConstraints(constraints) + .setBackoffCriteria( + BackoffPolicy.EXPONENTIAL, + MIN_BACKOFF_SECONDS, + TimeUnit.SECONDS, + ).addTag(TAG_PERIODIC) + .build() + + WorkManager.getInstance(context).enqueueUniquePeriodicWork( + TAG_PERIODIC, + ExistingPeriodicWorkPolicy.KEEP, + request, + ) + } + + /** + * Enqueue a one-shot sync that runs as soon as constraints are met. + * Uses [ExistingWorkPolicy.REPLACE] so rapid consecutive calls + * (e.g. saving multiple records quickly) collapse into a single run. + * + * Usage (after a local write): + * SyncWorker.scheduleImmediate(context) + */ + fun scheduleImmediate( + context: Context, + constraints: Constraints = defaultConstraints, + ) { + val request = + OneTimeWorkRequestBuilder() + .setConstraints(constraints) + .setBackoffCriteria( + BackoffPolicy.EXPONENTIAL, + MIN_BACKOFF_SECONDS, + TimeUnit.SECONDS, + ).addTag(TAG_IMMEDIATE) + .build() + + WorkManager.getInstance(context).enqueueUniqueWork( + TAG_IMMEDIATE, + ExistingWorkPolicy.REPLACE, + request, + ) + } + + /** + * Cancel all pending and running sync work. + * Call on logout to stop background sync. + */ + fun cancelAll(context: Context) { + with(WorkManager.getInstance(context)) { + cancelAllWorkByTag(TAG_PERIODIC) + cancelAllWorkByTag(TAG_IMMEDIATE) + } + } + } +} + +// ═════════════════════════════════════════════════════════════════════════════ +// Factory +// ═════════════════════════════════════════════════════════════════════════════ + +/** + * [WorkerFactory] that injects [SyncManager] and the DAO list into [SyncWorker]. + * + * WorkManager cannot use constructor injection natively, so we provide a + * custom factory and disable the default initializer in the manifest. + * + * ── Setup ───────────────────────────────────────────────────────────────────── + * + * 1. Disable auto-init in AndroidManifest.xml: + * + * + * + * + * + * 2. Initialize WorkManager manually in Application.onCreate(): + * + * val factory = SyncWorkerFactory(syncManager, listOf(userDao, postDao)) + * + * WorkManager.initialize( + * this, + * Configuration.Builder() + * .setWorkerFactory(factory) + * .build() + * ) + * + * 3. Then schedule: + * + * SyncWorker.schedulePeriodic(this) + */ +class SyncWorkerFactory( + private val syncManager: SyncManager, + private val daos: List>, +) : WorkerFactory() { + override fun createWorker( + appContext: Context, + workerClassName: String, + workerParameters: WorkerParameters, + ): ListenableWorker? = + when (workerClassName) { + SyncWorker::class.java.name -> { + SyncWorker(appContext, workerParameters, syncManager, daos) + } + + else -> { + null + } // Delegate to the next factory in the chain + } +}