Week 8: Offline Caching, Background Sync, Maps & Tests - Codeman239/TruckMate GitHub Wiki
Week 8 adds robust offline support and background sync so TruckMate continues to work when a device is offline, then synchronizes changes when connectivity returns. It also introduces a map view for truck locations and starter tests (unit & instrumentation).
This wiki documents:
- Gradle changes
- Room (Entity / DAO / Database)
- Repository updates (Room + Firestore + outbox)
- WorkManager
SyncWorker
implementation & enqueue patterns - Map integration notes and example Compose screen
- Tests and verification steps
- Troubleshooting and security notes
App module (app/build.gradle.kts
) — add these dependencies (versions may vary):
// Room implementation("androidx.room:room-runtime:2.6.1") kapt("androidx.room:room-compiler:2.6.1") implementation("androidx.room:room-ktx:2.6.1")
// WorkManager implementation("androidx.work:work-runtime-ktx:2.8.1")
// Google Maps and Maps Compose utilities implementation("com.google.android.gms:play-services-maps:18.1.0") implementation("com.google.maps.android:maps-compose:2.11.3") // optional - Compose maps helpers
// Coroutines implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
// Testing examples testImplementation("junit:junit:4.13.2") androidTestImplementation("androidx.test.ext:junit:1.1.5") androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
Room: Entity / DAO / Database 3.1 TruckEntity.kt
Path: app/src/main/java/com/codyhassey/truckmate/data/local/TruckEntity.kt
package com.codyhassey.truckmate.data.local
import androidx.room.Entity import androidx.room.PrimaryKey
@Entity(tableName = "trucks") data class TruckEntity( @PrimaryKey val id: String, val name: String, val status: String, val location: String, // "lat,lng" or a single string; recommend "lat,lng" val lastSeenMillis: Long? // epoch millis )
3.2 OutboxEntity.kt (for queued offline edits)
Path: app/src/main/java/com/codyhassey/truckmate/data/local/OutboxEntity.kt
package com.codyhassey.truckmate.data.local
import androidx.room.Entity import androidx.room.PrimaryKey
@Entity(tableName = "outbox") data class OutboxEntity( @PrimaryKey(autoGenerate = true) val uid: Long = 0, val truckId: String, val operation: String, // "update" | "delete" | "create" val payloadJson: String // simple way: JSON string of fields to apply )
3.3 TruckDao.kt
Path: app/src/main/java/com/codyhassey/truckmate/data/local/TruckDao.kt
package com.codyhassey.truckmate.data.local
import androidx.room.* import kotlinx.coroutines.flow.Flow
@Dao interface TruckDao { @Query("SELECT * FROM trucks") fun getAll(): Flow<List>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(vararg trucks: TruckEntity)
@Update
suspend fun update(truck: TruckEntity)
@Query("DELETE FROM trucks WHERE id = :id")
suspend fun deleteById(id: String)
}
3.4 AppDatabase.kt
Path: app/src/main/java/com/codyhassey/truckmate/data/local/AppDatabase.kt
package com.codyhassey.truckmate.data.local
import androidx.room.Database import androidx.room.RoomDatabase
@Database(entities = [TruckEntity::class, OutboxEntity::class], version = 1) abstract class AppDatabase : RoomDatabase() { abstract fun truckDao(): TruckDao abstract fun outboxDao(): OutboxDao? // implement OutboxDao similar to TruckDao if needed }
- Repository: Room + Firestore + Outbox TruckRepository.kt (core ideas)
Path: app/src/main/java/com/codyhassey/truckmate/data/repository/TruckRepository.kt
Responsibilities: Mirror Firestore changes into Room (on snapshot). Serve UI Flow from Room (so UI works offline). When user modifies a truck while offline, write to Room and write an Outbox entry. SyncWorker will read Outbox and apply to Firestore, then clear succeeded outbox entries.
Example outline (not full boilerplate):
class TruckRepository( private val db: FirebaseFirestore, private val truckDao: TruckDao, private val outboxDao: OutboxDao ) { private var listener: ListenerRegistration? = null
// Start Firestore listener and sync to Room
fun startListeningAndCache() {
listener = db.collection("trucks").addSnapshotListener { snapshot, error ->
if (error != null) return@addSnapshotListener
val entities = snapshot?.documents?.mapNotNull { doc ->
val id = doc.id
val name = doc.getString("name") ?: ""
val status = doc.getString("status") ?: ""
val location = doc.getString("location") ?: ""
val lastSeen = doc.getTimestamp("lastSeen")?.toDate()?.time
TruckEntity(id, name, status, location, lastSeen)
} ?: emptyList()
// write to Room on background coroutine
CoroutineScope(Dispatchers.IO).launch {
if (entities.isNotEmpty()) truckDao.insert(*entities.toTypedArray())
}
}
}
fun stopListening() { listener?.remove(); listener = null }
// Update locally and queue if offline
suspend fun updateTruckOffline(truck: TruckEntity, payload: Map<String, Any>) {
truckDao.update(truck)
// Add outbox entry
val payloadJson = JSONObject(payload).toString()
outboxDao.insert(OutboxEntity(truckId = truck.id, operation = "update", payloadJson = payloadJson))
}
// Read from Room for UI
fun observeTrucks(): Flow<List<TruckEntity>> = truckDao.getAll()
}
- SyncWorker (WorkManager) SyncWorker.kt (CoroutineWorker example)
Path: app/src/main/java/com/codyhassey/truckmate/data/sync/SyncWorker.kt
package com.codyhassey.truckmate.data.sync
import android.content.Context import androidx.work.CoroutineWorker import androidx.work.WorkerParameters import com.google.firebase.firestore.FirebaseFirestore import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.json.JSONObject
class SyncWorker(ctx: Context, params: WorkerParameters): CoroutineWorker(ctx, params) { private val db = FirebaseFirestore.getInstance() private val appDb = AppDatabase.getInstance(ctx) // implement a singleton getter private val outboxDao = appDb.outboxDao()
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
try {
val pending = outboxDao.getAllPending() // implement this
for (entry in pending) {
val payload = JSONObject(entry.payloadJson)
val updates = payloadToMap(payload)
when (entry.operation) {
"update" -> {
db.collection("trucks").document(entry.truckId)
.update(updates)
.await() // use Tasks.await or convert to suspend
}
"create" -> {
db.collection("trucks").document(entry.truckId)
.set(updates)
.await()
}
"delete" -> {
db.collection("trucks").document(entry.truckId)
.delete()
.await()
}
}
// delete outbox entry on success
outboxDao.delete(entry)
}
Result.success()
} catch (e: Exception) {
// keep entries for retry later
Result.retry()
}
}
private fun payloadToMap(json: JSONObject): Map<String, Any> {
val map = mutableMapOf<String, Any>()
val keys = json.keys()
while (keys.hasNext()) {
val k = keys.next()
map[k] = json.get(k)
}
return map
}
}
Notes: .await() usage implies converting Task to Kotlin suspend — use kotlinx-coroutines-play-services await() extension or Tasks.await() helper. Add the library or implement a small suspend wrapper. Register WorkManager job when network returns or schedule a periodic sync: val request = OneTimeWorkRequestBuilder().setConstraints(networkConstraint).build() WorkManager.getInstance(context).enqueue(request)
- Map Integration API Key
Obtain Google Maps API key from Google Cloud Console.
Add to AndroidManifest.xml: android:name="com.google.android.geo.API_KEY" android:value="YOUR_API_KEY_HERE"/>
(Restrict API key to your package name & SHA-1 for production.)
TruckMapScreen.kt (Compose example using Maps Compose)
Path: app/src/main/java/com/codyhassey/truckmate/ui/trucks/TruckMapScreen.kt
@Composable fun TruckMapScreen(trucks: List) { val cameraPositionState = rememberCameraPositionState { position = CameraPosition.fromLatLngZoom(LatLng(40.0, -74.0), 10f) } GoogleMap(cameraPositionState = cameraPositionState) { trucks.forEach { t -> val parts = t.location.split(",") if (parts.size == 2) { val lat = parts[0].toDoubleOrNull() val lng = parts[1].toDoubleOrNull() if (lat != null && lng != null) { Marker(position = LatLng(lat, lng), title = t.name) } } } } }
Add permission checks for location if you plan to show the user's location.
- Tests Unit tests (Repository)
Use kotlinx-coroutines-test to test flows from Room and the repository logic. Mock Firestore with fakes or use a local emulator.
Instrumentation tests Add an instrumentation test that: Launches the app Signs in with a test Firebase user (or use Firebase Auth emulator) Asserts that Truck List shows items (use Espresso or Compose Testing APIs)
Run tests: Unit: ./gradlew test Instrumentation: ./gradlew connectedAndroidTest
Troubleshooting & Tips If sync fails repeatedly, inspect WorkManager logs and SyncWorker exceptions. Use Result.retry() conservatively. Firestore security rules: during development, you can allow reads/writes for authenticated users. For production, tighten rules. Convert Task callbacks to Kotlin suspend using kotlinx-coroutines-play-services or helper extension functions. For Maps: if markers don’t show, check API key restrictions, billing, and enabled Maps SDK for Android. If app crashes on DB migration, bump Room DB version and provide a proper migration strategy.
- Security & Privacy Notes Do not commit API keys or google-services.json to a public repo. Use private repo or CI secrets for production builds. Minimize stored sensitive data in outbox payloads. Use server-side validation for any client updates.