android-sdd 1.0.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/dist/index.js +143 -0
- package/package.json +27 -0
- package/skills/Android Ecosystem/Baseline Profile Generator/SKILL.md +277 -0
- package/skills/Android Ecosystem/Glance/SKILL.md +315 -0
- package/skills/Android Platform/Configuration/SKILL.md +201 -0
- package/skills/Android Platform/Filesystem/SKILL.md +216 -0
- package/skills/Android Platform/Lifecycle/SKILL.md +233 -0
- package/skills/Android Platform/Manifest/SKILL.md +226 -0
- package/skills/Android Platform/Process Death Recovery/SKILL.md +214 -0
- package/skills/Android Platform/Resources/SKILL.md +234 -0
- package/skills/Android Platform/SavedStateHandle/SKILL.md +217 -0
- package/skills/Android Platform/State Restoration/SKILL.md +210 -0
- package/skills/Architecture/Bounded Context/SKILL.md +207 -0
- package/skills/Architecture/Clean Architecture/SKILL.md +229 -0
- package/skills/Architecture/Domain Modeling/SKILL.md +236 -0
- package/skills/Architecture/Entity Design/SKILL.md +243 -0
- package/skills/Architecture/Feature Isolation/SKILL.md +216 -0
- package/skills/Architecture/MVI/SKILL.md +224 -0
- package/skills/Architecture/MVVM/SKILL.md +198 -0
- package/skills/Architecture/Modularization/SKILL.md +194 -0
- package/skills/Architecture/Offline First/SKILL.md +249 -0
- package/skills/Architecture/Repository Pattern/SKILL.md +216 -0
- package/skills/Architecture/Side Effect Management/SKILL.md +278 -0
- package/skills/Architecture/State Management/SKILL.md +229 -0
- package/skills/Architecture/Unidirectional Data Flow/SKILL.md +196 -0
- package/skills/Architecture/Use Case Design/SKILL.md +244 -0
- package/skills/Architecture/Value Object/SKILL.md +226 -0
- package/skills/Build Infrastructure/Build Orchestration/SKILL.md +257 -0
- package/skills/Build Infrastructure/Dependency Compatibility Resolver/SKILL.md +259 -0
- package/skills/Build Infrastructure/Environment Validator/SKILL.md +311 -0
- package/skills/Build System/Build Cache/SKILL.md +233 -0
- package/skills/Build System/Build Flavor Strategy/SKILL.md +171 -0
- package/skills/Build System/Build Variant/SKILL.md +215 -0
- package/skills/Build System/Convention Plugin/SKILL.md +288 -0
- package/skills/Build System/Dependency Management/SKILL.md +261 -0
- package/skills/Build System/Gradle/SKILL.md +284 -0
- package/skills/Build System/Incremental Build/SKILL.md +199 -0
- package/skills/Build System/KAPT/SKILL.md +198 -0
- package/skills/Build System/KSP/SKILL.md +263 -0
- package/skills/Build System/Module Dependency Graph Validation/SKILL.md +223 -0
- package/skills/Build System/Specialized/C++/SKILL.md +308 -0
- package/skills/Build System/Specialized/JNI/SKILL.md +306 -0
- package/skills/Build System/Specialized/NDK/SKILL.md +264 -0
- package/skills/Build System/Version Catalog/SKILL.md +304 -0
- package/skills/Concurrency/Background Processing/SKILL.md +185 -0
- package/skills/Concurrency/Channel/SKILL.md +207 -0
- package/skills/Concurrency/Coroutine/SKILL.md +200 -0
- package/skills/Concurrency/Flow/SKILL.md +179 -0
- package/skills/Concurrency/Mutex Strategy/SKILL.md +185 -0
- package/skills/Concurrency/SharedFlow/SKILL.md +171 -0
- package/skills/Concurrency/StateFlow/SKILL.md +175 -0
- package/skills/Concurrency/Structured Concurrency/SKILL.md +197 -0
- package/skills/Concurrency/Synchronization Policy/SKILL.md +192 -0
- package/skills/Core Language/Annotation Processing/SKILL.md +224 -0
- package/skills/Core Language/DSL/SKILL.md +186 -0
- package/skills/Core Language/Extension Functions Design/SKILL.md +191 -0
- package/skills/Core Language/Immutability/SKILL.md +156 -0
- package/skills/Core Language/KMP/SKILL.md +182 -0
- package/skills/Core Language/Kotlin/SKILL.md +187 -0
- package/skills/Core Language/Reactive State Management/SKILL.md +228 -0
- package/skills/Core Language/Reactive Streams/SKILL.md +235 -0
- package/skills/Core Language/Serialization/SKILL.md +191 -0
- package/skills/Data Layer/Cache Strategy/SKILL.md +261 -0
- package/skills/Data Layer/Conflict Resolution/SKILL.md +248 -0
- package/skills/Data Layer/DAO/SKILL.md +225 -0
- package/skills/Data Layer/DTO Mapping/SKILL.md +269 -0
- package/skills/Data Layer/DataStore/SKILL.md +264 -0
- package/skills/Data Layer/Database Versioning Strategy/SKILL.md +215 -0
- package/skills/Data Layer/Encrypted Database/SKILL.md +212 -0
- package/skills/Data Layer/File Storage/SKILL.md +247 -0
- package/skills/Data Layer/Indexing/SKILL.md +184 -0
- package/skills/Data Layer/Key-Value Store Strategy/SKILL.md +185 -0
- package/skills/Data Layer/Merge Strategy/SKILL.md +240 -0
- package/skills/Data Layer/Migration/SKILL.md +243 -0
- package/skills/Data Layer/Paging/SKILL.md +264 -0
- package/skills/Data Layer/Proto DataStore/SKILL.md +250 -0
- package/skills/Data Layer/Room/SKILL.md +244 -0
- package/skills/Data Layer/SQLite/SKILL.md +255 -0
- package/skills/Data Layer/Sync Engine/SKILL.md +268 -0
- package/skills/Dependency Injection/Dagger/SKILL.md +283 -0
- package/skills/Dependency Injection/Hilt/SKILL.md +345 -0
- package/skills/Dependency Injection/Koin/SKILL.md +282 -0
- package/skills/Developer Experience/Detekt/SKILL.md +272 -0
- package/skills/Developer Experience/Lint Rule/SKILL.md +281 -0
- package/skills/Google Ecosystem/Analytics/SKILL.md +281 -0
- package/skills/Google Ecosystem/Crashlytics/SKILL.md +234 -0
- package/skills/Google Ecosystem/Firebase/SKILL.md +200 -0
- package/skills/Google Ecosystem/Firebase Messaging/SKILL.md +266 -0
- package/skills/Media/Audio/SKILL.md +257 -0
- package/skills/Media/Camera/SKILL.md +229 -0
- package/skills/Media/CameraX/SKILL.md +295 -0
- package/skills/Media/ExoPlayer/SKILL.md +258 -0
- package/skills/Media/Video/SKILL.md +228 -0
- package/skills/Meta Skills/Domain Error Model/SKILL.md +238 -0
- package/skills/Meta Skills/Error Handling/SKILL.md +255 -0
- package/skills/Meta Skills/Error Mapping/SKILL.md +232 -0
- package/skills/Meta Skills/Failure Strategy/SKILL.md +294 -0
- package/skills/Meta Skills/Migration Strategy/SKILL.md +305 -0
- package/skills/Meta Skills/User Friendly Errors/SKILL.md +334 -0
- package/skills/Navigation/Deep Navigation/SKILL.md +209 -0
- package/skills/Navigation/Navigation/SKILL.md +215 -0
- package/skills/Navigation/Nested Navigation/SKILL.md +214 -0
- package/skills/Networking/API Contract/SKILL.md +220 -0
- package/skills/Networking/Authentication/SKILL.md +210 -0
- package/skills/Networking/Certificate Pinning/SKILL.md +167 -0
- package/skills/Networking/Fallback Strategy/SKILL.md +182 -0
- package/skills/Networking/Ktor/SKILL.md +219 -0
- package/skills/Networking/Multipart Upload/SKILL.md +213 -0
- package/skills/Networking/OkHttp/SKILL.md +193 -0
- package/skills/Networking/REST/SKILL.md +178 -0
- package/skills/Networking/Rate Limiting/SKILL.md +170 -0
- package/skills/Networking/Retrofit/SKILL.md +241 -0
- package/skills/Networking/Retry-Backoff/SKILL.md +181 -0
- package/skills/Networking/Server-Sent Events (SSE)/SKILL.md +196 -0
- package/skills/Networking/WebSocket/SKILL.md +224 -0
- package/skills/Observability/Crash Reporting/SKILL.md +219 -0
- package/skills/Observability/Logging/SKILL.md +168 -0
- package/skills/Observability/Metrics/SKILL.md +227 -0
- package/skills/Observability/Structured Logging/SKILL.md +234 -0
- package/skills/Performance/ANR Prevention/SKILL.md +192 -0
- package/skills/Performance/Allocation Optimization/SKILL.md +179 -0
- package/skills/Performance/App Startup/SKILL.md +183 -0
- package/skills/Performance/Baseline Profile/SKILL.md +205 -0
- package/skills/Performance/Battery Optimization/SKILL.md +192 -0
- package/skills/Performance/Benchmark/SKILL.md +182 -0
- package/skills/Performance/Bitmap Optimization/SKILL.md +178 -0
- package/skills/Performance/Compose Optimization/SKILL.md +187 -0
- package/skills/Performance/Heap Management/SKILL.md +184 -0
- package/skills/Performance/Macrobenchmark/SKILL.md +214 -0
- package/skills/Performance/Memory Leak Prevention/SKILL.md +218 -0
- package/skills/Performance/Rendering Performance/SKILL.md +205 -0
- package/skills/Performance/Startup Optimization/SKILL.md +219 -0
- package/skills/Security/Biometric/SKILL.md +224 -0
- package/skills/Security/Certificate Transparency/SKILL.md +158 -0
- package/skills/Security/Cryptography/SKILL.md +244 -0
- package/skills/Security/Encrypted Storage/SKILL.md +273 -0
- package/skills/Security/Frida Detection/SKILL.md +230 -0
- package/skills/Security/Hook Detection/SKILL.md +197 -0
- package/skills/Security/Keystore/SKILL.md +272 -0
- package/skills/Security/Network Security Config/SKILL.md +186 -0
- package/skills/Security/Obfuscation/SKILL.md +226 -0
- package/skills/Security/Proguard/SKILL.md +202 -0
- package/skills/Security/R8/SKILL.md +234 -0
- package/skills/Security/Reverse Engineering Resistance/SKILL.md +267 -0
- package/skills/Security/Root Detection/SKILL.md +220 -0
- package/skills/Security/Secure Networking/SKILL.md +220 -0
- package/skills/System Integration/AlarmManager/SKILL.md +182 -0
- package/skills/System Integration/App Widget/SKILL.md +182 -0
- package/skills/System Integration/Deep Link/SKILL.md +187 -0
- package/skills/System Integration/Foreground Service/SKILL.md +212 -0
- package/skills/System Integration/Notification/SKILL.md +237 -0
- package/skills/System Integration/WorkManager/SKILL.md +256 -0
- package/skills/System Integration/clipboard/SKILL.md +155 -0
- package/skills/System Integration/share-intent/SKILL.md +182 -0
- package/skills/Testing/Compose Testing/SKILL.md +296 -0
- package/skills/Testing/Espresso/SKILL.md +292 -0
- package/skills/Testing/Fake Data/SKILL.md +245 -0
- package/skills/Testing/Integration Testing/SKILL.md +288 -0
- package/skills/Testing/Mocking/SKILL.md +229 -0
- package/skills/Testing/Snapshot Testing/SKILL.md +259 -0
- package/skills/Testing/UI Testing/SKILL.md +293 -0
- package/skills/Testing/Unit Testing/SKILL.md +309 -0
- package/skills/UI System/Bottom Sheet Patterns/SKILL.md +279 -0
- package/skills/UI System/Compose/SKILL.md +296 -0
- package/skills/UI System/Compose Animation/SKILL.md +281 -0
- package/skills/UI System/Compose Multiplatform/SKILL.md +261 -0
- package/skills/UI System/Compose Navigation/SKILL.md +255 -0
- package/skills/UI System/Compose Performance/SKILL.md +274 -0
- package/skills/UI System/Design System/SKILL.md +217 -0
- package/skills/UI System/Empty State Strategy/SKILL.md +208 -0
- package/skills/UI System/Keyboard Navigation/SKILL.md +214 -0
- package/skills/UI System/Loading Strategy/SKILL.md +254 -0
- package/skills/UI System/Material 3/SKILL.md +279 -0
- package/skills/UI System/RTL/SKILL.md +179 -0
- package/src/index.ts +182 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: migration-strategy
|
|
3
|
+
description: >
|
|
4
|
+
Migration strategy for Android apps — database, API, and data format migrations.
|
|
5
|
+
Load this skill when planning Room database schema migrations, handling
|
|
6
|
+
API version changes, migrating SharedPreferences to DataStore,
|
|
7
|
+
or designing backward-compatible data format changes.
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# Migration Strategy
|
|
11
|
+
|
|
12
|
+
## Overview
|
|
13
|
+
Migration strategy covers how the app evolves its data contracts without breaking existing users. This includes Room schema migrations (adding columns, renaming tables), API version transitions (v1 → v2 endpoints), and storage format migrations (SharedPreferences → DataStore, plain → encrypted). Each type requires a different approach.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Core Principles
|
|
18
|
+
|
|
19
|
+
- **Never break existing users** — migrations must be backward-compatible or provide a clear upgrade path
|
|
20
|
+
- **Migrations are one-way** — don't design rollback unless specifically required
|
|
21
|
+
- **Test migrations** — always test the upgrade path from the previous version
|
|
22
|
+
- **Data loss is unacceptable** — destructive migration (`fallbackToDestructiveMigration`) is only for dev/cache data
|
|
23
|
+
- **Incremental** — migrate one version at a time; never skip versions
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## Room Database Migration
|
|
28
|
+
|
|
29
|
+
```kotlin
|
|
30
|
+
// ✅ Version 1 → Version 2: Add column
|
|
31
|
+
val MIGRATION_1_2 = object : Migration(1, 2) {
|
|
32
|
+
override fun migrate(database: SupportSQLiteDatabase) {
|
|
33
|
+
database.execSQL(
|
|
34
|
+
"ALTER TABLE users ADD COLUMN is_verified INTEGER NOT NULL DEFAULT 0"
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ✅ Version 2 → Version 3: Add new table
|
|
40
|
+
val MIGRATION_2_3 = object : Migration(2, 3) {
|
|
41
|
+
override fun migrate(database: SupportSQLiteDatabase) {
|
|
42
|
+
database.execSQL("""
|
|
43
|
+
CREATE TABLE IF NOT EXISTS `user_sessions` (
|
|
44
|
+
`id` TEXT NOT NULL,
|
|
45
|
+
`user_id` TEXT NOT NULL,
|
|
46
|
+
`token` TEXT NOT NULL,
|
|
47
|
+
`expires_at` INTEGER NOT NULL,
|
|
48
|
+
PRIMARY KEY(`id`),
|
|
49
|
+
FOREIGN KEY(`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE
|
|
50
|
+
)
|
|
51
|
+
""".trimIndent())
|
|
52
|
+
database.execSQL("CREATE INDEX IF NOT EXISTS `index_user_sessions_user_id` ON `user_sessions` (`user_id`)")
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ✅ Version 3 → Version 4: Rename column (SQLite doesn't support RENAME COLUMN before API 29)
|
|
57
|
+
val MIGRATION_3_4 = object : Migration(3, 4) {
|
|
58
|
+
override fun migrate(database: SupportSQLiteDatabase) {
|
|
59
|
+
// SQLite < 3.25 workaround: create new table, copy, drop old
|
|
60
|
+
database.execSQL("""
|
|
61
|
+
CREATE TABLE `users_new` (
|
|
62
|
+
`id` TEXT NOT NULL,
|
|
63
|
+
`full_name` TEXT NOT NULL,
|
|
64
|
+
`email` TEXT NOT NULL,
|
|
65
|
+
`role` TEXT NOT NULL,
|
|
66
|
+
`is_active` INTEGER NOT NULL,
|
|
67
|
+
`is_verified` INTEGER NOT NULL DEFAULT 0,
|
|
68
|
+
`created_at` INTEGER NOT NULL,
|
|
69
|
+
`updated_at` INTEGER NOT NULL,
|
|
70
|
+
PRIMARY KEY(`id`)
|
|
71
|
+
)
|
|
72
|
+
""".trimIndent())
|
|
73
|
+
database.execSQL("""
|
|
74
|
+
INSERT INTO `users_new`
|
|
75
|
+
SELECT `id`, `name`, `email`, `role`, `is_active`, `is_verified`, `created_at`, `updated_at`
|
|
76
|
+
FROM `users`
|
|
77
|
+
""".trimIndent())
|
|
78
|
+
database.execSQL("DROP TABLE `users`")
|
|
79
|
+
database.execSQL("ALTER TABLE `users_new` RENAME TO `users`")
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ✅ Register all migrations
|
|
84
|
+
@Database(entities = [...], version = 4)
|
|
85
|
+
abstract class AppDatabase : RoomDatabase() {
|
|
86
|
+
companion object {
|
|
87
|
+
fun build(context: Context): AppDatabase =
|
|
88
|
+
Room.databaseBuilder(context, AppDatabase::class.java, "app.db")
|
|
89
|
+
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4)
|
|
90
|
+
.build()
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
## Auto Migration (Room 2.4+)
|
|
98
|
+
|
|
99
|
+
```kotlin
|
|
100
|
+
// ✅ Simple migrations can use @AutoMigration
|
|
101
|
+
@Database(
|
|
102
|
+
entities = [UserEntity::class],
|
|
103
|
+
version = 5,
|
|
104
|
+
autoMigrations = [
|
|
105
|
+
AutoMigration(from = 4, to = 5) // adds new columns with defaults automatically
|
|
106
|
+
]
|
|
107
|
+
)
|
|
108
|
+
abstract class AppDatabase : RoomDatabase()
|
|
109
|
+
|
|
110
|
+
// ✅ AutoMigration with spec for rename/delete
|
|
111
|
+
@RenameColumn(tableName = "users", fromColumnName = "name", toColumnName = "full_name")
|
|
112
|
+
class Migration4To5Spec : AutoMigrationSpec
|
|
113
|
+
|
|
114
|
+
@Database(
|
|
115
|
+
version = 5,
|
|
116
|
+
autoMigrations = [
|
|
117
|
+
AutoMigration(from = 4, to = 5, spec = Migration4To5Spec::class)
|
|
118
|
+
]
|
|
119
|
+
)
|
|
120
|
+
abstract class AppDatabase : RoomDatabase()
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
---
|
|
124
|
+
|
|
125
|
+
## Testing Migrations
|
|
126
|
+
|
|
127
|
+
```kotlin
|
|
128
|
+
// ✅ Always test migration path
|
|
129
|
+
@RunWith(AndroidJUnit4::class)
|
|
130
|
+
class MigrationTest {
|
|
131
|
+
|
|
132
|
+
@get:Rule
|
|
133
|
+
val helper = MigrationTestHelper(
|
|
134
|
+
InstrumentationRegistry.getInstrumentation(),
|
|
135
|
+
AppDatabase::class.java
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
@Test
|
|
139
|
+
fun migrate1To2() {
|
|
140
|
+
// Create v1 database with test data
|
|
141
|
+
helper.createDatabase("test.db", 1).apply {
|
|
142
|
+
execSQL("INSERT INTO users VALUES ('1', 'Ali', 'ali@test.com', 'member', 1, 1000, 1000)")
|
|
143
|
+
close()
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Run migration
|
|
147
|
+
val db = helper.runMigrationsAndValidate("test.db", 2, true, MIGRATION_1_2)
|
|
148
|
+
|
|
149
|
+
// Verify data intact + new column has default
|
|
150
|
+
val cursor = db.query("SELECT * FROM users WHERE id = '1'")
|
|
151
|
+
cursor.moveToFirst()
|
|
152
|
+
assertThat(cursor.getInt(cursor.getColumnIndex("is_verified"))).isEqualTo(0)
|
|
153
|
+
cursor.close()
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
---
|
|
159
|
+
|
|
160
|
+
## SharedPreferences → DataStore Migration
|
|
161
|
+
|
|
162
|
+
```kotlin
|
|
163
|
+
// ✅ One-time migration from SharedPreferences to DataStore
|
|
164
|
+
class PreferencesMigration @Inject constructor(
|
|
165
|
+
@ApplicationContext private val context: Context
|
|
166
|
+
) {
|
|
167
|
+
private val legacyPrefs = context.getSharedPreferences("app_prefs", Context.MODE_PRIVATE)
|
|
168
|
+
private val migrationKey = "prefs_migrated_v1"
|
|
169
|
+
|
|
170
|
+
suspend fun migrateIfNeeded(dataStore: DataStore<Preferences>) {
|
|
171
|
+
if (legacyPrefs.getBoolean(migrationKey, false)) return
|
|
172
|
+
|
|
173
|
+
dataStore.edit { prefs ->
|
|
174
|
+
// Copy all keys from SharedPreferences to DataStore
|
|
175
|
+
legacyPrefs.getString("auth_token", null)?.let {
|
|
176
|
+
prefs[stringPreferencesKey("auth_token")] = it
|
|
177
|
+
}
|
|
178
|
+
legacyPrefs.getBoolean("notifications_enabled", true).let {
|
|
179
|
+
prefs[booleanPreferencesKey("notifications_enabled")] = it
|
|
180
|
+
}
|
|
181
|
+
legacyPrefs.getString("selected_language", "en")?.let {
|
|
182
|
+
prefs[stringPreferencesKey("selected_language")] = it
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Mark migration complete
|
|
187
|
+
legacyPrefs.edit { putBoolean(migrationKey, true) }
|
|
188
|
+
|
|
189
|
+
// Optionally clear old prefs
|
|
190
|
+
legacyPrefs.edit { clear() }
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ✅ Run at app startup — before DataStore is used
|
|
195
|
+
class App : Application() {
|
|
196
|
+
@Inject lateinit var preferencesMigration: PreferencesMigration
|
|
197
|
+
@Inject lateinit var dataStore: DataStore<Preferences>
|
|
198
|
+
|
|
199
|
+
override fun onCreate() {
|
|
200
|
+
super.onCreate()
|
|
201
|
+
// Run migration in background
|
|
202
|
+
applicationScope.launch {
|
|
203
|
+
preferencesMigration.migrateIfNeeded(dataStore)
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
---
|
|
210
|
+
|
|
211
|
+
## API Version Migration
|
|
212
|
+
|
|
213
|
+
```kotlin
|
|
214
|
+
// ✅ Support multiple API versions via versioned endpoints
|
|
215
|
+
interface UserApiService {
|
|
216
|
+
@GET("v2/users/{id}")
|
|
217
|
+
suspend fun getUser(@Path("id") id: String): UserDtoV2
|
|
218
|
+
|
|
219
|
+
// Keep v1 for gradual migration
|
|
220
|
+
@GET("v1/users/{id}")
|
|
221
|
+
suspend fun getUserLegacy(@Path("id") id: String): UserDtoV1
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ✅ Repository handles version negotiation
|
|
225
|
+
class UserRepositoryImpl @Inject constructor(
|
|
226
|
+
private val api: UserApiService,
|
|
227
|
+
private val featureFlags: FeatureFlags
|
|
228
|
+
) : UserRepository {
|
|
229
|
+
|
|
230
|
+
override suspend fun getUser(id: String): Result<User> = runCatching {
|
|
231
|
+
if (featureFlags.isApiV2Enabled) {
|
|
232
|
+
api.getUser(id).toDomain()
|
|
233
|
+
} else {
|
|
234
|
+
api.getUserLegacy(id).toDomain()
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
---
|
|
241
|
+
|
|
242
|
+
## Data Format Migration
|
|
243
|
+
|
|
244
|
+
```kotlin
|
|
245
|
+
// ✅ Version-tagged serialized data
|
|
246
|
+
@Serializable
|
|
247
|
+
data class StoredData(
|
|
248
|
+
val version: Int = CURRENT_VERSION,
|
|
249
|
+
val payload: JsonElement
|
|
250
|
+
) {
|
|
251
|
+
companion object {
|
|
252
|
+
const val CURRENT_VERSION = 2
|
|
253
|
+
|
|
254
|
+
fun migrate(stored: StoredData): StoredData = when (stored.version) {
|
|
255
|
+
1 -> migrateV1ToV2(stored)
|
|
256
|
+
CURRENT_VERSION -> stored
|
|
257
|
+
else -> throw IllegalStateException("Unknown version: ${stored.version}")
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
private fun migrateV1ToV2(stored: StoredData): StoredData {
|
|
261
|
+
val v1 = Json.decodeFromJsonElement<DataV1>(stored.payload)
|
|
262
|
+
val v2 = DataV2(
|
|
263
|
+
id = v1.id,
|
|
264
|
+
fullName = "${v1.firstName} ${v1.lastName}", // merge fields
|
|
265
|
+
email = v1.email
|
|
266
|
+
)
|
|
267
|
+
return StoredData(version = 2, payload = Json.encodeToJsonElement(v2))
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
---
|
|
274
|
+
|
|
275
|
+
## Migration Checklist
|
|
276
|
+
|
|
277
|
+
```
|
|
278
|
+
Before releasing a migration:
|
|
279
|
+
✅ New @Entity version incremented in @Database
|
|
280
|
+
✅ Migration object written and tested
|
|
281
|
+
✅ Migration registered in Room.databaseBuilder
|
|
282
|
+
✅ MigrationTest written and passing
|
|
283
|
+
✅ Tested on device upgrading from previous version
|
|
284
|
+
✅ No data loss for existing users
|
|
285
|
+
✅ fallbackToDestructiveMigration NOT used (unless cache-only DB)
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
---
|
|
289
|
+
|
|
290
|
+
## Anti-Patterns
|
|
291
|
+
|
|
292
|
+
- `fallbackToDestructiveMigration()` for user data — wipes all data on missing migration
|
|
293
|
+
- Skipping migration versions — always chain migrations 1→2→3, never jump 1→3
|
|
294
|
+
- Not testing migration path — migration bugs only appear on upgrade, not fresh install
|
|
295
|
+
- Modifying old migrations — never change a released migration; add a new one
|
|
296
|
+
- Storing migration state in the migrated DB — if migration fails, the state check fails too
|
|
297
|
+
|
|
298
|
+
---
|
|
299
|
+
|
|
300
|
+
## Related Skills
|
|
301
|
+
- `room` — Room database setup
|
|
302
|
+
- `dao` — DAO patterns affected by migrations
|
|
303
|
+
- `entity-design` — entity changes that trigger migrations
|
|
304
|
+
- `datastore` — DataStore as migration target from SharedPreferences
|
|
305
|
+
- `gradle` — build versioning that tracks DB version
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: user-friendly-errors
|
|
3
|
+
description: >
|
|
4
|
+
Displaying user-friendly error messages in Android Compose UI.
|
|
5
|
+
Load this skill when designing error states in composables,
|
|
6
|
+
writing error string resources, building reusable error components,
|
|
7
|
+
or deciding between inline errors, snackbars, and full error screens.
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# User Friendly Errors
|
|
11
|
+
|
|
12
|
+
## Overview
|
|
13
|
+
User-friendly errors translate technical failures into clear, actionable messages. The goal is to tell the user what happened, why it matters, and what they can do. Error display varies by context: inline field errors, snackbars for transient failures, and full error screens for blocking failures.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Core Principles
|
|
18
|
+
|
|
19
|
+
- **Clear language** — no technical jargon; no stack traces shown to users
|
|
20
|
+
- **Actionable** — tell the user what to do, not just what went wrong
|
|
21
|
+
- **Contextual** — field-level errors inline; screen-level errors as states; transient as snackbars
|
|
22
|
+
- **Consistent** — same error type always looks the same across screens
|
|
23
|
+
- **Honest** — don't overpromise; "Try again" only when retry might work
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## Error Display Decision
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
Error type → Display method
|
|
31
|
+
─────────────────────────────────────────────────────
|
|
32
|
+
Form validation → Inline below field
|
|
33
|
+
Transient (network) → Snackbar with action
|
|
34
|
+
Blocking (load fail) → Full error state in screen
|
|
35
|
+
Permission denied → Inline message, no retry
|
|
36
|
+
Session expired → Navigate to login
|
|
37
|
+
Critical (corrupt) → Dialog, then navigate away
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## Error String Resources
|
|
43
|
+
|
|
44
|
+
```xml
|
|
45
|
+
<!-- res/values/strings_errors.xml -->
|
|
46
|
+
<resources>
|
|
47
|
+
<!-- Network -->
|
|
48
|
+
<string name="error_no_connection">No internet connection. Please check your network.</string>
|
|
49
|
+
<string name="error_timeout">Request timed out. Please try again.</string>
|
|
50
|
+
<string name="error_server">Server error. Please try again later.</string>
|
|
51
|
+
<string name="error_session_expired">Your session has expired. Please log in again.</string>
|
|
52
|
+
<string name="error_no_permission">You don\'t have permission to do this.</string>
|
|
53
|
+
<string name="error_not_found">The item you\'re looking for doesn\'t exist.</string>
|
|
54
|
+
<string name="error_rate_limited">Too many requests. Please wait a moment and try again.</string>
|
|
55
|
+
|
|
56
|
+
<!-- Data -->
|
|
57
|
+
<string name="error_item_not_found">Item not found.</string>
|
|
58
|
+
<string name="error_conflict">"%1$s" already exists.</string>
|
|
59
|
+
<string name="error_invalid_state">This action isn\'t available right now.</string>
|
|
60
|
+
|
|
61
|
+
<!-- Storage -->
|
|
62
|
+
<string name="error_disk_full">Your device storage is full. Free up space and try again.</string>
|
|
63
|
+
<string name="error_data_corrupted">Some data was corrupted. Please reinstall the app.</string>
|
|
64
|
+
|
|
65
|
+
<!-- Generic -->
|
|
66
|
+
<string name="error_unexpected">Something went wrong. Please try again.</string>
|
|
67
|
+
<string name="error_retry">Try Again</string>
|
|
68
|
+
<string name="error_dismiss">Dismiss</string>
|
|
69
|
+
</resources>
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## Reusable Error Components
|
|
75
|
+
|
|
76
|
+
```kotlin
|
|
77
|
+
// ✅ Full-screen error state
|
|
78
|
+
@Composable
|
|
79
|
+
fun FullScreenError(
|
|
80
|
+
message: String,
|
|
81
|
+
onRetry: (() -> Unit)? = null,
|
|
82
|
+
modifier: Modifier = Modifier
|
|
83
|
+
) {
|
|
84
|
+
Column(
|
|
85
|
+
modifier = modifier
|
|
86
|
+
.fillMaxSize()
|
|
87
|
+
.padding(horizontal = 32.dp),
|
|
88
|
+
horizontalAlignment = Alignment.CenterHorizontally,
|
|
89
|
+
verticalArrangement = Arrangement.Center
|
|
90
|
+
) {
|
|
91
|
+
Icon(
|
|
92
|
+
imageVector = Icons.Outlined.ErrorOutline,
|
|
93
|
+
contentDescription = null,
|
|
94
|
+
modifier = Modifier.size(64.dp),
|
|
95
|
+
tint = MaterialTheme.colorScheme.error
|
|
96
|
+
)
|
|
97
|
+
Spacer(Modifier.height(16.dp))
|
|
98
|
+
Text(
|
|
99
|
+
text = message,
|
|
100
|
+
style = MaterialTheme.typography.bodyLarge,
|
|
101
|
+
textAlign = TextAlign.Center,
|
|
102
|
+
color = MaterialTheme.colorScheme.onSurface
|
|
103
|
+
)
|
|
104
|
+
if (onRetry != null) {
|
|
105
|
+
Spacer(Modifier.height(24.dp))
|
|
106
|
+
Button(onClick = onRetry) {
|
|
107
|
+
Text(stringResource(R.string.error_retry))
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ✅ Inline error — below a field
|
|
114
|
+
@Composable
|
|
115
|
+
fun FieldError(
|
|
116
|
+
message: String,
|
|
117
|
+
modifier: Modifier = Modifier
|
|
118
|
+
) {
|
|
119
|
+
Text(
|
|
120
|
+
text = message,
|
|
121
|
+
style = MaterialTheme.typography.bodySmall,
|
|
122
|
+
color = MaterialTheme.colorScheme.error,
|
|
123
|
+
modifier = modifier.padding(start = 16.dp, top = 4.dp)
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ✅ Error banner — non-blocking top/bottom banner
|
|
128
|
+
@Composable
|
|
129
|
+
fun ErrorBanner(
|
|
130
|
+
message: String,
|
|
131
|
+
onDismiss: (() -> Unit)? = null,
|
|
132
|
+
modifier: Modifier = Modifier
|
|
133
|
+
) {
|
|
134
|
+
Surface(
|
|
135
|
+
modifier = modifier.fillMaxWidth(),
|
|
136
|
+
color = MaterialTheme.colorScheme.errorContainer,
|
|
137
|
+
tonalElevation = 2.dp
|
|
138
|
+
) {
|
|
139
|
+
Row(
|
|
140
|
+
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
|
141
|
+
verticalAlignment = Alignment.CenterVertically
|
|
142
|
+
) {
|
|
143
|
+
Icon(
|
|
144
|
+
imageVector = Icons.Outlined.Warning,
|
|
145
|
+
contentDescription = null,
|
|
146
|
+
tint = MaterialTheme.colorScheme.onErrorContainer,
|
|
147
|
+
modifier = Modifier.size(20.dp)
|
|
148
|
+
)
|
|
149
|
+
Spacer(Modifier.width(12.dp))
|
|
150
|
+
Text(
|
|
151
|
+
text = message,
|
|
152
|
+
style = MaterialTheme.typography.bodyMedium,
|
|
153
|
+
color = MaterialTheme.colorScheme.onErrorContainer,
|
|
154
|
+
modifier = Modifier.weight(1f)
|
|
155
|
+
)
|
|
156
|
+
if (onDismiss != null) {
|
|
157
|
+
IconButton(onClick = onDismiss, modifier = Modifier.size(24.dp)) {
|
|
158
|
+
Icon(Icons.Default.Close, contentDescription = stringResource(R.string.error_dismiss))
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
---
|
|
167
|
+
|
|
168
|
+
## Snackbar Error Pattern
|
|
169
|
+
|
|
170
|
+
```kotlin
|
|
171
|
+
// ✅ Transient error via snackbar
|
|
172
|
+
@Composable
|
|
173
|
+
fun ProductScreen(
|
|
174
|
+
viewModel: ProductViewModel = hiltViewModel()
|
|
175
|
+
) {
|
|
176
|
+
val snackbarHostState = remember { SnackbarHostState() }
|
|
177
|
+
val context = LocalContext.current
|
|
178
|
+
|
|
179
|
+
// Collect one-time error events
|
|
180
|
+
LaunchedEffect(Unit) {
|
|
181
|
+
viewModel.events.collect { event ->
|
|
182
|
+
when (event) {
|
|
183
|
+
is ProductEvent.ShowError -> {
|
|
184
|
+
val result = snackbarHostState.showSnackbar(
|
|
185
|
+
message = event.message,
|
|
186
|
+
actionLabel = if (event.canRetry) context.getString(R.string.error_retry) else null,
|
|
187
|
+
duration = SnackbarDuration.Long
|
|
188
|
+
)
|
|
189
|
+
if (result == SnackbarResult.ActionPerformed && event.canRetry) {
|
|
190
|
+
viewModel.onRetry()
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
Scaffold(snackbarHost = { SnackbarHost(snackbarHostState) }) { padding ->
|
|
198
|
+
ProductContent(modifier = Modifier.padding(padding))
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
---
|
|
204
|
+
|
|
205
|
+
## Form Validation Errors
|
|
206
|
+
|
|
207
|
+
```kotlin
|
|
208
|
+
// ✅ Inline validation errors on form fields
|
|
209
|
+
@Composable
|
|
210
|
+
fun EmailField(
|
|
211
|
+
value: String,
|
|
212
|
+
error: String?,
|
|
213
|
+
onValueChange: (String) -> Unit,
|
|
214
|
+
modifier: Modifier = Modifier
|
|
215
|
+
) {
|
|
216
|
+
Column(modifier = modifier) {
|
|
217
|
+
OutlinedTextField(
|
|
218
|
+
value = value,
|
|
219
|
+
onValueChange = onValueChange,
|
|
220
|
+
label = { Text("Email") },
|
|
221
|
+
isError = error != null,
|
|
222
|
+
supportingText = {
|
|
223
|
+
if (error != null) {
|
|
224
|
+
Text(
|
|
225
|
+
text = error,
|
|
226
|
+
color = MaterialTheme.colorScheme.error
|
|
227
|
+
)
|
|
228
|
+
}
|
|
229
|
+
},
|
|
230
|
+
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
|
|
231
|
+
singleLine = true,
|
|
232
|
+
modifier = Modifier.fillMaxWidth()
|
|
233
|
+
)
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ✅ UiState with per-field errors
|
|
238
|
+
data class RegistrationUiState(
|
|
239
|
+
val email: String = "",
|
|
240
|
+
val password: String = "",
|
|
241
|
+
val isLoading: Boolean = false,
|
|
242
|
+
val emailError: String? = null,
|
|
243
|
+
val passwordError: String? = null,
|
|
244
|
+
val generalError: String? = null
|
|
245
|
+
)
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
---
|
|
249
|
+
|
|
250
|
+
## AppError → User Message Mapping
|
|
251
|
+
|
|
252
|
+
```kotlin
|
|
253
|
+
// ✅ Centralized mapping — use string resources
|
|
254
|
+
@Composable
|
|
255
|
+
fun AppError.toMessage(): String = when (this) {
|
|
256
|
+
is AppError.Network.NoConnection -> stringResource(R.string.error_no_connection)
|
|
257
|
+
is AppError.Network.Timeout -> stringResource(R.string.error_timeout)
|
|
258
|
+
is AppError.Network.Unauthorized -> stringResource(R.string.error_session_expired)
|
|
259
|
+
is AppError.Network.Forbidden -> stringResource(R.string.error_no_permission)
|
|
260
|
+
is AppError.Network.NotFound -> stringResource(R.string.error_not_found)
|
|
261
|
+
is AppError.Network.ServerError -> stringResource(R.string.error_server)
|
|
262
|
+
is AppError.Network.RateLimited -> stringResource(R.string.error_rate_limited)
|
|
263
|
+
is AppError.Data.NotFound -> stringResource(R.string.error_item_not_found)
|
|
264
|
+
is AppError.Data.Conflict -> stringResource(R.string.error_conflict, field)
|
|
265
|
+
is AppError.Data.Validation -> errors.values.first()
|
|
266
|
+
is AppError.Data.InvalidState -> reason
|
|
267
|
+
is AppError.Storage.DiskFull -> stringResource(R.string.error_disk_full)
|
|
268
|
+
is AppError.Storage.Corrupted -> stringResource(R.string.error_data_corrupted)
|
|
269
|
+
is AppError.Storage.Unknown,
|
|
270
|
+
is AppError.Unexpected -> stringResource(R.string.error_unexpected)
|
|
271
|
+
else -> stringResource(R.string.error_unexpected)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ✅ Non-composable mapping (for ViewModel)
|
|
275
|
+
fun AppError.toMessageResId(): Int = when (this) {
|
|
276
|
+
is AppError.Network.NoConnection -> R.string.error_no_connection
|
|
277
|
+
is AppError.Network.Timeout -> R.string.error_timeout
|
|
278
|
+
is AppError.Network.Unauthorized -> R.string.error_session_expired
|
|
279
|
+
// ...
|
|
280
|
+
else -> R.string.error_unexpected
|
|
281
|
+
}
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
---
|
|
285
|
+
|
|
286
|
+
## Screen-Level Error Handling Pattern
|
|
287
|
+
|
|
288
|
+
```kotlin
|
|
289
|
+
// ✅ Composable that handles all UiState cases including error
|
|
290
|
+
@Composable
|
|
291
|
+
fun ProductListScreen(
|
|
292
|
+
viewModel: ProductListViewModel = hiltViewModel()
|
|
293
|
+
) {
|
|
294
|
+
val state by viewModel.state.collectAsStateWithLifecycle()
|
|
295
|
+
|
|
296
|
+
when (val s = state) {
|
|
297
|
+
is ProductListUiState.Loading -> {
|
|
298
|
+
LoadingIndicator(modifier = Modifier.fillMaxSize())
|
|
299
|
+
}
|
|
300
|
+
is ProductListUiState.Success -> {
|
|
301
|
+
ProductList(
|
|
302
|
+
products = s.products,
|
|
303
|
+
onProductClick = viewModel::onProductClick
|
|
304
|
+
)
|
|
305
|
+
}
|
|
306
|
+
is ProductListUiState.Error -> {
|
|
307
|
+
FullScreenError(
|
|
308
|
+
message = s.message,
|
|
309
|
+
onRetry = if (s.canRetry) viewModel::loadProducts else null
|
|
310
|
+
)
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
---
|
|
317
|
+
|
|
318
|
+
## Anti-Patterns
|
|
319
|
+
|
|
320
|
+
- Showing raw exception messages (`e.message`) to users — technical and unhelpful
|
|
321
|
+
- Same snackbar for every error — `Unauthorized` needs login redirect, not a snackbar
|
|
322
|
+
- Error messages without action — "Error occurred" with no retry button frustrates users
|
|
323
|
+
- Hardcoding error strings in code — use string resources for localization
|
|
324
|
+
- Generic "Something went wrong" for every error — obscures actionable information
|
|
325
|
+
|
|
326
|
+
---
|
|
327
|
+
|
|
328
|
+
## Related Skills
|
|
329
|
+
- `domain-error-model` — typed errors being displayed
|
|
330
|
+
- `error-handling` — how errors reach the UI
|
|
331
|
+
- `failure-strategy` — which display method to use per error
|
|
332
|
+
- `state-management` — modeling error state in UiState
|
|
333
|
+
- `compose` — Compose fundamentals for error composables
|
|
334
|
+
- `material3` — error colors and components from Material 3
|