Skip to content

Commit 784c766

Browse files
committed
feat: implement Firebase Firestore integration with offline-first sync
- Add Firebase dependencies and configuration (google-services.json) - Implement offline-first architecture with Room as primary data source - Add bidirectional sync between local Room database and Firestore - Create comprehensive sync management system with conflict resolution - Add sync status tracking and UI indicators throughout the app - Implement background sync with WorkManager for periodic data synchronization - Add network connectivity monitoring for intelligent sync triggers - Create conflict resolution strategies (timestamp-based, smart merge, policy-driven) - Update database schema with sync fields and proper migrations - Add startup sync triggers and comprehensive error handling - Fix WorkManager initialization in AndroidManifest for custom configuration This implementation provides a production-ready offline-first sync solution that maintains data integrity while ensuring seamless user experience across multiple devices and network conditions.
1 parent 5815cc4 commit 784c766

32 files changed

Lines changed: 1675 additions & 19 deletions

.github/workflows/android.yml

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
name: Android CI
2+
3+
on:
4+
push:
5+
branches: [ "main", "develop" ]
6+
pull_request:
7+
branches: [ "main" ]
8+
9+
jobs:
10+
test:
11+
runs-on: ubuntu-latest
12+
13+
steps:
14+
- uses: actions/checkout@v4
15+
16+
- name: Set up JDK 17
17+
uses: actions/setup-java@v4
18+
with:
19+
java-version: '17'
20+
distribution: 'temurin'
21+
22+
- name: Grant execute permission for gradlew
23+
run: chmod +x gradlew
24+
25+
- name: Setup Gradle
26+
uses: gradle/actions/setup-gradle@v3
27+
28+
- name: Run lint
29+
run: ./gradlew lintDebug
30+
31+
- name: Run unit tests
32+
run: ./gradlew testDebugUnitTest
33+
34+
- name: Build debug APK
35+
run: ./gradlew assembleDebug
36+
37+
- name: Upload build reports
38+
uses: actions/upload-artifact@v4
39+
if: always()
40+
with:
41+
name: build-reports
42+
path: app/build/reports
43+
44+
- name: Upload APK
45+
uses: actions/upload-artifact@v4
46+
with:
47+
name: debug-apk
48+
path: app/build/outputs/apk/debug/*.apk
49+
50+
instrumented-test:
51+
runs-on: ubuntu-latest
52+
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
53+
54+
steps:
55+
- uses: actions/checkout@v4
56+
57+
- name: Set up JDK 17
58+
uses: actions/setup-java@v4
59+
with:
60+
java-version: '17'
61+
distribution: 'temurin'
62+
63+
- name: Grant execute permission for gradlew
64+
run: chmod +x gradlew
65+
66+
- name: Setup Gradle
67+
uses: gradle/actions/setup-gradle@v3
68+
69+
- name: Enable KVM group perms
70+
run: |
71+
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
72+
sudo udevadm control --reload-rules
73+
sudo udevadm trigger --name-match=kvm
74+
75+
- name: Run instrumented tests
76+
uses: reactivecircus/android-emulator-runner@v2
77+
with:
78+
api-level: 29
79+
target: default
80+
arch: x86_64
81+
profile: Nexus 6
82+
script: ./gradlew connectedAndroidTest
83+
84+
- name: Upload test reports
85+
uses: actions/upload-artifact@v4
86+
if: always()
87+
with:
88+
name: instrumented-test-reports
89+
path: app/build/reports/androidTests

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,4 @@
1313
.externalNativeBuild
1414
.cxx
1515
local.properties
16+
/app/google-services.json

app/build.gradle.kts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ plugins {
22
alias(libs.plugins.android.application)
33
alias(libs.plugins.kotlin.android)
44
alias(libs.plugins.hilt.android)
5+
alias(libs.plugins.google.services)
56
kotlin("kapt")
67
}
78

@@ -63,6 +64,7 @@ dependencies {
6364
implementation(libs.hilt.android)
6465
kapt(libs.hilt.compiler)
6566
implementation(libs.androidx.hilt.navigation.compose)
67+
implementation(libs.androidx.hilt.work)
6668

6769
// Navigation
6870
implementation(libs.androidx.navigation.compose)
@@ -74,6 +76,13 @@ dependencies {
7476
implementation(libs.kotlinx.coroutines.core)
7577
implementation(libs.kotlinx.coroutines.android)
7678

79+
// Firebase
80+
implementation(platform(libs.firebase.bom))
81+
implementation(libs.firebase.firestore.ktx)
82+
83+
// WorkManager
84+
implementation(libs.androidx.work.runtime.ktx)
85+
7786
testImplementation(libs.junit)
7887
androidTestImplementation(libs.androidx.junit)
7988
androidTestImplementation(libs.androidx.espresso.core)

app/src/main/AndroidManifest.xml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
33
xmlns:tools="http://schemas.android.com/tools">
44

5+
<uses-permission android:name="android.permission.INTERNET" />
6+
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
7+
58
<application
69
android:name=".SofiaTrackerApplication"
710
android:allowBackup="true"
@@ -13,6 +16,19 @@
1316
android:supportsRtl="true"
1417
android:theme="@style/Theme.SofiaTracker"
1518
tools:targetApi="31">
19+
20+
<!-- Remove default WorkManager initialization since we implement Configuration.Provider -->
21+
<provider
22+
android:name="androidx.startup.InitializationProvider"
23+
android:authorities="${applicationId}.androidx-startup"
24+
android:exported="false"
25+
tools:node="merge">
26+
<meta-data
27+
android:name="androidx.work.WorkManagerInitializer"
28+
android:value="androidx.startup"
29+
tools:node="remove" />
30+
</provider>
31+
1632
<activity
1733
android:name=".MainActivity"
1834
android:exported="true"
Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,91 @@
11
package com.dpconde.sofiatracker
22

33
import android.app.Application
4+
import android.util.Log
5+
import androidx.hilt.work.HiltWorkerFactory
6+
import androidx.work.Configuration
7+
import com.dpconde.sofiatracker.data.remote.FirebaseConnectionChecker
8+
import com.dpconde.sofiatracker.data.sync.SyncManager
9+
import com.dpconde.sofiatracker.data.work.SyncWorkManager
10+
import com.google.firebase.FirebaseApp
411
import dagger.hilt.android.HiltAndroidApp
12+
import kotlinx.coroutines.CoroutineScope
13+
import kotlinx.coroutines.Dispatchers
14+
import kotlinx.coroutines.SupervisorJob
15+
import kotlinx.coroutines.launch
16+
import javax.inject.Inject
517

618
@HiltAndroidApp
7-
class SofiaTrackerApplication : Application()
19+
class SofiaTrackerApplication : Application(), Configuration.Provider {
20+
21+
@Inject
22+
lateinit var workerFactory: HiltWorkerFactory
23+
24+
@Inject
25+
lateinit var syncWorkManager: SyncWorkManager
26+
27+
@Inject
28+
lateinit var firebaseConnectionChecker: FirebaseConnectionChecker
29+
30+
@Inject
31+
lateinit var syncManager: SyncManager
32+
33+
private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
34+
35+
override fun onCreate() {
36+
super.onCreate()
37+
38+
// Initialize Firebase
39+
try {
40+
FirebaseApp.initializeApp(this)
41+
Log.d("SofiaTracker", "Firebase initialized successfully")
42+
} catch (e: Exception) {
43+
Log.e("SofiaTracker", "Firebase initialization failed", e)
44+
}
45+
46+
// Test Firebase connection and trigger initial sync
47+
applicationScope.launch {
48+
try {
49+
val result = firebaseConnectionChecker.checkConnection()
50+
if (result.isSuccess) {
51+
Log.d("SofiaTracker", "Firebase connection test: ${result.getOrNull()}")
52+
53+
// Schedule background sync
54+
syncWorkManager.schedulePeriodicSync()
55+
56+
// Trigger immediate sync on app startup
57+
Log.d("SofiaTracker", "Triggering initial sync on app startup")
58+
syncManager.performFullSync().collect { syncResult ->
59+
when (syncResult) {
60+
is com.dpconde.sofiatracker.data.sync.SyncResult.InProgress -> {
61+
Log.d("SofiaTracker", "Initial sync in progress")
62+
}
63+
is com.dpconde.sofiatracker.data.sync.SyncResult.Progress -> {
64+
Log.d("SofiaTracker", "Initial sync progress: ${syncResult.message}")
65+
}
66+
is com.dpconde.sofiatracker.data.sync.SyncResult.Success -> {
67+
Log.d("SofiaTracker", "Initial sync completed: ${syncResult.message}")
68+
}
69+
is com.dpconde.sofiatracker.data.sync.SyncResult.Error -> {
70+
Log.e("SofiaTracker", "Initial sync failed", syncResult.exception)
71+
}
72+
}
73+
}
74+
} else {
75+
Log.e("SofiaTracker", "Firebase connection test failed: ${result.exceptionOrNull()}")
76+
// Still schedule periodic sync for when connection is restored
77+
syncWorkManager.schedulePeriodicSync()
78+
}
79+
} catch (e: Exception) {
80+
Log.e("SofiaTracker", "Error testing Firebase connection", e)
81+
// Still schedule periodic sync for when connection is restored
82+
syncWorkManager.schedulePeriodicSync()
83+
}
84+
}
85+
}
86+
87+
override val workManagerConfiguration: Configuration
88+
get() = Configuration.Builder()
89+
.setWorkerFactory(workerFactory)
90+
.build()
91+
}

app/src/main/java/com/dpconde/sofiatracker/data/local/Converters.kt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package com.dpconde.sofiatracker.data.local
22

33
import androidx.room.TypeConverter
44
import com.dpconde.sofiatracker.domain.model.EventType
5+
import com.dpconde.sofiatracker.domain.model.SyncStatus
56
import java.time.LocalDateTime
67
import java.time.format.DateTimeFormatter
78

@@ -28,4 +29,14 @@ class Converters {
2829
fun toEventType(eventTypeString: String): EventType {
2930
return EventType.valueOf(eventTypeString)
3031
}
32+
33+
@TypeConverter
34+
fun fromSyncStatus(syncStatus: SyncStatus): String {
35+
return syncStatus.name
36+
}
37+
38+
@TypeConverter
39+
fun toSyncStatus(syncStatusString: String): SyncStatus {
40+
return SyncStatus.valueOf(syncStatusString)
41+
}
3142
}

app/src/main/java/com/dpconde/sofiatracker/data/local/SofiaTrackerDatabase.kt

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,21 @@ import androidx.room.RoomDatabase
66
import androidx.room.TypeConverters
77
import android.content.Context
88
import com.dpconde.sofiatracker.data.local.dao.EventDao
9+
import com.dpconde.sofiatracker.data.local.dao.SyncStateDao
910
import com.dpconde.sofiatracker.data.local.entity.EventEntity
11+
import com.dpconde.sofiatracker.data.local.entity.SyncStateEntity
12+
import com.dpconde.sofiatracker.data.local.migrations.MIGRATION_1_2
1013

1114
@Database(
12-
entities = [EventEntity::class],
13-
version = 1,
15+
entities = [EventEntity::class, SyncStateEntity::class],
16+
version = 2,
1417
exportSchema = false
1518
)
1619
@TypeConverters(Converters::class)
1720
abstract class SofiaTrackerDatabase : RoomDatabase() {
1821

1922
abstract fun eventDao(): EventDao
23+
abstract fun syncStateDao(): SyncStateDao
2024

2125
companion object {
2226
@Volatile
@@ -28,7 +32,9 @@ abstract class SofiaTrackerDatabase : RoomDatabase() {
2832
context.applicationContext,
2933
SofiaTrackerDatabase::class.java,
3034
"sofia_tracker_database"
31-
).build()
35+
)
36+
.addMigrations(MIGRATION_1_2)
37+
.build()
3238
INSTANCE = instance
3339
instance
3440
}

app/src/main/java/com/dpconde/sofiatracker/data/local/dao/EventDao.kt

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,11 @@ package com.dpconde.sofiatracker.data.local.dao
33
import androidx.room.Dao
44
import androidx.room.Insert
55
import androidx.room.Query
6+
import androidx.room.Update
7+
import androidx.room.Upsert
68
import com.dpconde.sofiatracker.data.local.entity.EventEntity
79
import com.dpconde.sofiatracker.domain.model.EventType
10+
import com.dpconde.sofiatracker.domain.model.SyncStatus
811
import kotlinx.coroutines.flow.Flow
912

1013
@Dao
@@ -21,4 +24,26 @@ interface EventDao {
2124

2225
@Query("SELECT * FROM events WHERE type = :type ORDER BY timestamp DESC LIMIT 2")
2326
fun getLastTwoEventsByType(type: EventType): Flow<List<EventEntity>>
27+
28+
// Sync-related queries
29+
@Query("SELECT * FROM events WHERE syncStatus = :status")
30+
suspend fun getEventsBySyncStatus(status: SyncStatus): List<EventEntity>
31+
32+
@Query("SELECT COUNT(*) FROM events WHERE syncStatus = 'PENDING_SYNC'")
33+
suspend fun getPendingSyncCount(): Int
34+
35+
@Query("UPDATE events SET syncStatus = :status WHERE id = :eventId")
36+
suspend fun updateSyncStatus(eventId: Long, status: SyncStatus)
37+
38+
@Query("UPDATE events SET syncStatus = :status, lastSyncAttempt = :syncTime WHERE id = :eventId")
39+
suspend fun updateSyncStatusWithTime(eventId: Long, status: SyncStatus, syncTime: java.time.LocalDateTime)
40+
41+
@Query("UPDATE events SET syncStatus = :status, remoteId = :remoteId WHERE id = :eventId")
42+
suspend fun updateSyncStatusWithRemoteId(eventId: Long, status: SyncStatus, remoteId: String)
43+
44+
@Upsert
45+
suspend fun upsertEvent(event: EventEntity)
46+
47+
@Update
48+
suspend fun updateEvent(event: EventEntity)
2449
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package com.dpconde.sofiatracker.data.local.dao
2+
3+
import androidx.room.*
4+
import com.dpconde.sofiatracker.data.local.entity.SyncStateEntity
5+
import kotlinx.coroutines.flow.Flow
6+
7+
@Dao
8+
interface SyncStateDao {
9+
10+
@Query("SELECT * FROM sync_state WHERE id = 'app_sync_state'")
11+
fun getSyncState(): Flow<SyncStateEntity?>
12+
13+
@Query("SELECT * FROM sync_state WHERE id = 'app_sync_state'")
14+
suspend fun getSyncStateOnce(): SyncStateEntity?
15+
16+
@Upsert
17+
suspend fun updateSyncState(syncState: SyncStateEntity)
18+
19+
@Query("UPDATE sync_state SET pendingEventsCount = :count WHERE id = 'app_sync_state'")
20+
suspend fun updatePendingEventsCount(count: Int)
21+
}

0 commit comments

Comments
 (0)