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.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()
}
}
}

View File

@ -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
}
}

View File

@ -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) {

View File

@ -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)
}

View File

@ -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

View File

@ -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)
}

View File

@ -106,7 +106,7 @@ inline fun <T> 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) {

View File

@ -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<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: 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 {
return fields.joinToString(",") { field ->
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
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) }

View File

@ -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<String> {
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")

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
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)
}

View File

@ -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)

View File

@ -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<String, Habit>()
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())
}

View File

@ -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<Habit>,
dir: File
private val selectedHabits: List<Habit>
) {
private val generatedDirs = LinkedList<String>()
private val generatedFilenames = LinkedList<String>()
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<Entry>> = ArrayList()
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 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<LocalDate> {
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
}
}

View File

@ -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)

View File

@ -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()

View File

@ -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()

View File

@ -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<Habit> {
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
* 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<Habit> {
"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<Habit> {
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()

View File

@ -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<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
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<Habit>,
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()
}

View File

@ -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<Habit>,
outputDir: File,
outputDir: String,
listener: ExportCSVTask.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.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<Habit> = 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 {

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.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
}
}

View File

@ -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")
}

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("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
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")
}
}
}

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
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")
}
}

View File

@ -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")
}
}

View File

@ -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<Habit> = 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)

View File

@ -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()
}
}

View File

@ -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

View File

@ -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)
}

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.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)
}

View File

@ -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)

View File

@ -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)
}
}