Skip to content

Commit cf82991

Browse files
authored
Add splash-gated local app migration framework (#1083)
1 parent 3b3c94d commit cf82991

11 files changed

Lines changed: 469 additions & 2 deletions

File tree

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package net.primal.android.migration
2+
3+
const val CURRENT_APP_VERSION = 0
4+
5+
/**
6+
* An app-global migration step, run once per device at startup.
7+
*
8+
* Implementations must be consecutive ([endVersion] == [startVersion] + 1) and
9+
* idempotent: [migrate] may run on a fresh install or re-run after a later step
10+
* fails, so avoid anything that breaks when applied twice. It runs on the splash
11+
* path with no timeout, so keep it fast.
12+
*
13+
* Register via `@Provides @IntoSet` in AppMigrationModule and bump [CURRENT_APP_VERSION].
14+
*/
15+
interface AppMigration {
16+
val startVersion: Int
17+
val endVersion: Int
18+
suspend fun migrate()
19+
}
20+
21+
/**
22+
* Validates the registered chain: every step consecutive, no duplicate start
23+
* versions, and exactly [targetVersion] migrations covering `0 until targetVersion`.
24+
* Throws [IllegalArgumentException] on any violation.
25+
*/
26+
fun validateMigrationChain(migrations: Set<AppMigration>, targetVersion: Int) {
27+
migrations.forEach {
28+
require(it.endVersion == it.startVersion + 1) {
29+
"Migration ${it.startVersion} -> ${it.endVersion} is not strictly consecutive."
30+
}
31+
}
32+
val startVersions = migrations.map { it.startVersion }
33+
require(startVersions.toSet().size == startVersions.size) {
34+
"Duplicate startVersion detected among registered migrations."
35+
}
36+
require(migrations.size == targetVersion) {
37+
"Expected $targetVersion migrations to reach CURRENT_APP_VERSION but found ${migrations.size}."
38+
}
39+
for (version in 0 until targetVersion) {
40+
require(version in startVersions) {
41+
"No AppMigration registered from version $version."
42+
}
43+
}
44+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package net.primal.android.migration
2+
3+
import io.github.aakira.napier.Napier
4+
import javax.inject.Inject
5+
import javax.inject.Singleton
6+
import net.primal.android.migration.di.CurrentAppVersion
7+
import net.primal.core.utils.onFailure
8+
import net.primal.core.utils.runCatching
9+
10+
/**
11+
* Runs pending [AppMigration]s once at startup, persisting the version after each
12+
* step so a failure resumes from the last good version on the next launch. Failures
13+
* are logged and stop the chain rather than propagating (cancellation aside).
14+
*/
15+
@Singleton
16+
class AppMigrationRunner @Inject constructor(
17+
migrations: Set<@JvmSuppressWildcards AppMigration>,
18+
private val versionStore: AppMigrationStore,
19+
@CurrentAppVersion private val targetVersion: Int,
20+
) {
21+
private val byStart = migrations.associateBy { it.startVersion }
22+
23+
suspend fun runPendingMigrations() {
24+
var version = 0
25+
runCatching {
26+
version = versionStore.currentVersion()
27+
while (version < targetVersion) {
28+
val step = byStart[version]
29+
if (step == null) {
30+
Napier.e { "No AppMigration registered from version $version; stopping chain." }
31+
return@runCatching
32+
}
33+
step.migrate()
34+
version = step.endVersion
35+
versionStore.setVersion(version)
36+
}
37+
}.onFailure { error ->
38+
Napier.e(throwable = error) {
39+
"App migration failed at version $version; will retry next launch."
40+
}
41+
}
42+
}
43+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package net.primal.android.migration
2+
3+
import androidx.datastore.core.DataStore
4+
import javax.inject.Inject
5+
import javax.inject.Singleton
6+
import kotlinx.coroutines.flow.first
7+
import net.primal.android.migration.di.AppMigrationVersionDataStore
8+
9+
@Singleton
10+
class AppMigrationStore @Inject constructor(
11+
@AppMigrationVersionDataStore private val persistence: DataStore<String>,
12+
) {
13+
suspend fun currentVersion(): Int =
14+
persistence.data.first().toIntOrNull() ?: 0
15+
16+
suspend fun setVersion(version: Int) =
17+
persistence.updateData { version.toString() }
18+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package net.primal.android.migration.di
2+
3+
import dagger.Module
4+
import dagger.hilt.InstallIn
5+
import dagger.hilt.components.SingletonComponent
6+
import dagger.multibindings.Multibinds
7+
import net.primal.android.migration.AppMigration
8+
9+
/** Declares the [AppMigration] multibinding so an empty set injects while no migrations are registered. */
10+
@Module
11+
@InstallIn(SingletonComponent::class)
12+
interface AppMigrationBindingsModule {
13+
14+
@Multibinds
15+
fun appMigrations(): Set<AppMigration>
16+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package net.primal.android.migration.di
2+
3+
import android.content.Context
4+
import androidx.datastore.core.DataStore
5+
import androidx.datastore.core.DataStoreFactory
6+
import androidx.datastore.dataStoreFile
7+
import dagger.Module
8+
import dagger.Provides
9+
import dagger.hilt.InstallIn
10+
import dagger.hilt.android.qualifiers.ApplicationContext
11+
import dagger.hilt.components.SingletonComponent
12+
import javax.inject.Qualifier
13+
import javax.inject.Singleton
14+
import net.primal.android.core.serialization.datastore.StringSerializer
15+
import net.primal.android.migration.CURRENT_APP_VERSION
16+
17+
@Module
18+
@InstallIn(SingletonComponent::class)
19+
object AppMigrationModule {
20+
21+
@Provides
22+
@Singleton
23+
@AppMigrationVersionDataStore
24+
fun appMigrationVersionDataStore(@ApplicationContext context: Context): DataStore<String> =
25+
DataStoreFactory.create(
26+
produceFile = { context.dataStoreFile("app_migration_version.txt") },
27+
serializer = StringSerializer(),
28+
)
29+
30+
@Provides
31+
@CurrentAppVersion
32+
fun currentAppVersion(): Int = CURRENT_APP_VERSION
33+
34+
// Register migrations here with @Provides @IntoSet as the chain grows.
35+
}
36+
37+
@Qualifier
38+
@Target(AnnotationTarget.FUNCTION, AnnotationTarget.VALUE_PARAMETER)
39+
annotation class AppMigrationVersionDataStore
40+
41+
@Qualifier
42+
@Target(AnnotationTarget.FUNCTION, AnnotationTarget.VALUE_PARAMETER)
43+
annotation class CurrentAppVersion

app/src/main/kotlin/net/primal/android/navigation/splash/SplashViewModel.kt

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import kotlin.time.Duration.Companion.seconds
99
import kotlinx.coroutines.flow.MutableStateFlow
1010
import kotlinx.coroutines.launch
1111
import kotlinx.coroutines.withTimeoutOrNull
12+
import net.primal.android.migration.AppMigrationRunner
1213
import net.primal.android.user.accounts.active.ActiveAccountStore
1314
import net.primal.android.user.credentials.CredentialsStore
1415
import net.primal.core.config.AppConfigHandler
@@ -22,6 +23,7 @@ import net.primal.domain.nostr.cryptography.utils.hexToNpubHrp
2223
class SplashViewModel @Inject constructor(
2324
private val activeAccountStore: ActiveAccountStore,
2425
private val appConfigHandler: AppConfigHandler,
26+
private val appMigrationRunner: AppMigrationRunner,
2527
private val credentialsStore: CredentialsStore,
2628
private val feedsRepository: FeedsRepository,
2729
) : ViewModel() {
@@ -37,12 +39,13 @@ class SplashViewModel @Inject constructor(
3739
fun start(prefetchFeeds: Boolean) {
3840
if (started) return
3941
started = true
40-
checkAuthState(prefetchFeeds = prefetchFeeds)
42+
runStartupSequence(prefetchFeeds = prefetchFeeds)
4143
fetchLatestAppConfig()
4244
}
4345

44-
private fun checkAuthState(prefetchFeeds: Boolean) =
46+
private fun runStartupSequence(prefetchFeeds: Boolean) =
4547
viewModelScope.launch {
48+
appMigrationRunner.runPendingMigrations()
4649
val userId = activeAccountStore.activeUserId()
4750
_isLoggedIn.value = userId.isNotEmpty()
4851

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package net.primal.android.migration
2+
3+
import io.kotest.assertions.throwables.shouldThrow
4+
import io.kotest.assertions.throwables.shouldNotThrowAny
5+
import org.junit.Test
6+
7+
class AppMigrationChainTest {
8+
9+
private fun migration(start: Int, end: Int): AppMigration =
10+
object : AppMigration {
11+
override val startVersion = start
12+
override val endVersion = end
13+
override suspend fun migrate() = Unit
14+
}
15+
16+
@Test
17+
fun `empty set with target zero is valid`() {
18+
shouldNotThrowAny {
19+
validateMigrationChain(migrations = emptySet(), targetVersion = 0)
20+
}
21+
}
22+
23+
@Test
24+
fun `consecutive chain covering zero until target is valid`() {
25+
val chain = setOf(migration(0, 1), migration(1, 2), migration(2, 3))
26+
shouldNotThrowAny {
27+
validateMigrationChain(migrations = chain, targetVersion = 3)
28+
}
29+
}
30+
31+
@Test
32+
fun `non consecutive migration is rejected`() {
33+
val chain = setOf(migration(0, 2))
34+
shouldThrow<IllegalArgumentException> {
35+
validateMigrationChain(migrations = chain, targetVersion = 2)
36+
}
37+
}
38+
39+
@Test
40+
fun `gap in start versions is rejected`() {
41+
val chain = setOf(migration(0, 1), migration(2, 3))
42+
shouldThrow<IllegalArgumentException> {
43+
validateMigrationChain(migrations = chain, targetVersion = 3)
44+
}
45+
}
46+
47+
@Test
48+
fun `gap in middle of covered range is rejected`() {
49+
// size == target (3), all consecutive, distinct starts [0,1,3], but version 2 is missing.
50+
val chain = setOf(migration(0, 1), migration(1, 2), migration(3, 4))
51+
shouldThrow<IllegalArgumentException> {
52+
validateMigrationChain(migrations = chain, targetVersion = 3)
53+
}
54+
}
55+
56+
@Test
57+
fun `duplicate start version is rejected`() {
58+
// Two distinct instances share startVersion 0; set union keeps both (identity-based).
59+
val duplicated = setOf(migration(0, 1)) + setOf(migration(0, 1)) + setOf(migration(1, 2))
60+
shouldThrow<IllegalArgumentException> {
61+
validateMigrationChain(migrations = duplicated, targetVersion = 2)
62+
}
63+
}
64+
65+
@Test
66+
fun `size not equal to target is rejected`() {
67+
val chain = setOf(migration(0, 1))
68+
shouldThrow<IllegalArgumentException> {
69+
validateMigrationChain(migrations = chain, targetVersion = 2)
70+
}
71+
}
72+
}

0 commit comments

Comments
 (0)