Remove runBlocking from common source sets and update coroutines for KMP

This commit is contained in:
Alinson S. Xavier 2026-04-08 07:24:28 -05:00
parent 9cf4ec417c
commit 36fad2491e
17 changed files with 85 additions and 42 deletions

View File

@ -17,7 +17,6 @@ junitJupiter = "5.10.1"
junitVersion = "4.13.2" junitVersion = "4.13.2"
konfetti-xml = "2.0.2" konfetti-xml = "2.0.2"
kotlin = "2.1.10" kotlin = "2.1.10"
kotlinxCoroutinesCoreCommon = "1.3.8"
ksp = "2.1.10-1.0.30" ksp = "2.1.10-1.0.30"
ktlint-plugin = "11.6.1" ktlint-plugin = "11.6.1"
ktor = "1.6.8" ktor = "1.6.8"
@ -54,8 +53,8 @@ konfetti-xml = { group = "nl.dionsegijn", name = "konfetti-xml", version.ref = "
kotlin-stdlib-jdk8 = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib-jdk8", version.ref = "kotlin" } kotlin-stdlib-jdk8 = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib-jdk8", version.ref = "kotlin" }
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "ktxCoroutine" } kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "ktxCoroutine" }
kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "ktxCoroutine" } kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "ktxCoroutine" }
kotlinx-coroutines-core-common = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core-common", version.ref = "kotlinxCoroutinesCoreCommon" }
kotlinx-coroutines-core-jvm = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm", version.ref = "ktxCoroutine" } kotlinx-coroutines-core-jvm = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm", version.ref = "ktxCoroutine" }
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "ktxCoroutine" }
ktor-client-android = { group = "io.ktor", name = "ktor-client-android", version.ref = "ktor" } ktor-client-android = { group = "io.ktor", name = "ktor-client-android", version.ref = "ktor" }
ktor-client-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor" } ktor-client-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor" }
ktor-client-jackson = { group = "io.ktor", name = "ktor-client-jackson", version.ref = "ktor" } ktor-client-jackson = { group = "io.ktor", name = "ktor-client-jackson", version.ref = "ktor" }

View File

@ -31,7 +31,8 @@ kotlin {
val commonMain by getting { val commonMain by getting {
dependencies { dependencies {
implementation(kotlin("stdlib-common")) implementation(kotlin("stdlib-common"))
implementation(libs.kotlinx.coroutines.core.common) implementation(libs.kotlinx.coroutines.core)
compileOnly(libs.kotlin.inject.runtime)
} }
} }
@ -39,13 +40,13 @@ kotlin {
dependencies { dependencies {
implementation(kotlin("test-common")) implementation(kotlin("test-common"))
implementation(kotlin("test-annotations-common")) implementation(kotlin("test-annotations-common"))
implementation(libs.kotlinx.coroutines.test)
} }
} }
val jvmMain by getting { val jvmMain by getting {
dependencies { dependencies {
implementation(kotlin("stdlib-jdk8")) implementation(kotlin("stdlib-jdk8"))
compileOnly(libs.kotlin.inject.runtime)
implementation(libs.guava) implementation(libs.guava)
implementation(libs.kotlinx.coroutines.core.jvm) implementation(libs.kotlinx.coroutines.core.jvm)
implementation(libs.annotation) implementation(libs.annotation)

View File

@ -0,0 +1,21 @@
/*
* Copyright (C) 2016-2025 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.platform
expect fun <T> runSuspend(block: suspend () -> T): T

View File

@ -18,8 +18,8 @@
*/ */
package org.isoron.uhabits.core.tasks package org.isoron.uhabits.core.tasks
import kotlinx.coroutines.runBlocking
import org.isoron.platform.io.UserFile import org.isoron.platform.io.UserFile
import org.isoron.platform.runSuspend
import org.isoron.platform.time.getToday import org.isoron.platform.time.getToday
import org.isoron.uhabits.core.io.HabitsCSVExporter import org.isoron.uhabits.core.io.HabitsCSVExporter
import org.isoron.uhabits.core.models.Habit import org.isoron.uhabits.core.models.Habit
@ -35,10 +35,10 @@ class ExportCSVTask(
override fun doInBackground() { override fun doInBackground() {
try { try {
val exporter = HabitsCSVExporter(habitList, selectedHabits) val exporter = HabitsCSVExporter(habitList, selectedHabits)
val bytes = runBlocking { exporter.writeArchive() } val bytes = runSuspend { exporter.writeArchive() }
val date = getToday().toCSVString() val date = getToday().toCSVString()
val zipFile = outputDir.resolve("Loop Habits CSV $date.zip") val zipFile = outputDir.resolve("Loop Habits CSV $date.zip")
runBlocking { zipFile.writeBytes(bytes) } runSuspend { zipFile.writeBytes(bytes) }
archiveFilename = zipFile.pathString archiveFilename = zipFile.pathString
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()

View File

@ -1,6 +1,6 @@
package org.isoron.platform.io package org.isoron.platform.io
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
@ -32,7 +32,7 @@ class MigrationTest {
} }
@Test @Test
fun testMigrateIdempotent() = runBlocking { fun testMigrateIdempotent() = runTest {
val db = TestDatabaseHelper.createEmptyDatabase() val db = TestDatabaseHelper.createEmptyDatabase()
val version = db.getVersion() val version = db.getVersion()
db.migrateTo(version) { v -> TestDatabaseHelper.loadMigrationSQL(v) } db.migrateTo(version) { v -> TestDatabaseHelper.loadMigrationSQL(v) }

View File

@ -18,7 +18,6 @@
*/ */
package org.isoron.uhabits.core package org.isoron.uhabits.core
import kotlinx.coroutines.runBlocking
import org.isoron.platform.io.Database import org.isoron.platform.io.Database
import org.isoron.platform.io.DatabaseOpener import org.isoron.platform.io.DatabaseOpener
import org.isoron.platform.io.FileOpener import org.isoron.platform.io.FileOpener
@ -26,6 +25,7 @@ import org.isoron.platform.io.TestDatabaseHelper
import org.isoron.platform.io.UserFile import org.isoron.platform.io.UserFile
import org.isoron.platform.io.createTestDatabaseOpener import org.isoron.platform.io.createTestDatabaseOpener
import org.isoron.platform.io.createTestFileOpener import org.isoron.platform.io.createTestFileOpener
import org.isoron.platform.runSuspend
import org.isoron.platform.time.LocalDate import org.isoron.platform.time.LocalDate
import org.isoron.platform.time.setToday import org.isoron.platform.time.setToday
import org.isoron.uhabits.core.commands.CommandRunner import org.isoron.uhabits.core.commands.CommandRunner
@ -56,20 +56,20 @@ open class BaseUnitTest {
commandRunner = CommandRunner(taskRunner) commandRunner = CommandRunner(taskRunner)
} }
protected fun createTempDir(): UserFile = runBlocking { protected fun createTempDir(): UserFile = runSuspend {
val dir = fileOpener.openUserFile("test-temp-dir-${tempFileCounter++}") val dir = fileOpener.openUserFile("test-temp-dir-${tempFileCounter++}")
dir.mkdirs() dir.mkdirs()
dir dir
} }
protected fun copyResourceToTempFile(resourcePath: String): UserFile = runBlocking { protected fun copyResourceToTempFile(resourcePath: String): UserFile = runSuspend {
val cleanPath = resourcePath.removePrefix("/") val cleanPath = resourcePath.removePrefix("/")
val tempFile = fileOpener.openUserFile("test-temp-${tempFileCounter++}") val tempFile = fileOpener.openUserFile("test-temp-${tempFileCounter++}")
fileOpener.openResourceFile(cleanPath).copyTo(tempFile) fileOpener.openResourceFile(cleanPath).copyTo(tempFile)
tempFile tempFile
} }
protected fun openDatabaseResource(resourcePath: String): Database = runBlocking { protected fun openDatabaseResource(resourcePath: String): Database = runSuspend {
val tempFile = copyResourceToTempFile(resourcePath) val tempFile = copyResourceToTempFile(resourcePath)
databaseOpener.open(tempFile.pathString) databaseOpener.open(tempFile.pathString)
} }

View File

@ -18,11 +18,11 @@
*/ */
package org.isoron.uhabits.core.database.migrations package org.isoron.uhabits.core.database.migrations
import kotlinx.coroutines.runBlocking
import org.isoron.platform.io.Database import org.isoron.platform.io.Database
import org.isoron.platform.io.migrateTo import org.isoron.platform.io.migrateTo
import org.isoron.platform.io.querySingle import org.isoron.platform.io.querySingle
import org.isoron.platform.io.run import org.isoron.platform.io.run
import org.isoron.platform.runSuspend
import org.isoron.uhabits.core.BaseUnitTest import org.isoron.uhabits.core.BaseUnitTest
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertContains import kotlin.test.assertContains
@ -37,7 +37,7 @@ class Version22Test : BaseUnitTest() {
db = openDatabaseResource("/databases/021.db") db = openDatabaseResource("/databases/021.db")
} }
private fun migrateTo(version: Int) = runBlocking { private fun migrateTo(version: Int) = runSuspend {
db.migrateTo(version) { v -> db.migrateTo(version) { v ->
val path = "migrations/%02d.sql".format(v) val path = "migrations/%02d.sql".format(v)
fileOpener.openResourceFile(path).lines().joinToString("\n") fileOpener.openResourceFile(path).lines().joinToString("\n")

View File

@ -19,10 +19,10 @@
package org.isoron.uhabits.core.database.migrations package org.isoron.uhabits.core.database.migrations
import kotlinx.coroutines.runBlocking
import org.isoron.platform.io.Database import org.isoron.platform.io.Database
import org.isoron.platform.io.migrateTo import org.isoron.platform.io.migrateTo
import org.isoron.platform.io.query import org.isoron.platform.io.query
import org.isoron.platform.runSuspend
import org.isoron.uhabits.core.BaseUnitTest import org.isoron.uhabits.core.BaseUnitTest
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
@ -36,7 +36,7 @@ class Version23Test : BaseUnitTest() {
db = openDatabaseResource("/databases/022.db") db = openDatabaseResource("/databases/022.db")
} }
private fun migrateTo(version: Int) = runBlocking { private fun migrateTo(version: Int) = runSuspend {
db.migrateTo(version) { v -> db.migrateTo(version) { v ->
val path = "migrations/%02d.sql".format(v) val path = "migrations/%02d.sql".format(v)
fileOpener.openResourceFile(path).lines().joinToString("\n") fileOpener.openResourceFile(path).lines().joinToString("\n")

View File

@ -18,7 +18,7 @@
*/ */
package org.isoron.uhabits.core.io package org.isoron.uhabits.core.io
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest
import org.isoron.platform.io.ZipReader import org.isoron.platform.io.ZipReader
import org.isoron.uhabits.core.BaseUnitTest import org.isoron.uhabits.core.BaseUnitTest
import org.isoron.uhabits.core.models.Habit import org.isoron.uhabits.core.models.Habit
@ -36,7 +36,7 @@ class HabitsCSVExporterTest : BaseUnitTest() {
} }
@Test @Test
fun testExportCSV() = runBlocking { fun testExportCSV() = runTest {
val selected: MutableList<Habit> = mutableListOf() val selected: MutableList<Habit> = mutableListOf()
for (h in habitList) selected.add(h) for (h in habitList) selected.add(h)
val exporter = HabitsCSVExporter(habitList, selected) val exporter = HabitsCSVExporter(habitList, selected)

View File

@ -18,7 +18,7 @@
*/ */
package org.isoron.uhabits.core.io package org.isoron.uhabits.core.io
import kotlinx.coroutines.runBlocking import org.isoron.platform.runSuspend
import org.isoron.platform.time.LocalDate import org.isoron.platform.time.LocalDate
import org.isoron.uhabits.core.BaseUnitTest import org.isoron.uhabits.core.BaseUnitTest
import org.isoron.uhabits.core.models.Entry import org.isoron.uhabits.core.models.Entry
@ -157,7 +157,7 @@ class ImportTest : BaseUnitTest() {
return h.originalEntries.get(LocalDate(year, month, day)).notes == notes return h.originalEntries.get(LocalDate(year, month, day)).notes == notes
} }
private fun importFromFile(assetFilename: String) = runBlocking { private fun importFromFile(assetFilename: String) = runSuspend {
val userFile = copyResourceToTempFile(assetFilename) val userFile = copyResourceToTempFile(assetFilename)
assertTrue(userFile.exists()) assertTrue(userFile.exists())
val importer = GenericImporter( val importer = GenericImporter(

View File

@ -26,8 +26,8 @@ import dev.mokkery.matcher.any
import dev.mokkery.mock import dev.mokkery.mock
import dev.mokkery.spy import dev.mokkery.spy
import dev.mokkery.verify import dev.mokkery.verify
import kotlinx.coroutines.runBlocking
import org.isoron.platform.io.UserFile import org.isoron.platform.io.UserFile
import org.isoron.platform.runSuspend
import org.isoron.platform.time.getToday import org.isoron.platform.time.getToday
import org.isoron.uhabits.core.BaseUnitTest import org.isoron.uhabits.core.BaseUnitTest
import org.isoron.uhabits.core.models.Entry import org.isoron.uhabits.core.models.Entry
@ -94,7 +94,7 @@ class ListHabitsBehaviorTest : BaseUnitTest() {
every { dirFinder.getCSVOutputDir() } returns outputDir every { dirFinder.getCSVOutputDir() } returns outputDir
behavior.onExportCSV() behavior.onExportCSV()
verify { screen.showSendFileScreen(any()) } verify { screen.showSendFileScreen(any()) }
val files = runBlocking { outputDir.listFiles() } val files = runSuspend { outputDir.listFiles() }
assertEquals(1, files!!.size) assertEquals(1, files!!.size)
} }

View File

@ -22,7 +22,7 @@ import dev.mokkery.answering.returns
import dev.mokkery.every import dev.mokkery.every
import dev.mokkery.mock import dev.mokkery.mock
import dev.mokkery.verify import dev.mokkery.verify
import kotlinx.coroutines.runBlocking import org.isoron.platform.runSuspend
import org.isoron.uhabits.core.BaseUnitTest import org.isoron.uhabits.core.BaseUnitTest
import org.isoron.uhabits.core.models.Habit import org.isoron.uhabits.core.models.Habit
import kotlin.test.Test import kotlin.test.Test
@ -60,7 +60,7 @@ class ShowHabitMenuPresenterTest : BaseUnitTest() {
val outputDir = createTempDir() val outputDir = createTempDir()
every { system.getCSVOutputDir() } returns outputDir every { system.getCSVOutputDir() } returns outputDir
menu.onExportCSV() menu.onExportCSV()
val files = runBlocking { outputDir.listFiles() } val files = runSuspend { outputDir.listFiles() }
assertEquals(1, files!!.size) assertEquals(1, files!!.size)
} }
} }

View File

@ -19,7 +19,7 @@
package org.isoron.uhabits.core.ui.views package org.isoron.uhabits.core.ui.views
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest
import org.isoron.platform.gui.assertRenders import org.isoron.platform.gui.assertRenders
import org.isoron.platform.io.createTestDateFormatter import org.isoron.platform.io.createTestDateFormatter
import org.isoron.platform.time.LocalDate import org.isoron.platform.time.LocalDate
@ -41,24 +41,24 @@ class BarChartTest {
} }
@Test @Test
fun testDraw() = runBlocking { fun testDraw() = runTest {
assertRenders(300, 200, "$base/base.png", component) assertRenders(300, 200, "$base/base.png", component)
} }
@Test @Test
fun testDrawDarkTheme() = runBlocking { fun testDrawDarkTheme() = runTest {
component.theme = DarkTheme() component.theme = DarkTheme()
assertRenders(300, 200, "$base/themeDark.png", component) assertRenders(300, 200, "$base/themeDark.png", component)
} }
@Test @Test
fun testDrawWidgetTheme() = runBlocking { fun testDrawWidgetTheme() = runTest {
component.theme = WidgetTheme() component.theme = WidgetTheme()
assertRenders(300, 200, "$base/themeWidget.png", component) assertRenders(300, 200, "$base/themeWidget.png", component)
} }
@Test @Test
fun testDrawWithOffset() = runBlocking { fun testDrawWithOffset() = runTest {
component.dataOffset = 5 component.dataOffset = 5
assertRenders(300, 200, "$base/offset.png", component) assertRenders(300, 200, "$base/offset.png", component)
} }

View File

@ -23,7 +23,7 @@ import dev.mokkery.mock
import dev.mokkery.resetCalls import dev.mokkery.resetCalls
import dev.mokkery.verify import dev.mokkery.verify
import dev.mokkery.verifyNoMoreCalls import dev.mokkery.verifyNoMoreCalls
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest
import org.isoron.platform.gui.assertRenders import org.isoron.platform.gui.assertRenders
import org.isoron.platform.io.createTestDateFormatter import org.isoron.platform.io.createTestDateFormatter
import org.isoron.platform.time.DayOfWeek import org.isoron.platform.time.DayOfWeek
@ -78,12 +78,12 @@ class HistoryChartTest {
) )
@Test @Test
fun testDraw() = runBlocking { fun testDraw() = runTest {
assertRenders(400, 200, "$base/base.png", view) assertRenders(400, 200, "$base/base.png", view)
} }
@Test @Test
fun testClick() = runBlocking { fun testClick() = runTest {
assertRenders(400, 200, "$base/base.png", view) assertRenders(400, 200, "$base/base.png", view)
// Click top left date // Click top left date
@ -114,7 +114,7 @@ class HistoryChartTest {
} }
@Test @Test
fun testLongClick() = runBlocking { fun testLongClick() = runTest {
assertRenders(400, 200, "$base/base.png", view) assertRenders(400, 200, "$base/base.png", view)
// Click top left date // Click top left date
@ -145,30 +145,30 @@ class HistoryChartTest {
} }
@Test @Test
fun testDrawWeekDay() = runBlocking { fun testDrawWeekDay() = runTest {
view.firstWeekday = DayOfWeek.MONDAY view.firstWeekday = DayOfWeek.MONDAY
assertRenders(400, 200, "$base/weekday.png", view) assertRenders(400, 200, "$base/weekday.png", view)
} }
@Test @Test
fun testDrawDifferentSize() = runBlocking { fun testDrawDifferentSize() = runTest {
assertRenders(200, 200, "$base/small.png", view) assertRenders(200, 200, "$base/small.png", view)
} }
@Test @Test
fun testDrawDarkTheme() = runBlocking { fun testDrawDarkTheme() = runTest {
view.theme = DarkTheme() view.theme = DarkTheme()
assertRenders(400, 200, "$base/themeDark.png", view) assertRenders(400, 200, "$base/themeDark.png", view)
} }
@Test @Test
fun testDrawWidgetTheme() = runBlocking { fun testDrawWidgetTheme() = runTest {
view.theme = WidgetTheme() view.theme = WidgetTheme()
assertRenders(400, 200, "$base/themeWidget.png", view) assertRenders(400, 200, "$base/themeWidget.png", view)
} }
@Test @Test
fun testDrawOffset() = runBlocking { fun testDrawOffset() = runTest {
view.dataOffset = 2 view.dataOffset = 2
assertRenders(400, 200, "$base/scroll.png", view) assertRenders(400, 200, "$base/scroll.png", view)
} }

View File

@ -1,6 +1,6 @@
package org.isoron.uhabits.core.utils package org.isoron.uhabits.core.utils
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest
import org.isoron.uhabits.core.BaseUnitTest import org.isoron.uhabits.core.BaseUnitTest
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertTrue import kotlin.test.assertTrue
@ -8,7 +8,7 @@ import kotlin.test.assertTrue
class FileExtensionsTest : BaseUnitTest() { class FileExtensionsTest : BaseUnitTest() {
@Test @Test
fun testIsSQLite3File() = runBlocking { fun testIsSQLite3File() = runTest {
val userFile = copyResourceToTempFile("loop.db") val userFile = copyResourceToTempFile("loop.db")
val isSqlite3File = isSQLite3File(userFile) val isSqlite3File = isSQLite3File(userFile)
assertTrue(isSqlite3File) assertTrue(isSqlite3File)

View File

@ -0,0 +1,23 @@
/*
* Copyright (C) 2016-2025 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.platform
import kotlinx.coroutines.runBlocking
actual fun <T> runSuspend(block: suspend () -> T): T = runBlocking { block() }

View File

@ -57,5 +57,4 @@ class MidnightTimerTest : BaseUnitTest() {
assertEquals(true, suspendedListener) assertEquals(true, suspendedListener)
} }
} }
} }