Install
$ agentstack add skill-piyushverma0-android-agent-skills-offline-first ✓ scanned · ✓ verified — works with Claude Code, Cursor, and more.
Security review
✓ PassedNo issues found. Passed automated security review. · v0.1.0 How review works →
- ✓ Prompt-injection patterns
- ✓ Secret / credential exfiltration
- ✓ Dangerous shell & filesystem operations
- ✓ Untrusted network calls
- ✓ Known-malicious package signatures
What it can access
- ✓ Network access No
- ✓ Filesystem access No
- ✓ Shell / process execution No
- ✓ Environment & secrets No
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
About
Offline-First Architecture
Apps that require internet to work lose 30%+ of users. These patterns make your app work everywhere — with or without connectivity.
Core principle: Local DB is source of truth
User Action → ViewModel → Repository
↓
Local DB (Room) ← single source of truth
↓
UI observes Flow from Room
↓ (background)
Network sync → update Local DB → UI auto-updates
Rule 1: Single-direction data flow — never mix local + remote in UI
// ✅ Repository: local DB is source of truth, network updates DB silently
class ItemRepositoryImpl @Inject constructor(
private val itemDao: ItemDao,
private val api: ItemApiService,
@IoDispatcher private val dispatcher: CoroutineDispatcher
) : ItemRepository {
// UI always observes local DB
override fun getItemsStream(): Flow> =
itemDao.getAll().map { it.map { entity -> entity.toDomain() } }
// Sync pulls from network and updates local DB
override suspend fun sync(): Result = withContext(dispatcher) {
runCatching {
val remoteItems = api.getItems()
itemDao.upsertAll(remoteItems.map { it.toEntity() })
}
}
}
// ✅ ViewModel triggers sync but observes local DB
@HiltViewModel
class HomeViewModel @Inject constructor(
private val repository: ItemRepository
) : ViewModel() {
val items: StateFlow> = repository.getItemsStream()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
init { sync() }
fun sync() {
viewModelScope.launch {
repository.sync() // updates DB → Flow auto-updates UI
}
}
}
Rule 2: Optimistic UI updates
// ✅ Apply change locally immediately, sync to server in background
override suspend fun toggleFavorite(itemId: String): Result = withContext(dispatcher) {
// 1. Update local DB immediately (UI updates instantly)
val item = itemDao.getByIdOnce(itemId) ?: return@withContext Result.failure(NotFoundException())
val newValue = !item.isFavorite
itemDao.updateFavorite(itemId, newValue)
// 2. Sync to server in background
runCatching {
api.patchItem(itemId, mapOf("is_favorite" to newValue))
}.onFailure {
// 3. Rollback local change if server fails
itemDao.updateFavorite(itemId, !newValue)
}
}
Rule 3: Network connectivity monitoring
// ✅ Observable connectivity status
class NetworkMonitor @Inject constructor(
@ApplicationContext private val context: Context
) {
val isOnline: Flow = callbackFlow {
val connectivityManager = context.getSystemService()!!
val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) { trySend(true) }
override fun onLost(network: Network) { trySend(false) }
}
val request = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
connectivityManager.registerNetworkCallback(request, callback)
// Emit current state immediately
trySend(connectivityManager.isCurrentlyConnected())
awaitClose { connectivityManager.unregisterNetworkCallback(callback) }
}.distinctUntilChanged()
.shareIn(CoroutineScope(Dispatchers.IO), SharingStarted.WhileSubscribed(), 1)
private fun ConnectivityManager.isCurrentlyConnected(): Boolean =
activeNetwork?.let { getNetworkCapabilities(it) }
?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true
}
// ✅ In ViewModel — sync when connection is restored
@HiltViewModel
class HomeViewModel @Inject constructor(
private val repository: ItemRepository,
private val networkMonitor: NetworkMonitor
) : ViewModel() {
init {
viewModelScope.launch {
networkMonitor.isOnline
.filter { it } // only emit when online
.collect { repository.sync() }
}
}
}
Rule 4: WorkManager for background sync
// ✅ Periodic sync with WorkManager
@HiltWorker
class SyncWorker @AssistedInject constructor(
@Assisted appContext: Context,
@Assisted workerParams: WorkerParameters,
private val repository: ItemRepository
) : CoroutineWorker(appContext, workerParams) {
override suspend fun doWork(): Result {
return try {
repository.sync().fold(
onSuccess = { Result.success() },
onFailure = {
if (runAttemptCount (
repeatInterval = 15,
repeatIntervalTimeUnit = TimeUnit.MINUTES
)
.setConstraints(constraints)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
.build()
workManager.enqueueUniquePeriodicWork(
WORK_NAME,
ExistingPeriodicWorkPolicy.KEEP,
request
)
}
}
}
Rule 5: Pending operations queue — for offline writes
// ✅ Store operations when offline, replay when online
@Entity(tableName = "pending_operations")
data class PendingOperationEntity(
@PrimaryKey(autoGenerate = true) val id: Int = 0,
@ColumnInfo(name = "operation_type") val operationType: String, // "CREATE", "UPDATE", "DELETE"
@ColumnInfo(name = "entity_id") val entityId: String,
@ColumnInfo(name = "payload") val payload: String, // JSON-serialized request
@ColumnInfo(name = "created_at") val createdAt: Long = System.currentTimeMillis(),
@ColumnInfo(name = "retry_count") val retryCount: Int = 0
)
// ✅ Repository queues when offline
override suspend fun createItem(item: Item): Result = withContext(dispatcher) {
// Always save locally first
itemDao.upsert(item.toEntity())
// Try to sync immediately
if (networkMonitor.isCurrentlyOnline()) {
runCatching { api.createItem(item.toRequest()) }
.onFailure {
// Queue for later retry
pendingOpsDao.insert(PendingOperationEntity(
operationType = "CREATE",
entityId = item.id,
payload = Json.encodeToString(item.toRequest())
))
}
} else {
// Offline — queue immediately
pendingOpsDao.insert(PendingOperationEntity(
operationType = "CREATE",
entityId = item.id,
payload = Json.encodeToString(item.toRequest())
))
}
Result.success(Unit)
}
Rule 6: Stale data indicator
// ✅ Show "last synced" to inform users of data freshness
data class SyncMetadata(
val lastSyncTime: Instant?,
val isSyncing: Boolean,
val syncError: String?
)
@HiltViewModel
class HomeViewModel : ViewModel() {
private val _syncMetadata = MutableStateFlow(SyncMetadata(null, false, null))
val syncMetadata: StateFlow = _syncMetadata.asStateFlow()
private fun sync() {
viewModelScope.launch {
_syncMetadata.update { it.copy(isSyncing = true, syncError = null) }
repository.sync()
.onSuccess {
_syncMetadata.update { it.copy(
isSyncing = false,
lastSyncTime = Clock.System.now()
) }
}
.onFailure { error ->
_syncMetadata.update { it.copy(
isSyncing = false,
syncError = "Last sync failed. Showing cached data."
) }
}
}
}
}
Common Mistakes
❌ Showing loading while Room DB loads — DB is fast, show data immediately ❌ UI observing network response directly — UI must only observe local DB ❌ No retry logic for failed sync — use exponential backoff ❌ Running WorkManager sync on unconstrained network — always require CONNECTED ❌ Not handling sync conflicts — decide on server-wins or last-write-wins policy ❌ Clearing local cache on every fresh load — breaks offline experience
Source & license
This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.
- Author: piyushverma0
- Source: piyushverma0/android-agent-skills
- License: MIT
- Homepage: https://android-agent-skills.vercel.app
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet — be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.