Week 8: Offline Caching, Background Sync, Maps & Tests - Codeman239/TruckMate GitHub Wiki

Overview

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

1. Gradle / Dependencies

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 }

  1. 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()

}

  1. 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)

  1. 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.

  1. 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.

  1. 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.
⚠️ **GitHub.com Fallback** ⚠️