Migrator improvements (#588)

This commit is contained in:
Andreas 2024-03-28 19:36:33 +01:00 committed by GitHub
parent 666d6aa117
commit 0265c16eb2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 284 additions and 116 deletions

View file

@ -254,6 +254,8 @@ dependencies {
// For detecting memory leaks; see https://square.github.io/leakcanary/
// debugImplementation(libs.leakcanary.android)
implementation(libs.leakcanary.plumber)
testImplementation(kotlinx.coroutines.test)
}
androidComponents {

View file

@ -50,8 +50,12 @@ import kotlinx.coroutines.flow.onEach
import logcat.AndroidLogcatLogger
import logcat.LogPriority
import logcat.LogcatLogger
import mihon.core.migration.Migrator
import mihon.core.migration.migrations.migrations
import org.conscrypt.Conscrypt
import tachiyomi.core.common.i18n.stringResource
import tachiyomi.core.common.preference.Preference
import tachiyomi.core.common.preference.PreferenceStore
import tachiyomi.core.common.util.system.logcat
import tachiyomi.i18n.MR
import tachiyomi.presentation.widget.WidgetManager
@ -131,6 +135,23 @@ class App : Application(), DefaultLifecycleObserver, SingletonImageLoader.Factor
if (!LogcatLogger.isInstalled && networkPreferences.verboseLogging().get()) {
LogcatLogger.install(AndroidLogcatLogger(LogPriority.VERBOSE))
}
initializeMigrator()
}
private fun initializeMigrator() {
val preferenceStore = Injekt.get<PreferenceStore>()
val preference = preferenceStore.getInt(Preference.appStateKey("last_version_code"), 0)
logcat { "Migration from ${preference.get()} to ${BuildConfig.VERSION_CODE}" }
Migrator.initialize(
old = preference.get(),
new = BuildConfig.VERSION_CODE,
migrations = migrations,
onMigrationComplete = {
logcat { "Updating last version to ${BuildConfig.VERSION_CODE}" }
preference.set(BuildConfig.VERSION_CODE)
},
)
}
override fun newImageLoader(context: Context): ImageLoader {

View file

@ -87,10 +87,7 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import logcat.LogPriority
import mihon.core.migration.Migrator
import mihon.core.migration.migrations.migrations
import tachiyomi.core.common.Constants
import tachiyomi.core.common.preference.Preference
import tachiyomi.core.common.preference.PreferenceStore
import tachiyomi.core.common.util.lang.launchIO
import tachiyomi.core.common.util.system.logcat
import tachiyomi.domain.library.service.LibraryPreferences
@ -99,8 +96,6 @@ import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.util.collectAsState
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
import androidx.compose.ui.graphics.Color.Companion as ComposeColor
@ -129,7 +124,7 @@ class MainActivity : BaseActivity() {
super.onCreate(savedInstanceState)
val didMigration = migrate()
val didMigration = Migrator.awaitAndRelease()
// Do not let the launcher create a new activity http://stackoverflow.com/questions/16283079
if (!isTaskRoot) {
@ -340,21 +335,6 @@ class MainActivity : BaseActivity() {
}
}
private fun migrate(): Boolean {
val preferenceStore = Injekt.get<PreferenceStore>()
val preference = preferenceStore.getInt(Preference.appStateKey("last_version_code"), 0)
logcat { "Migration from ${preference.get()} to ${BuildConfig.VERSION_CODE}" }
return Migrator.migrate(
old = preference.get(),
new = BuildConfig.VERSION_CODE,
migrations = migrations,
onMigrationComplete = {
logcat { "Updating last version to ${BuildConfig.VERSION_CODE}" }
preference.set(BuildConfig.VERSION_CODE)
},
)
}
/**
* Sets custom splash screen exit animation on devices prior to Android 12.
*

View file

@ -5,6 +5,9 @@ interface Migration {
suspend operator fun invoke(migrationContext: MigrationContext): Boolean
val isAlways: Boolean
get() = version == ALWAYS
companion object {
const val ALWAYS = -1f

View file

@ -0,0 +1,3 @@
package mihon.core.migration
typealias MigrationCompletedListener = () -> Unit

View file

@ -2,7 +2,7 @@ package mihon.core.migration
import uy.kohesive.injekt.Injekt
class MigrationContext {
class MigrationContext(val dryrun: Boolean) {
inline fun <reified T> get(): T? {
return Injekt.getInstanceOrNull(T::class.java)

View file

@ -0,0 +1,30 @@
package mihon.core.migration
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async
import tachiyomi.core.common.util.system.logcat
class MigrationJobFactory(
private val migrationContext: MigrationContext,
private val scope: CoroutineScope
) {
@SuppressWarnings("MaxLineLength")
fun create(migrations: List<Migration>): Deferred<Boolean> = with(scope) {
return migrations.sortedBy { it.version }
.fold(CompletableDeferred(true)) { acc: Deferred<Boolean>, migration: Migration ->
if (!migrationContext.dryrun) {
logcat { "Running migration: { name = ${migration::class.simpleName}, version = ${migration.version} }" }
async {
val prev = acc.await()
migration(migrationContext) || prev
}
} else {
logcat { "(Dry-run) Running migration: { name = ${migration::class.simpleName}, version = ${migration.version} }" }
CompletableDeferred(true)
}
}
}
}

View file

@ -0,0 +1,55 @@
package mihon.core.migration
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.launch
interface MigrationStrategy {
operator fun invoke(migrations: List<Migration>): Deferred<Boolean>
}
class DefaultMigrationStrategy(
private val migrationJobFactory: MigrationJobFactory,
private val migrationCompletedListener: MigrationCompletedListener,
private val scope: CoroutineScope
) : MigrationStrategy {
override operator fun invoke(migrations: List<Migration>): Deferred<Boolean> = with(scope) {
if (migrations.isEmpty()) {
return@with CompletableDeferred(false)
}
val chain = migrationJobFactory.create(migrations)
launch {
if (chain.await()) migrationCompletedListener()
}.start()
chain
}
}
class InitialMigrationStrategy(private val strategy: DefaultMigrationStrategy) : MigrationStrategy {
override operator fun invoke(migrations: List<Migration>): Deferred<Boolean> {
return strategy(migrations.filter { it.isAlways })
}
}
class NoopMigrationStrategy(val state: Boolean) : MigrationStrategy {
override fun invoke(migrations: List<Migration>): Deferred<Boolean> {
return CompletableDeferred(state)
}
}
class VersionRangeMigrationStrategy(
private val versions: IntRange,
private val strategy: DefaultMigrationStrategy
) : MigrationStrategy {
override operator fun invoke(migrations: List<Migration>): Deferred<Boolean> {
return strategy(migrations.filter { it.isAlways || it.version.toInt() in versions })
}
}

View file

@ -0,0 +1,23 @@
package mihon.core.migration
class MigrationStrategyFactory(
private val factory: MigrationJobFactory,
private val migrationCompletedListener: MigrationCompletedListener,
) {
fun create(old: Int, new: Int): MigrationStrategy {
val versions = (old + 1)..new
val strategy = when {
old == 0 -> InitialMigrationStrategy(
strategy = DefaultMigrationStrategy(factory, migrationCompletedListener, Migrator.scope),
)
old >= new -> NoopMigrationStrategy(false)
else -> VersionRangeMigrationStrategy(
versions = versions,
strategy = DefaultMigrationStrategy(factory, migrationCompletedListener, Migrator.scope),
)
}
return strategy
}
}

View file

@ -1,53 +1,41 @@
package mihon.core.migration
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.runBlocking
import tachiyomi.core.common.util.system.logcat
object Migrator {
@SuppressWarnings("ReturnCount")
fun migrate(
private var result: Deferred<Boolean>? = null
val scope = CoroutineScope(Dispatchers.Main + Job())
fun initialize(
old: Int,
new: Int,
migrations: List<Migration>,
dryrun: Boolean = false,
onMigrationComplete: () -> Unit
): Boolean {
val migrationContext = MigrationContext()
if (old == 0) {
return migrationContext.migrate(
migrations = migrations.filter { it.isAlways() },
dryrun = dryrun
)
.also { onMigrationComplete() }
}
if (old >= new) {
return false
}
return migrationContext.migrate(
migrations = migrations.filter { it.isAlways() || it.version.toInt() in (old + 1)..new },
dryrun = dryrun
)
.also { onMigrationComplete() }
) {
val migrationContext = MigrationContext(dryrun)
val migrationJobFactory = MigrationJobFactory(migrationContext, scope)
val migrationStrategyFactory = MigrationStrategyFactory(migrationJobFactory, onMigrationComplete)
val strategy = migrationStrategyFactory.create(old, new)
result = strategy(migrations)
}
private fun Migration.isAlways() = version == Migration.ALWAYS
suspend fun await(): Boolean {
val result = result ?: CompletableDeferred(false)
return result.await()
}
@SuppressWarnings("MaxLineLength")
private fun MigrationContext.migrate(migrations: List<Migration>, dryrun: Boolean): Boolean {
return migrations.sortedBy { it.version }
.map { migration ->
if (!dryrun) {
logcat { "Running migration: { name = ${migration::class.simpleName}, version = ${migration.version} }" }
runBlocking { migration(this@migrate) }
} else {
logcat { "(Dry-run) Running migration: { name = ${migration::class.simpleName}, version = ${migration.version} }" }
true
}
}
.reduce { acc, b -> acc || b }
fun release() {
result = null
}
fun awaitAndRelease(): Boolean = runBlocking {
await().also { release() }
}
}

View file

@ -1,59 +1,97 @@
package mihon.core.migration
import io.mockk.Called
import io.mockk.slot
import io.mockk.spyk
import io.mockk.verify
import org.junit.jupiter.api.Assertions
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.newSingleThreadContext
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertInstanceOf
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
class MigratorTest {
@Test
fun initialVersion() {
val onMigrationComplete: () -> Unit = {}
val onMigrationCompleteSpy = spyk(onMigrationComplete)
val didMigration = Migrator.migrate(
old = 0,
new = 1,
migrations = listOf(Migration.of(Migration.ALWAYS) { true }, Migration.of(2f) { false }),
onMigrationComplete = onMigrationCompleteSpy
)
verify { onMigrationCompleteSpy() }
Assertions.assertTrue(didMigration)
lateinit var migrationCompletedListener: MigrationCompletedListener
lateinit var migrationContext: MigrationContext
lateinit var migrationJobFactory: MigrationJobFactory
lateinit var migrationStrategyFactory: MigrationStrategyFactory
@BeforeEach
fun initilize() {
migrationContext = MigrationContext(false)
migrationJobFactory = spyk(MigrationJobFactory(migrationContext, CoroutineScope(Dispatchers.Main + Job())))
migrationCompletedListener = spyk<() -> Unit>({})
migrationStrategyFactory = spyk(MigrationStrategyFactory(migrationJobFactory, migrationCompletedListener))
}
@Test
fun sameVersion() {
val onMigrationComplete: () -> Unit = {}
val onMigrationCompleteSpy = spyk(onMigrationComplete)
val didMigration = Migrator.migrate(
old = 1,
new = 1,
migrations = listOf(Migration.of(Migration.ALWAYS) { true }, Migration.of(2f) { true }),
onMigrationComplete = onMigrationCompleteSpy
)
verify { onMigrationCompleteSpy wasNot Called }
Assertions.assertFalse(didMigration)
fun initialVersion() = runBlocking {
val strategy = migrationStrategyFactory.create(0, 1)
assertInstanceOf(InitialMigrationStrategy::class.java, strategy)
val migrations = slot<List<Migration>>()
val execute = strategy(listOf(Migration.of(Migration.ALWAYS) { true }, Migration.of(2f) { false }))
execute.await()
verify { migrationJobFactory.create(capture(migrations)) }
assertEquals(1, migrations.captured.size)
verify { migrationCompletedListener() }
}
@Test
fun smallMigration() {
val onMigrationComplete: () -> Unit = {}
val onMigrationCompleteSpy = spyk(onMigrationComplete)
val didMigration = Migrator.migrate(
old = 1,
new = 2,
migrations = listOf(Migration.of(Migration.ALWAYS) { true }, Migration.of(2f) { true }),
onMigrationComplete = onMigrationCompleteSpy
)
verify { onMigrationCompleteSpy() }
Assertions.assertTrue(didMigration)
fun sameVersion() = runBlocking {
val strategy = migrationStrategyFactory.create(1, 1)
assertInstanceOf(NoopMigrationStrategy::class.java, strategy)
val execute = strategy(listOf(Migration.of(Migration.ALWAYS) { true }, Migration.of(2f) { false }))
val result = execute.await()
assertFalse(result)
verify { migrationJobFactory.create(any()) wasNot Called }
}
@Test
fun largeMigration() {
val onMigrationComplete: () -> Unit = {}
val onMigrationCompleteSpy = spyk(onMigrationComplete)
fun noMigrations() = runBlocking {
val strategy = migrationStrategyFactory.create(1, 2)
assertInstanceOf(VersionRangeMigrationStrategy::class.java, strategy)
val execute = strategy(emptyList())
val result = execute.await()
assertFalse(result)
verify { migrationJobFactory.create(any()) wasNot Called }
}
@Test
fun smallMigration() = runBlocking {
val strategy = migrationStrategyFactory.create(1, 2)
assertInstanceOf(VersionRangeMigrationStrategy::class.java, strategy)
val migrations = slot<List<Migration>>()
val execute = strategy(listOf(Migration.of(Migration.ALWAYS) { true }, Migration.of(2f) { true }))
execute.await()
verify { migrationJobFactory.create(capture(migrations)) }
assertEquals(2, migrations.captured.size)
verify { migrationCompletedListener() }
}
@Test
fun largeMigration() = runBlocking {
val input = listOf(
Migration.of(Migration.ALWAYS) { true },
Migration.of(2f) { true },
@ -66,31 +104,56 @@ class MigratorTest {
Migration.of(9f) { true },
Migration.of(10f) { true },
)
val didMigration = Migrator.migrate(
old = 1,
new = 10,
migrations = input,
onMigrationComplete = onMigrationCompleteSpy
)
verify { onMigrationCompleteSpy() }
Assertions.assertTrue(didMigration)
val strategy = migrationStrategyFactory.create(1, 10)
assertInstanceOf(VersionRangeMigrationStrategy::class.java, strategy)
val migrations = slot<List<Migration>>()
val execute = strategy(input)
execute.await()
verify { migrationJobFactory.create(capture(migrations)) }
assertEquals(10, migrations.captured.size)
verify { migrationCompletedListener() }
}
@Test
fun withinRangeMigration() {
val onMigrationComplete: () -> Unit = {}
val onMigrationCompleteSpy = spyk(onMigrationComplete)
val didMigration = Migrator.migrate(
old = 1,
new = 2,
migrations = listOf(
fun withinRangeMigration() = runBlocking {
val strategy = migrationStrategyFactory.create(1, 2)
assertInstanceOf(VersionRangeMigrationStrategy::class.java, strategy)
val migrations = slot<List<Migration>>()
val execute = strategy(
listOf(
Migration.of(Migration.ALWAYS) { true },
Migration.of(2f) { true },
Migration.of(3f) { false }
),
onMigrationComplete = onMigrationCompleteSpy
)
)
verify { onMigrationCompleteSpy() }
Assertions.assertTrue(didMigration)
execute.await()
verify { migrationJobFactory.create(capture(migrations)) }
assertEquals(2, migrations.captured.size)
verify { migrationCompletedListener() }
}
companion object {
val mainThreadSurrogate = newSingleThreadContext("UI thread")
@BeforeAll
@JvmStatic
fun setUp() {
Dispatchers.setMain(mainThreadSurrogate)
}
@AfterAll
@JvmStatic
fun tearDown() {
Dispatchers.resetMain() // reset the main dispatcher to the original Main dispatcher
mainThreadSurrogate.close()
}
}
}