diff --git a/uhabits-android/src/main/java/org/isoron/platform/io/AndroidFiles.kt b/uhabits-android/src/main/java/org/isoron/platform/io/AndroidFiles.kt new file mode 100644 index 00000000..93c54947 --- /dev/null +++ b/uhabits-android/src/main/java/org/isoron/platform/io/AndroidFiles.kt @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2016-2025 Álinson Santos Xavier + * + * 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 . + */ +package org.isoron.platform.io + +import android.content.res.AssetManager +import android.graphics.BitmapFactory +import org.isoron.platform.gui.AndroidImage +import org.isoron.platform.gui.Image +import java.io.File +import java.io.IOException + +class AndroidResourceFile( + private val assets: AssetManager, + private val path: String +) : ResourceFile { + override suspend fun lines(): List { + return assets.open(path).bufferedReader().readLines() + } + + override suspend fun exists(): Boolean { + return try { + assets.open(path).close() + true + } catch (e: IOException) { + false + } + } + + override suspend fun copyTo(dest: UserFile) { + val bytes = assets.open(path).use { it.readBytes() } + dest.writeBytes(bytes) + } + + override suspend fun toImage(): Image { + val bitmap = assets.open(path).use { BitmapFactory.decodeStream(it) } + return AndroidImage(bitmap) + } +} + +class AndroidFileOpener( + private val assets: AssetManager, + private val filesDir: File +) : FileOpener { + override fun openResourceFile(path: String): ResourceFile { + return AndroidResourceFile(assets, path) + } + + override fun openUserFile(path: String): UserFile { + return JavaUserFile(File(filesDir, path).toPath()) + } +} diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/HabitsDatabaseOpener.kt b/uhabits-android/src/main/java/org/isoron/uhabits/HabitsDatabaseOpener.kt index f6d4a68a..35058ebf 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/HabitsDatabaseOpener.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/HabitsDatabaseOpener.kt @@ -22,6 +22,7 @@ package org.isoron.uhabits import android.content.Context import android.database.sqlite.SQLiteDatabase import android.database.sqlite.SQLiteOpenHelper +import kotlinx.coroutines.runBlocking import org.isoron.platform.io.migrateTo import org.isoron.platform.io.setVersion import org.isoron.uhabits.core.database.UnsupportedDatabaseVersionException @@ -53,9 +54,11 @@ class HabitsDatabaseOpener( if (db.version < 8) throw UnsupportedDatabaseVersionException() val wrappedDb = AndroidDatabase(db) wrappedDb.setVersion(db.version) - wrappedDb.migrateTo(newVersion) { version -> - val filename = "%02d.sql".format(version) - context.assets.open("migrations/$filename").bufferedReader().readText() + runBlocking { + wrappedDb.migrateTo(newVersion) { version -> + val filename = "%02d.sql".format(version) + context.assets.open("migrations/$filename").bufferedReader().readText() + } } } diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/activities/HabitsDirFinder.kt b/uhabits-android/src/main/java/org/isoron/uhabits/activities/HabitsDirFinder.kt index 2003306f..293246ff 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/activities/HabitsDirFinder.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/activities/HabitsDirFinder.kt @@ -22,14 +22,13 @@ import me.tatarka.inject.annotations.Inject import org.isoron.uhabits.AndroidDirFinder import org.isoron.uhabits.core.ui.screens.habits.list.ListHabitsBehavior import org.isoron.uhabits.core.ui.screens.habits.show.ShowHabitMenuPresenter -import java.io.File @Inject class HabitsDirFinder( private val androidDirFinder: AndroidDirFinder ) : ShowHabitMenuPresenter.System, ListHabitsBehavior.DirFinder { - override fun getCSVOutputDir(): File { - return androidDirFinder.getFilesDir("CSV")!! + override fun getCSVOutputDir(): String { + return androidDirFinder.getFilesDir("CSV")!!.absolutePath } } diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsScreen.kt b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsScreen.kt index d03c8cbd..16891c9f 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsScreen.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsScreen.kt @@ -30,6 +30,7 @@ import nl.dionsegijn.konfetti.core.Party import nl.dionsegijn.konfetti.core.Position import nl.dionsegijn.konfetti.core.emitter.Emitter import org.isoron.platform.gui.toInt +import org.isoron.platform.io.JavaUserFile import org.isoron.uhabits.R import org.isoron.uhabits.activities.common.dialogs.CheckmarkDialog import org.isoron.uhabits.activities.common.dialogs.ColorPickerDialogFactory @@ -137,7 +138,7 @@ class ListHabitsScreen( val cacheDir = activity.externalCacheDir val tempFile = File.createTempFile("import", "", cacheDir) inStream.copyTo(tempFile) - onImportData(tempFile) { tempFile.delete() } + onImportData(JavaUserFile(tempFile.toPath())) { tempFile.delete() } } catch (e: IOException) { activity.showMessage(activity.resources.getString(R.string.could_not_import)) e.printStackTrace() @@ -341,7 +342,7 @@ class ListHabitsScreen( } } - private fun onImportData(file: File, onFinished: () -> Unit) { + private fun onImportData(file: org.isoron.platform.io.UserFile, onFinished: () -> Unit) { taskRunner.execute( importTaskFactory.create(file) { result -> when (result) { diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/inject/HabitsApplicationComponent.kt b/uhabits-android/src/main/java/org/isoron/uhabits/inject/HabitsApplicationComponent.kt index ad368220..f293d4cc 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/inject/HabitsApplicationComponent.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/inject/HabitsApplicationComponent.kt @@ -21,7 +21,9 @@ package org.isoron.uhabits.inject import android.content.Context import me.tatarka.inject.annotations.Component import me.tatarka.inject.annotations.Provides +import org.isoron.platform.io.AndroidFileOpener import org.isoron.platform.io.DatabaseOpener +import org.isoron.platform.io.FileOpener import org.isoron.uhabits.core.AppScope import org.isoron.uhabits.core.commands.CommandRunner import org.isoron.uhabits.core.io.GenericImporter @@ -136,4 +138,9 @@ abstract class HabitsApplicationComponent( @AppScope @Provides open fun taskRunner(): TaskRunner = AndroidTaskRunner() + + @AppScope + @Provides + open fun fileOpener(): FileOpener = + AndroidFileOpener(appContext.assets, appContext.filesDir) } diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/tasks/ImportDataTask.kt b/uhabits-android/src/main/java/org/isoron/uhabits/tasks/ImportDataTask.kt index 794a9597..699ba99b 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/tasks/ImportDataTask.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/tasks/ImportDataTask.kt @@ -19,18 +19,19 @@ package org.isoron.uhabits.tasks import android.util.Log +import kotlinx.coroutines.runBlocking +import org.isoron.platform.io.UserFile import org.isoron.platform.io.begin import org.isoron.platform.io.commit import org.isoron.uhabits.core.io.GenericImporter import org.isoron.uhabits.core.models.ModelFactory import org.isoron.uhabits.core.models.sqlite.SQLModelFactory import org.isoron.uhabits.core.tasks.Task -import java.io.File class ImportDataTask( private val importer: GenericImporter, modelFactory: ModelFactory, - private val file: File, + private val file: UserFile, private val listener: Listener ) : Task { private var result = 0 @@ -38,13 +39,15 @@ class ImportDataTask( override fun doInBackground() { modelFactory.database.begin() try { - if (importer.canHandle(file)) { - importer.importHabitsFromFile(file) - result = SUCCESS - modelFactory.database.commit() - } else { - result = NOT_RECOGNIZED - modelFactory.database.commit() + runBlocking { + if (importer.canHandle(file)) { + importer.importHabitsFromFile(file) + result = SUCCESS + modelFactory.database.commit() + } else { + result = NOT_RECOGNIZED + modelFactory.database.commit() + } } } catch (e: Exception) { result = FAILED diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/tasks/ImportDataTaskFactory.kt b/uhabits-android/src/main/java/org/isoron/uhabits/tasks/ImportDataTaskFactory.kt index 66638e8e..c18cb0ec 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/tasks/ImportDataTaskFactory.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/tasks/ImportDataTaskFactory.kt @@ -20,15 +20,15 @@ package org.isoron.uhabits.tasks import me.tatarka.inject.annotations.Inject +import org.isoron.platform.io.UserFile import org.isoron.uhabits.core.io.GenericImporter import org.isoron.uhabits.core.models.ModelFactory -import java.io.File @Inject class ImportDataTaskFactory( private val importer: GenericImporter, private val modelFactory: ModelFactory ) { - fun create(file: File, listener: ImportDataTask.Listener) = + fun create(file: UserFile, listener: ImportDataTask.Listener) = ImportDataTask(importer, modelFactory, file, listener) } diff --git a/uhabits-core/src/jvmMain/resources/migrations/09.sql b/uhabits-core/assets/main/migrations/09.sql similarity index 100% rename from uhabits-core/src/jvmMain/resources/migrations/09.sql rename to uhabits-core/assets/main/migrations/09.sql diff --git a/uhabits-core/src/jvmMain/resources/migrations/10.sql b/uhabits-core/assets/main/migrations/10.sql similarity index 100% rename from uhabits-core/src/jvmMain/resources/migrations/10.sql rename to uhabits-core/assets/main/migrations/10.sql diff --git a/uhabits-core/src/jvmMain/resources/migrations/11.sql b/uhabits-core/assets/main/migrations/11.sql similarity index 100% rename from uhabits-core/src/jvmMain/resources/migrations/11.sql rename to uhabits-core/assets/main/migrations/11.sql diff --git a/uhabits-core/src/jvmMain/resources/migrations/12.sql b/uhabits-core/assets/main/migrations/12.sql similarity index 100% rename from uhabits-core/src/jvmMain/resources/migrations/12.sql rename to uhabits-core/assets/main/migrations/12.sql diff --git a/uhabits-core/src/jvmMain/resources/migrations/13.sql b/uhabits-core/assets/main/migrations/13.sql similarity index 100% rename from uhabits-core/src/jvmMain/resources/migrations/13.sql rename to uhabits-core/assets/main/migrations/13.sql diff --git a/uhabits-core/src/jvmMain/resources/migrations/14.sql b/uhabits-core/assets/main/migrations/14.sql similarity index 100% rename from uhabits-core/src/jvmMain/resources/migrations/14.sql rename to uhabits-core/assets/main/migrations/14.sql diff --git a/uhabits-core/src/jvmMain/resources/migrations/15.sql b/uhabits-core/assets/main/migrations/15.sql similarity index 100% rename from uhabits-core/src/jvmMain/resources/migrations/15.sql rename to uhabits-core/assets/main/migrations/15.sql diff --git a/uhabits-core/src/jvmMain/resources/migrations/16.sql b/uhabits-core/assets/main/migrations/16.sql similarity index 100% rename from uhabits-core/src/jvmMain/resources/migrations/16.sql rename to uhabits-core/assets/main/migrations/16.sql diff --git a/uhabits-core/src/jvmMain/resources/migrations/17.sql b/uhabits-core/assets/main/migrations/17.sql similarity index 100% rename from uhabits-core/src/jvmMain/resources/migrations/17.sql rename to uhabits-core/assets/main/migrations/17.sql diff --git a/uhabits-core/src/jvmMain/resources/migrations/18.sql b/uhabits-core/assets/main/migrations/18.sql similarity index 100% rename from uhabits-core/src/jvmMain/resources/migrations/18.sql rename to uhabits-core/assets/main/migrations/18.sql diff --git a/uhabits-core/src/jvmMain/resources/migrations/19.sql b/uhabits-core/assets/main/migrations/19.sql similarity index 100% rename from uhabits-core/src/jvmMain/resources/migrations/19.sql rename to uhabits-core/assets/main/migrations/19.sql diff --git a/uhabits-core/src/jvmMain/resources/migrations/20.sql b/uhabits-core/assets/main/migrations/20.sql similarity index 100% rename from uhabits-core/src/jvmMain/resources/migrations/20.sql rename to uhabits-core/assets/main/migrations/20.sql diff --git a/uhabits-core/src/jvmMain/resources/migrations/21.sql b/uhabits-core/assets/main/migrations/21.sql similarity index 100% rename from uhabits-core/src/jvmMain/resources/migrations/21.sql rename to uhabits-core/assets/main/migrations/21.sql diff --git a/uhabits-core/src/jvmMain/resources/migrations/22.sql b/uhabits-core/assets/main/migrations/22.sql similarity index 100% rename from uhabits-core/src/jvmMain/resources/migrations/22.sql rename to uhabits-core/assets/main/migrations/22.sql diff --git a/uhabits-core/src/jvmMain/resources/migrations/23.sql b/uhabits-core/assets/main/migrations/23.sql similarity index 100% rename from uhabits-core/src/jvmMain/resources/migrations/23.sql rename to uhabits-core/assets/main/migrations/23.sql diff --git a/uhabits-core/src/jvmMain/resources/migrations/24.sql b/uhabits-core/assets/main/migrations/24.sql similarity index 100% rename from uhabits-core/src/jvmMain/resources/migrations/24.sql rename to uhabits-core/assets/main/migrations/24.sql diff --git a/uhabits-core/src/jvmMain/resources/migrations/25.sql b/uhabits-core/assets/main/migrations/25.sql similarity index 100% rename from uhabits-core/src/jvmMain/resources/migrations/25.sql rename to uhabits-core/assets/main/migrations/25.sql diff --git a/uhabits-core/src/commonMain/kotlin/org/isoron/platform/io/Database.kt b/uhabits-core/src/commonMain/kotlin/org/isoron/platform/io/Database.kt index 6e39ae71..091e6c20 100644 --- a/uhabits-core/src/commonMain/kotlin/org/isoron/platform/io/Database.kt +++ b/uhabits-core/src/commonMain/kotlin/org/isoron/platform/io/Database.kt @@ -106,7 +106,7 @@ inline fun Database.querySingle( return result } -fun Database.migrateTo(targetVersion: Int, loadMigrationSQL: (Int) -> String) { +suspend fun Database.migrateTo(targetVersion: Int, loadMigrationSQL: suspend (Int) -> String) { val currentVersion = getVersion() if (currentVersion >= targetVersion) return for (v in (currentVersion + 1)..targetVersion) { diff --git a/uhabits-core/src/commonMain/kotlin/org/isoron/platform/io/Files.kt b/uhabits-core/src/commonMain/kotlin/org/isoron/platform/io/Files.kt index 4de8bf9c..f24ea153 100644 --- a/uhabits-core/src/commonMain/kotlin/org/isoron/platform/io/Files.kt +++ b/uhabits-core/src/commonMain/kotlin/org/isoron/platform/io/Files.kt @@ -52,6 +52,11 @@ interface FileOpener { * result of some user action, such as databases and logs. */ interface UserFile { + /** + * The resolved absolute path string. + */ + val pathString: String + /** * Deletes the user file. If the file does not exist, nothing happens. */ @@ -67,6 +72,21 @@ interface UserFile { * exception. */ suspend fun lines(): List + + /** + * Overwrites the file with [content], creating it if it doesn't exist. + */ + suspend fun writeString(content: String) + + /** + * Overwrites the file with [bytes], creating it if it doesn't exist. + */ + suspend fun writeBytes(bytes: ByteArray) + + /** + * Reads the first [limit] bytes from the file. + */ + suspend fun readBytes(limit: Int): ByteArray } /** diff --git a/uhabits-core/src/commonMain/kotlin/org/isoron/platform/io/Strings.kt b/uhabits-core/src/commonMain/kotlin/org/isoron/platform/io/Strings.kt index 585a2d3d..6b17e8f5 100644 --- a/uhabits-core/src/commonMain/kotlin/org/isoron/platform/io/Strings.kt +++ b/uhabits-core/src/commonMain/kotlin/org/isoron/platform/io/Strings.kt @@ -23,6 +23,40 @@ expect fun format(format: String, arg: String): String expect fun format(format: String, arg: Int): String expect fun format(format: String, arg: Double): String +fun parseCsvLine(line: String): List { + val result = mutableListOf() + val sb = StringBuilder() + var inQuotes = false + var i = 0 + while (i < line.length) { + val c = line[i] + if (inQuotes) { + if (c == '"') { + if (i + 1 < line.length && line[i + 1] == '"') { + sb.append('"') + i++ + } else { + inQuotes = false + } + } else { + sb.append(c) + } + } else { + when (c) { + ',' -> { + result.add(sb.toString()) + sb.clear() + } + '"' -> inQuotes = true + else -> sb.append(c) + } + } + i++ + } + result.add(sb.toString()) + return result +} + fun csvLine(fields: Array): String { return fields.joinToString(",") { field -> if (field.any { it == ',' || it == '"' || it == '\n' || it == '\r' }) { diff --git a/uhabits-core/src/commonMain/kotlin/org/isoron/platform/io/ZipWriter.kt b/uhabits-core/src/commonMain/kotlin/org/isoron/platform/io/ZipWriter.kt new file mode 100644 index 00000000..77eb9aae --- /dev/null +++ b/uhabits-core/src/commonMain/kotlin/org/isoron/platform/io/ZipWriter.kt @@ -0,0 +1,25 @@ +/* + * Copyright (C) 2016-2025 Álinson Santos Xavier + * + * 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 . + */ + +package org.isoron.platform.io + +expect class ZipWriter() { + fun addEntry(name: String, content: String) + suspend fun toBytes(): ByteArray +} diff --git a/uhabits-core/src/commonTest/kotlin/org/isoron/platform/io/MigrationTest.kt b/uhabits-core/src/commonTest/kotlin/org/isoron/platform/io/MigrationTest.kt index 72cc633c..820cdc56 100644 --- a/uhabits-core/src/commonTest/kotlin/org/isoron/platform/io/MigrationTest.kt +++ b/uhabits-core/src/commonTest/kotlin/org/isoron/platform/io/MigrationTest.kt @@ -1,5 +1,6 @@ package org.isoron.platform.io +import kotlinx.coroutines.runBlocking import kotlin.test.Test import kotlin.test.assertEquals @@ -31,7 +32,7 @@ class MigrationTest { } @Test - fun testMigrateIdempotent() { + fun testMigrateIdempotent() = runBlocking { val db = TestDatabaseHelper.createEmptyDatabase() val version = db.getVersion() db.migrateTo(version) { v -> TestDatabaseHelper.loadMigrationSQL(v) } diff --git a/uhabits-core/src/jvmMain/java/org/isoron/platform/io/JavaFiles.kt b/uhabits-core/src/jvmMain/java/org/isoron/platform/io/JavaFiles.kt index 9e5770d0..d4d2bcde 100644 --- a/uhabits-core/src/jvmMain/java/org/isoron/platform/io/JavaFiles.kt +++ b/uhabits-core/src/jvmMain/java/org/isoron/platform/io/JavaFiles.kt @@ -66,6 +66,9 @@ class JavaResourceFile(val path: String) : ResourceFile { @Suppress("NewApi") class JavaUserFile(val path: Path) : UserFile { + override val pathString: String + get() = path.toString() + override suspend fun lines(): List { return Files.readAllLines(path) } @@ -77,6 +80,25 @@ class JavaUserFile(val path: Path) : UserFile { override suspend fun delete() { Files.delete(path) } + + override suspend fun writeString(content: String) { + path.toFile().parentFile?.mkdirs() + Files.write(path, content.toByteArray()) + } + + override suspend fun writeBytes(bytes: ByteArray) { + path.toFile().parentFile?.mkdirs() + Files.write(path, bytes) + } + + override suspend fun readBytes(limit: Int): ByteArray { + val file = path.toFile() + file.inputStream().use { stream -> + val buf = ByteArray(limit) + val n = stream.read(buf) + return if (n <= 0) ByteArray(0) else buf.copyOf(n) + } + } } @Suppress("NewApi") diff --git a/uhabits-core/src/jvmMain/java/org/isoron/platform/io/ZipWriter.kt b/uhabits-core/src/jvmMain/java/org/isoron/platform/io/ZipWriter.kt new file mode 100644 index 00000000..33ad8c5d --- /dev/null +++ b/uhabits-core/src/jvmMain/java/org/isoron/platform/io/ZipWriter.kt @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2016-2025 Álinson Santos Xavier + * + * 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 . + */ + +package org.isoron.platform.io + +import java.io.ByteArrayOutputStream +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +actual class ZipWriter { + private val baos = ByteArrayOutputStream() + private val zos = ZipOutputStream(baos) + + actual fun addEntry(name: String, content: String) { + zos.putNextEntry(ZipEntry(name)) + zos.write(content.toByteArray()) + zos.closeEntry() + } + + actual suspend fun toBytes(): ByteArray { + zos.close() + return baos.toByteArray() + } +} diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/io/AbstractImporter.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/io/AbstractImporter.kt index d5194719..5f0f8bad 100644 --- a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/io/AbstractImporter.kt +++ b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/io/AbstractImporter.kt @@ -18,9 +18,9 @@ */ package org.isoron.uhabits.core.io -import java.io.File +import org.isoron.platform.io.UserFile abstract class AbstractImporter { - abstract fun canHandle(file: File): Boolean - abstract fun importHabitsFromFile(file: File) + abstract suspend fun canHandle(file: UserFile): Boolean + abstract suspend fun importHabitsFromFile(file: UserFile) } diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/io/GenericImporter.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/io/GenericImporter.kt index e7aad2c1..5bb1ad1f 100644 --- a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/io/GenericImporter.kt +++ b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/io/GenericImporter.kt @@ -19,7 +19,7 @@ package org.isoron.uhabits.core.io import me.tatarka.inject.annotations.Inject -import java.io.File +import org.isoron.platform.io.UserFile /** * A GenericImporter decides which implementation of AbstractImporter is able to @@ -40,7 +40,7 @@ class GenericImporter( habitBullCSVImporter ) - override fun canHandle(file: File): Boolean { + override suspend fun canHandle(file: UserFile): Boolean { for (importer in importers) { if (importer.canHandle(file)) { return true @@ -49,7 +49,7 @@ class GenericImporter( return false } - override fun importHabitsFromFile(file: File) { + override suspend fun importHabitsFromFile(file: UserFile) { for (importer in importers) { if (importer.canHandle(file)) { importer.importHabitsFromFile(file) diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/io/HabitBullCSVImporter.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/io/HabitBullCSVImporter.kt index 687014fe..b0b8b9f0 100644 --- a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/io/HabitBullCSVImporter.kt +++ b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/io/HabitBullCSVImporter.kt @@ -18,8 +18,9 @@ */ package org.isoron.uhabits.core.io -import com.opencsv.CSVReader import me.tatarka.inject.annotations.Inject +import org.isoron.platform.io.UserFile +import org.isoron.platform.io.parseCsvLine import org.isoron.platform.time.LocalDate import org.isoron.uhabits.core.models.Entry import org.isoron.uhabits.core.models.Frequency @@ -27,9 +28,6 @@ import org.isoron.uhabits.core.models.Habit import org.isoron.uhabits.core.models.HabitList import org.isoron.uhabits.core.models.HabitType import org.isoron.uhabits.core.models.ModelFactory -import java.io.BufferedReader -import java.io.File -import java.io.FileReader /** * Class that imports data from HabitBull CSV files. @@ -43,16 +41,22 @@ class HabitBullCSVImporter( private val logger = logging.getLogger("HabitBullCSVImporter") - override fun canHandle(file: File): Boolean { - val reader = BufferedReader(FileReader(file)) - val line = reader.readLine() - return line.startsWith("HabitName,HabitDescription,HabitCategory") + override suspend fun canHandle(file: UserFile): Boolean { + return try { + val lines = file.lines() + if (lines.isEmpty()) return false + lines[0].startsWith("HabitName,HabitDescription,HabitCategory") + } catch (e: Exception) { + false + } } - override fun importHabitsFromFile(file: File) { - val reader = CSVReader(FileReader(file)) + override suspend fun importHabitsFromFile(file: UserFile) { + val lines = file.lines() val map = HashMap() - for (cols in reader) { + for (line in lines) { + val cols = parseCsvLine(line) + if (cols.size < 6) continue val name = cols[0] if (name == "HabitName") continue val description = cols[1] @@ -61,13 +65,13 @@ class HabitBullCSVImporter( if (h == null) { h = modelFactory.buildHabit() h.name = name - h.description = description ?: "" + h.description = description h.frequency = Frequency.DAILY habitList.add(h) map[name] = h logger.info("Creating habit: $name") } - val notes = cols[5] ?: "" + val notes = cols[5] when (val value = parseInt(cols[4])) { 0 -> h.originalEntries.add(Entry(date, Entry.NO, notes)) 1 -> h.originalEntries.add(Entry(date, Entry.YES_MANUAL, notes)) @@ -86,12 +90,10 @@ class HabitBullCSVImporter( private fun parseDate(rawValue: String): LocalDate { if (rawValue.contains("-")) { - // yyyy-MM-dd val parts = rawValue.split("-") return LocalDate(parts[0].toInt(), parts[1].toInt(), parts[2].toInt()) } if (rawValue.contains("/")) { - // M/d/yyyy val parts = rawValue.split("/") return LocalDate(parts[2].toInt(), parts[0].toInt(), parts[1].toInt()) } diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/io/HabitsCSVExporter.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/io/HabitsCSVExporter.kt index 04fe9ea4..87e2f60a 100644 --- a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/io/HabitsCSVExporter.kt +++ b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/io/HabitsCSVExporter.kt @@ -18,61 +18,41 @@ */ package org.isoron.uhabits.core.io -import com.opencsv.CSVWriter +import org.isoron.platform.io.ZipWriter +import org.isoron.platform.io.csvLine +import org.isoron.platform.io.format import org.isoron.platform.time.LocalDate import org.isoron.platform.time.getToday -import org.isoron.uhabits.core.models.Entry import org.isoron.uhabits.core.models.EntryList import org.isoron.uhabits.core.models.Habit import org.isoron.uhabits.core.models.HabitList -import org.isoron.uhabits.core.models.Score -import java.io.File -import java.io.FileInputStream -import java.io.FileOutputStream -import java.io.FileWriter -import java.io.IOException -import java.io.Writer -import java.util.LinkedList -import java.util.Locale -import java.util.zip.ZipEntry -import java.util.zip.ZipOutputStream import kotlin.math.min /** - * Class that exports the application data to CSV files. + * Class that exports the application data to CSV files inside a ZIP archive. */ class HabitsCSVExporter( private val allHabits: HabitList, - private val selectedHabits: List, - dir: File + private val selectedHabits: List ) { - private val generatedDirs = LinkedList() - private val generatedFilenames = LinkedList() - private val exportDirName: String = dir.absolutePath + "/" private val delimiter = "," - fun writeArchive(): String { - writeHabits() - val zipFilename = writeZipFile() - cleanup() - return zipFilename + suspend fun writeArchive(): ByteArray { + val zip = ZipWriter() + zip.addEntry("Habits.csv", allHabits.writeCSV()) + for (h in selectedHabits) { + val habitDirName = habitDirName(h) + zip.addEntry("${habitDirName}Scores.csv", writeScores(h)) + zip.addEntry("${habitDirName}Checkmarks.csv", writeEntries(h.computedEntries)) + } + zip.addEntry("Scores.csv", writeMultipleHabitsScores()) + zip.addEntry("Checkmarks.csv", writeMultipleHabitsCheckmarks()) + return zip.toBytes() } - private fun addFileToZip(zos: ZipOutputStream, filename: String) { - val fis = FileInputStream(File(exportDirName + filename)) - val ze = ZipEntry(filename) - zos.putNextEntry(ze) - var length: Int - val bytes = ByteArray(1024) - while (fis.read(bytes).also { length = it } >= 0) zos.write(bytes, 0, length) - zos.closeEntry() - fis.close() - } - - private fun cleanup() { - for (filename in generatedFilenames) File(exportDirName + filename).delete() - for (filename in generatedDirs) File(exportDirName + filename).delete() - File(exportDirName).delete() + private fun habitDirName(h: Habit): String { + val sane = sanitizeFilename(h.name) + return format("%03d", allHabits.indexOf(h) + 1) + " " + sane.trim() + "/" } private fun sanitizeFilename(name: String): String { @@ -80,139 +60,75 @@ class HabitsCSVExporter( return s.substring(0, min(s.length, 100)) } - private fun writeHabits() { - val filename = "Habits.csv" - File(exportDirName).mkdirs() - val out = FileWriter(exportDirName + filename) - generatedFilenames.add(filename) - allHabits.writeCSV(out) - out.close() - for (h in selectedHabits) { - val sane = sanitizeFilename(h.name) - var habitDirName = String.format(Locale.US, "%03d %s", allHabits.indexOf(h) + 1, sane) - habitDirName = habitDirName.trim() + "/" - File(exportDirName + habitDirName).mkdirs() - generatedDirs.add(habitDirName) - writeScores(habitDirName, h) - writeEntries(habitDirName, h.computedEntries) - } - writeMultipleHabits() - } - - private fun writeScores(habitDirName: String, habit: Habit) { - val path = habitDirName + "Scores.csv" - val out = FileWriter(exportDirName + path) - generatedFilenames.add(path) + private fun writeScores(habit: Habit): String { + val sb = StringBuilder() val today = getToday() var oldest = today val known = habit.computedEntries.getKnown() if (known.isNotEmpty()) oldest = known[known.size - 1].date - val csv = CSVWriter(out) - csv.writeNext(arrayOf("Date", "Score"), false) + sb.append(csvLine(arrayOf("Date", "Score"))) for (s in habit.scores.getByInterval(oldest, today)) { - val date = s.date.toCSVString() - val score = String.format(Locale.US, "%.4f", s.value) - csv.writeNext(arrayOf(date, score), false) + sb.append(csvLine(arrayOf(s.date.toCSVString(), format("%.4f", s.value)))) } - csv.close() - out.close() + return sb.toString() } - private fun writeEntries(habitDirName: String, entries: EntryList) { - val filename = habitDirName + "Checkmarks.csv" - val out = FileWriter(exportDirName + filename) - generatedFilenames.add(filename) - val csv = CSVWriter(out) - csv.writeNext(arrayOf("Date", "Value", "Notes"), false) + private fun writeEntries(entries: EntryList): String { + val sb = StringBuilder() + sb.append(csvLine(arrayOf("Date", "Value", "Notes"))) for (entry in entries.getKnown()) { - val date = entry.date.toCSVString() - csv.writeNext( - arrayOf( - date, - entry.formattedValue, - entry.notes - ), - false - ) + sb.append(csvLine(arrayOf(entry.date.toCSVString(), entry.formattedValue, entry.notes))) } - csv.close() - out.close() + return sb.toString() } - /** - * Writes a scores file and a checkmarks file containing scores and checkmarks of every habit. - * The first column corresponds to the date. Subsequent columns correspond to a habit. - * Habits are taken from the list of selected habits. - * Dates are determined from the oldest repetition date to the newest repetition date found in - * the list of habits. - */ - private fun writeMultipleHabits() { - val scoresFileName = "Scores.csv" - val checksFileName = "Checkmarks.csv" - generatedFilenames.add(scoresFileName) - generatedFilenames.add(checksFileName) - - val scoresWriter = FileWriter(exportDirName + scoresFileName) - val checksWriter = FileWriter(exportDirName + checksFileName) - writeMultipleHabitsHeader(scoresWriter) - writeMultipleHabitsHeader(checksWriter) - + private fun writeMultipleHabitsScores(): String { + val sb = StringBuilder() + writeMultipleHabitsHeader(sb) val timeframe = getTimeframe() val oldest = timeframe[0] val newest = getToday() - val checkmarks: MutableList> = ArrayList() - val scores: MutableList> = ArrayList() - for (habit in selectedHabits) { - checkmarks.add(ArrayList(habit.computedEntries.getByInterval(oldest, newest))) - scores.add(ArrayList(habit.scores.getByInterval(oldest, newest))) - } - + val scores = selectedHabits.map { ArrayList(it.scores.getByInterval(oldest, newest)) } val days = oldest.daysUntil(newest) for (i in 0..days) { val date = newest.minus(i).toCSVString() - val sb = StringBuilder() sb.append(date).append(delimiter) - checksWriter.write(sb.toString()) - scoresWriter.write(sb.toString()) for (j in selectedHabits.indices) { - checksWriter.write(checkmarks[j][i].formattedValue) - checksWriter.write(delimiter) - val score = String.format(Locale.US, "%.4f", scores[j][i].value) - scoresWriter.write(score) - scoresWriter.write(delimiter) + val score = format("%.4f", scores[j][i].value) + sb.append(score).append(delimiter) } - checksWriter.write("\n") - scoresWriter.write("\n") + sb.append("\n") } - scoresWriter.close() - checksWriter.close() + return sb.toString() } - /** - * Writes the first row, containing header information, using the given writer. - * This consists of the date title and the names of the selected habits. - * - * @param out the writer to use - * @throws IOException if there was a problem writing - */ - @Throws(IOException::class) - private fun writeMultipleHabitsHeader(out: Writer) { - out.write("Date$delimiter") + private fun writeMultipleHabitsCheckmarks(): String { + val sb = StringBuilder() + writeMultipleHabitsHeader(sb) + val timeframe = getTimeframe() + val oldest = timeframe[0] + val newest = getToday() + val checkmarks = selectedHabits.map { ArrayList(it.computedEntries.getByInterval(oldest, newest)) } + val days = oldest.daysUntil(newest) + for (i in 0..days) { + val date = newest.minus(i).toCSVString() + sb.append(date).append(delimiter) + for (j in selectedHabits.indices) { + sb.append(checkmarks[j][i].formattedValue).append(delimiter) + } + sb.append("\n") + } + return sb.toString() + } + + private fun writeMultipleHabitsHeader(sb: StringBuilder) { + sb.append("Date$delimiter") for (habit in selectedHabits) { - out.write(habit.name) - out.write(delimiter) + sb.append(habit.name).append(delimiter) } - out.write("\n") + sb.append("\n") } - /** - * Gets the overall timeframe of the selected habits. - * The timeframe is an array containing the oldest timestamp among the habits and the - * newest timestamp among the habits. - * Both timestamps are in milliseconds. - * - * @return the timeframe containing the oldest timestamp and the newest timestamp - */ private fun getTimeframe(): Array { var oldest = LocalDate(1000000) var newest = LocalDate(0) @@ -226,15 +142,4 @@ class HabitsCSVExporter( } return arrayOf(oldest, newest) } - - private fun writeZipFile(): String { - val date = getToday().toCSVString() - val zipFilename = String.format("%s/Loop Habits CSV %s.zip", exportDirName, date) - val fos = FileOutputStream(zipFilename) - val zos = ZipOutputStream(fos) - for (filename in generatedFilenames) addFileToZip(zos, filename) - zos.close() - fos.close() - return zipFilename - } } diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/io/LoopDBImporter.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/io/LoopDBImporter.kt index aa8a2226..ff7670ef 100644 --- a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/io/LoopDBImporter.kt +++ b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/io/LoopDBImporter.kt @@ -21,6 +21,8 @@ package org.isoron.uhabits.core.io import me.tatarka.inject.annotations.Inject import org.isoron.platform.io.Database import org.isoron.platform.io.DatabaseOpener +import org.isoron.platform.io.FileOpener +import org.isoron.platform.io.UserFile import org.isoron.platform.io.getVersion import org.isoron.platform.io.migrateTo import org.isoron.platform.io.query @@ -37,7 +39,6 @@ import org.isoron.uhabits.core.models.HabitList import org.isoron.uhabits.core.models.ModelFactory import org.isoron.uhabits.core.models.sqlite.SQLiteHabitList import org.isoron.uhabits.core.utils.isSQLite3File -import java.io.File /** * Class that imports data from database files exported by Loop Habit Tracker. @@ -48,14 +49,15 @@ class LoopDBImporter( @AppScope val modelFactory: ModelFactory, @AppScope val opener: DatabaseOpener, @AppScope val runner: CommandRunner, - @AppScope logging: Logging + @AppScope logging: Logging, + @AppScope val fileOpener: FileOpener ) : AbstractImporter() { private val logger = logging.getLogger("LoopDBImporter") - override fun canHandle(file: File): Boolean { - if (!file.isSQLite3File()) return false - val db = opener.open(file.absolutePath) + override suspend fun canHandle(file: UserFile): Boolean { + if (!isSQLite3File(file)) return false + val db = opener.open(file.pathString) var canHandle = true val count = db.querySingle( "select count(*) from SQLITE_MASTER where name='Habits' or name='Repetitions'" @@ -72,12 +74,11 @@ class LoopDBImporter( return canHandle } - override fun importHabitsFromFile(file: File) { - val db = opener.open(file.absolutePath) + override suspend fun importHabitsFromFile(file: UserFile) { + val db = opener.open(file.pathString) db.migrateTo(DATABASE_VERSION) { version -> val filename = "%02d.sql".format(version) - javaClass.getResourceAsStream("/migrations/$filename")!! - .bufferedReader().readText() + fileOpener.openResourceFile("migrations/$filename").lines().joinToString("\n") } val habitDataList = loadHabits(db) diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/io/RewireDBImporter.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/io/RewireDBImporter.kt index 15738de7..ff21fafb 100644 --- a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/io/RewireDBImporter.kt +++ b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/io/RewireDBImporter.kt @@ -21,6 +21,7 @@ package org.isoron.uhabits.core.io import me.tatarka.inject.annotations.Inject import org.isoron.platform.io.Database import org.isoron.platform.io.DatabaseOpener +import org.isoron.platform.io.UserFile import org.isoron.platform.io.begin import org.isoron.platform.io.commit import org.isoron.platform.io.query @@ -34,7 +35,6 @@ import org.isoron.uhabits.core.models.ModelFactory import org.isoron.uhabits.core.models.Reminder import org.isoron.uhabits.core.models.WeekdayList import org.isoron.uhabits.core.utils.isSQLite3File -import java.io.File /** * Class that imports database files exported by Rewire. @@ -46,9 +46,9 @@ class RewireDBImporter( private val opener: DatabaseOpener ) : AbstractImporter() { - override fun canHandle(file: File): Boolean { - if (!file.isSQLite3File()) return false - val db = opener.open(file.absolutePath) + override suspend fun canHandle(file: UserFile): Boolean { + if (!isSQLite3File(file)) return false + val db = opener.open(file.pathString) val count = db.querySingle( "select count(*) from SQLITE_MASTER where name='CHECKINS' or name='UNIT'" ) { it.getInt(0) } @@ -56,8 +56,8 @@ class RewireDBImporter( return count == 2 } - override fun importHabitsFromFile(file: File) { - val db = opener.open(file.absolutePath) + override suspend fun importHabitsFromFile(file: UserFile) { + val db = opener.open(file.pathString) db.begin() createHabits(db) db.commit() diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/io/TickmateDBImporter.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/io/TickmateDBImporter.kt index 244c4a09..058b7475 100644 --- a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/io/TickmateDBImporter.kt +++ b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/io/TickmateDBImporter.kt @@ -21,6 +21,7 @@ package org.isoron.uhabits.core.io import me.tatarka.inject.annotations.Inject import org.isoron.platform.io.Database import org.isoron.platform.io.DatabaseOpener +import org.isoron.platform.io.UserFile import org.isoron.platform.io.begin import org.isoron.platform.io.commit import org.isoron.platform.io.query @@ -32,7 +33,6 @@ import org.isoron.uhabits.core.models.Habit import org.isoron.uhabits.core.models.HabitList import org.isoron.uhabits.core.models.ModelFactory import org.isoron.uhabits.core.utils.isSQLite3File -import java.io.File /** * Class that imports data from database files exported by Tickmate. @@ -44,9 +44,9 @@ class TickmateDBImporter( private val opener: DatabaseOpener ) : AbstractImporter() { - override fun canHandle(file: File): Boolean { - if (!file.isSQLite3File()) return false - val db = opener.open(file.absolutePath) + override suspend fun canHandle(file: UserFile): Boolean { + if (!isSQLite3File(file)) return false + val db = opener.open(file.pathString) val count = db.querySingle( "select count(*) from SQLITE_MASTER where name='tracks' or name='track2groups'" ) { it.getInt(0) } @@ -54,8 +54,8 @@ class TickmateDBImporter( return count == 2 } - override fun importHabitsFromFile(file: File) { - val db = opener.open(file.absolutePath) + override suspend fun importHabitsFromFile(file: UserFile) { + val db = opener.open(file.pathString) db.begin() createHabits(db) db.commit() diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/HabitList.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/HabitList.kt index 73becf14..ca4cba51 100644 --- a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/HabitList.kt +++ b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/HabitList.kt @@ -20,14 +20,10 @@ package org.isoron.uhabits.core.models import org.isoron.platform.io.csvLine import org.isoron.platform.io.format -import java.io.IOException -import java.io.Writer -import javax.annotation.concurrent.ThreadSafe /** * An ordered collection of [Habit]s. */ -@ThreadSafe abstract class HabitList : Iterable { val observable: ModelObservable @@ -168,16 +164,12 @@ abstract class HabitList : Iterable { } /** - * Writes the list of habits to the given writer, in CSV format. There is - * one line for each habit, containing the fields name, description, - * frequency numerator, frequency denominator and color. The color is - * written in HTML format (#000000). - * - * @param out the writer that will receive the result - * @throws IOException if write operations fail + * Returns the list of habits in CSV format. There is one line for each + * habit, containing the fields name, description, frequency numerator, + * frequency denominator and color. */ - @Throws(IOException::class) - fun writeCSV(out: Writer) { + fun writeCSV(): String { + val sb = StringBuilder() val header = arrayOf( "Position", "Name", @@ -192,7 +184,7 @@ abstract class HabitList : Iterable { "Target Value", "Archived?" ) - out.write(csvLine(header)) + sb.append(csvLine(header)) for (habit in this) { val (numerator, denominator) = habit.frequency val cols = arrayOf( @@ -209,9 +201,9 @@ abstract class HabitList : Iterable { if (habit.isNumerical) habit.targetValue.toString() else "", habit.isArchived.toString() ) - out.write(csvLine(cols)) + sb.append(csvLine(cols)) } - out.close() + return sb.toString() } abstract fun resort() diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/StreakList.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/StreakList.kt index dc83f79e..03cd23bf 100644 --- a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/StreakList.kt +++ b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/StreakList.kt @@ -19,10 +19,8 @@ package org.isoron.uhabits.core.models import org.isoron.platform.time.LocalDate -import javax.annotation.concurrent.ThreadSafe import kotlin.math.min -@ThreadSafe class StreakList { private val list = ArrayList() diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/preferences/MemoryStorage.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/preferences/MemoryStorage.kt new file mode 100644 index 00000000..4fc7d36f --- /dev/null +++ b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/preferences/MemoryStorage.kt @@ -0,0 +1,62 @@ +/* + * Copyright (C) 2016-2025 Álinson Santos Xavier + * + * 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 . + */ + +package org.isoron.uhabits.core.preferences + +class MemoryStorage : Preferences.Storage { + private val map = HashMap() + + override fun clear() { + map.clear() + } + + override fun getBoolean(key: String, defValue: Boolean): Boolean = + map[key]?.toBoolean() ?: defValue + + override fun getInt(key: String, defValue: Int): Int = + map[key]?.toIntOrNull() ?: defValue + + override fun getLong(key: String, defValue: Long): Long = + map[key]?.toLongOrNull() ?: defValue + + override fun getString(key: String, defValue: String): String = + map[key] ?: defValue + + override fun onAttached(preferences: Preferences) {} + + override fun putBoolean(key: String, value: Boolean) { + map[key] = value.toString() + } + + override fun putInt(key: String, value: Int) { + map[key] = value.toString() + } + + override fun putLong(key: String, value: Long) { + map[key] = value.toString() + } + + override fun putString(key: String, value: String) { + map[key] = value + } + + override fun remove(key: String) { + map.remove(key) + } +} diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/preferences/PropertiesStorage.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/preferences/PropertiesStorage.kt deleted file mode 100644 index da43509f..00000000 --- a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/preferences/PropertiesStorage.kt +++ /dev/null @@ -1,99 +0,0 @@ -/* - * Copyright (C) 2016-2025 Álinson Santos Xavier - * - * 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 . - */ -package org.isoron.uhabits.core.preferences - -import java.io.File -import java.io.FileInputStream -import java.io.FileOutputStream -import java.io.IOException -import java.util.Properties - -class PropertiesStorage(file: File) : Preferences.Storage { - private var props: Properties - private val file: File - override fun clear() { - for (key in props.stringPropertyNames()) props.remove(key) - flush() - } - - override fun getBoolean(key: String, defValue: Boolean): Boolean { - val value = props.getProperty(key, java.lang.Boolean.toString(defValue)) - return java.lang.Boolean.parseBoolean(value) - } - - override fun getInt(key: String, defValue: Int): Int { - val value = props.getProperty(key, defValue.toString()) - return value.toInt() - } - - override fun getLong(key: String, defValue: Long): Long { - val value = props.getProperty(key, defValue.toString()) - return value.toLong() - } - - override fun getString(key: String, defValue: String): String { - return props.getProperty(key, defValue) - } - - override fun onAttached(preferences: Preferences) { - // nop - } - - override fun putBoolean(key: String, value: Boolean) { - props.setProperty(key, java.lang.Boolean.toString(value)) - } - - override fun putInt(key: String, value: Int) { - props.setProperty(key, value.toString()) - flush() - } - - private fun flush() { - try { - props.store(FileOutputStream(file), "") - } catch (e: IOException) { - throw RuntimeException(e) - } - } - - override fun putLong(key: String, value: Long) { - props.setProperty(key, value.toString()) - flush() - } - - override fun putString(key: String, value: String) { - props.setProperty(key, value) - flush() - } - - override fun remove(key: String) { - props.remove(key) - flush() - } - - init { - try { - this.file = file - props = Properties() - props.load(FileInputStream(file)) - } catch (e: IOException) { - throw RuntimeException(e) - } - } -} diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/tasks/ExportCSVTask.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/tasks/ExportCSVTask.kt index 63f6ea4f..66b78af2 100644 --- a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/tasks/ExportCSVTask.kt +++ b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/tasks/ExportCSVTask.kt @@ -18,6 +18,8 @@ */ package org.isoron.uhabits.core.tasks +import kotlinx.coroutines.runBlocking +import org.isoron.platform.time.getToday import org.isoron.uhabits.core.io.HabitsCSVExporter import org.isoron.uhabits.core.models.Habit import org.isoron.uhabits.core.models.HabitList @@ -26,14 +28,20 @@ import java.io.File class ExportCSVTask( private val habitList: HabitList, private val selectedHabits: List, - private val outputDir: File, + private val outputDir: String, private val listener: Listener ) : Task { private var archiveFilename: String? = null override fun doInBackground() { try { - val exporter = HabitsCSVExporter(habitList, selectedHabits, outputDir) - archiveFilename = exporter.writeArchive() + val exporter = HabitsCSVExporter(habitList, selectedHabits) + val bytes = runBlocking { exporter.writeArchive() } + val date = getToday().toCSVString() + val dir = File(outputDir) + dir.mkdirs() + val zipFile = File(dir, "Loop Habits CSV $date.zip") + zipFile.writeBytes(bytes) + archiveFilename = zipFile.absolutePath } catch (e: Exception) { e.printStackTrace() } diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/tasks/ExportCSVTaskFactory.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/tasks/ExportCSVTaskFactory.kt index 9ab87469..c34a03e1 100644 --- a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/tasks/ExportCSVTaskFactory.kt +++ b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/tasks/ExportCSVTaskFactory.kt @@ -22,7 +22,6 @@ package org.isoron.uhabits.core.tasks import me.tatarka.inject.annotations.Inject import org.isoron.uhabits.core.models.Habit import org.isoron.uhabits.core.models.HabitList -import java.io.File @Inject class ExportCSVTaskFactory( @@ -30,7 +29,7 @@ class ExportCSVTaskFactory( ) { fun create( selectedHabits: List, - outputDir: File, + outputDir: String, listener: ExportCSVTask.Listener ) = ExportCSVTask(habitList, selectedHabits, outputDir, listener) } diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/ui/screens/habits/list/ListHabitsBehavior.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/ui/screens/habits/list/ListHabitsBehavior.kt index 74749d31..29bc901d 100644 --- a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/ui/screens/habits/list/ListHabitsBehavior.kt +++ b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/ui/screens/habits/list/ListHabitsBehavior.kt @@ -33,9 +33,6 @@ import org.isoron.uhabits.core.models.PaletteColor import org.isoron.uhabits.core.preferences.Preferences import org.isoron.uhabits.core.tasks.ExportCSVTask import org.isoron.uhabits.core.tasks.TaskRunner -import java.io.File -import java.io.IOException -import java.util.LinkedList import kotlin.math.roundToInt @Inject @@ -81,8 +78,7 @@ open class ListHabitsBehavior( } fun onExportCSV() { - val selected: MutableList = LinkedList() - for (h in habitList) selected.add(h) + val selected = habitList.toList() val outputDir = dirFinder.getCSVOutputDir() taskRunner.execute( ExportCSVTask(habitList, selected, outputDir) { filename: String? -> @@ -119,7 +115,7 @@ open class ListHabitsBehavior( try { val log = bugReporter.getBugReport() screen.showSendBugReportToDeveloperScreen(log) - } catch (e: IOException) { + } catch (e: Exception) { e.printStackTrace() screen.showMessage(Message.COULD_NOT_GENERATE_BUG_REPORT) } @@ -149,12 +145,11 @@ open class ListHabitsBehavior( interface BugReporter { fun dumpBugReportToFile() - @Throws(IOException::class) fun getBugReport(): String } interface DirFinder { - fun getCSVOutputDir(): File + fun getCSVOutputDir(): String } fun interface NumberPickerCallback { diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/ui/screens/habits/show/ShowHabitMenuPresenter.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/ui/screens/habits/show/ShowHabitMenuPresenter.kt index e18b4bf3..477f5bb3 100644 --- a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/ui/screens/habits/show/ShowHabitMenuPresenter.kt +++ b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/ui/screens/habits/show/ShowHabitMenuPresenter.kt @@ -29,7 +29,6 @@ import org.isoron.uhabits.core.models.HabitList import org.isoron.uhabits.core.tasks.ExportCSVTask import org.isoron.uhabits.core.tasks.TaskRunner import org.isoron.uhabits.core.ui.callbacks.OnConfirmedCallback -import java.io.File import kotlin.math.PI import kotlin.math.cos import kotlin.math.ln @@ -127,6 +126,6 @@ class ShowHabitMenuPresenter( } interface System { - fun getCSVOutputDir(): File + fun getCSVOutputDir(): String } } diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/utils/FileExtensions.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/utils/FileExtensions.kt index 77edcfe8..e81c3c9e 100644 --- a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/utils/FileExtensions.kt +++ b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/utils/FileExtensions.kt @@ -19,13 +19,10 @@ package org.isoron.uhabits.core.utils -import java.io.File -import java.io.FileInputStream +import org.isoron.platform.io.UserFile -fun File.isSQLite3File(): Boolean { - val fis = FileInputStream(this) - val sqliteHeader = "SQLite format 3".toByteArray() - val buffer = ByteArray(sqliteHeader.size) - val count = fis.read(buffer) - return if (count < sqliteHeader.size) false else buffer.contentEquals(sqliteHeader) +suspend fun isSQLite3File(file: UserFile): Boolean { + if (!file.exists()) return false + val header = file.readBytes(16) + return header.decodeToString().startsWith("SQLite format 3") } diff --git a/uhabits-core/src/jvmTest/java/org/isoron/platform/io/JavaFilesTest.kt b/uhabits-core/src/jvmTest/java/org/isoron/platform/io/JavaFilesTest.kt new file mode 100644 index 00000000..0171f8fc --- /dev/null +++ b/uhabits-core/src/jvmTest/java/org/isoron/platform/io/JavaFilesTest.kt @@ -0,0 +1,76 @@ +/* + * Copyright (C) 2016-2025 Álinson Santos Xavier + * + * 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 . + */ + +package org.isoron.platform.io + +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class JavaFilesTest { + + @Test + fun testWriteStringAndLines() = runBlocking { + val file = JavaUserFile(kotlin.io.path.createTempFile("test", ".txt")) + file.writeString("hello\nworld\n") + val lines = file.lines() + assertEquals(listOf("hello", "world"), lines) + file.delete() + } + + @Test + fun testWriteBytesAndReadBytes() = runBlocking { + val file = JavaUserFile(kotlin.io.path.createTempFile("test", ".bin")) + val data = byteArrayOf(0x53, 0x51, 0x4C, 0x69, 0x74, 0x65) + file.writeBytes(data) + val read = file.readBytes(6) + assertTrue(data.contentEquals(read)) + file.delete() + } + + @Test + fun testReadBytesWithLimit() = runBlocking { + val file = JavaUserFile(kotlin.io.path.createTempFile("test", ".bin")) + file.writeBytes(byteArrayOf(1, 2, 3, 4, 5, 6, 7, 8)) + val read = file.readBytes(3) + assertEquals(3, read.size) + assertEquals(1, read[0]) + assertEquals(2, read[1]) + assertEquals(3, read[2]) + file.delete() + } + + @Test + fun testPath() = runBlocking { + val tmpPath = kotlin.io.path.createTempFile("test", ".txt") + val file = JavaUserFile(tmpPath) + assertEquals(tmpPath.toString(), file.pathString) + file.delete() + } + + @Test + fun testExists() = runBlocking { + val file = JavaUserFile(kotlin.io.path.createTempFile("test", ".txt")) + assertTrue(file.exists()) + file.delete() + assertFalse(file.exists()) + } +} diff --git a/uhabits-core/src/jvmTest/java/org/isoron/platform/io/StringsTest.kt b/uhabits-core/src/jvmTest/java/org/isoron/platform/io/StringsTest.kt index 23e9c336..c24ffe6f 100644 --- a/uhabits-core/src/jvmTest/java/org/isoron/platform/io/StringsTest.kt +++ b/uhabits-core/src/jvmTest/java/org/isoron/platform/io/StringsTest.kt @@ -34,4 +34,15 @@ class StringsTest { assertEquals("00013.42", format("%08.2f", 13.419187263)) assertEquals("13.42 ", format("%-8.2f", 13.419187263)) } + + @Test + fun testParseCsvLine() { + assertEquals(listOf("a", "b", "c"), parseCsvLine("a,b,c")) + assertEquals(listOf("hello world", "foo", "bar"), parseCsvLine("hello world,foo,bar")) + assertEquals(listOf("has,comma", "normal"), parseCsvLine("\"has,comma\",normal")) + assertEquals(listOf("has\"quote", "x"), parseCsvLine("\"has\"\"quote\",x")) + assertEquals(listOf("", "", ""), parseCsvLine(",,")) + assertEquals(listOf("single"), parseCsvLine("single")) + assertEquals(listOf("a", "line\nbreak", "b"), parseCsvLine("a,\"line\nbreak\",b")) + } } diff --git a/uhabits-core/src/jvmTest/java/org/isoron/platform/io/TestDatabaseHelper.kt b/uhabits-core/src/jvmTest/java/org/isoron/platform/io/TestDatabaseHelper.kt index eeab9e1b..95ddf251 100644 --- a/uhabits-core/src/jvmTest/java/org/isoron/platform/io/TestDatabaseHelper.kt +++ b/uhabits-core/src/jvmTest/java/org/isoron/platform/io/TestDatabaseHelper.kt @@ -1,19 +1,24 @@ package org.isoron.platform.io +import kotlinx.coroutines.runBlocking import org.isoron.uhabits.core.DATABASE_VERSION actual object TestDatabaseHelper { + private val fileOpener = JavaFileOpener() + actual fun createEmptyDatabase(): Database { val db = JavaDatabaseOpener().open(":memory:") db.setVersion(8) - db.migrateTo(DATABASE_VERSION) { v -> loadMigrationSQL(v) } + runBlocking { + db.migrateTo(DATABASE_VERSION) { v -> loadMigrationSQL(v) } + } return db } actual fun loadMigrationSQL(version: Int): String { - val filename = "/migrations/%02d.sql".format(version) - return TestDatabaseHelper::class.java - .getResourceAsStream(filename)!! - .bufferedReader().readText() + val path = "migrations/%02d.sql".format(version) + return runBlocking { + fileOpener.openResourceFile(path).lines().joinToString("\n") + } } } diff --git a/uhabits-core/src/jvmTest/java/org/isoron/platform/io/ZipWriterTest.kt b/uhabits-core/src/jvmTest/java/org/isoron/platform/io/ZipWriterTest.kt new file mode 100644 index 00000000..0a4f4e9b --- /dev/null +++ b/uhabits-core/src/jvmTest/java/org/isoron/platform/io/ZipWriterTest.kt @@ -0,0 +1,92 @@ +/* + * Copyright (C) 2016-2025 Álinson Santos Xavier + * + * 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 . + */ + +package org.isoron.platform.io + +import kotlinx.coroutines.runBlocking +import java.io.ByteArrayInputStream +import java.util.zip.ZipInputStream +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ZipWriterTest { + + @Test + fun testSingleEntry() = runBlocking { + val zip = ZipWriter() + zip.addEntry("hello.txt", "Hello, World!") + val bytes = zip.toBytes() + + val entries = readZipEntries(bytes) + assertEquals(1, entries.size) + assertEquals("Hello, World!", entries["hello.txt"]) + } + + @Test + fun testMultipleEntries() = runBlocking { + val zip = ZipWriter() + zip.addEntry("a.csv", "name,value\nfoo,1\n") + zip.addEntry("subdir/b.csv", "col1,col2\nbar,2\n") + zip.addEntry("subdir/c.csv", "x\n") + val bytes = zip.toBytes() + + val entries = readZipEntries(bytes) + assertEquals(3, entries.size) + assertEquals("name,value\nfoo,1\n", entries["a.csv"]) + assertEquals("col1,col2\nbar,2\n", entries["subdir/b.csv"]) + assertEquals("x\n", entries["subdir/c.csv"]) + } + + @Test + fun testEmptyContent() = runBlocking { + val zip = ZipWriter() + zip.addEntry("empty.txt", "") + val bytes = zip.toBytes() + + val entries = readZipEntries(bytes) + assertEquals(1, entries.size) + assertEquals("", entries["empty.txt"]) + } + + @Test + fun testLargeContent() = runBlocking { + val zip = ZipWriter() + val large = "x".repeat(100_000) + zip.addEntry("large.txt", large) + val bytes = zip.toBytes() + + assertTrue(bytes.size < large.length, "ZIP should compress repeated content") + val entries = readZipEntries(bytes) + assertEquals(large, entries["large.txt"]) + } + + private fun readZipEntries(bytes: ByteArray): Map { + val result = mutableMapOf() + val zis = ZipInputStream(ByteArrayInputStream(bytes)) + var entry = zis.nextEntry + while (entry != null) { + result[entry.name] = zis.readBytes().decodeToString() + zis.closeEntry() + entry = zis.nextEntry + } + zis.close() + return result + } +} diff --git a/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/database/migrations/Version22Test.kt b/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/database/migrations/Version22Test.kt index ab772d96..0a1642d7 100644 --- a/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/database/migrations/Version22Test.kt +++ b/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/database/migrations/Version22Test.kt @@ -18,10 +18,12 @@ */ package org.isoron.uhabits.core.database.migrations +import kotlinx.coroutines.runBlocking import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers import org.hamcrest.Matchers.equalTo import org.isoron.platform.io.Database +import org.isoron.platform.io.JavaFileOpener import org.isoron.platform.io.migrateTo import org.isoron.platform.io.querySingle import org.isoron.platform.io.run @@ -38,11 +40,12 @@ class Version22Test : BaseUnitTest() { db = openDatabaseResource("/databases/021.db") } - private fun migrateTo(version: Int) { + private val fileOpener = JavaFileOpener() + + private fun migrateTo(version: Int) = runBlocking { db.migrateTo(version) { v -> - val filename = "%02d.sql".format(v) - javaClass.getResourceAsStream("/migrations/$filename")!! - .bufferedReader().readText() + val path = "migrations/%02d.sql".format(v) + fileOpener.openResourceFile(path).lines().joinToString("\n") } } diff --git a/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/database/migrations/Version23Test.kt b/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/database/migrations/Version23Test.kt index 4e6af63b..c4fdf601 100644 --- a/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/database/migrations/Version23Test.kt +++ b/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/database/migrations/Version23Test.kt @@ -19,9 +19,11 @@ package org.isoron.uhabits.core.database.migrations +import kotlinx.coroutines.runBlocking import org.hamcrest.CoreMatchers.equalTo import org.hamcrest.MatcherAssert.assertThat import org.isoron.platform.io.Database +import org.isoron.platform.io.JavaFileOpener import org.isoron.platform.io.migrateTo import org.isoron.platform.io.query import org.isoron.uhabits.core.BaseUnitTest @@ -36,11 +38,12 @@ class Version23Test : BaseUnitTest() { db = openDatabaseResource("/databases/022.db") } - private fun migrateTo(version: Int) { + private val fileOpener = JavaFileOpener() + + private fun migrateTo(version: Int) = runBlocking { db.migrateTo(version) { v -> - val filename = "%02d.sql".format(v) - javaClass.getResourceAsStream("/migrations/$filename")!! - .bufferedReader().readText() + val path = "migrations/%02d.sql".format(v) + fileOpener.openResourceFile(path).lines().joinToString("\n") } } diff --git a/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/io/HabitsCSVExporterTest.kt b/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/io/HabitsCSVExporterTest.kt index 65a0a6e4..31f8d33b 100644 --- a/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/io/HabitsCSVExporterTest.kt +++ b/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/io/HabitsCSVExporterTest.kt @@ -18,17 +18,17 @@ */ package org.isoron.uhabits.core.io -import org.apache.commons.io.IOUtils +import kotlinx.coroutines.runBlocking import org.isoron.uhabits.core.BaseUnitTest import org.isoron.uhabits.core.models.Habit import org.junit.Before import org.junit.Test +import java.io.ByteArrayInputStream import java.io.File -import java.io.FileOutputStream import java.io.IOException import java.nio.file.Files import java.util.* -import java.util.zip.ZipFile +import java.util.zip.ZipInputStream import kotlin.test.assertEquals import kotlin.test.assertTrue @@ -47,18 +47,16 @@ class HabitsCSVExporterTest : BaseUnitTest() { @Test @Throws(IOException::class) - fun testExportCSV() { + fun testExportCSV() = runBlocking { val selected: MutableList = LinkedList() for (h in habitList) selected.add(h) - val exporter = HabitsCSVExporter( - habitList, - selected, - baseDir - ) - val filename = exporter.writeArchive() - assertAbsolutePathExists(filename) - val archive = File(filename) - unzip(archive) + val exporter = HabitsCSVExporter(habitList, selected) + val bytes = exporter.writeArchive() + assertTrue(bytes.isNotEmpty()) + + // Extract zip entries to baseDir for comparison + unzip(bytes) + val filesToCheck = arrayOf( "001 Meditate/Checkmarks.csv", "001 Meditate/Scores.csv", @@ -75,32 +73,21 @@ class HabitsCSVExporterTest : BaseUnitTest() { } } - @Throws(IOException::class) - private fun unzip(file: File) { - val zip = ZipFile(file) - val e = zip.entries() - while (e.hasMoreElements()) { - val entry = e.nextElement() - val stream = zip.getInputStream(entry) - val outputFilename = String.format( - "%s/%s", - baseDir.absolutePath, - entry.name - ) - val out = File(outputFilename) - val parent = out.parentFile - parent?.mkdirs() - IOUtils.copy(stream, FileOutputStream(out)) + private fun unzip(bytes: ByteArray) { + val zis = ZipInputStream(ByteArrayInputStream(bytes)) + var entry = zis.nextEntry + while (entry != null) { + val outFile = File(baseDir, entry.name) + outFile.parentFile?.mkdirs() + outFile.writeBytes(zis.readBytes()) + zis.closeEntry() + entry = zis.nextEntry } - zip.close() + zis.close() } private fun assertPathExists(s: String) { - assertAbsolutePathExists(String.format("%s/%s", baseDir.absolutePath, s)) - } - - private fun assertAbsolutePathExists(s: String) { - val file = File(s) + val file = File(baseDir, s) assertTrue( String.format("File %s should exist", file.absolutePath) ) { file.exists() } @@ -108,7 +95,7 @@ class HabitsCSVExporterTest : BaseUnitTest() { private fun assertFileAndReferenceAreEqual(s: String) { val assetFilename = String.format("csv_export/%s", s) - val actualFile = File(String.format("%s/%s", baseDir.absolutePath, s)) + val actualFile = File(baseDir, s) val expectedFile = File.createTempFile("asset", "") expectedFile.deleteOnExit() copyAssetToFile(assetFilename, expectedFile) diff --git a/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/io/ImportTest.kt b/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/io/ImportTest.kt index 2801f943..31b23869 100644 --- a/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/io/ImportTest.kt +++ b/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/io/ImportTest.kt @@ -18,8 +18,11 @@ */ package org.isoron.uhabits.core.io +import kotlinx.coroutines.runBlocking import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.core.IsEqual.equalTo +import org.isoron.platform.io.JavaFileOpener +import org.isoron.platform.io.JavaUserFile import org.isoron.platform.time.LocalDate import org.isoron.uhabits.core.BaseUnitTest import org.isoron.uhabits.core.models.Entry @@ -173,25 +176,27 @@ class ImportTest : BaseUnitTest() { } @Throws(IOException::class) - private fun importFromFile(assetFilename: String) { + private fun importFromFile(assetFilename: String) = runBlocking { val file = File.createTempFile("asset", "") copyAssetToFile(assetFilename, file) assertTrue(file.exists()) assertTrue(file.canRead()) + val userFile = JavaUserFile(file.toPath()) val importer = GenericImporter( LoopDBImporter( habitList, modelFactory, databaseOpener, commandRunner, - StandardLogging() + StandardLogging(), + JavaFileOpener() ), RewireDBImporter(habitList, modelFactory, databaseOpener), TickmateDBImporter(habitList, modelFactory, databaseOpener), HabitBullCSVImporter(habitList, modelFactory, StandardLogging()) ) - assertTrue(importer.canHandle(file)) - importer.importHabitsFromFile(file) + assertTrue(importer.canHandle(userFile)) + importer.importHabitsFromFile(userFile) file.delete() } } diff --git a/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/models/HabitListTest.kt b/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/models/HabitListTest.kt index 040f6952..7c6ad410 100644 --- a/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/models/HabitListTest.kt +++ b/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/models/HabitListTest.kt @@ -25,7 +25,6 @@ import org.isoron.uhabits.core.BaseUnitTest import org.junit.Assert.assertThrows import org.junit.Test import java.io.IOException -import java.io.StringWriter import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNull @@ -214,9 +213,7 @@ class HabitListTest : BaseUnitTest() { 003,Wake up early,YES_NO,Did you wake up before 6am?,,2,3,#AFB42B,,,,false """.trimIndent() - val writer = StringWriter() - list.writeCSV(writer) - assertThat(writer.toString(), equalTo(expectedCSV)) + assertThat(list.writeCSV(), equalTo(expectedCSV)) } @Test diff --git a/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/preferences/PreferencesTest.kt b/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/preferences/PreferencesTest.kt index c5f13965..e246fb68 100644 --- a/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/preferences/PreferencesTest.kt +++ b/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/preferences/PreferencesTest.kt @@ -27,7 +27,6 @@ import org.isoron.uhabits.core.ui.ThemeSwitcher import org.junit.Before import org.junit.Test import org.mockito.kotlin.mock -import java.io.File import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue @@ -36,15 +35,13 @@ class PreferencesTest : BaseUnitTest() { private lateinit var prefs: Preferences private var listener: Preferences.Listener = mock() - private lateinit var storage: PropertiesStorage + private lateinit var storage: MemoryStorage @Before @Throws(Exception::class) override fun setUp() { super.setUp() - val file = File.createTempFile("prefs", ".properties") - file.deleteOnExit() - storage = PropertiesStorage(file) + storage = MemoryStorage() prefs = Preferences(storage) prefs.addListener(listener) } diff --git a/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/preferences/PropertiesStorageTest.kt b/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/preferences/PropertiesStorageTest.kt deleted file mode 100644 index ddcc2672..00000000 --- a/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/preferences/PropertiesStorageTest.kt +++ /dev/null @@ -1,102 +0,0 @@ -/* - * Copyright (C) 2016-2025 Álinson Santos Xavier - * - * 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 . - */ -package org.isoron.uhabits.core.preferences - -import org.hamcrest.CoreMatchers.equalTo -import org.hamcrest.MatcherAssert.assertThat -import org.isoron.uhabits.core.BaseUnitTest -import org.junit.Before -import org.junit.Test -import java.io.File -import java.util.Arrays -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -class PropertiesStorageTest : BaseUnitTest() { - private lateinit var storage: PropertiesStorage - private lateinit var file: File - - @Before - @Throws(Exception::class) - override fun setUp() { - super.setUp() - file = File.createTempFile("test", ".properties") - file.deleteOnExit() - storage = PropertiesStorage(file) - } - - @Test - @Throws(Exception::class) - fun testPutGetRemove() { - storage.putBoolean("booleanKey", true) - assertTrue(storage.getBoolean("booleanKey", false)) - assertFalse(storage.getBoolean("random", false)) - storage.putInt("intKey", 64) - assertThat(storage.getInt("intKey", 200), equalTo(64)) - assertThat(storage.getInt("random", 200), equalTo(200)) - storage.putLong("longKey", 64L) - assertThat(storage.getLong("intKey", 200L), equalTo(64L)) - assertThat(storage.getLong("random", 200L), equalTo(200L)) - storage.putString("stringKey", "Hello") - assertThat(storage.getString("stringKey", ""), equalTo("Hello")) - assertThat(storage.getString("random", ""), equalTo("")) - storage.remove("stringKey") - assertThat(storage.getString("stringKey", ""), equalTo("")) - storage.clear() - assertThat(storage.getLong("intKey", 200L), equalTo(200L)) - assertFalse(storage.getBoolean("booleanKey", false)) - } - - @Test - @Throws(Exception::class) - fun testPersistence() { - storage.putBoolean("booleanKey", true) - storage.putInt("intKey", 64) - storage.putLong("longKey", 64L) - storage.putString("stringKey", "Hello") - val storage2 = PropertiesStorage(file) - assertTrue(storage2.getBoolean("booleanKey", false)) - assertThat(storage2.getInt("intKey", 200), equalTo(64)) - assertThat(storage2.getLong("intKey", 200L), equalTo(64L)) - assertThat(storage2.getString("stringKey", ""), equalTo("Hello")) - } - - @Test - @Throws(Exception::class) - fun testLongArray() { - val expected1 = longArrayOf(1L, 2L, 3L, 5L) - val expected2 = longArrayOf(1L) - val expected3 = longArrayOf() - val expected4 = longArrayOf() - storage.putLongArray("key1", expected1) - storage.putLongArray("key2", expected2) - storage.putLongArray("key3", expected3) - val actual1 = storage.getLongArray("key1", longArrayOf()) - val actual2 = storage.getLongArray("key2", longArrayOf()) - val actual3 = storage.getLongArray("key3", longArrayOf()) - val actual4 = storage.getLongArray("invalidKey", longArrayOf()) - assertTrue(Arrays.equals(actual1, expected1)) - assertTrue(Arrays.equals(actual2, expected2)) - assertTrue(Arrays.equals(actual3, expected3)) - assertTrue(Arrays.equals(actual4, expected4)) - assertEquals("1,2,3,5", storage.getString("key1", "")) - assertEquals(1, storage.getLong("key2", -1)) - } -} diff --git a/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/ui/screens/habits/list/ListHabitsBehaviorTest.kt b/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/ui/screens/habits/list/ListHabitsBehaviorTest.kt index 7e904966..84e73ef3 100644 --- a/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/ui/screens/habits/list/ListHabitsBehaviorTest.kt +++ b/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/ui/screens/habits/list/ListHabitsBehaviorTest.kt @@ -36,7 +36,6 @@ import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.mockito.kotlin.whenever -import java.io.IOException import java.nio.file.Files import kotlin.test.assertFalse import kotlin.test.assertTrue @@ -92,7 +91,7 @@ class ListHabitsBehaviorTest : BaseUnitTest() { @Throws(Exception::class) fun testOnExportCSV() { val outputDir = Files.createTempDirectory("CSV").toFile() - whenever(dirFinder.getCSVOutputDir()).thenReturn(outputDir) + whenever(dirFinder.getCSVOutputDir()).thenReturn(outputDir.absolutePath) behavior.onExportCSV() verify(screen).showSendFileScreen(any()) assertThat(FileUtils.listFiles(outputDir, null, false).size, equalTo(1)) @@ -104,7 +103,7 @@ class ListHabitsBehaviorTest : BaseUnitTest() { fun testOnExportCSV_fail() { val outputDir = Files.createTempDirectory("CSV").toFile() outputDir.setWritable(false) - whenever(dirFinder.getCSVOutputDir()).thenReturn(outputDir) + whenever(dirFinder.getCSVOutputDir()).thenReturn(outputDir.absolutePath) behavior.onExportCSV() verify(screen).showMessage(ListHabitsBehavior.Message.COULD_NOT_EXPORT) assertTrue(outputDir.delete()) @@ -132,13 +131,12 @@ class ListHabitsBehaviorTest : BaseUnitTest() { } @Test - @Throws(IOException::class) fun testOnSendBugReport() { whenever(bugReporter.getBugReport()).thenReturn("hello") behavior.onSendBugReport() verify(bugReporter).dumpBugReportToFile() verify(screen).showSendBugReportToDeveloperScreen("hello") - whenever(bugReporter.getBugReport()).thenThrow(IOException()) + whenever(bugReporter.getBugReport()).thenThrow(RuntimeException()) behavior.onSendBugReport() verify(screen).showMessage(ListHabitsBehavior.Message.COULD_NOT_GENERATE_BUG_REPORT) } diff --git a/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/ui/screens/habits/show/ShowHabitMenuPresenterTest.kt b/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/ui/screens/habits/show/ShowHabitMenuPresenterTest.kt index 8d90e5c9..ff042936 100644 --- a/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/ui/screens/habits/show/ShowHabitMenuPresenterTest.kt +++ b/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/ui/screens/habits/show/ShowHabitMenuPresenterTest.kt @@ -61,7 +61,7 @@ class ShowHabitMenuPresenterTest : BaseUnitTest() { @Throws(Exception::class) fun testOnExport() { val outputDir = Files.createTempDirectory("CSV").toFile() - whenever(system.getCSVOutputDir()).thenReturn(outputDir) + whenever(system.getCSVOutputDir()).thenReturn(outputDir.absolutePath) menu.onExportCSV() assertThat(FileUtils.listFiles(outputDir, null, false).size, equalTo(1)) FileUtils.deleteDirectory(outputDir) diff --git a/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/utils/FileExtensionsTest.kt b/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/utils/FileExtensionsTest.kt index 62742260..58e21b39 100644 --- a/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/utils/FileExtensionsTest.kt +++ b/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/utils/FileExtensionsTest.kt @@ -1,5 +1,7 @@ package org.isoron.uhabits.core.utils +import kotlinx.coroutines.runBlocking +import org.isoron.platform.io.JavaUserFile import org.isoron.uhabits.core.BaseUnitTest import org.junit.Test import java.io.File @@ -8,10 +10,11 @@ import kotlin.test.assertTrue class FileExtensionsTest : BaseUnitTest() { @Test - fun testIsSQLite3File() { + fun testIsSQLite3File() = runBlocking { val file = File.createTempFile("asset", "") copyAssetToFile("loop.db", file) - val isSqlite3File = file.isSQLite3File() + val userFile = JavaUserFile(file.toPath()) + val isSqlite3File = isSQLite3File(userFile) assertTrue(isSqlite3File) } }