Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions app/src/main/kotlin/net/primal/android/migration/AppMigration.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package net.primal.android.migration

const val CURRENT_APP_VERSION = 0

/**
* An app-global migration step, run once per device at startup.
*
* Implementations must be consecutive ([endVersion] == [startVersion] + 1) and
* idempotent: [migrate] may run on a fresh install or re-run after a later step
* fails, so avoid anything that breaks when applied twice. It runs on the splash
* path with no timeout, so keep it fast.
*
* Register via `@Provides @IntoSet` in AppMigrationModule and bump [CURRENT_APP_VERSION].
*/
interface AppMigration {
val startVersion: Int
val endVersion: Int
suspend fun migrate()
}

/**
* Validates the registered chain: every step consecutive, no duplicate start
* versions, and exactly [targetVersion] migrations covering `0 until targetVersion`.
* Throws [IllegalArgumentException] on any violation.
*/
fun validateMigrationChain(migrations: Set<AppMigration>, targetVersion: Int) {
migrations.forEach {
require(it.endVersion == it.startVersion + 1) {
"Migration ${it.startVersion} -> ${it.endVersion} is not strictly consecutive."
}
}
val startVersions = migrations.map { it.startVersion }
require(startVersions.toSet().size == startVersions.size) {
"Duplicate startVersion detected among registered migrations."
}
require(migrations.size == targetVersion) {
"Expected $targetVersion migrations to reach CURRENT_APP_VERSION but found ${migrations.size}."
}
for (version in 0 until targetVersion) {
require(version in startVersions) {
"No AppMigration registered from version $version."
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package net.primal.android.migration

import io.github.aakira.napier.Napier
import javax.inject.Inject
import javax.inject.Singleton
import net.primal.android.migration.di.CurrentAppVersion
import net.primal.core.utils.onFailure
import net.primal.core.utils.runCatching

/**
* Runs pending [AppMigration]s once at startup, persisting the version after each
* step so a failure resumes from the last good version on the next launch. Failures
* are logged and stop the chain rather than propagating (cancellation aside).
*/
@Singleton
class AppMigrationRunner @Inject constructor(
migrations: Set<@JvmSuppressWildcards AppMigration>,
private val versionStore: AppMigrationStore,
@CurrentAppVersion private val targetVersion: Int,
) {
private val byStart = migrations.associateBy { it.startVersion }

suspend fun runPendingMigrations() {
var version = 0
runCatching {
version = versionStore.currentVersion()
while (version < targetVersion) {
val step = byStart[version]
if (step == null) {
Napier.e { "No AppMigration registered from version $version; stopping chain." }
return@runCatching
}
step.migrate()
version = step.endVersion
versionStore.setVersion(version)
}
}.onFailure { error ->
Napier.e(throwable = error) {
"App migration failed at version $version; will retry next launch."
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package net.primal.android.migration

import androidx.datastore.core.DataStore
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.flow.first
import net.primal.android.migration.di.AppMigrationVersionDataStore

@Singleton
class AppMigrationStore @Inject constructor(
@AppMigrationVersionDataStore private val persistence: DataStore<String>,
) {
suspend fun currentVersion(): Int =
persistence.data.first().toIntOrNull() ?: 0

suspend fun setVersion(version: Int) =
persistence.updateData { version.toString() }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package net.primal.android.migration.di

import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.Multibinds
import net.primal.android.migration.AppMigration

/** Declares the [AppMigration] multibinding so an empty set injects while no migrations are registered. */
@Module
@InstallIn(SingletonComponent::class)
interface AppMigrationBindingsModule {

@Multibinds
fun appMigrations(): Set<AppMigration>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package net.primal.android.migration.di

import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.core.DataStoreFactory
import androidx.datastore.dataStoreFile
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Qualifier
import javax.inject.Singleton
import net.primal.android.core.serialization.datastore.StringSerializer
import net.primal.android.migration.CURRENT_APP_VERSION

@Module
@InstallIn(SingletonComponent::class)
object AppMigrationModule {

@Provides
@Singleton
@AppMigrationVersionDataStore
fun appMigrationVersionDataStore(@ApplicationContext context: Context): DataStore<String> =
DataStoreFactory.create(
produceFile = { context.dataStoreFile("app_migration_version.txt") },
serializer = StringSerializer(),
)

@Provides
@CurrentAppVersion
fun currentAppVersion(): Int = CURRENT_APP_VERSION

// Register migrations here with @Provides @IntoSet as the chain grows.
}

@Qualifier
@Target(AnnotationTarget.FUNCTION, AnnotationTarget.VALUE_PARAMETER)
annotation class AppMigrationVersionDataStore

@Qualifier
@Target(AnnotationTarget.FUNCTION, AnnotationTarget.VALUE_PARAMETER)
annotation class CurrentAppVersion
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import kotlin.time.Duration.Companion.seconds
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import net.primal.android.migration.AppMigrationRunner
import net.primal.android.user.accounts.active.ActiveAccountStore
import net.primal.android.user.credentials.CredentialsStore
import net.primal.core.config.AppConfigHandler
Expand All @@ -22,6 +23,7 @@ import net.primal.domain.nostr.cryptography.utils.hexToNpubHrp
class SplashViewModel @Inject constructor(
private val activeAccountStore: ActiveAccountStore,
private val appConfigHandler: AppConfigHandler,
private val appMigrationRunner: AppMigrationRunner,
private val credentialsStore: CredentialsStore,
private val feedsRepository: FeedsRepository,
) : ViewModel() {
Expand All @@ -37,12 +39,13 @@ class SplashViewModel @Inject constructor(
fun start(prefetchFeeds: Boolean) {
if (started) return
started = true
checkAuthState(prefetchFeeds = prefetchFeeds)
runStartupSequence(prefetchFeeds = prefetchFeeds)
fetchLatestAppConfig()
}

private fun checkAuthState(prefetchFeeds: Boolean) =
private fun runStartupSequence(prefetchFeeds: Boolean) =
viewModelScope.launch {
appMigrationRunner.runPendingMigrations()
val userId = activeAccountStore.activeUserId()
_isLoggedIn.value = userId.isNotEmpty()

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package net.primal.android.migration

import io.kotest.assertions.throwables.shouldThrow
import io.kotest.assertions.throwables.shouldNotThrowAny
import org.junit.Test

class AppMigrationChainTest {

private fun migration(start: Int, end: Int): AppMigration =
object : AppMigration {
override val startVersion = start
override val endVersion = end
override suspend fun migrate() = Unit
}

@Test
fun `empty set with target zero is valid`() {
shouldNotThrowAny {
validateMigrationChain(migrations = emptySet(), targetVersion = 0)
}
}

@Test
fun `consecutive chain covering zero until target is valid`() {
val chain = setOf(migration(0, 1), migration(1, 2), migration(2, 3))
shouldNotThrowAny {
validateMigrationChain(migrations = chain, targetVersion = 3)
}
}

@Test
fun `non consecutive migration is rejected`() {
val chain = setOf(migration(0, 2))
shouldThrow<IllegalArgumentException> {
validateMigrationChain(migrations = chain, targetVersion = 2)
}
}

@Test
fun `gap in start versions is rejected`() {
val chain = setOf(migration(0, 1), migration(2, 3))
shouldThrow<IllegalArgumentException> {
validateMigrationChain(migrations = chain, targetVersion = 3)
}
}

@Test
fun `gap in middle of covered range is rejected`() {
// size == target (3), all consecutive, distinct starts [0,1,3], but version 2 is missing.
val chain = setOf(migration(0, 1), migration(1, 2), migration(3, 4))
shouldThrow<IllegalArgumentException> {
validateMigrationChain(migrations = chain, targetVersion = 3)
}
}

@Test
fun `duplicate start version is rejected`() {
// Two distinct instances share startVersion 0; set union keeps both (identity-based).
val duplicated = setOf(migration(0, 1)) + setOf(migration(0, 1)) + setOf(migration(1, 2))
shouldThrow<IllegalArgumentException> {
validateMigrationChain(migrations = duplicated, targetVersion = 2)
}
}

@Test
fun `size not equal to target is rejected`() {
val chain = setOf(migration(0, 1))
shouldThrow<IllegalArgumentException> {
validateMigrationChain(migrations = chain, targetVersion = 2)
}
}
}
Loading
Loading