{"id":"7589515d-d323-472f-aa95-cb67d880306c","shortId":"e2RMwx","kind":"skill","title":"offline-first","tagline":"27 Android skills for AI agents (Claude Code, Codex, Cursor). Fixes Supabase auth, Hilt errors, design inconsistency, kapt→ksp, missing UiState states. Reduced my token bills 5×. FitGenZ AI shipped in 18 days.","description":"# Offline-First Architecture\r\n\r\nApps that require internet to work lose 30%+ of users. These patterns make your app work\r\neverywhere — with or without connectivity.\r\n\r\n## Core principle: Local DB is source of truth\r\n\r\n```\r\nUser Action → ViewModel → Repository\r\n                               ↓\r\n                         Local DB (Room)  ← single source of truth\r\n                               ↓\r\n                          UI observes Flow from Room\r\n                               ↓ (background)\r\n                         Network sync → update Local DB → UI auto-updates\r\n```\r\n\r\n## Rule 1: Single-direction data flow — never mix local + remote in UI\r\n\r\n```kotlin\r\n// ✅ Repository: local DB is source of truth, network updates DB silently\r\nclass ItemRepositoryImpl @Inject constructor(\r\n    private val itemDao: ItemDao,\r\n    private val api: ItemApiService,\r\n    @IoDispatcher private val dispatcher: CoroutineDispatcher\r\n) : ItemRepository {\r\n\r\n    // UI always observes local DB\r\n    override fun getItemsStream(): Flow<List<Item>> =\r\n        itemDao.getAll().map { it.map { entity -> entity.toDomain() } }\r\n\r\n    // Sync pulls from network and updates local DB\r\n    override suspend fun sync(): Result<Unit> = withContext(dispatcher) {\r\n        runCatching {\r\n            val remoteItems = api.getItems()\r\n            itemDao.upsertAll(remoteItems.map { it.toEntity() })\r\n        }\r\n    }\r\n}\r\n\r\n// ✅ ViewModel triggers sync but observes local DB\r\n@HiltViewModel\r\nclass HomeViewModel @Inject constructor(\r\n    private val repository: ItemRepository\r\n) : ViewModel() {\r\n\r\n    val items: StateFlow<List<Item>> = repository.getItemsStream()\r\n        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())\r\n\r\n    init { sync() }\r\n\r\n    fun sync() {\r\n        viewModelScope.launch {\r\n            repository.sync()   // updates DB → Flow auto-updates UI\r\n        }\r\n    }\r\n}\r\n```\r\n\r\n## Rule 2: Optimistic UI updates\r\n\r\n```kotlin\r\n// ✅ Apply change locally immediately, sync to server in background\r\noverride suspend fun toggleFavorite(itemId: String): Result<Unit> = withContext(dispatcher) {\r\n    // 1. Update local DB immediately (UI updates instantly)\r\n    val item = itemDao.getByIdOnce(itemId) ?: return@withContext Result.failure(NotFoundException())\r\n    val newValue = !item.isFavorite\r\n    itemDao.updateFavorite(itemId, newValue)\r\n\r\n    // 2. Sync to server in background\r\n    runCatching {\r\n        api.patchItem(itemId, mapOf(\"is_favorite\" to newValue))\r\n    }.onFailure {\r\n        // 3. Rollback local change if server fails\r\n        itemDao.updateFavorite(itemId, !newValue)\r\n    }\r\n}\r\n```\r\n\r\n## Rule 3: Network connectivity monitoring\r\n\r\n```kotlin\r\n// ✅ Observable connectivity status\r\nclass NetworkMonitor @Inject constructor(\r\n    @ApplicationContext private val context: Context\r\n) {\r\n    val isOnline: Flow<Boolean> = callbackFlow {\r\n        val connectivityManager = context.getSystemService<ConnectivityManager>()!!\r\n\r\n        val callback = object : ConnectivityManager.NetworkCallback() {\r\n            override fun onAvailable(network: Network) { trySend(true) }\r\n            override fun onLost(network: Network) { trySend(false) }\r\n        }\r\n\r\n        val request = NetworkRequest.Builder()\r\n            .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)\r\n            .build()\r\n\r\n        connectivityManager.registerNetworkCallback(request, callback)\r\n        // Emit current state immediately\r\n        trySend(connectivityManager.isCurrentlyConnected())\r\n\r\n        awaitClose { connectivityManager.unregisterNetworkCallback(callback) }\r\n    }.distinctUntilChanged()\r\n        .shareIn(CoroutineScope(Dispatchers.IO), SharingStarted.WhileSubscribed(), 1)\r\n\r\n    private fun ConnectivityManager.isCurrentlyConnected(): Boolean =\r\n        activeNetwork?.let { getNetworkCapabilities(it) }\r\n            ?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true\r\n}\r\n\r\n// ✅ In ViewModel — sync when connection is restored\r\n@HiltViewModel\r\nclass HomeViewModel @Inject constructor(\r\n    private val repository: ItemRepository,\r\n    private val networkMonitor: NetworkMonitor\r\n) : ViewModel() {\r\n    init {\r\n        viewModelScope.launch {\r\n            networkMonitor.isOnline\r\n                .filter { it }       // only emit when online\r\n                .collect { repository.sync() }\r\n        }\r\n    }\r\n}\r\n```\r\n\r\n## Rule 4: WorkManager for background sync\r\n\r\n```kotlin\r\n// ✅ Periodic sync with WorkManager\r\n@HiltWorker\r\nclass SyncWorker @AssistedInject constructor(\r\n    @Assisted appContext: Context,\r\n    @Assisted workerParams: WorkerParameters,\r\n    private val repository: ItemRepository\r\n) : CoroutineWorker(appContext, workerParams) {\r\n\r\n    override suspend fun doWork(): Result {\r\n        return try {\r\n            repository.sync().fold(\r\n                onSuccess = { Result.success() },\r\n                onFailure = {\r\n                    if (runAttemptCount < 3) Result.retry()\r\n                    else Result.failure()\r\n                }\r\n            )\r\n        } catch (e: Exception) {\r\n            if (runAttemptCount < 3) Result.retry() else Result.failure()\r\n        }\r\n    }\r\n\r\n    companion object {\r\n        const val WORK_NAME = \"SyncWorker\"\r\n\r\n        fun schedule(workManager: WorkManager) {\r\n            val constraints = Constraints.Builder()\r\n                .setRequiredNetworkType(NetworkType.CONNECTED)\r\n                .build()\r\n\r\n            val request = PeriodicWorkRequestBuilder<SyncWorker>(\r\n                repeatInterval = 15,\r\n                repeatIntervalTimeUnit = TimeUnit.MINUTES\r\n            )\r\n                .setConstraints(constraints)\r\n                .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)\r\n                .build()\r\n\r\n            workManager.enqueueUniquePeriodicWork(\r\n                WORK_NAME,\r\n                ExistingPeriodicWorkPolicy.KEEP,\r\n                request\r\n            )\r\n        }\r\n    }\r\n}\r\n```\r\n\r\n## Rule 5: Pending operations queue — for offline writes\r\n\r\n```kotlin\r\n// ✅ Store operations when offline, replay when online\r\n@Entity(tableName = \"pending_operations\")\r\ndata class PendingOperationEntity(\r\n    @PrimaryKey(autoGenerate = true) val id: Int = 0,\r\n    @ColumnInfo(name = \"operation_type\") val operationType: String,   // \"CREATE\", \"UPDATE\", \"DELETE\"\r\n    @ColumnInfo(name = \"entity_id\") val entityId: String,\r\n    @ColumnInfo(name = \"payload\") val payload: String,                // JSON-serialized request\r\n    @ColumnInfo(name = \"created_at\") val createdAt: Long = System.currentTimeMillis(),\r\n    @ColumnInfo(name = \"retry_count\") val retryCount: Int = 0\r\n)\r\n\r\n// ✅ Repository queues when offline\r\noverride suspend fun createItem(item: Item): Result<Unit> = withContext(dispatcher) {\r\n    // Always save locally first\r\n    itemDao.upsert(item.toEntity())\r\n\r\n    // Try to sync immediately\r\n    if (networkMonitor.isCurrentlyOnline()) {\r\n        runCatching { api.createItem(item.toRequest()) }\r\n            .onFailure {\r\n                // Queue for later retry\r\n                pendingOpsDao.insert(PendingOperationEntity(\r\n                    operationType = \"CREATE\",\r\n                    entityId = item.id,\r\n                    payload = Json.encodeToString(item.toRequest())\r\n                ))\r\n            }\r\n    } else {\r\n        // Offline — queue immediately\r\n        pendingOpsDao.insert(PendingOperationEntity(\r\n            operationType = \"CREATE\",\r\n            entityId = item.id,\r\n            payload = Json.encodeToString(item.toRequest())\r\n        ))\r\n    }\r\n\r\n    Result.success(Unit)\r\n}\r\n```\r\n\r\n## Rule 6: Stale data indicator\r\n\r\n```kotlin\r\n// ✅ Show \"last synced\" to inform users of data freshness\r\ndata class SyncMetadata(\r\n    val lastSyncTime: Instant?,\r\n    val isSyncing: Boolean,\r\n    val syncError: String?\r\n)\r\n\r\n@HiltViewModel\r\nclass HomeViewModel : ViewModel() {\r\n    private val _syncMetadata = MutableStateFlow(SyncMetadata(null, false, null))\r\n    val syncMetadata: StateFlow<SyncMetadata> = _syncMetadata.asStateFlow()\r\n\r\n    private fun sync() {\r\n        viewModelScope.launch {\r\n            _syncMetadata.update { it.copy(isSyncing = true, syncError = null) }\r\n            repository.sync()\r\n                .onSuccess {\r\n                    _syncMetadata.update { it.copy(\r\n                        isSyncing = false,\r\n                        lastSyncTime = Clock.System.now()\r\n                    ) }\r\n                }\r\n                .onFailure { error ->\r\n                    _syncMetadata.update { it.copy(\r\n                        isSyncing = false,\r\n                        syncError = \"Last sync failed. Showing cached data.\"\r\n                    ) }\r\n                }\r\n        }\r\n    }\r\n}\r\n```\r\n\r\n## Common Mistakes\r\n\r\n❌ Showing loading while Room DB loads — DB is fast, show data immediately\r\n❌ UI observing network response directly — UI must only observe local DB\r\n❌ No retry logic for failed sync — use exponential backoff\r\n❌ Running WorkManager sync on unconstrained network — always require CONNECTED\r\n❌ Not handling sync conflicts — decide on server-wins or last-write-wins policy\r\n❌ Clearing local cache on every fresh load — breaks offline experience","tags":["offline","first","android","agent","skills","piyushverma0","agent-skills","ai-agent","antigravity","claude-code","codex","cursor"],"capabilities":["skill","source-piyushverma0","skill-offline-first","topic-agent-skills","topic-ai-agent","topic-android","topic-antigravity","topic-claude-code","topic-codex","topic-cursor","topic-gemini-cli","topic-hilt","topic-jetpack-compose","topic-kotlin","topic-material3"],"categories":["android-agent-skills"],"synonyms":[],"warnings":[],"endpointUrl":"https://skills.sh/piyushverma0/android-agent-skills/offline-first","protocol":"skill","transport":"skills-sh","auth":{"type":"none","details":{"cli":"npx skills add piyushverma0/android-agent-skills","source_repo":"https://github.com/piyushverma0/android-agent-skills","install_from":"skills.sh"}},"qualityScore":"0.454","qualityRationale":"deterministic score 0.45 from registry signals: · indexed on github topic:agent-skills · 8 github stars · SKILL.md body (9,182 chars)","verified":false,"liveness":"unknown","lastLivenessCheck":null,"agentReviews":{"count":0,"score_avg":null,"cost_usd_avg":null,"success_rate":null,"latency_p50_ms":null,"narrative_summary":null,"summary_updated_at":null},"enrichmentModel":"deterministic:skill-github:v1","enrichmentVersion":1,"enrichedAt":"2026-05-18T19:09:10.363Z","embedding":null,"createdAt":"2026-05-18T13:14:50.056Z","updatedAt":"2026-05-18T19:09:10.363Z","lastSeenAt":"2026-05-18T19:09:10.363Z","tsv":"'0':523,566 '000':202 '1':97,241,356 '15':479 '18':35 '2':218,263 '27':4 '3':278,289,445,454 '30':48,486 '4':403 '5':30,201,495 '6':625 'action':71 'activenetwork':361 'addcap':334 'agent':9 'ai':8,32 'alway':140,580,738 'android':5 'api':131 'api.createitem':593 'api.getitems':172 'api.patchitem':270 'app':41,55 'appcontext':419,429 'appli':223 'applicationcontext':301 'architectur':40 'assist':418,421 'assistedinject':416 'auth':16 'auto':94,214 'auto-upd':93,213 'autogener':518 'awaitclos':348 'background':86,231,268,406 'backoff':731 'backoffpolicy.exponential':485 'bill':29 'boolean':360,647 'break':763 'build':338,474,488 'cach':696,758 'callback':314,341,350 'callbackflow':309 'capabl':336,367 'catch':449 'chang':224,281 'class':121,184,297,378,414,515,640,652 'claud':10 'clear':756 'clock.system.now':684 'code':11 'codex':12 'collect':400 'columninfo':524,534,541,551,559 'common':698 'companion':458 'conflict':744 'connect':61,291,295,374,740 'connectivitymanag':311 'connectivitymanager.iscurrentlyconnected':347,359 'connectivitymanager.networkcallback':316 'connectivitymanager.registernetworkcallback':339 'connectivitymanager.unregisternetworkcallback':349 'const':460 'constraint':470,483 'constraints.builder':471 'constructor':124,187,300,381,417 'context':304,305,420 'context.getsystemservice':312 'core':62 'coroutinedispatch':137 'coroutinescop':353 'coroutinework':428 'count':562 'creat':531,553,603,616 'createdat':556 'createitem':574 'current':343 'cursor':13 'data':101,514,627,637,639,697,710 'day':36 'db':65,75,91,112,119,143,161,182,211,244,704,706,722 'decid':745 'delet':533 'design':19 'direct':100,716 'dispatch':136,168,240,579 'dispatchers.io':354 'distinctuntilchang':351 'dowork':434 'e':450 'els':447,456,609 'emit':342,397 'emptylist':203 'entiti':152,510,536 'entity.todomain':153 'entityid':539,604,617 'error':18,686 'everi':760 'everywher':57 'except':451 'existingperiodicworkpolicy.keep':492 'experi':765 'exponenti':730 'fail':284,694,727 'fals':330,661,682,690 'fast':708 'favorit':274 'filter':394 'first':3,39,583 'fitgenz':31 'fix':14 'flow':83,102,147,212,308 'fold':439 'fresh':638,761 'fun':145,164,206,234,318,325,358,433,465,573,668 'getitemsstream':146 'getnetworkcap':363 'handl':742 'hascap':365 'hilt':17 'hiltviewmodel':183,377,651 'hiltwork':413 'homeviewmodel':185,379,653 'id':521,537 'immedi':226,245,345,589,612,711 'inconsist':20 'indic':628 'inform':634 'init':204,391 'inject':123,186,299,380 'instant':248,644 'int':522,565 'internet':44,337,368 'iodispatch':133 'isonlin':307 'issync':646,673,681,689 'it.copy':672,680,688 'it.map':151 'it.toentity':175 'item':194,250,575,576 'item.id':605,618 'item.isfavorite':259 'item.toentity':585 'item.torequest':594,608,621 'itemapiservic':132 'itemdao':127,128 'itemdao.getall':149 'itemdao.getbyidonce':251 'itemdao.updatefavorite':260,285 'itemdao.upsert':584 'itemdao.upsertall':173 'itemid':236,252,261,271,286 'itemrepositori':138,191,385,427 'itemrepositoryimpl':122 'json':548 'json-seri':547 'json.encodetostring':607,620 'kapt':21 'kotlin':109,222,293,408,502,629 'ksp':22 'last':631,692,752 'last-write-win':751 'lastsynctim':643,683 'later':598 'let':362 'list':148,196 'load':701,705,762 'local':64,74,90,105,111,142,160,181,225,243,280,582,721,757 'logic':725 'long':557 'lose':47 'make':53 'map':150 'mapof':272 'miss':23 'mistak':699 'mix':104 'monitor':292 'must':718 'mutablestateflow':658 'name':463,491,525,535,542,552,560 'network':87,117,157,290,320,321,327,328,714,737 'networkcapabilities.net':335,366 'networkmonitor':298,388,389 'networkmonitor.iscurrentlyonline':591 'networkmonitor.isonline':393 'networkrequest.builder':333 'networktype.connected':473 'never':103 'newvalu':258,262,276,287 'notfoundexcept':256 'null':660,662,676 'object':315,459 'observ':82,141,180,294,713,720 'offlin':2,38,500,506,570,610,764 'offline-first':1,37 'onavail':319 'onfailur':277,442,595,685 'onlin':399,509 'onlost':326 'onsuccess':440,678 'oper':497,504,513,526 'operationtyp':529,602,615 'optimist':219 'overrid':144,162,232,317,324,431,571 'pattern':52 'payload':543,545,606,619 'pend':496,512 'pendingoperationent':516,601,614 'pendingopsdao.insert':600,613 'period':409 'periodicworkrequestbuild':477 'polici':755 'primarykey':517 'principl':63 'privat':125,129,134,188,302,357,382,386,424,655,667 'pull':155 'queue':498,568,596,611 'reduc':26 'remot':106 'remoteitem':171 'remoteitems.map':174 'repeatinterv':478 'repeatintervaltimeunit':480 'replay':507 'repositori':73,110,190,384,426,567 'repository.getitemsstream':197 'repository.sync':209,401,438,677 'request':332,340,476,493,550 'requir':43,739 'respons':715 'restor':376 'result':166,238,435,577 'result.failure':255,448,457 'result.retry':446,455 'result.success':441,622 'retri':561,599,724 'retrycount':564 'return':253,436 'rollback':279 'room':76,85,703 'rule':96,217,288,402,494,624 'run':732 'runattemptcount':444,453 'runcatch':169,269,592 'save':581 'schedul':466 'serial':549 'server':229,266,283,748 'server-win':747 'setbackoffcriteria':484 'setconstraint':482 'setrequirednetworktyp':472 'sharein':352 'sharingstarted.whilesubscribed':200,355 'ship':33 'show':630,695,700,709 'silent':120 'singl':77,99 'single-direct':98 'skill':6 'skill-offline-first' 'sourc':67,78,114 'source-piyushverma0' 'stale':626 'state':25,344 'stateflow':195,665 'statein':198 'status':296 'store':503 'string':237,530,540,546,650 'supabas':15 'suspend':163,233,432,572 'sync':88,154,165,178,205,207,227,264,372,407,410,588,632,669,693,728,734,743 'syncerror':649,675,691 'syncmetadata':641,657,659,664 'syncmetadata.asstateflow':666 'syncmetadata.update':671,679,687 'syncwork':415,464 'system.currenttimemillis':558 'tablenam':511 'timeunit.minutes':481 'timeunit.seconds':487 'togglefavorit':235 'token':28 'topic-agent-skills' 'topic-ai-agent' 'topic-android' 'topic-antigravity' 'topic-claude-code' 'topic-codex' 'topic-cursor' 'topic-gemini-cli' 'topic-hilt' 'topic-jetpack-compose' 'topic-kotlin' 'topic-material3' 'tri':437,586 'trigger':177 'true':323,369,519,674 'truth':69,80,116 'trysend':322,329,346 'type':527 'ui':81,92,108,139,216,220,246,712,717 'uistat':24 'unconstrain':736 'unit':623 'updat':89,95,118,159,210,215,221,242,247,532 'use':729 'user':50,70,635 'val':126,130,135,170,189,193,249,257,303,306,310,313,331,383,387,425,461,469,475,520,528,538,544,555,563,642,645,648,656,663 'viewmodel':72,176,192,371,390,654 'viewmodelscop':199 'viewmodelscope.launch':208,392,670 'win':749,754 'withcontext':167,239,254,578 'without':60 'work':46,56,462,490 'workerparam':422,430 'workerparamet':423 'workmanag':404,412,467,468,733 'workmanager.enqueueuniqueperiodicwork':489 'write':501,753","prices":[{"id":"7a72739c-b122-456d-9311-f8c3f4feeb40","listingId":"7589515d-d323-472f-aa95-cb67d880306c","amountUsd":"0","unit":"free","nativeCurrency":null,"nativeAmount":null,"chain":null,"payTo":null,"paymentMethod":"skill-free","isPrimary":true,"details":{"org":"piyushverma0","category":"android-agent-skills","install_from":"skills.sh"},"createdAt":"2026-05-18T13:14:50.056Z"}],"sources":[{"listingId":"7589515d-d323-472f-aa95-cb67d880306c","source":"github","sourceId":"piyushverma0/android-agent-skills/offline-first","sourceUrl":"https://github.com/piyushverma0/android-agent-skills/tree/main/skills/offline-first","isPrimary":false,"firstSeenAt":"2026-05-18T13:14:50.056Z","lastSeenAt":"2026-05-18T19:09:10.363Z"}],"details":{"listingId":"7589515d-d323-472f-aa95-cb67d880306c","quickStartSnippet":null,"exampleRequest":null,"exampleResponse":null,"schema":null,"openapiUrl":null,"agentsTxtUrl":null,"citations":[],"useCases":[],"bestFor":[],"notFor":[],"kindDetails":{"org":"piyushverma0","slug":"offline-first","github":{"repo":"piyushverma0/android-agent-skills","stars":8,"topics":["agent-skills","ai-agent","android","antigravity","claude-code","codex","cursor","gemini-cli","hilt","jetpack-compose","kotlin","material3","open-source","skills","supabase"],"license":"mit","html_url":"https://github.com/piyushverma0/android-agent-skills","pushed_at":"2026-04-27T09:15:31Z","description":"27 Android skills for AI agents (Claude Code, Codex, Cursor). Fixes Supabase auth, Hilt errors, design inconsistency, kapt→ksp, missing UiState states. Reduced my token bills 5×. FitGenZ AI shipped in 18 days.","skill_md_sha":"3fbffd0a59f6dc1dfb856893b6208ad32c15b0f3","skill_md_path":"skills/offline-first/SKILL.md","default_branch":"main","skill_tree_url":"https://github.com/piyushverma0/android-agent-skills/tree/main/skills/offline-first"},"layout":"multi","source":"github","category":"android-agent-skills","frontmatter":{},"skills_sh_url":"https://skills.sh/piyushverma0/android-agent-skills/offline-first"},"updatedAt":"2026-05-18T19:09:10.363Z"}}