Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,50 +5,46 @@
package io.askimo.ui.common.preferences

import io.askimo.core.logging.logger
import io.askimo.core.util.AskimoHome
import java.time.Duration
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.util.prefs.Preferences

/**
* Per-account preferences scoped to a specific authenticated user.
*
* Stored under the Java Preferences path:
* `io/askimo/app/accounts/<accountId>/`
* Stored under `<AskimoHome>/prefs/accounts/<accountId>.properties`.
*
* Keeps sync cursors, default model, plan inputs, and user-behavior tracking
* fully isolated between users on the same machine. These are NOT reset when
* the user clears app preferences — they survive across settings resets.
*
* Obtain via [AccountPreferences.forAccount].
*/
class AccountPreferences private constructor(private val prefs: Preferences) {
class AccountPreferences private constructor(private val prefs: PropertyFilePreferences) {

companion object {
private const val ROOT_NODE = "io/askimo/app/accounts"

private const val MINIMUM_DAYS_BEFORE_PROMPT = 7L
private const val MINIMUM_MESSAGES_BEFORE_PROMPT = 20
private const val SNOOZE_DAYS = 30L

/**
* Returns [AccountPreferences] scoped to [accountId] (typically the user's email,
* lowercased and sanitized for use as a Preferences path segment).
* Safe to call multiple times — Preferences nodes are singletons per path.
* lowercased and sanitized for use as a file name).
*/
fun forAccount(accountId: String): AccountPreferences {
val safeId = accountId.trim().lowercase()
.replace(Regex("[^a-z0-9._@-]"), "_")
return AccountPreferences(Preferences.userRoot().node("$ROOT_NODE/$safeId"))
return AccountPreferences(PropertyFilePreferences(AskimoHome.base().resolve("prefs/accounts/$safeId.properties")))
}

/**
* Returns a device-level (no account) [AccountPreferences] node for fields
* that must persist even before the user logs in (e.g. first-use date,
* launch count, analytics consent).
*/
fun device(): AccountPreferences = AccountPreferences(Preferences.userRoot().node("$ROOT_NODE/__device__"))
fun device(): AccountPreferences = AccountPreferences(PropertyFilePreferences(AskimoHome.base().resolve("prefs/accounts/__device__.properties")))
}

private val log = logger<AccountPreferences>()
Expand All @@ -61,26 +57,17 @@ class AccountPreferences private constructor(private val prefs: Preferences) {
.onFailure { log.warn("AccountPreferences.get failed key='$key': ${it.message}") }
.getOrDefault(default)

private fun safePutBoolean(key: String, value: Boolean) = runCatching { prefs.putBoolean(key, value) }
.onFailure { log.warn("AccountPreferences.putBoolean failed key='$key': ${it.message}") }
private fun safePutBoolean(key: String, value: Boolean) = safePut(key, value.toString())

private fun safeGetBoolean(key: String, default: Boolean): Boolean = runCatching { prefs.getBoolean(key, default) }
.onFailure { log.warn("AccountPreferences.getBoolean failed key='$key': ${it.message}") }
.getOrDefault(default)
private fun safeGetBoolean(key: String, default: Boolean): Boolean = safeGet(key, null)?.toBooleanStrictOrNull() ?: default

private fun safePutInt(key: String, value: Int) = runCatching { prefs.putInt(key, value) }
.onFailure { log.warn("AccountPreferences.putInt failed key='$key': ${it.message}") }
private fun safePutInt(key: String, value: Int) = safePut(key, value.toString())

private fun safeGetInt(key: String, default: Int): Int = runCatching { prefs.getInt(key, default) }
.onFailure { log.warn("AccountPreferences.getInt failed key='$key': ${it.message}") }
.getOrDefault(default)
private fun safeGetInt(key: String, default: Int): Int = safeGet(key, null)?.toIntOrNull() ?: default

private fun safePutLong(key: String, value: Long) = runCatching { prefs.putLong(key, value) }
.onFailure { log.warn("AccountPreferences.putLong failed key='$key': ${it.message}") }
private fun safePutLong(key: String, value: Long) = safePut(key, value.toString())

private fun safeGetLong(key: String, default: Long): Long = runCatching { prefs.getLong(key, default) }
.onFailure { log.warn("AccountPreferences.getLong failed key='$key': ${it.message}") }
.getOrDefault(default)
private fun safeGetLong(key: String, default: Long): Long = safeGet(key, null)?.toLongOrNull() ?: default

// ── Conversation sync ─────────────────────────────────────────────────────

Expand Down Expand Up @@ -132,11 +119,7 @@ class AccountPreferences private constructor(private val prefs: Preferences) {
}
safePut(
"plan.inputs.$planId",
if (raw.length > Preferences.MAX_VALUE_LENGTH) {
raw.substring(0, Preferences.MAX_VALUE_LENGTH)
} else {
raw
},
raw,
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,44 +5,38 @@
package io.askimo.ui.common.preferences

import io.askimo.core.logging.logger
import java.util.prefs.Preferences
import kotlin.jvm.java
import io.askimo.core.util.AskimoHome

/**
* Resettable application preferences — UI layout, onboarding state, and device identity.
*/
object ApplicationPreferences {
private val prefs = Preferences.userNodeForPackage(ApplicationPreferences::class.java)
private val log = logger<ApplicationPreferences>()

private fun prefs() = PropertyFilePreferences(AskimoHome.base().resolve("prefs/app.properties"))

// ── Safe preference accessors ─────────────────────────────────────────────

private fun safePut(key: String, value: String) {
runCatching { prefs.put(key, value) }
runCatching { prefs().put(key, value) }
.onFailure { log.warn("Preferences.put failed for key='$key': ${it.message}") }
}

private fun safeGet(key: String, default: String?): String? = runCatching { prefs.get(key, default) }
private fun safeGet(key: String, default: String?): String? = runCatching { prefs().get(key, default) }
.onFailure { log.warn("Preferences.get failed for key='$key': ${it.message}") }
.getOrDefault(default)

private fun safePutBoolean(key: String, value: Boolean) {
runCatching { prefs.putBoolean(key, value) }
.onFailure { log.warn("Preferences.putBoolean failed for key='$key': ${it.message}") }
safePut(key, value.toString())
}

private fun safeGetBoolean(key: String, default: Boolean): Boolean = runCatching { prefs.getBoolean(key, default) }
.onFailure { log.warn("Preferences.getBoolean failed for key='$key': ${it.message}") }
.getOrDefault(default)
private fun safeGetBoolean(key: String, default: Boolean): Boolean = safeGet(key, null)?.toBooleanStrictOrNull() ?: default

private fun safePutInt(key: String, value: Int) {
runCatching { prefs.putInt(key, value) }
.onFailure { log.warn("Preferences.putInt failed for key='$key': ${it.message}") }
safePut(key, value.toString())
}

private fun safeGetInt(key: String, default: Int): Int = runCatching { prefs.getInt(key, default) }
.onFailure { log.warn("Preferences.getInt failed for key='$key': ${it.message}") }
.getOrDefault(default)
private fun safeGetInt(key: String, default: Int): Int = safeGet(key, null)?.toIntOrNull() ?: default

// ============================================================
// TUTORIAL & ONBOARDING (resettable — re-shows on clearAll)
Expand Down Expand Up @@ -136,7 +130,7 @@ object ApplicationPreferences {
* update dismissals) are stored in [AccountPreferences] and are NOT affected.
*/
fun clearAll() {
runCatching { prefs.clear() }
runCatching { prefs().clear() }
.onFailure { log.warn("Preferences.clear() failed: ${it.message}") }
log.info("All resettable application preferences cleared")
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/* SPDX-License-Identifier: AGPLv3
*
* Copyright (c) 2026 Askimo
*/
package io.askimo.ui.common.preferences

import java.nio.file.AtomicMoveNotSupportedException
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.StandardCopyOption
import java.util.Properties
import java.util.concurrent.ConcurrentHashMap

/** Small thread-safe preference store backed by a plain Java properties file. */
internal class PropertyFilePreferences(private val path: Path) {
private val lock = locks.computeIfAbsent(path.toAbsolutePath().normalize()) { Any() }

fun get(key: String, default: String?): String? = synchronized(lock) {
load().getProperty(key, default)
}

fun put(key: String, value: String) = synchronized(lock) {
val properties = load()
properties.setProperty(key, value)
save(properties)
}

fun remove(key: String) = synchronized(lock) {
val properties = load()
properties.remove(key)
save(properties)
}

fun clear() = synchronized(lock) {
save(Properties())
}

private fun load(): Properties = Properties().also { properties ->
if (Files.isRegularFile(path)) {
Files.newInputStream(path).buffered().use(properties::load)
}
}

private fun save(properties: Properties) {
val parent = path.parent
Files.createDirectories(parent)
val temporary = Files.createTempFile(parent, ".${path.fileName}", ".tmp")
try {
Files.newOutputStream(temporary).buffered().use { output ->
properties.store(output, "Askimo preferences")
}
try {
Files.move(
temporary,
path,
StandardCopyOption.ATOMIC_MOVE,
StandardCopyOption.REPLACE_EXISTING,
)
} catch (_: AtomicMoveNotSupportedException) {
Files.move(temporary, path, StandardCopyOption.REPLACE_EXISTING)
}
} finally {
Files.deleteIfExists(temporary)
}
}

private companion object {
val locks = ConcurrentHashMap<Path, Any>()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/* SPDX-License-Identifier: AGPLv3
*
* Copyright (c) 2026 Askimo
*/
package io.askimo.ui.common.preferences

import io.askimo.core.util.AskimoHome
import java.nio.file.Files
import java.util.Properties
import kotlin.io.path.inputStream
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue

class PreferencesStorageTest {
@Test
fun `application preferences use Askimo home property file`() {
val root = Files.createTempDirectory("askimo-app-prefs")

AskimoHome.withTestBase(root).use {
ApplicationPreferences.clearAll()
ApplicationPreferences.setProjectSidePanelWidth(512)

val file = AskimoHome.base().resolve("prefs/app.properties")
assertTrue(Files.isRegularFile(file))
assertEquals("512", load(file).getProperty("ui.project_side_panel_width"))
assertEquals(512, ApplicationPreferences.getProjectSidePanelWidth())
}
}

@Test
fun `account and device preferences use isolated property files`() {
val root = Files.createTempDirectory("askimo-account-prefs")

AskimoHome.withTestBase(root).use {
val account = AccountPreferences.forAccount("User Name@example.com")
account.saveLastSyncSeq(42)
AccountPreferences.device().setHardwareAccelerationEnabled(false)

val accountsDir = AskimoHome.base().resolve("prefs/accounts")
val accountFile = accountsDir.resolve("user_name@example.com.properties")
val deviceFile = accountsDir.resolve("__device__.properties")
assertEquals("42", load(accountFile).getProperty("sync.last_seq"))
assertEquals("false", load(deviceFile).getProperty("perf.hardware_acceleration_enabled"))
assertFalse(AccountPreferences.device().getHardwareAccelerationEnabled())
}
}

private fun load(path: java.nio.file.Path): Properties = Properties().also { properties ->
path.inputStream().use(properties::load)
}
}