Replace java.io.File with multiplatform Files abstraction
This commit is contained in:
parent
0dbebecbfb
commit
de38383a34
@ -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())
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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' }) {
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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) }
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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())
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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>()
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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")
|
||||
}
|
||||
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
@ -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"))
|
||||
}
|
||||
}
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user