Migrator improvements (#588)

(cherry picked from commit 0265c16eb239518d52b7e9fb4200b5b003418d5d)

# Conflicts:
#	app/build.gradle.kts
#	app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt
This commit is contained in:
Andreas 2024-03-28 19:36:33 +01:00 committed by Jobobby04
parent a657c65261
commit a6c4f01c74
12 changed files with 301 additions and 132 deletions

View File

@ -269,6 +269,8 @@ dependencies {
// debugImplementation(libs.leakcanary.android)
implementation(libs.leakcanary.plumber)
testImplementation(kotlinx.coroutines.test)
// SY -->
// Text distance (EH)
implementation(sylibs.simularity)

View File

@ -72,8 +72,12 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
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.domain.storage.service.StorageManager
import tachiyomi.i18n.MR
@ -175,6 +179,25 @@ class App : Application(), DefaultLifecycleObserver, SingletonImageLoader.Factor
) {
SyncDataJob.startNow(this@App)
}
initializeMigrator()
}
private fun initializeMigrator() {
val preferenceStore = Injekt.get<PreferenceStore>()
// SY -->
val preference = preferenceStore.getInt(Preference.appStateKey("eh_last_version_code"), 0)
// SY <--
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

@ -95,10 +95,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.UnsortedPreferences
@ -106,8 +103,6 @@ import tachiyomi.domain.library.service.LibraryPreferences
import tachiyomi.domain.release.interactor.GetApplicationRelease
import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.util.collectAsState
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
import java.util.LinkedList
import androidx.compose.ui.graphics.Color.Companion as ComposeColor
@ -164,7 +159,7 @@ class MainActivity : BaseActivity() {
val didMigration = if (isLaunch) {
addAnalytics()
migrate()
Migrator.awaitAndRelease()
} else {
false
}
@ -408,23 +403,6 @@ class MainActivity : BaseActivity() {
}
}
private fun migrate(): Boolean {
val preferenceStore = Injekt.get<PreferenceStore>()
// SY -->
val preference = preferenceStore.getInt(Preference.appStateKey("eh_last_version_code"), 0)
// SY <--
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

@ -19,6 +19,9 @@ import exh.source.nHentaiSourceIds
import exh.util.jobScheduler
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.protobuf.schema.ProtoBufSchemaGenerator
import mihon.core.migration.MigrationContext
import mihon.core.migration.MigrationJobFactory
import mihon.core.migration.MigrationStrategyFactory
import mihon.core.migration.Migrator
import mihon.core.migration.migrations.migrations
import tachiyomi.data.DatabaseHandler
@ -47,22 +50,20 @@ object DebugFunctions {
private val getSearchMetadata: GetSearchMetadata by injectLazy()
private val getAllManga: GetAllManga by injectLazy()
fun forceUpgradeMigration() {
Migrator.migrate(
old = 1,
new = BuildConfig.VERSION_CODE,
migrations = migrations,
onMigrationComplete = {}
)
fun forceUpgradeMigration(): Boolean {
val migrationContext = MigrationContext(dryrun = false)
val migrationJobFactory = MigrationJobFactory(migrationContext, Migrator.scope)
val migrationStrategyFactory = MigrationStrategyFactory(migrationJobFactory, {})
val strategy = migrationStrategyFactory.create(1, BuildConfig.VERSION_CODE)
return runBlocking { strategy(migrations).await() }
}
fun forceSetupJobs() {
Migrator.migrate(
old = 0,
new = BuildConfig.VERSION_CODE,
migrations = migrations,
onMigrationComplete = {}
)
fun forceSetupJobs(): Boolean {
val migrationContext = MigrationContext(dryrun = false)
val migrationJobFactory = MigrationJobFactory(migrationContext, Migrator.scope)
val migrationStrategyFactory = MigrationStrategyFactory(migrationJobFactory, {})
val strategy = migrationStrategyFactory.create(0, BuildConfig.VERSION_CODE)
return runBlocking { strategy(migrations).await() }
}
fun resetAgedFlagInEXHManga() {

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() }
) {
val migrationContext = MigrationContext(dryrun)
val migrationJobFactory = MigrationJobFactory(migrationContext, scope)
val migrationStrategyFactory = MigrationStrategyFactory(migrationJobFactory, onMigrationComplete)
val strategy = migrationStrategyFactory.create(old, new)
result = strategy(migrations)
}
if (old >= new) {
return false
suspend fun await(): Boolean {
val result = result ?: CompletableDeferred(false)
return result.await()
}
return migrationContext.migrate(
migrations = migrations.filter { it.isAlways() || it.version.toInt() in (old + 1)..new },
dryrun = dryrun
)
.also { onMigrationComplete() }
fun release() {
result = null
}
private fun Migration.isAlways() = version == Migration.ALWAYS
@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 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()
}
}
}