Replace java.io.File with multiplatform Files abstraction

This commit is contained in:
Alinson S. Xavier 2026-04-06 10:56:08 -05:00
parent 0dbebecbfb
commit de38383a34
61 changed files with 686 additions and 530 deletions

View File

@ -0,0 +1,67 @@
/*
* 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.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<String> {
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())
}
}

View File

@ -22,6 +22,7 @@ package org.isoron.uhabits
import android.content.Context import android.content.Context
import android.database.sqlite.SQLiteDatabase import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper import android.database.sqlite.SQLiteOpenHelper
import kotlinx.coroutines.runBlocking
import org.isoron.platform.io.migrateTo import org.isoron.platform.io.migrateTo
import org.isoron.platform.io.setVersion import org.isoron.platform.io.setVersion
import org.isoron.uhabits.core.database.UnsupportedDatabaseVersionException import org.isoron.uhabits.core.database.UnsupportedDatabaseVersionException
@ -53,9 +54,11 @@ class HabitsDatabaseOpener(
if (db.version < 8) throw UnsupportedDatabaseVersionException() if (db.version < 8) throw UnsupportedDatabaseVersionException()
val wrappedDb = AndroidDatabase(db) val wrappedDb = AndroidDatabase(db)
wrappedDb.setVersion(db.version) wrappedDb.setVersion(db.version)
wrappedDb.migrateTo(newVersion) { version -> runBlocking {
val filename = "%02d.sql".format(version) wrappedDb.migrateTo(newVersion) { version ->
context.assets.open("migrations/$filename").bufferedReader().readText() val filename = "%02d.sql".format(version)
context.assets.open("migrations/$filename").bufferedReader().readText()
}
} }
} }

View File

@ -22,14 +22,13 @@ import me.tatarka.inject.annotations.Inject
import org.isoron.uhabits.AndroidDirFinder import org.isoron.uhabits.AndroidDirFinder
import org.isoron.uhabits.core.ui.screens.habits.list.ListHabitsBehavior import org.isoron.uhabits.core.ui.screens.habits.list.ListHabitsBehavior
import org.isoron.uhabits.core.ui.screens.habits.show.ShowHabitMenuPresenter import org.isoron.uhabits.core.ui.screens.habits.show.ShowHabitMenuPresenter
import java.io.File
@Inject @Inject
class HabitsDirFinder( class HabitsDirFinder(
private val androidDirFinder: AndroidDirFinder private val androidDirFinder: AndroidDirFinder
) : ShowHabitMenuPresenter.System, ListHabitsBehavior.DirFinder { ) : ShowHabitMenuPresenter.System, ListHabitsBehavior.DirFinder {
override fun getCSVOutputDir(): File { override fun getCSVOutputDir(): String {
return androidDirFinder.getFilesDir("CSV")!! return androidDirFinder.getFilesDir("CSV")!!.absolutePath
} }
} }

View File

@ -30,6 +30,7 @@ import nl.dionsegijn.konfetti.core.Party
import nl.dionsegijn.konfetti.core.Position import nl.dionsegijn.konfetti.core.Position
import nl.dionsegijn.konfetti.core.emitter.Emitter import nl.dionsegijn.konfetti.core.emitter.Emitter
import org.isoron.platform.gui.toInt import org.isoron.platform.gui.toInt
import org.isoron.platform.io.JavaUserFile
import org.isoron.uhabits.R import org.isoron.uhabits.R
import org.isoron.uhabits.activities.common.dialogs.CheckmarkDialog import org.isoron.uhabits.activities.common.dialogs.CheckmarkDialog
import org.isoron.uhabits.activities.common.dialogs.ColorPickerDialogFactory import org.isoron.uhabits.activities.common.dialogs.ColorPickerDialogFactory
@ -137,7 +138,7 @@ class ListHabitsScreen(
val cacheDir = activity.externalCacheDir val cacheDir = activity.externalCacheDir
val tempFile = File.createTempFile("import", "", cacheDir) val tempFile = File.createTempFile("import", "", cacheDir)
inStream.copyTo(tempFile) inStream.copyTo(tempFile)
onImportData(tempFile) { tempFile.delete() } onImportData(JavaUserFile(tempFile.toPath())) { tempFile.delete() }
} catch (e: IOException) { } catch (e: IOException) {
activity.showMessage(activity.resources.getString(R.string.could_not_import)) activity.showMessage(activity.resources.getString(R.string.could_not_import))
e.printStackTrace() 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( taskRunner.execute(
importTaskFactory.create(file) { result -> importTaskFactory.create(file) { result ->
when (result) { when (result) {

View File

@ -21,7 +21,9 @@ package org.isoron.uhabits.inject
import android.content.Context import android.content.Context
import me.tatarka.inject.annotations.Component import me.tatarka.inject.annotations.Component
import me.tatarka.inject.annotations.Provides import me.tatarka.inject.annotations.Provides
import org.isoron.platform.io.AndroidFileOpener
import org.isoron.platform.io.DatabaseOpener import org.isoron.platform.io.DatabaseOpener
import org.isoron.platform.io.FileOpener
import org.isoron.uhabits.core.AppScope import org.isoron.uhabits.core.AppScope
import org.isoron.uhabits.core.commands.CommandRunner import org.isoron.uhabits.core.commands.CommandRunner
import org.isoron.uhabits.core.io.GenericImporter import org.isoron.uhabits.core.io.GenericImporter
@ -136,4 +138,9 @@ abstract class HabitsApplicationComponent(
@AppScope @AppScope
@Provides @Provides
open fun taskRunner(): TaskRunner = AndroidTaskRunner() open fun taskRunner(): TaskRunner = AndroidTaskRunner()
@AppScope
@Provides
open fun fileOpener(): FileOpener =
AndroidFileOpener(appContext.assets, appContext.filesDir)
} }

View File

@ -19,18 +19,19 @@
package org.isoron.uhabits.tasks package org.isoron.uhabits.tasks
import android.util.Log 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.begin
import org.isoron.platform.io.commit import org.isoron.platform.io.commit
import org.isoron.uhabits.core.io.GenericImporter import org.isoron.uhabits.core.io.GenericImporter
import org.isoron.uhabits.core.models.ModelFactory import org.isoron.uhabits.core.models.ModelFactory
import org.isoron.uhabits.core.models.sqlite.SQLModelFactory import org.isoron.uhabits.core.models.sqlite.SQLModelFactory
import org.isoron.uhabits.core.tasks.Task import org.isoron.uhabits.core.tasks.Task
import java.io.File
class ImportDataTask( class ImportDataTask(
private val importer: GenericImporter, private val importer: GenericImporter,
modelFactory: ModelFactory, modelFactory: ModelFactory,
private val file: File, private val file: UserFile,
private val listener: Listener private val listener: Listener
) : Task { ) : Task {
private var result = 0 private var result = 0
@ -38,13 +39,15 @@ class ImportDataTask(
override fun doInBackground() { override fun doInBackground() {
modelFactory.database.begin() modelFactory.database.begin()
try { try {
if (importer.canHandle(file)) { runBlocking {
importer.importHabitsFromFile(file) if (importer.canHandle(file)) {
result = SUCCESS importer.importHabitsFromFile(file)
modelFactory.database.commit() result = SUCCESS
} else { modelFactory.database.commit()
result = NOT_RECOGNIZED } else {
modelFactory.database.commit() result = NOT_RECOGNIZED
modelFactory.database.commit()
}
} }
} catch (e: Exception) { } catch (e: Exception) {
result = FAILED result = FAILED

View File

@ -20,15 +20,15 @@
package org.isoron.uhabits.tasks package org.isoron.uhabits.tasks
import me.tatarka.inject.annotations.Inject import me.tatarka.inject.annotations.Inject
import org.isoron.platform.io.UserFile
import org.isoron.uhabits.core.io.GenericImporter import org.isoron.uhabits.core.io.GenericImporter
import org.isoron.uhabits.core.models.ModelFactory import org.isoron.uhabits.core.models.ModelFactory
import java.io.File
@Inject @Inject
class ImportDataTaskFactory( class ImportDataTaskFactory(
private val importer: GenericImporter, private val importer: GenericImporter,
private val modelFactory: ModelFactory private val modelFactory: ModelFactory
) { ) {
fun create(file: File, listener: ImportDataTask.Listener) = fun create(file: UserFile, listener: ImportDataTask.Listener) =
ImportDataTask(importer, modelFactory, file, listener) ImportDataTask(importer, modelFactory, file, listener)
} }

View File

@ -106,7 +106,7 @@ inline fun <T> Database.querySingle(
return result return result
} }
fun Database.migrateTo(targetVersion: Int, loadMigrationSQL: (Int) -> String) { suspend fun Database.migrateTo(targetVersion: Int, loadMigrationSQL: suspend (Int) -> String) {
val currentVersion = getVersion() val currentVersion = getVersion()
if (currentVersion >= targetVersion) return if (currentVersion >= targetVersion) return
for (v in (currentVersion + 1)..targetVersion) { for (v in (currentVersion + 1)..targetVersion) {

View File

@ -52,6 +52,11 @@ interface FileOpener {
* result of some user action, such as databases and logs. * result of some user action, such as databases and logs.
*/ */
interface UserFile { interface UserFile {
/**
* The resolved absolute path string.
*/
val pathString: String
/** /**
* Deletes the user file. If the file does not exist, nothing happens. * Deletes the user file. If the file does not exist, nothing happens.
*/ */
@ -67,6 +72,21 @@ interface UserFile {
* exception. * exception.
*/ */
suspend fun lines(): List<String> suspend fun lines(): List<String>
/**
* 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
} }
/** /**

View File

@ -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: Int): String
expect fun format(format: String, arg: Double): String expect fun format(format: String, arg: Double): String
fun parseCsvLine(line: String): List<String> {
val result = mutableListOf<String>()
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>): String { fun csvLine(fields: Array<String>): String {
return fields.joinToString(",") { field -> return fields.joinToString(",") { field ->
if (field.any { it == ',' || it == '"' || it == '\n' || it == '\r' }) { if (field.any { it == ',' || it == '"' || it == '\n' || it == '\r' }) {

View File

@ -0,0 +1,25 @@
/*
* 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.io
expect class ZipWriter() {
fun addEntry(name: String, content: String)
suspend fun toBytes(): ByteArray
}

View File

@ -1,5 +1,6 @@
package org.isoron.platform.io package org.isoron.platform.io
import kotlinx.coroutines.runBlocking
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
@ -31,7 +32,7 @@ class MigrationTest {
} }
@Test @Test
fun testMigrateIdempotent() { fun testMigrateIdempotent() = runBlocking {
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

@ -66,6 +66,9 @@ class JavaResourceFile(val path: String) : ResourceFile {
@Suppress("NewApi") @Suppress("NewApi")
class JavaUserFile(val path: Path) : UserFile { class JavaUserFile(val path: Path) : UserFile {
override val pathString: String
get() = path.toString()
override suspend fun lines(): List<String> { override suspend fun lines(): List<String> {
return Files.readAllLines(path) return Files.readAllLines(path)
} }
@ -77,6 +80,25 @@ class JavaUserFile(val path: Path) : UserFile {
override suspend fun delete() { override suspend fun delete() {
Files.delete(path) 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") @Suppress("NewApi")

View File

@ -0,0 +1,40 @@
/*
* 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.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()
}
}

View File

@ -18,9 +18,9 @@
*/ */
package org.isoron.uhabits.core.io package org.isoron.uhabits.core.io
import java.io.File import org.isoron.platform.io.UserFile
abstract class AbstractImporter { abstract class AbstractImporter {
abstract fun canHandle(file: File): Boolean abstract suspend fun canHandle(file: UserFile): Boolean
abstract fun importHabitsFromFile(file: File) abstract suspend fun importHabitsFromFile(file: UserFile)
} }

View File

@ -19,7 +19,7 @@
package org.isoron.uhabits.core.io package org.isoron.uhabits.core.io
import me.tatarka.inject.annotations.Inject 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 * A GenericImporter decides which implementation of AbstractImporter is able to
@ -40,7 +40,7 @@ class GenericImporter(
habitBullCSVImporter habitBullCSVImporter
) )
override fun canHandle(file: File): Boolean { override suspend fun canHandle(file: UserFile): Boolean {
for (importer in importers) { for (importer in importers) {
if (importer.canHandle(file)) { if (importer.canHandle(file)) {
return true return true
@ -49,7 +49,7 @@ class GenericImporter(
return false return false
} }
override fun importHabitsFromFile(file: File) { override suspend fun importHabitsFromFile(file: UserFile) {
for (importer in importers) { for (importer in importers) {
if (importer.canHandle(file)) { if (importer.canHandle(file)) {
importer.importHabitsFromFile(file) importer.importHabitsFromFile(file)

View File

@ -18,8 +18,9 @@
*/ */
package org.isoron.uhabits.core.io package org.isoron.uhabits.core.io
import com.opencsv.CSVReader
import me.tatarka.inject.annotations.Inject 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.platform.time.LocalDate
import org.isoron.uhabits.core.models.Entry import org.isoron.uhabits.core.models.Entry
import org.isoron.uhabits.core.models.Frequency 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.HabitList
import org.isoron.uhabits.core.models.HabitType import org.isoron.uhabits.core.models.HabitType
import org.isoron.uhabits.core.models.ModelFactory 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. * Class that imports data from HabitBull CSV files.
@ -43,16 +41,22 @@ class HabitBullCSVImporter(
private val logger = logging.getLogger("HabitBullCSVImporter") private val logger = logging.getLogger("HabitBullCSVImporter")
override fun canHandle(file: File): Boolean { override suspend fun canHandle(file: UserFile): Boolean {
val reader = BufferedReader(FileReader(file)) return try {
val line = reader.readLine() val lines = file.lines()
return line.startsWith("HabitName,HabitDescription,HabitCategory") if (lines.isEmpty()) return false
lines[0].startsWith("HabitName,HabitDescription,HabitCategory")
} catch (e: Exception) {
false
}
} }
override fun importHabitsFromFile(file: File) { override suspend fun importHabitsFromFile(file: UserFile) {
val reader = CSVReader(FileReader(file)) val lines = file.lines()
val map = HashMap<String, Habit>() val map = HashMap<String, Habit>()
for (cols in reader) { for (line in lines) {
val cols = parseCsvLine(line)
if (cols.size < 6) continue
val name = cols[0] val name = cols[0]
if (name == "HabitName") continue if (name == "HabitName") continue
val description = cols[1] val description = cols[1]
@ -61,13 +65,13 @@ class HabitBullCSVImporter(
if (h == null) { if (h == null) {
h = modelFactory.buildHabit() h = modelFactory.buildHabit()
h.name = name h.name = name
h.description = description ?: "" h.description = description
h.frequency = Frequency.DAILY h.frequency = Frequency.DAILY
habitList.add(h) habitList.add(h)
map[name] = h map[name] = h
logger.info("Creating habit: $name") logger.info("Creating habit: $name")
} }
val notes = cols[5] ?: "" val notes = cols[5]
when (val value = parseInt(cols[4])) { when (val value = parseInt(cols[4])) {
0 -> h.originalEntries.add(Entry(date, Entry.NO, notes)) 0 -> h.originalEntries.add(Entry(date, Entry.NO, notes))
1 -> h.originalEntries.add(Entry(date, Entry.YES_MANUAL, notes)) 1 -> h.originalEntries.add(Entry(date, Entry.YES_MANUAL, notes))
@ -86,12 +90,10 @@ class HabitBullCSVImporter(
private fun parseDate(rawValue: String): LocalDate { private fun parseDate(rawValue: String): LocalDate {
if (rawValue.contains("-")) { if (rawValue.contains("-")) {
// yyyy-MM-dd
val parts = rawValue.split("-") val parts = rawValue.split("-")
return LocalDate(parts[0].toInt(), parts[1].toInt(), parts[2].toInt()) return LocalDate(parts[0].toInt(), parts[1].toInt(), parts[2].toInt())
} }
if (rawValue.contains("/")) { if (rawValue.contains("/")) {
// M/d/yyyy
val parts = rawValue.split("/") val parts = rawValue.split("/")
return LocalDate(parts[2].toInt(), parts[0].toInt(), parts[1].toInt()) return LocalDate(parts[2].toInt(), parts[0].toInt(), parts[1].toInt())
} }

View File

@ -18,61 +18,41 @@
*/ */
package org.isoron.uhabits.core.io 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.LocalDate
import org.isoron.platform.time.getToday 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.EntryList
import org.isoron.uhabits.core.models.Habit import org.isoron.uhabits.core.models.Habit
import org.isoron.uhabits.core.models.HabitList 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 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( class HabitsCSVExporter(
private val allHabits: HabitList, private val allHabits: HabitList,
private val selectedHabits: List<Habit>, private val selectedHabits: List<Habit>
dir: File
) { ) {
private val generatedDirs = LinkedList<String>()
private val generatedFilenames = LinkedList<String>()
private val exportDirName: String = dir.absolutePath + "/"
private val delimiter = "," private val delimiter = ","
fun writeArchive(): String { suspend fun writeArchive(): ByteArray {
writeHabits() val zip = ZipWriter()
val zipFilename = writeZipFile() zip.addEntry("Habits.csv", allHabits.writeCSV())
cleanup() for (h in selectedHabits) {
return zipFilename 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) { private fun habitDirName(h: Habit): String {
val fis = FileInputStream(File(exportDirName + filename)) val sane = sanitizeFilename(h.name)
val ze = ZipEntry(filename) return format("%03d", allHabits.indexOf(h) + 1) + " " + sane.trim() + "/"
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 sanitizeFilename(name: String): String { private fun sanitizeFilename(name: String): String {
@ -80,139 +60,75 @@ class HabitsCSVExporter(
return s.substring(0, min(s.length, 100)) return s.substring(0, min(s.length, 100))
} }
private fun writeHabits() { private fun writeScores(habit: Habit): String {
val filename = "Habits.csv" val sb = StringBuilder()
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)
val today = getToday() val today = getToday()
var oldest = today var oldest = today
val known = habit.computedEntries.getKnown() val known = habit.computedEntries.getKnown()
if (known.isNotEmpty()) oldest = known[known.size - 1].date if (known.isNotEmpty()) oldest = known[known.size - 1].date
val csv = CSVWriter(out) sb.append(csvLine(arrayOf("Date", "Score")))
csv.writeNext(arrayOf("Date", "Score"), false)
for (s in habit.scores.getByInterval(oldest, today)) { for (s in habit.scores.getByInterval(oldest, today)) {
val date = s.date.toCSVString() sb.append(csvLine(arrayOf(s.date.toCSVString(), format("%.4f", s.value))))
val score = String.format(Locale.US, "%.4f", s.value)
csv.writeNext(arrayOf(date, score), false)
} }
csv.close() return sb.toString()
out.close()
} }
private fun writeEntries(habitDirName: String, entries: EntryList) { private fun writeEntries(entries: EntryList): String {
val filename = habitDirName + "Checkmarks.csv" val sb = StringBuilder()
val out = FileWriter(exportDirName + filename) sb.append(csvLine(arrayOf("Date", "Value", "Notes")))
generatedFilenames.add(filename)
val csv = CSVWriter(out)
csv.writeNext(arrayOf("Date", "Value", "Notes"), false)
for (entry in entries.getKnown()) { for (entry in entries.getKnown()) {
val date = entry.date.toCSVString() sb.append(csvLine(arrayOf(entry.date.toCSVString(), entry.formattedValue, entry.notes)))
csv.writeNext(
arrayOf(
date,
entry.formattedValue,
entry.notes
),
false
)
} }
csv.close() return sb.toString()
out.close()
} }
/** private fun writeMultipleHabitsScores(): String {
* Writes a scores file and a checkmarks file containing scores and checkmarks of every habit. val sb = StringBuilder()
* The first column corresponds to the date. Subsequent columns correspond to a habit. writeMultipleHabitsHeader(sb)
* 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)
val timeframe = getTimeframe() val timeframe = getTimeframe()
val oldest = timeframe[0] val oldest = timeframe[0]
val newest = getToday() val newest = getToday()
val checkmarks: MutableList<ArrayList<Entry>> = ArrayList() val scores = selectedHabits.map { ArrayList(it.scores.getByInterval(oldest, newest)) }
val scores: MutableList<ArrayList<Score>> = ArrayList()
for (habit in selectedHabits) {
checkmarks.add(ArrayList(habit.computedEntries.getByInterval(oldest, newest)))
scores.add(ArrayList(habit.scores.getByInterval(oldest, newest)))
}
val days = oldest.daysUntil(newest) val days = oldest.daysUntil(newest)
for (i in 0..days) { for (i in 0..days) {
val date = newest.minus(i).toCSVString() val date = newest.minus(i).toCSVString()
val sb = StringBuilder()
sb.append(date).append(delimiter) sb.append(date).append(delimiter)
checksWriter.write(sb.toString())
scoresWriter.write(sb.toString())
for (j in selectedHabits.indices) { for (j in selectedHabits.indices) {
checksWriter.write(checkmarks[j][i].formattedValue) val score = format("%.4f", scores[j][i].value)
checksWriter.write(delimiter) sb.append(score).append(delimiter)
val score = String.format(Locale.US, "%.4f", scores[j][i].value)
scoresWriter.write(score)
scoresWriter.write(delimiter)
} }
checksWriter.write("\n") sb.append("\n")
scoresWriter.write("\n")
} }
scoresWriter.close() return sb.toString()
checksWriter.close()
} }
/** private fun writeMultipleHabitsCheckmarks(): String {
* Writes the first row, containing header information, using the given writer. val sb = StringBuilder()
* This consists of the date title and the names of the selected habits. writeMultipleHabitsHeader(sb)
* val timeframe = getTimeframe()
* @param out the writer to use val oldest = timeframe[0]
* @throws IOException if there was a problem writing val newest = getToday()
*/ val checkmarks = selectedHabits.map { ArrayList(it.computedEntries.getByInterval(oldest, newest)) }
@Throws(IOException::class) val days = oldest.daysUntil(newest)
private fun writeMultipleHabitsHeader(out: Writer) { for (i in 0..days) {
out.write("Date$delimiter") 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) { for (habit in selectedHabits) {
out.write(habit.name) sb.append(habit.name).append(delimiter)
out.write(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<LocalDate> { private fun getTimeframe(): Array<LocalDate> {
var oldest = LocalDate(1000000) var oldest = LocalDate(1000000)
var newest = LocalDate(0) var newest = LocalDate(0)
@ -226,15 +142,4 @@ class HabitsCSVExporter(
} }
return arrayOf(oldest, newest) 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
}
} }

View File

@ -21,6 +21,8 @@ package org.isoron.uhabits.core.io
import me.tatarka.inject.annotations.Inject import me.tatarka.inject.annotations.Inject
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.UserFile
import org.isoron.platform.io.getVersion import org.isoron.platform.io.getVersion
import org.isoron.platform.io.migrateTo import org.isoron.platform.io.migrateTo
import org.isoron.platform.io.query 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.ModelFactory
import org.isoron.uhabits.core.models.sqlite.SQLiteHabitList import org.isoron.uhabits.core.models.sqlite.SQLiteHabitList
import org.isoron.uhabits.core.utils.isSQLite3File import org.isoron.uhabits.core.utils.isSQLite3File
import java.io.File
/** /**
* Class that imports data from database files exported by Loop Habit Tracker. * Class that imports data from database files exported by Loop Habit Tracker.
@ -48,14 +49,15 @@ class LoopDBImporter(
@AppScope val modelFactory: ModelFactory, @AppScope val modelFactory: ModelFactory,
@AppScope val opener: DatabaseOpener, @AppScope val opener: DatabaseOpener,
@AppScope val runner: CommandRunner, @AppScope val runner: CommandRunner,
@AppScope logging: Logging @AppScope logging: Logging,
@AppScope val fileOpener: FileOpener
) : AbstractImporter() { ) : AbstractImporter() {
private val logger = logging.getLogger("LoopDBImporter") private val logger = logging.getLogger("LoopDBImporter")
override fun canHandle(file: File): Boolean { override suspend fun canHandle(file: UserFile): Boolean {
if (!file.isSQLite3File()) return false if (!isSQLite3File(file)) return false
val db = opener.open(file.absolutePath) val db = opener.open(file.pathString)
var canHandle = true var canHandle = true
val count = db.querySingle( val count = db.querySingle(
"select count(*) from SQLITE_MASTER where name='Habits' or name='Repetitions'" "select count(*) from SQLITE_MASTER where name='Habits' or name='Repetitions'"
@ -72,12 +74,11 @@ class LoopDBImporter(
return canHandle return canHandle
} }
override fun importHabitsFromFile(file: File) { override suspend fun importHabitsFromFile(file: UserFile) {
val db = opener.open(file.absolutePath) val db = opener.open(file.pathString)
db.migrateTo(DATABASE_VERSION) { version -> db.migrateTo(DATABASE_VERSION) { version ->
val filename = "%02d.sql".format(version) val filename = "%02d.sql".format(version)
javaClass.getResourceAsStream("/migrations/$filename")!! fileOpener.openResourceFile("migrations/$filename").lines().joinToString("\n")
.bufferedReader().readText()
} }
val habitDataList = loadHabits(db) val habitDataList = loadHabits(db)

View File

@ -21,6 +21,7 @@ package org.isoron.uhabits.core.io
import me.tatarka.inject.annotations.Inject import me.tatarka.inject.annotations.Inject
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.UserFile
import org.isoron.platform.io.begin import org.isoron.platform.io.begin
import org.isoron.platform.io.commit import org.isoron.platform.io.commit
import org.isoron.platform.io.query 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.Reminder
import org.isoron.uhabits.core.models.WeekdayList import org.isoron.uhabits.core.models.WeekdayList
import org.isoron.uhabits.core.utils.isSQLite3File import org.isoron.uhabits.core.utils.isSQLite3File
import java.io.File
/** /**
* Class that imports database files exported by Rewire. * Class that imports database files exported by Rewire.
@ -46,9 +46,9 @@ class RewireDBImporter(
private val opener: DatabaseOpener private val opener: DatabaseOpener
) : AbstractImporter() { ) : AbstractImporter() {
override fun canHandle(file: File): Boolean { override suspend fun canHandle(file: UserFile): Boolean {
if (!file.isSQLite3File()) return false if (!isSQLite3File(file)) return false
val db = opener.open(file.absolutePath) val db = opener.open(file.pathString)
val count = db.querySingle( val count = db.querySingle(
"select count(*) from SQLITE_MASTER where name='CHECKINS' or name='UNIT'" "select count(*) from SQLITE_MASTER where name='CHECKINS' or name='UNIT'"
) { it.getInt(0) } ) { it.getInt(0) }
@ -56,8 +56,8 @@ class RewireDBImporter(
return count == 2 return count == 2
} }
override fun importHabitsFromFile(file: File) { override suspend fun importHabitsFromFile(file: UserFile) {
val db = opener.open(file.absolutePath) val db = opener.open(file.pathString)
db.begin() db.begin()
createHabits(db) createHabits(db)
db.commit() db.commit()

View File

@ -21,6 +21,7 @@ package org.isoron.uhabits.core.io
import me.tatarka.inject.annotations.Inject import me.tatarka.inject.annotations.Inject
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.UserFile
import org.isoron.platform.io.begin import org.isoron.platform.io.begin
import org.isoron.platform.io.commit import org.isoron.platform.io.commit
import org.isoron.platform.io.query 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.HabitList
import org.isoron.uhabits.core.models.ModelFactory import org.isoron.uhabits.core.models.ModelFactory
import org.isoron.uhabits.core.utils.isSQLite3File import org.isoron.uhabits.core.utils.isSQLite3File
import java.io.File
/** /**
* Class that imports data from database files exported by Tickmate. * Class that imports data from database files exported by Tickmate.
@ -44,9 +44,9 @@ class TickmateDBImporter(
private val opener: DatabaseOpener private val opener: DatabaseOpener
) : AbstractImporter() { ) : AbstractImporter() {
override fun canHandle(file: File): Boolean { override suspend fun canHandle(file: UserFile): Boolean {
if (!file.isSQLite3File()) return false if (!isSQLite3File(file)) return false
val db = opener.open(file.absolutePath) val db = opener.open(file.pathString)
val count = db.querySingle( val count = db.querySingle(
"select count(*) from SQLITE_MASTER where name='tracks' or name='track2groups'" "select count(*) from SQLITE_MASTER where name='tracks' or name='track2groups'"
) { it.getInt(0) } ) { it.getInt(0) }
@ -54,8 +54,8 @@ class TickmateDBImporter(
return count == 2 return count == 2
} }
override fun importHabitsFromFile(file: File) { override suspend fun importHabitsFromFile(file: UserFile) {
val db = opener.open(file.absolutePath) val db = opener.open(file.pathString)
db.begin() db.begin()
createHabits(db) createHabits(db)
db.commit() db.commit()

View File

@ -20,14 +20,10 @@ package org.isoron.uhabits.core.models
import org.isoron.platform.io.csvLine import org.isoron.platform.io.csvLine
import org.isoron.platform.io.format 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. * An ordered collection of [Habit]s.
*/ */
@ThreadSafe
abstract class HabitList : Iterable<Habit> { abstract class HabitList : Iterable<Habit> {
val observable: ModelObservable val observable: ModelObservable
@ -168,16 +164,12 @@ abstract class HabitList : Iterable<Habit> {
} }
/** /**
* Writes the list of habits to the given writer, in CSV format. There is * Returns the list of habits in CSV format. There is one line for each
* one line for each habit, containing the fields name, description, * habit, containing the fields name, description, frequency numerator,
* frequency numerator, frequency denominator and color. The color is * frequency denominator and color.
* written in HTML format (#000000).
*
* @param out the writer that will receive the result
* @throws IOException if write operations fail
*/ */
@Throws(IOException::class) fun writeCSV(): String {
fun writeCSV(out: Writer) { val sb = StringBuilder()
val header = arrayOf( val header = arrayOf(
"Position", "Position",
"Name", "Name",
@ -192,7 +184,7 @@ abstract class HabitList : Iterable<Habit> {
"Target Value", "Target Value",
"Archived?" "Archived?"
) )
out.write(csvLine(header)) sb.append(csvLine(header))
for (habit in this) { for (habit in this) {
val (numerator, denominator) = habit.frequency val (numerator, denominator) = habit.frequency
val cols = arrayOf( val cols = arrayOf(
@ -209,9 +201,9 @@ abstract class HabitList : Iterable<Habit> {
if (habit.isNumerical) habit.targetValue.toString() else "", if (habit.isNumerical) habit.targetValue.toString() else "",
habit.isArchived.toString() habit.isArchived.toString()
) )
out.write(csvLine(cols)) sb.append(csvLine(cols))
} }
out.close() return sb.toString()
} }
abstract fun resort() abstract fun resort()

View File

@ -19,10 +19,8 @@
package org.isoron.uhabits.core.models package org.isoron.uhabits.core.models
import org.isoron.platform.time.LocalDate import org.isoron.platform.time.LocalDate
import javax.annotation.concurrent.ThreadSafe
import kotlin.math.min import kotlin.math.min
@ThreadSafe
class StreakList { class StreakList {
private val list = ArrayList<Streak>() private val list = ArrayList<Streak>()

View File

@ -0,0 +1,62 @@
/*
* 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.uhabits.core.preferences
class MemoryStorage : Preferences.Storage {
private val map = HashMap<String, String>()
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)
}
}

View File

@ -1,99 +0,0 @@
/*
* 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.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)
}
}
}

View File

@ -18,6 +18,8 @@
*/ */
package org.isoron.uhabits.core.tasks 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.io.HabitsCSVExporter
import org.isoron.uhabits.core.models.Habit import org.isoron.uhabits.core.models.Habit
import org.isoron.uhabits.core.models.HabitList import org.isoron.uhabits.core.models.HabitList
@ -26,14 +28,20 @@ import java.io.File
class ExportCSVTask( class ExportCSVTask(
private val habitList: HabitList, private val habitList: HabitList,
private val selectedHabits: List<Habit>, private val selectedHabits: List<Habit>,
private val outputDir: File, private val outputDir: String,
private val listener: Listener private val listener: Listener
) : Task { ) : Task {
private var archiveFilename: String? = null private var archiveFilename: String? = null
override fun doInBackground() { override fun doInBackground() {
try { try {
val exporter = HabitsCSVExporter(habitList, selectedHabits, outputDir) val exporter = HabitsCSVExporter(habitList, selectedHabits)
archiveFilename = exporter.writeArchive() 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) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
} }

View File

@ -22,7 +22,6 @@ package org.isoron.uhabits.core.tasks
import me.tatarka.inject.annotations.Inject import me.tatarka.inject.annotations.Inject
import org.isoron.uhabits.core.models.Habit import org.isoron.uhabits.core.models.Habit
import org.isoron.uhabits.core.models.HabitList import org.isoron.uhabits.core.models.HabitList
import java.io.File
@Inject @Inject
class ExportCSVTaskFactory( class ExportCSVTaskFactory(
@ -30,7 +29,7 @@ class ExportCSVTaskFactory(
) { ) {
fun create( fun create(
selectedHabits: List<Habit>, selectedHabits: List<Habit>,
outputDir: File, outputDir: String,
listener: ExportCSVTask.Listener listener: ExportCSVTask.Listener
) = ExportCSVTask(habitList, selectedHabits, outputDir, listener) ) = ExportCSVTask(habitList, selectedHabits, outputDir, listener)
} }

View File

@ -33,9 +33,6 @@ import org.isoron.uhabits.core.models.PaletteColor
import org.isoron.uhabits.core.preferences.Preferences import org.isoron.uhabits.core.preferences.Preferences
import org.isoron.uhabits.core.tasks.ExportCSVTask import org.isoron.uhabits.core.tasks.ExportCSVTask
import org.isoron.uhabits.core.tasks.TaskRunner import org.isoron.uhabits.core.tasks.TaskRunner
import java.io.File
import java.io.IOException
import java.util.LinkedList
import kotlin.math.roundToInt import kotlin.math.roundToInt
@Inject @Inject
@ -81,8 +78,7 @@ open class ListHabitsBehavior(
} }
fun onExportCSV() { fun onExportCSV() {
val selected: MutableList<Habit> = LinkedList() val selected = habitList.toList()
for (h in habitList) selected.add(h)
val outputDir = dirFinder.getCSVOutputDir() val outputDir = dirFinder.getCSVOutputDir()
taskRunner.execute( taskRunner.execute(
ExportCSVTask(habitList, selected, outputDir) { filename: String? -> ExportCSVTask(habitList, selected, outputDir) { filename: String? ->
@ -119,7 +115,7 @@ open class ListHabitsBehavior(
try { try {
val log = bugReporter.getBugReport() val log = bugReporter.getBugReport()
screen.showSendBugReportToDeveloperScreen(log) screen.showSendBugReportToDeveloperScreen(log)
} catch (e: IOException) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
screen.showMessage(Message.COULD_NOT_GENERATE_BUG_REPORT) screen.showMessage(Message.COULD_NOT_GENERATE_BUG_REPORT)
} }
@ -149,12 +145,11 @@ open class ListHabitsBehavior(
interface BugReporter { interface BugReporter {
fun dumpBugReportToFile() fun dumpBugReportToFile()
@Throws(IOException::class)
fun getBugReport(): String fun getBugReport(): String
} }
interface DirFinder { interface DirFinder {
fun getCSVOutputDir(): File fun getCSVOutputDir(): String
} }
fun interface NumberPickerCallback { fun interface NumberPickerCallback {

View File

@ -29,7 +29,6 @@ import org.isoron.uhabits.core.models.HabitList
import org.isoron.uhabits.core.tasks.ExportCSVTask import org.isoron.uhabits.core.tasks.ExportCSVTask
import org.isoron.uhabits.core.tasks.TaskRunner import org.isoron.uhabits.core.tasks.TaskRunner
import org.isoron.uhabits.core.ui.callbacks.OnConfirmedCallback import org.isoron.uhabits.core.ui.callbacks.OnConfirmedCallback
import java.io.File
import kotlin.math.PI import kotlin.math.PI
import kotlin.math.cos import kotlin.math.cos
import kotlin.math.ln import kotlin.math.ln
@ -127,6 +126,6 @@ class ShowHabitMenuPresenter(
} }
interface System { interface System {
fun getCSVOutputDir(): File fun getCSVOutputDir(): String
} }
} }

View File

@ -19,13 +19,10 @@
package org.isoron.uhabits.core.utils package org.isoron.uhabits.core.utils
import java.io.File import org.isoron.platform.io.UserFile
import java.io.FileInputStream
fun File.isSQLite3File(): Boolean { suspend fun isSQLite3File(file: UserFile): Boolean {
val fis = FileInputStream(this) if (!file.exists()) return false
val sqliteHeader = "SQLite format 3".toByteArray() val header = file.readBytes(16)
val buffer = ByteArray(sqliteHeader.size) return header.decodeToString().startsWith("SQLite format 3")
val count = fis.read(buffer)
return if (count < sqliteHeader.size) false else buffer.contentEquals(sqliteHeader)
} }

View File

@ -0,0 +1,76 @@
/*
* 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.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())
}
}

View File

@ -34,4 +34,15 @@ class StringsTest {
assertEquals("00013.42", format("%08.2f", 13.419187263)) assertEquals("00013.42", format("%08.2f", 13.419187263))
assertEquals("13.42 ", format("%-8.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"))
}
} }

View File

@ -1,19 +1,24 @@
package org.isoron.platform.io package org.isoron.platform.io
import kotlinx.coroutines.runBlocking
import org.isoron.uhabits.core.DATABASE_VERSION import org.isoron.uhabits.core.DATABASE_VERSION
actual object TestDatabaseHelper { actual object TestDatabaseHelper {
private val fileOpener = JavaFileOpener()
actual fun createEmptyDatabase(): Database { actual fun createEmptyDatabase(): Database {
val db = JavaDatabaseOpener().open(":memory:") val db = JavaDatabaseOpener().open(":memory:")
db.setVersion(8) db.setVersion(8)
db.migrateTo(DATABASE_VERSION) { v -> loadMigrationSQL(v) } runBlocking {
db.migrateTo(DATABASE_VERSION) { v -> loadMigrationSQL(v) }
}
return db return db
} }
actual fun loadMigrationSQL(version: Int): String { actual fun loadMigrationSQL(version: Int): String {
val filename = "/migrations/%02d.sql".format(version) val path = "migrations/%02d.sql".format(version)
return TestDatabaseHelper::class.java return runBlocking {
.getResourceAsStream(filename)!! fileOpener.openResourceFile(path).lines().joinToString("\n")
.bufferedReader().readText() }
} }
} }

View File

@ -0,0 +1,92 @@
/*
* 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.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<String, String> {
val result = mutableMapOf<String, String>()
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
}
}

View File

@ -18,10 +18,12 @@
*/ */
package org.isoron.uhabits.core.database.migrations package org.isoron.uhabits.core.database.migrations
import kotlinx.coroutines.runBlocking
import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers import org.hamcrest.Matchers
import org.hamcrest.Matchers.equalTo import org.hamcrest.Matchers.equalTo
import org.isoron.platform.io.Database import org.isoron.platform.io.Database
import org.isoron.platform.io.JavaFileOpener
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
@ -38,11 +40,12 @@ class Version22Test : BaseUnitTest() {
db = openDatabaseResource("/databases/021.db") db = openDatabaseResource("/databases/021.db")
} }
private fun migrateTo(version: Int) { private val fileOpener = JavaFileOpener()
private fun migrateTo(version: Int) = runBlocking {
db.migrateTo(version) { v -> db.migrateTo(version) { v ->
val filename = "%02d.sql".format(v) val path = "migrations/%02d.sql".format(v)
javaClass.getResourceAsStream("/migrations/$filename")!! fileOpener.openResourceFile(path).lines().joinToString("\n")
.bufferedReader().readText()
} }
} }

View File

@ -19,9 +19,11 @@
package org.isoron.uhabits.core.database.migrations package org.isoron.uhabits.core.database.migrations
import kotlinx.coroutines.runBlocking
import org.hamcrest.CoreMatchers.equalTo import org.hamcrest.CoreMatchers.equalTo
import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.MatcherAssert.assertThat
import org.isoron.platform.io.Database import org.isoron.platform.io.Database
import org.isoron.platform.io.JavaFileOpener
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.uhabits.core.BaseUnitTest import org.isoron.uhabits.core.BaseUnitTest
@ -36,11 +38,12 @@ class Version23Test : BaseUnitTest() {
db = openDatabaseResource("/databases/022.db") db = openDatabaseResource("/databases/022.db")
} }
private fun migrateTo(version: Int) { private val fileOpener = JavaFileOpener()
private fun migrateTo(version: Int) = runBlocking {
db.migrateTo(version) { v -> db.migrateTo(version) { v ->
val filename = "%02d.sql".format(v) val path = "migrations/%02d.sql".format(v)
javaClass.getResourceAsStream("/migrations/$filename")!! fileOpener.openResourceFile(path).lines().joinToString("\n")
.bufferedReader().readText()
} }
} }

View File

@ -18,17 +18,17 @@
*/ */
package org.isoron.uhabits.core.io 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.BaseUnitTest
import org.isoron.uhabits.core.models.Habit import org.isoron.uhabits.core.models.Habit
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
import java.io.ByteArrayInputStream
import java.io.File import java.io.File
import java.io.FileOutputStream
import java.io.IOException import java.io.IOException
import java.nio.file.Files import java.nio.file.Files
import java.util.* import java.util.*
import java.util.zip.ZipFile import java.util.zip.ZipInputStream
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertTrue import kotlin.test.assertTrue
@ -47,18 +47,16 @@ class HabitsCSVExporterTest : BaseUnitTest() {
@Test @Test
@Throws(IOException::class) @Throws(IOException::class)
fun testExportCSV() { fun testExportCSV() = runBlocking {
val selected: MutableList<Habit> = LinkedList() val selected: MutableList<Habit> = LinkedList()
for (h in habitList) selected.add(h) for (h in habitList) selected.add(h)
val exporter = HabitsCSVExporter( val exporter = HabitsCSVExporter(habitList, selected)
habitList, val bytes = exporter.writeArchive()
selected, assertTrue(bytes.isNotEmpty())
baseDir
) // Extract zip entries to baseDir for comparison
val filename = exporter.writeArchive() unzip(bytes)
assertAbsolutePathExists(filename)
val archive = File(filename)
unzip(archive)
val filesToCheck = arrayOf( val filesToCheck = arrayOf(
"001 Meditate/Checkmarks.csv", "001 Meditate/Checkmarks.csv",
"001 Meditate/Scores.csv", "001 Meditate/Scores.csv",
@ -75,32 +73,21 @@ class HabitsCSVExporterTest : BaseUnitTest() {
} }
} }
@Throws(IOException::class) private fun unzip(bytes: ByteArray) {
private fun unzip(file: File) { val zis = ZipInputStream(ByteArrayInputStream(bytes))
val zip = ZipFile(file) var entry = zis.nextEntry
val e = zip.entries() while (entry != null) {
while (e.hasMoreElements()) { val outFile = File(baseDir, entry.name)
val entry = e.nextElement() outFile.parentFile?.mkdirs()
val stream = zip.getInputStream(entry) outFile.writeBytes(zis.readBytes())
val outputFilename = String.format( zis.closeEntry()
"%s/%s", entry = zis.nextEntry
baseDir.absolutePath,
entry.name
)
val out = File(outputFilename)
val parent = out.parentFile
parent?.mkdirs()
IOUtils.copy(stream, FileOutputStream(out))
} }
zip.close() zis.close()
} }
private fun assertPathExists(s: String) { private fun assertPathExists(s: String) {
assertAbsolutePathExists(String.format("%s/%s", baseDir.absolutePath, s)) val file = File(baseDir, s)
}
private fun assertAbsolutePathExists(s: String) {
val file = File(s)
assertTrue( assertTrue(
String.format("File %s should exist", file.absolutePath) String.format("File %s should exist", file.absolutePath)
) { file.exists() } ) { file.exists() }
@ -108,7 +95,7 @@ class HabitsCSVExporterTest : BaseUnitTest() {
private fun assertFileAndReferenceAreEqual(s: String) { private fun assertFileAndReferenceAreEqual(s: String) {
val assetFilename = String.format("csv_export/%s", s) 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", "") val expectedFile = File.createTempFile("asset", "")
expectedFile.deleteOnExit() expectedFile.deleteOnExit()
copyAssetToFile(assetFilename, expectedFile) copyAssetToFile(assetFilename, expectedFile)

View File

@ -18,8 +18,11 @@
*/ */
package org.isoron.uhabits.core.io package org.isoron.uhabits.core.io
import kotlinx.coroutines.runBlocking
import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.core.IsEqual.equalTo 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.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
@ -173,25 +176,27 @@ class ImportTest : BaseUnitTest() {
} }
@Throws(IOException::class) @Throws(IOException::class)
private fun importFromFile(assetFilename: String) { private fun importFromFile(assetFilename: String) = runBlocking {
val file = File.createTempFile("asset", "") val file = File.createTempFile("asset", "")
copyAssetToFile(assetFilename, file) copyAssetToFile(assetFilename, file)
assertTrue(file.exists()) assertTrue(file.exists())
assertTrue(file.canRead()) assertTrue(file.canRead())
val userFile = JavaUserFile(file.toPath())
val importer = GenericImporter( val importer = GenericImporter(
LoopDBImporter( LoopDBImporter(
habitList, habitList,
modelFactory, modelFactory,
databaseOpener, databaseOpener,
commandRunner, commandRunner,
StandardLogging() StandardLogging(),
JavaFileOpener()
), ),
RewireDBImporter(habitList, modelFactory, databaseOpener), RewireDBImporter(habitList, modelFactory, databaseOpener),
TickmateDBImporter(habitList, modelFactory, databaseOpener), TickmateDBImporter(habitList, modelFactory, databaseOpener),
HabitBullCSVImporter(habitList, modelFactory, StandardLogging()) HabitBullCSVImporter(habitList, modelFactory, StandardLogging())
) )
assertTrue(importer.canHandle(file)) assertTrue(importer.canHandle(userFile))
importer.importHabitsFromFile(file) importer.importHabitsFromFile(userFile)
file.delete() file.delete()
} }
} }

View File

@ -25,7 +25,6 @@ import org.isoron.uhabits.core.BaseUnitTest
import org.junit.Assert.assertThrows import org.junit.Assert.assertThrows
import org.junit.Test import org.junit.Test
import java.io.IOException import java.io.IOException
import java.io.StringWriter
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertFalse import kotlin.test.assertFalse
import kotlin.test.assertNull 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 003,Wake up early,YES_NO,Did you wake up before 6am?,,2,3,#AFB42B,,,,false
""".trimIndent() """.trimIndent()
val writer = StringWriter() assertThat(list.writeCSV(), equalTo(expectedCSV))
list.writeCSV(writer)
assertThat(writer.toString(), equalTo(expectedCSV))
} }
@Test @Test

View File

@ -27,7 +27,6 @@ import org.isoron.uhabits.core.ui.ThemeSwitcher
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
import org.mockito.kotlin.mock import org.mockito.kotlin.mock
import java.io.File
import kotlin.test.assertFalse import kotlin.test.assertFalse
import kotlin.test.assertNull import kotlin.test.assertNull
import kotlin.test.assertTrue import kotlin.test.assertTrue
@ -36,15 +35,13 @@ class PreferencesTest : BaseUnitTest() {
private lateinit var prefs: Preferences private lateinit var prefs: Preferences
private var listener: Preferences.Listener = mock() private var listener: Preferences.Listener = mock()
private lateinit var storage: PropertiesStorage private lateinit var storage: MemoryStorage
@Before @Before
@Throws(Exception::class) @Throws(Exception::class)
override fun setUp() { override fun setUp() {
super.setUp() super.setUp()
val file = File.createTempFile("prefs", ".properties") storage = MemoryStorage()
file.deleteOnExit()
storage = PropertiesStorage(file)
prefs = Preferences(storage) prefs = Preferences(storage)
prefs.addListener(listener) prefs.addListener(listener)
} }

View File

@ -1,102 +0,0 @@
/*
* 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.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))
}
}

View File

@ -36,7 +36,6 @@ import org.mockito.kotlin.eq
import org.mockito.kotlin.mock import org.mockito.kotlin.mock
import org.mockito.kotlin.verify import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever import org.mockito.kotlin.whenever
import java.io.IOException
import java.nio.file.Files import java.nio.file.Files
import kotlin.test.assertFalse import kotlin.test.assertFalse
import kotlin.test.assertTrue import kotlin.test.assertTrue
@ -92,7 +91,7 @@ class ListHabitsBehaviorTest : BaseUnitTest() {
@Throws(Exception::class) @Throws(Exception::class)
fun testOnExportCSV() { fun testOnExportCSV() {
val outputDir = Files.createTempDirectory("CSV").toFile() val outputDir = Files.createTempDirectory("CSV").toFile()
whenever(dirFinder.getCSVOutputDir()).thenReturn(outputDir) whenever(dirFinder.getCSVOutputDir()).thenReturn(outputDir.absolutePath)
behavior.onExportCSV() behavior.onExportCSV()
verify(screen).showSendFileScreen(any()) verify(screen).showSendFileScreen(any())
assertThat(FileUtils.listFiles(outputDir, null, false).size, equalTo(1)) assertThat(FileUtils.listFiles(outputDir, null, false).size, equalTo(1))
@ -104,7 +103,7 @@ class ListHabitsBehaviorTest : BaseUnitTest() {
fun testOnExportCSV_fail() { fun testOnExportCSV_fail() {
val outputDir = Files.createTempDirectory("CSV").toFile() val outputDir = Files.createTempDirectory("CSV").toFile()
outputDir.setWritable(false) outputDir.setWritable(false)
whenever(dirFinder.getCSVOutputDir()).thenReturn(outputDir) whenever(dirFinder.getCSVOutputDir()).thenReturn(outputDir.absolutePath)
behavior.onExportCSV() behavior.onExportCSV()
verify(screen).showMessage(ListHabitsBehavior.Message.COULD_NOT_EXPORT) verify(screen).showMessage(ListHabitsBehavior.Message.COULD_NOT_EXPORT)
assertTrue(outputDir.delete()) assertTrue(outputDir.delete())
@ -132,13 +131,12 @@ class ListHabitsBehaviorTest : BaseUnitTest() {
} }
@Test @Test
@Throws(IOException::class)
fun testOnSendBugReport() { fun testOnSendBugReport() {
whenever(bugReporter.getBugReport()).thenReturn("hello") whenever(bugReporter.getBugReport()).thenReturn("hello")
behavior.onSendBugReport() behavior.onSendBugReport()
verify(bugReporter).dumpBugReportToFile() verify(bugReporter).dumpBugReportToFile()
verify(screen).showSendBugReportToDeveloperScreen("hello") verify(screen).showSendBugReportToDeveloperScreen("hello")
whenever(bugReporter.getBugReport()).thenThrow(IOException()) whenever(bugReporter.getBugReport()).thenThrow(RuntimeException())
behavior.onSendBugReport() behavior.onSendBugReport()
verify(screen).showMessage(ListHabitsBehavior.Message.COULD_NOT_GENERATE_BUG_REPORT) verify(screen).showMessage(ListHabitsBehavior.Message.COULD_NOT_GENERATE_BUG_REPORT)
} }

View File

@ -61,7 +61,7 @@ class ShowHabitMenuPresenterTest : BaseUnitTest() {
@Throws(Exception::class) @Throws(Exception::class)
fun testOnExport() { fun testOnExport() {
val outputDir = Files.createTempDirectory("CSV").toFile() val outputDir = Files.createTempDirectory("CSV").toFile()
whenever(system.getCSVOutputDir()).thenReturn(outputDir) whenever(system.getCSVOutputDir()).thenReturn(outputDir.absolutePath)
menu.onExportCSV() menu.onExportCSV()
assertThat(FileUtils.listFiles(outputDir, null, false).size, equalTo(1)) assertThat(FileUtils.listFiles(outputDir, null, false).size, equalTo(1))
FileUtils.deleteDirectory(outputDir) FileUtils.deleteDirectory(outputDir)

View File

@ -1,5 +1,7 @@
package org.isoron.uhabits.core.utils package org.isoron.uhabits.core.utils
import kotlinx.coroutines.runBlocking
import org.isoron.platform.io.JavaUserFile
import org.isoron.uhabits.core.BaseUnitTest import org.isoron.uhabits.core.BaseUnitTest
import org.junit.Test import org.junit.Test
import java.io.File import java.io.File
@ -8,10 +10,11 @@ import kotlin.test.assertTrue
class FileExtensionsTest : BaseUnitTest() { class FileExtensionsTest : BaseUnitTest() {
@Test @Test
fun testIsSQLite3File() { fun testIsSQLite3File() = runBlocking {
val file = File.createTempFile("asset", "") val file = File.createTempFile("asset", "")
copyAssetToFile("loop.db", file) copyAssetToFile("loop.db", file)
val isSqlite3File = file.isSQLite3File() val userFile = JavaUserFile(file.toPath())
val isSqlite3File = isSQLite3File(userFile)
assertTrue(isSqlite3File) assertTrue(isSqlite3File)
} }
} }