@zigrivers/scaffold 3.6.0 → 3.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +127 -12
- package/content/knowledge/backend/backend-api-design.md +103 -0
- package/content/knowledge/backend/backend-architecture.md +100 -0
- package/content/knowledge/backend/backend-async-patterns.md +101 -0
- package/content/knowledge/backend/backend-auth-patterns.md +100 -0
- package/content/knowledge/backend/backend-conventions.md +105 -0
- package/content/knowledge/backend/backend-data-modeling.md +102 -0
- package/content/knowledge/backend/backend-deployment.md +100 -0
- package/content/knowledge/backend/backend-dev-environment.md +102 -0
- package/content/knowledge/backend/backend-observability.md +102 -0
- package/content/knowledge/backend/backend-project-structure.md +100 -0
- package/content/knowledge/backend/backend-requirements.md +103 -0
- package/content/knowledge/backend/backend-security.md +104 -0
- package/content/knowledge/backend/backend-testing.md +101 -0
- package/content/knowledge/backend/backend-worker-patterns.md +100 -0
- package/content/knowledge/cli/cli-architecture.md +101 -0
- package/content/knowledge/cli/cli-conventions.md +117 -0
- package/content/knowledge/cli/cli-dev-environment.md +121 -0
- package/content/knowledge/cli/cli-distribution-patterns.md +106 -0
- package/content/knowledge/cli/cli-interactivity-patterns.md +116 -0
- package/content/knowledge/cli/cli-output-patterns.md +107 -0
- package/content/knowledge/cli/cli-project-structure.md +124 -0
- package/content/knowledge/cli/cli-requirements.md +101 -0
- package/content/knowledge/cli/cli-shell-integration.md +130 -0
- package/content/knowledge/cli/cli-testing.md +134 -0
- package/content/knowledge/library/library-api-design.md +306 -0
- package/content/knowledge/library/library-architecture.md +247 -0
- package/content/knowledge/library/library-bundling.md +244 -0
- package/content/knowledge/library/library-conventions.md +229 -0
- package/content/knowledge/library/library-dev-environment.md +220 -0
- package/content/knowledge/library/library-documentation.md +300 -0
- package/content/knowledge/library/library-project-structure.md +237 -0
- package/content/knowledge/library/library-requirements.md +173 -0
- package/content/knowledge/library/library-security.md +257 -0
- package/content/knowledge/library/library-testing.md +319 -0
- package/content/knowledge/library/library-type-definitions.md +284 -0
- package/content/knowledge/library/library-versioning.md +300 -0
- package/content/knowledge/mobile-app/mobile-app-architecture.md +283 -0
- package/content/knowledge/mobile-app/mobile-app-conventions.md +180 -0
- package/content/knowledge/mobile-app/mobile-app-deployment.md +298 -0
- package/content/knowledge/mobile-app/mobile-app-dev-environment.md +257 -0
- package/content/knowledge/mobile-app/mobile-app-distribution.md +264 -0
- package/content/knowledge/mobile-app/mobile-app-observability.md +317 -0
- package/content/knowledge/mobile-app/mobile-app-offline-patterns.md +311 -0
- package/content/knowledge/mobile-app/mobile-app-project-structure.md +245 -0
- package/content/knowledge/mobile-app/mobile-app-push-notifications.md +321 -0
- package/content/knowledge/mobile-app/mobile-app-requirements.md +147 -0
- package/content/knowledge/mobile-app/mobile-app-security.md +338 -0
- package/content/knowledge/mobile-app/mobile-app-testing.md +400 -0
- package/content/knowledge/web-app/web-app-api-patterns.md +224 -0
- package/content/knowledge/web-app/web-app-architecture.md +116 -0
- package/content/knowledge/web-app/web-app-auth-patterns.md +256 -0
- package/content/knowledge/web-app/web-app-conventions.md +121 -0
- package/content/knowledge/web-app/web-app-data-patterns.md +218 -0
- package/content/knowledge/web-app/web-app-deployment-workflow.md +143 -0
- package/content/knowledge/web-app/web-app-deployment.md +134 -0
- package/content/knowledge/web-app/web-app-design-system.md +158 -0
- package/content/knowledge/web-app/web-app-dev-environment.md +173 -0
- package/content/knowledge/web-app/web-app-observability.md +221 -0
- package/content/knowledge/web-app/web-app-project-structure.md +160 -0
- package/content/knowledge/web-app/web-app-rendering-strategies.md +133 -0
- package/content/knowledge/web-app/web-app-requirements.md +112 -0
- package/content/knowledge/web-app/web-app-security.md +193 -0
- package/content/knowledge/web-app/web-app-session-patterns.md +214 -0
- package/content/knowledge/web-app/web-app-testing.md +249 -0
- package/content/knowledge/web-app/web-app-ux-patterns.md +162 -0
- package/content/methodology/backend-overlay.yml +73 -0
- package/content/methodology/cli-overlay.yml +69 -0
- package/content/methodology/library-overlay.yml +67 -0
- package/content/methodology/mobile-app-overlay.yml +71 -0
- package/content/methodology/web-app-overlay.yml +79 -0
- package/dist/cli/commands/init.d.ts +21 -0
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/init.js +261 -13
- package/dist/cli/commands/init.js.map +1 -1
- package/dist/cli/commands/init.test.js +206 -0
- package/dist/cli/commands/init.test.js.map +1 -1
- package/dist/config/schema.d.ts +1392 -64
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/config/schema.js +82 -5
- package/dist/config/schema.js.map +1 -1
- package/dist/config/schema.test.js +302 -1
- package/dist/config/schema.test.js.map +1 -1
- package/dist/core/assembly/overlay-loader.d.ts.map +1 -1
- package/dist/core/assembly/overlay-loader.js +2 -1
- package/dist/core/assembly/overlay-loader.js.map +1 -1
- package/dist/core/assembly/overlay-loader.test.js +56 -0
- package/dist/core/assembly/overlay-loader.test.js.map +1 -1
- package/dist/e2e/game-pipeline.test.js +1 -0
- package/dist/e2e/game-pipeline.test.js.map +1 -1
- package/dist/e2e/project-type-overlays.test.d.ts +16 -0
- package/dist/e2e/project-type-overlays.test.d.ts.map +1 -0
- package/dist/e2e/project-type-overlays.test.js +834 -0
- package/dist/e2e/project-type-overlays.test.js.map +1 -0
- package/dist/types/config.d.ts +19 -2
- package/dist/types/config.d.ts.map +1 -1
- package/dist/types/index.d.ts +0 -1
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js +0 -1
- package/dist/types/index.js.map +1 -1
- package/dist/wizard/questions.d.ts +27 -1
- package/dist/wizard/questions.d.ts.map +1 -1
- package/dist/wizard/questions.js +142 -3
- package/dist/wizard/questions.js.map +1 -1
- package/dist/wizard/questions.test.js +206 -8
- package/dist/wizard/questions.test.js.map +1 -1
- package/dist/wizard/wizard.d.ts +21 -0
- package/dist/wizard/wizard.d.ts.map +1 -1
- package/dist/wizard/wizard.js +27 -1
- package/dist/wizard/wizard.js.map +1 -1
- package/package.json +1 -1
- package/dist/types/wizard.d.ts +0 -14
- package/dist/types/wizard.d.ts.map +0 -1
- package/dist/types/wizard.js +0 -2
- package/dist/types/wizard.js.map +0 -1
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: mobile-app-observability
|
|
3
|
+
description: Crash reporting (Crashlytics/Sentry), analytics, performance monitoring, network tracing, and structured logging for mobile apps
|
|
4
|
+
topics: [mobile-app, observability, crashlytics, sentry, analytics, performance-monitoring, network-tracing, logging]
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
Mobile observability is harder than server observability: you cannot SSH into a user's phone, crashes happen on thousands of device/OS combinations you cannot reproduce locally, and performance issues manifest differently across network conditions and hardware tiers. The goal is to know about problems before users report them, understand why they occurred, and have enough context to reproduce and fix them.
|
|
8
|
+
|
|
9
|
+
## Summary
|
|
10
|
+
|
|
11
|
+
Mobile observability requires crash reporting (Crashlytics or Sentry), structured analytics for user behavior and funnel tracking, real-user performance monitoring (launch time, screen transitions, network requests), and structured logging that survives app termination. Crashlytics and Sentry both capture symbolicated stack traces — symbolication requires uploading dSYMs (iOS) or mapping files (Android) in CI. Analytics events must be defined in a taxonomy before implementation. Performance monitoring should cover app start time, screen render time, and API latency percentiles.
|
|
12
|
+
|
|
13
|
+
## Deep Guidance
|
|
14
|
+
|
|
15
|
+
### Crash Reporting
|
|
16
|
+
|
|
17
|
+
**Firebase Crashlytics setup**
|
|
18
|
+
|
|
19
|
+
iOS (SPM):
|
|
20
|
+
```swift
|
|
21
|
+
// AppDelegate
|
|
22
|
+
import FirebaseCrashlytics
|
|
23
|
+
import FirebaseCore
|
|
24
|
+
|
|
25
|
+
func application(_ application: UIApplication, didFinishLaunchingWithOptions...) -> Bool {
|
|
26
|
+
FirebaseApp.configure()
|
|
27
|
+
return true
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Android (Gradle):
|
|
32
|
+
```kotlin
|
|
33
|
+
// app/build.gradle.kts
|
|
34
|
+
plugins {
|
|
35
|
+
id("com.google.firebase.crashlytics")
|
|
36
|
+
}
|
|
37
|
+
dependencies {
|
|
38
|
+
implementation(platform("com.google.firebase:firebase-bom:33.0.0"))
|
|
39
|
+
implementation("com.google.firebase:firebase-crashlytics")
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
**dSYM upload (iOS)**
|
|
44
|
+
Without dSYMs, crash reports show memory addresses, not function names. Crashlytics auto-uploads dSYMs when the Crashlytics build phase is configured:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
# Xcode build phase: Run Script
|
|
48
|
+
"${PODS_ROOT}/FirebaseCrashlytics/run"
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
For Bitcode-disabled builds (Xcode 14+ default), dSYMs are generated at build time and uploaded automatically if the build phase is present. For CI builds:
|
|
52
|
+
```bash
|
|
53
|
+
# Upload dSYMs manually after archiving
|
|
54
|
+
./Pods/FirebaseCrashlytics/upload-symbols -gsp GoogleService-Info.plist -p ios path/to/dSYMs
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
**Mapping file upload (Android)**
|
|
58
|
+
```kotlin
|
|
59
|
+
// app/build.gradle.kts
|
|
60
|
+
buildTypes {
|
|
61
|
+
release {
|
|
62
|
+
firebaseCrashlytics {
|
|
63
|
+
mappingFileUploadEnabled = true // uploads R8/ProGuard mapping file automatically
|
|
64
|
+
nativeSymbolUploadEnabled = true // for NDK crash symbolication
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
**Enriching crash reports**
|
|
71
|
+
```swift
|
|
72
|
+
// iOS: Add user context and breadcrumbs
|
|
73
|
+
Crashlytics.crashlytics().setUserID(userId)
|
|
74
|
+
Crashlytics.crashlytics().setCustomValue("premium", forKey: "subscription_tier")
|
|
75
|
+
|
|
76
|
+
// Record non-fatal errors
|
|
77
|
+
Crashlytics.crashlytics().record(error: networkError)
|
|
78
|
+
|
|
79
|
+
// Add breadcrumb before risky operation
|
|
80
|
+
Crashlytics.crashlytics().log("Attempting payment with method: \(paymentMethod)")
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
```kotlin
|
|
84
|
+
// Android
|
|
85
|
+
FirebaseCrashlytics.getInstance().setUserId(userId)
|
|
86
|
+
FirebaseCrashlytics.getInstance().setCustomKey("subscription_tier", "premium")
|
|
87
|
+
FirebaseCrashlytics.getInstance().recordException(exception)
|
|
88
|
+
FirebaseCrashlytics.getInstance().log("Cart checkout started: items=${cartItems.size}")
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
**Sentry as alternative**
|
|
92
|
+
Sentry provides richer error grouping, performance tracing in the same SDK, and self-hosting options:
|
|
93
|
+
|
|
94
|
+
```swift
|
|
95
|
+
// iOS
|
|
96
|
+
import Sentry
|
|
97
|
+
SentrySDK.start { options in
|
|
98
|
+
options.dsn = "https://key@sentry.io/project"
|
|
99
|
+
options.tracesSampleRate = 0.2 // 20% of sessions traced
|
|
100
|
+
options.profilesSampleRate = 0.1 // 10% of transactions profiled
|
|
101
|
+
options.enableCrashHandler = true
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
```kotlin
|
|
106
|
+
// Android
|
|
107
|
+
SentryAndroid.init(context) { options ->
|
|
108
|
+
options.dsn = "https://key@sentry.io/project"
|
|
109
|
+
options.tracesSampleRate = 0.2
|
|
110
|
+
options.isEnableAutoActivityLifecycleBreadcrumbs = true
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### Analytics
|
|
115
|
+
|
|
116
|
+
**Event taxonomy design**
|
|
117
|
+
Define events before implementation. Use a consistent naming convention and document in a schema registry:
|
|
118
|
+
|
|
119
|
+
```
|
|
120
|
+
Event naming: {object}_{action}
|
|
121
|
+
Examples:
|
|
122
|
+
user_signed_up
|
|
123
|
+
user_signed_in
|
|
124
|
+
product_viewed
|
|
125
|
+
product_added_to_cart
|
|
126
|
+
cart_checkout_started
|
|
127
|
+
order_placed
|
|
128
|
+
order_cancelled
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Event properties follow snake_case:
|
|
132
|
+
```
|
|
133
|
+
product_viewed:
|
|
134
|
+
product_id: string
|
|
135
|
+
product_name: string
|
|
136
|
+
category: string
|
|
137
|
+
price_cents: int
|
|
138
|
+
position_in_list: int # for recommendation tracking
|
|
139
|
+
source: string # "search" | "recommendation" | "category_browse"
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
**Firebase Analytics (iOS)**
|
|
143
|
+
```swift
|
|
144
|
+
import FirebaseAnalytics
|
|
145
|
+
|
|
146
|
+
Analytics.logEvent("product_viewed", parameters: [
|
|
147
|
+
"product_id": productId,
|
|
148
|
+
"product_name": productName,
|
|
149
|
+
"price_cents": priceCents,
|
|
150
|
+
"source": source
|
|
151
|
+
])
|
|
152
|
+
|
|
153
|
+
// User properties (persistent, applied to all subsequent events)
|
|
154
|
+
Analytics.setUserProperty("premium", forName: "subscription_tier")
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
**Firebase Analytics (Android)**
|
|
158
|
+
```kotlin
|
|
159
|
+
firebaseAnalytics.logEvent("product_viewed") {
|
|
160
|
+
param("product_id", productId)
|
|
161
|
+
param("product_name", productName)
|
|
162
|
+
param("price_cents", priceCents.toLong())
|
|
163
|
+
param("source", source)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
firebaseAnalytics.setUserProperty("subscription_tier", "premium")
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
**Analytics abstraction layer**
|
|
170
|
+
Never call analytics SDKs directly from feature code — use an abstraction:
|
|
171
|
+
|
|
172
|
+
```swift
|
|
173
|
+
protocol AnalyticsService {
|
|
174
|
+
func track(_ event: AnalyticsEvent)
|
|
175
|
+
func setUserProperty(_ value: String, for key: String)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
enum AnalyticsEvent {
|
|
179
|
+
case productViewed(productId: String, name: String, priceCents: Int, source: String)
|
|
180
|
+
case orderPlaced(orderId: String, itemCount: Int, totalCents: Int)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
final class FirebaseAnalyticsService: AnalyticsService {
|
|
184
|
+
func track(_ event: AnalyticsEvent) {
|
|
185
|
+
switch event {
|
|
186
|
+
case .productViewed(let id, let name, let price, let source):
|
|
187
|
+
Analytics.logEvent("product_viewed", parameters: [
|
|
188
|
+
"product_id": id, "product_name": name,
|
|
189
|
+
"price_cents": price, "source": source
|
|
190
|
+
])
|
|
191
|
+
case .orderPlaced(let id, let count, let total):
|
|
192
|
+
Analytics.logEvent("order_placed", parameters: [
|
|
193
|
+
"order_id": id, "item_count": count, "total_cents": total
|
|
194
|
+
])
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
This allows swapping analytics providers, testing with a mock, and preventing typos in event names.
|
|
201
|
+
|
|
202
|
+
### Performance Monitoring
|
|
203
|
+
|
|
204
|
+
**App startup time (iOS)**
|
|
205
|
+
```swift
|
|
206
|
+
// Measure time from app launch to first interactive frame
|
|
207
|
+
// FirebasePerformance trace
|
|
208
|
+
let trace = Performance.startTrace(name: "app_startup")
|
|
209
|
+
// ... app initialization ...
|
|
210
|
+
trace?.stop()
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
Instruments > App Launch template records the full cold start time with a flame graph of initialization cost. Target: under 400ms pre-main time (Swift static initializers, ObjC +load methods).
|
|
214
|
+
|
|
215
|
+
**App startup time (Android)**
|
|
216
|
+
```kotlin
|
|
217
|
+
// Firebase Performance: automatic activity launch monitoring
|
|
218
|
+
// Manual trace for custom startup paths
|
|
219
|
+
val trace = Firebase.performance.newTrace("app_cold_start")
|
|
220
|
+
trace.start()
|
|
221
|
+
// ... initialization ...
|
|
222
|
+
trace.stop()
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
Systrace and Android Studio Profiler > CPU: record app startup to identify slow initialization. Common causes: synchronous disk I/O, large dependency injection graphs, synchronous network calls.
|
|
226
|
+
|
|
227
|
+
**Network performance (Firebase Performance)**
|
|
228
|
+
Firebase Performance automatically monitors HTTP requests made through URLSession (iOS) and OkHttp/HttpURLConnection (Android) without code changes.
|
|
229
|
+
|
|
230
|
+
Manual HTTP tracing:
|
|
231
|
+
```swift
|
|
232
|
+
// iOS custom network trace
|
|
233
|
+
let metric = HTTPMetric(url: url, httpMethod: .get)
|
|
234
|
+
metric?.start()
|
|
235
|
+
let (data, response) = try await URLSession.shared.data(from: url)
|
|
236
|
+
metric?.responseCode = (response as? HTTPURLResponse)?.statusCode ?? -1
|
|
237
|
+
metric?.stop()
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
```kotlin
|
|
241
|
+
// Android: OkHttp interceptor for custom metrics
|
|
242
|
+
class PerformanceInterceptor : Interceptor {
|
|
243
|
+
override fun intercept(chain: Interceptor.Chain): Response {
|
|
244
|
+
val request = chain.request()
|
|
245
|
+
val metric = Firebase.performance.newHttpMetric(request.url.toString(), request.method)
|
|
246
|
+
metric.start()
|
|
247
|
+
val response = chain.proceed(request)
|
|
248
|
+
metric.setHttpResponseCode(response.code)
|
|
249
|
+
metric.stop()
|
|
250
|
+
return response
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
**Screen render time**
|
|
256
|
+
```swift
|
|
257
|
+
// iOS: measure time to interactive per screen
|
|
258
|
+
func measureScreenLoad(screenName: String) {
|
|
259
|
+
let trace = Performance.startTrace(name: "screen_\(screenName)_load")
|
|
260
|
+
// Stop trace when first meaningful paint is complete
|
|
261
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
|
262
|
+
trace?.stop()
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
Set performance budget alerts in Firebase Performance: alert when p75 app start > 3s, p90 network response > 2s.
|
|
268
|
+
|
|
269
|
+
### Structured Logging
|
|
270
|
+
|
|
271
|
+
**iOS: os_log / OSLog framework**
|
|
272
|
+
```swift
|
|
273
|
+
import OSLog
|
|
274
|
+
|
|
275
|
+
private let logger = Logger(subsystem: "com.example.myapp", category: "Checkout")
|
|
276
|
+
|
|
277
|
+
func processOrder(_ order: Order) async throws {
|
|
278
|
+
logger.info("Order processing started: orderId=\(order.id, privacy: .public)")
|
|
279
|
+
do {
|
|
280
|
+
let result = try await paymentService.charge(order)
|
|
281
|
+
logger.info("Payment succeeded: orderId=\(order.id, privacy: .public) amount=\(order.totalCents)")
|
|
282
|
+
} catch {
|
|
283
|
+
logger.error("Payment failed: orderId=\(order.id, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
|
|
284
|
+
throw error
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
Privacy annotations matter:
|
|
290
|
+
- `privacy: .public`: logged in production — use for non-PII identifiers
|
|
291
|
+
- `privacy: .private`: redacted in production (shown as `<private>`) — default for user data
|
|
292
|
+
- `privacy: .sensitive`: always redacted — for credentials and PII
|
|
293
|
+
|
|
294
|
+
**Android: structured logging with tags**
|
|
295
|
+
```kotlin
|
|
296
|
+
private const val TAG = "Checkout"
|
|
297
|
+
|
|
298
|
+
fun processOrder(order: Order) {
|
|
299
|
+
Log.i(TAG, "Order processing started orderId=${order.id}")
|
|
300
|
+
try {
|
|
301
|
+
val result = paymentService.charge(order)
|
|
302
|
+
Log.i(TAG, "Payment succeeded orderId=${order.id} amount=${order.totalCents}")
|
|
303
|
+
} catch (e: Exception) {
|
|
304
|
+
Log.e(TAG, "Payment failed orderId=${order.id}", e)
|
|
305
|
+
throw e
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
In production, always integrate with a crash reporter (Crashlytics, Sentry) — `android.util.Log` output is not captured in release builds without a custom log handler.
|
|
311
|
+
|
|
312
|
+
**Log levels**
|
|
313
|
+
- DEBUG: development-only context, not emitted in production builds
|
|
314
|
+
- INFO: business events worth auditing (login, purchase, key feature usage)
|
|
315
|
+
- WARNING: recoverable errors, degraded functionality
|
|
316
|
+
- ERROR: failures that should be investigated (but app continues)
|
|
317
|
+
- FATAL/CRITICAL: unrecoverable errors preceding a crash — rarely used directly
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: mobile-app-offline-patterns
|
|
3
|
+
description: Local storage (SQLite/Room/Core Data), sync engines, conflict resolution, and background sync for offline-capable mobile apps
|
|
4
|
+
topics: [mobile-app, offline, sqlite, room, core-data, sync, conflict-resolution, background-sync]
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
Offline capability is not optional for mobile apps — cellular networks are unreliable, users enter tunnels and basements, and users expect their data to persist between sessions. The complexity of offline architecture scales with sync complexity: read-only cache is trivial; bidirectional sync with conflict resolution is one of the hardest problems in software. Define your offline model explicitly before implementing persistence.
|
|
8
|
+
|
|
9
|
+
## Summary
|
|
10
|
+
|
|
11
|
+
Mobile offline patterns use local databases (Room+SQLite for Android, Core Data or GRDB for iOS) as the primary source of truth, with network sync as a background process. The "offline-first" pattern means the UI reads from local storage and writes to a local queue, which syncs independently. Conflict resolution strategies range from "last write wins" to CRDTs for complex cases. Background sync uses WorkManager (Android) or BGTaskScheduler (iOS) — not raw background threads.
|
|
12
|
+
|
|
13
|
+
## Deep Guidance
|
|
14
|
+
|
|
15
|
+
### Storage Layer Options
|
|
16
|
+
|
|
17
|
+
**Android: Room (SQLite abstraction)**
|
|
18
|
+
|
|
19
|
+
Room is the recommended local database for Android. It enforces compile-time query validation:
|
|
20
|
+
|
|
21
|
+
```kotlin
|
|
22
|
+
@Entity(tableName = "users")
|
|
23
|
+
data class UserEntity(
|
|
24
|
+
@PrimaryKey val id: String,
|
|
25
|
+
val name: String,
|
|
26
|
+
val email: String,
|
|
27
|
+
@ColumnInfo(name = "updated_at") val updatedAt: Long,
|
|
28
|
+
@ColumnInfo(name = "sync_status") val syncStatus: SyncStatus = SyncStatus.SYNCED
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
enum class SyncStatus { SYNCED, PENDING_CREATE, PENDING_UPDATE, PENDING_DELETE }
|
|
32
|
+
|
|
33
|
+
@Dao
|
|
34
|
+
interface UserDao {
|
|
35
|
+
@Query("SELECT * FROM users WHERE sync_status != 'PENDING_DELETE'")
|
|
36
|
+
fun observeUsers(): Flow<List<UserEntity>>
|
|
37
|
+
|
|
38
|
+
@Query("SELECT * FROM users WHERE sync_status != 'SYNCED'")
|
|
39
|
+
suspend fun getPendingSync(): List<UserEntity>
|
|
40
|
+
|
|
41
|
+
@Upsert
|
|
42
|
+
suspend fun upsert(user: UserEntity)
|
|
43
|
+
|
|
44
|
+
@Query("UPDATE users SET sync_status = :status WHERE id = :id")
|
|
45
|
+
suspend fun updateSyncStatus(id: String, status: SyncStatus)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
@Database(entities = [UserEntity::class], version = 1, exportSchema = true)
|
|
49
|
+
abstract class AppDatabase : RoomDatabase() {
|
|
50
|
+
abstract fun userDao(): UserDao
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Schema migrations: export schema with `exportSchema = true` (required for migration testing). Write explicit migrations:
|
|
55
|
+
```kotlin
|
|
56
|
+
val MIGRATION_1_2 = object : Migration(1, 2) {
|
|
57
|
+
override fun migrate(db: SupportSQLiteDatabase) {
|
|
58
|
+
db.execSQL("ALTER TABLE users ADD COLUMN avatar_url TEXT")
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
**Android: DataStore for preferences**
|
|
64
|
+
- `Preferences DataStore`: key-value pairs for simple settings (replaces SharedPreferences)
|
|
65
|
+
- `Proto DataStore`: typed storage using Protocol Buffers for structured preferences
|
|
66
|
+
- DataStore is coroutine-native; SharedPreferences is synchronous on the main thread — never use SharedPreferences for new code
|
|
67
|
+
|
|
68
|
+
```kotlin
|
|
69
|
+
val dataStore: DataStore<Preferences> = context.createDataStore(name = "settings")
|
|
70
|
+
val USER_TOKEN = stringPreferencesKey("user_token")
|
|
71
|
+
|
|
72
|
+
// Write
|
|
73
|
+
dataStore.edit { settings -> settings[USER_TOKEN] = token }
|
|
74
|
+
|
|
75
|
+
// Read
|
|
76
|
+
val tokenFlow: Flow<String?> = dataStore.data.map { it[USER_TOKEN] }
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
**iOS: GRDB (SQLite wrapper)**
|
|
80
|
+
|
|
81
|
+
GRDB provides a type-safe Swift SQLite interface with Combine/async integration:
|
|
82
|
+
|
|
83
|
+
```swift
|
|
84
|
+
struct User: Codable, FetchableRecord, PersistableRecord {
|
|
85
|
+
var id: String
|
|
86
|
+
var name: String
|
|
87
|
+
var email: String
|
|
88
|
+
var updatedAt: Date
|
|
89
|
+
var syncStatus: SyncStatus = .synced
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Write
|
|
93
|
+
try dbQueue.write { db in
|
|
94
|
+
try user.save(db) // INSERT OR REPLACE
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Observe changes
|
|
98
|
+
let observation = ValueObservation.tracking { db in
|
|
99
|
+
try User.filter(Column("syncStatus") != SyncStatus.pendingDelete).fetchAll(db)
|
|
100
|
+
}
|
|
101
|
+
observation.start(in: dbQueue) { users in
|
|
102
|
+
// React to database changes
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
**iOS: Core Data (Apple's ORM)**
|
|
107
|
+
|
|
108
|
+
Core Data is the Apple-native persistence framework. It provides object graph management, lazy faulting, and iCloud sync (CloudKit integration):
|
|
109
|
+
|
|
110
|
+
```swift
|
|
111
|
+
// NSManagedObject subclass
|
|
112
|
+
@objc(UserMO)
|
|
113
|
+
class UserMO: NSManagedObject {
|
|
114
|
+
@NSManaged var id: String
|
|
115
|
+
@NSManaged var name: String
|
|
116
|
+
@NSManaged var syncStatus: Int16
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Fetch with NSFetchedResultsController for UI reactivity
|
|
120
|
+
let request: NSFetchRequest<UserMO> = UserMO.fetchRequest()
|
|
121
|
+
request.predicate = NSPredicate(format: "syncStatus != %d", SyncStatus.pendingDelete.rawValue)
|
|
122
|
+
request.sortDescriptors = [NSSortDescriptor(keyPath: \UserMO.name, ascending: true)]
|
|
123
|
+
|
|
124
|
+
let controller = NSFetchedResultsController(
|
|
125
|
+
fetchRequest: request,
|
|
126
|
+
managedObjectContext: viewContext,
|
|
127
|
+
sectionNameKeyPath: nil,
|
|
128
|
+
cacheName: nil
|
|
129
|
+
)
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Use a background `NSManagedObjectContext` for data imports — never import on the `viewContext` (main thread). Merge changes back to viewContext with `mergeChanges(fromContextDidSave:)`.
|
|
133
|
+
|
|
134
|
+
**iOS: SwiftData (iOS 17+)**
|
|
135
|
+
|
|
136
|
+
SwiftData is the modern replacement for Core Data with a Swift-native API:
|
|
137
|
+
|
|
138
|
+
```swift
|
|
139
|
+
@Model
|
|
140
|
+
class User {
|
|
141
|
+
var id: String
|
|
142
|
+
var name: String
|
|
143
|
+
var email: String
|
|
144
|
+
var syncStatus: SyncStatus
|
|
145
|
+
|
|
146
|
+
init(id: String, name: String, email: String) {
|
|
147
|
+
self.id = id
|
|
148
|
+
self.name = name
|
|
149
|
+
self.email = email
|
|
150
|
+
self.syncStatus = .synced
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Query with SwiftUI
|
|
155
|
+
@Query(filter: #Predicate<User> { $0.syncStatus != .pendingDelete })
|
|
156
|
+
var users: [User]
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
SwiftData targets iOS 17+ — if supporting iOS 16, use GRDB or Core Data.
|
|
160
|
+
|
|
161
|
+
### Offline-First Architecture
|
|
162
|
+
|
|
163
|
+
**The offline-first pattern**
|
|
164
|
+
The UI never calls the network directly. All writes go to the local database with a `PENDING` sync status. A background sync engine reads pending items and attempts to sync:
|
|
165
|
+
|
|
166
|
+
```
|
|
167
|
+
User Action → Write to Local DB (status: PENDING) → Notify UI
|
|
168
|
+
↓
|
|
169
|
+
Background Sync Engine
|
|
170
|
+
↓
|
|
171
|
+
POST/PATCH/DELETE to API
|
|
172
|
+
↓
|
|
173
|
+
Success: Update status to SYNCED
|
|
174
|
+
Failure: Retry with exponential backoff
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
**Repository pattern for offline-first (Android)**
|
|
178
|
+
```kotlin
|
|
179
|
+
class UserRepository @Inject constructor(
|
|
180
|
+
private val userDao: UserDao,
|
|
181
|
+
private val userApi: UserApi,
|
|
182
|
+
private val syncQueue: SyncQueue
|
|
183
|
+
) {
|
|
184
|
+
fun observeUsers(): Flow<List<User>> =
|
|
185
|
+
userDao.observeUsers().map { entities -> entities.map { it.toDomain() } }
|
|
186
|
+
|
|
187
|
+
suspend fun updateUser(user: User) {
|
|
188
|
+
// Write locally first — UI responds immediately
|
|
189
|
+
userDao.upsert(user.toEntity().copy(syncStatus = SyncStatus.PENDING_UPDATE))
|
|
190
|
+
// Enqueue sync (fires in background)
|
|
191
|
+
syncQueue.enqueue(SyncOperation.UpdateUser(user.id))
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
**Sync queue implementation (Android: WorkManager)**
|
|
197
|
+
```kotlin
|
|
198
|
+
class SyncWorker(
|
|
199
|
+
context: Context,
|
|
200
|
+
params: WorkerParameters,
|
|
201
|
+
private val userRepository: UserRepository,
|
|
202
|
+
private val userApi: UserApi
|
|
203
|
+
) : CoroutineWorker(context, params) {
|
|
204
|
+
|
|
205
|
+
override suspend fun doWork(): Result {
|
|
206
|
+
val pending = userRepository.getPendingSync()
|
|
207
|
+
var hasFailure = false
|
|
208
|
+
|
|
209
|
+
for (item in pending) {
|
|
210
|
+
try {
|
|
211
|
+
when (item.syncStatus) {
|
|
212
|
+
SyncStatus.PENDING_CREATE -> userApi.createUser(item.toDto())
|
|
213
|
+
SyncStatus.PENDING_UPDATE -> userApi.updateUser(item.id, item.toDto())
|
|
214
|
+
SyncStatus.PENDING_DELETE -> userApi.deleteUser(item.id)
|
|
215
|
+
else -> continue
|
|
216
|
+
}
|
|
217
|
+
userRepository.markSynced(item.id)
|
|
218
|
+
} catch (e: Exception) {
|
|
219
|
+
hasFailure = true
|
|
220
|
+
// Log but continue — sync other items
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return if (hasFailure) Result.retry() else Result.success()
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Schedule on connectivity restored
|
|
228
|
+
val syncRequest = OneTimeWorkRequestBuilder<SyncWorker>()
|
|
229
|
+
.setConstraints(Constraints(requiredNetworkType = NetworkType.CONNECTED))
|
|
230
|
+
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 15, TimeUnit.SECONDS)
|
|
231
|
+
.build()
|
|
232
|
+
WorkManager.getInstance(context).enqueueUniqueWork("sync", ExistingWorkPolicy.KEEP, syncRequest)
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
**Sync queue implementation (iOS: BGTaskScheduler)**
|
|
236
|
+
```swift
|
|
237
|
+
// Register in AppDelegate/App
|
|
238
|
+
BGTaskScheduler.shared.register(
|
|
239
|
+
forTaskWithIdentifier: "com.example.myapp.sync",
|
|
240
|
+
using: nil
|
|
241
|
+
) { task in
|
|
242
|
+
SyncEngine.shared.performSync(task: task as! BGProcessingTask)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Schedule sync
|
|
246
|
+
func scheduleSync() {
|
|
247
|
+
let request = BGProcessingTaskRequest(identifier: "com.example.myapp.sync")
|
|
248
|
+
request.requiresNetworkConnectivity = true
|
|
249
|
+
request.requiresExternalPower = false
|
|
250
|
+
try? BGTaskScheduler.shared.submit(request)
|
|
251
|
+
}
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
### Conflict Resolution Strategies
|
|
255
|
+
|
|
256
|
+
**Last Write Wins (LWW)**
|
|
257
|
+
- Simplest strategy: the record with the most recent `updatedAt` timestamp wins
|
|
258
|
+
- Appropriate for: user preferences, profile data where overwriting is acceptable
|
|
259
|
+
- Pitfall: clock skew between client and server can cause older data to win. Always use server time, not client time, as the authoritative timestamp.
|
|
260
|
+
|
|
261
|
+
```kotlin
|
|
262
|
+
// Server-side merge
|
|
263
|
+
fun mergeUser(local: UserDto, server: UserDto): UserDto =
|
|
264
|
+
if (local.updatedAt > server.updatedAt) local else server
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
**Server Wins**
|
|
268
|
+
- On conflict, the server version is always authoritative. Client changes are discarded.
|
|
269
|
+
- Appropriate for: data owned by the server (inventory, pricing), or when conflicts are rare
|
|
270
|
+
- Implementation: on sync, fetch the latest server version and overwrite local changes
|
|
271
|
+
|
|
272
|
+
**Client Wins**
|
|
273
|
+
- On conflict, the client's offline changes are always applied.
|
|
274
|
+
- Appropriate for: personal data the user expects to control (notes, settings, journal entries)
|
|
275
|
+
- Implementation: send client changes with `If-Unmodified-Since` or ETag; if conflict, apply client version and bump server version
|
|
276
|
+
|
|
277
|
+
**Three-Way Merge**
|
|
278
|
+
- Merge the base version, client version, and server version to produce a merged result
|
|
279
|
+
- Used for text fields where both sides should be preserved (collaborative editing)
|
|
280
|
+
- Complex to implement correctly — use a CRDT library or operational transform engine rather than writing from scratch
|
|
281
|
+
|
|
282
|
+
**CRDTs (Conflict-free Replicated Data Types)**
|
|
283
|
+
- Data structures that guarantee convergence without central coordination
|
|
284
|
+
- Appropriate for: collaborative features, distributed sync without a central server
|
|
285
|
+
- Common types: G-Counter (increment only), PN-Counter (increment/decrement), LWW-Register, OR-Set (add/remove set)
|
|
286
|
+
- Libraries: Automerge (Swift+Kotlin), Yjs (cross-platform via Wasm)
|
|
287
|
+
|
|
288
|
+
### Network State Detection
|
|
289
|
+
|
|
290
|
+
**Android: ConnectivityManager**
|
|
291
|
+
```kotlin
|
|
292
|
+
val connectivityManager = context.getSystemService<ConnectivityManager>()
|
|
293
|
+
val networkCallback = object : ConnectivityManager.NetworkCallback() {
|
|
294
|
+
override fun onAvailable(network: Network) { triggerSync() }
|
|
295
|
+
override fun onLost(network: Network) { pauseSync() }
|
|
296
|
+
}
|
|
297
|
+
connectivityManager.registerDefaultNetworkCallback(networkCallback)
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
**iOS: NWPathMonitor**
|
|
301
|
+
```swift
|
|
302
|
+
let monitor = NWPathMonitor()
|
|
303
|
+
monitor.pathUpdateHandler = { path in
|
|
304
|
+
if path.status == .satisfied {
|
|
305
|
+
Task { await SyncEngine.shared.sync() }
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
monitor.start(queue: DispatchQueue(label: "NetworkMonitor"))
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
Always debounce network transitions — connectivity can oscillate rapidly when entering/leaving coverage. Implement a minimum stable duration (e.g., 2 seconds of connectivity) before triggering sync.
|