diff --git a/AGENTS.md b/AGENTS.md index 640c506c8..62b8793c6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,7 +24,7 @@ - DI uses Metro (`AppGraph`, `@Provides`); access via `LocalAppGraph` (avoid singletons). ## Localization & Resources -- Never hardcode user-visible text; add keys to `SeforimApp/src/commonMain/composeResources/strings.xml`. +- Never hardcode user-visible text; add keys to `appkit/src/commonMain/composeResources/strings.xml`. - The required locale for user-facing text is Hebrew (`he`); provide Hebrew strings for all new keys. ## Testing Guidelines diff --git a/CLAUDE.md b/CLAUDE.md index c01893041..fa6c7d4bd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -134,9 +134,21 @@ see `SeforimLibrary/JVM_RUNTIME.md`. The view models reach the desktop shell only through the ports of `SearchHost.kt` (`SearchTabs`, `SearchSessionStore`, `SearchSettings`, `SearchHistory`); keep `java.*`, AWT and desktop types out of `searchkit/src/commonMain`. +### The app without its shell: `appkit` +Everything else that doesn't need the desktop is the KMP module `appkit` (JVM, Android 10+, iOS), packages unchanged: +the reader (`BookContentViewModel`, its state, use cases, paging), `TabsViewModel`, the session (`SessionManager`), the +user's data (stores, `UserSettingsDb` schema), the settings and onboarding view models, the catalog, the Compose +resources (`appkit/src/commonMain/composeResources`, `Res` still in `seforimapp.seforimapp.generated.resources`) and +`FontCatalog`. `earthwidget` and `pagination` are multiplatform too. It reaches the desktop shell only through ports, +which `SeforimApp` implements: `TabsHost` (`DesktopTabsHost`), `TabsResolver` (`DesktopManager`), `SessionRestoreState` +/`SessionShell` (`SessionManager`, `DesktopSessionShell`). `AppKitBindings` (contributed to `AppScope`) provides the +common bindings; each app's graph provides `AppDatabases` (its SQLite connections, `expect`/`actual`) and its ports. +`DesktopManager`, the windows and the Jewel screens stay in `SeforimApp`. Keep `java.*`, AWT, Jewel and Nucleus out of +`appkit/src/commonMain`. + The verse quote links of the Talmud (temporary, until the DB carries them) follow the same pattern: the private `io.github.kdroidfilter:seforim-quotelinks` package (repo `SeforimQuoteLinks`, read with the siddur's token by default, -or `quotelinks.token`/`SEFORIM_QUOTELINKS_TOKEN`/`gh`, or `-Pquotelinks.local=true` to include `../SeforimQuoteLinks/jvm`) and `addons/quotelinks/enabled`, which `LinesPagingSource` calls on each page it loads, in +or `quotelinks.token`/`SEFORIM_QUOTELINKS_TOKEN`/`gh`, or `-Pquotelinks.local=true` to include `../SeforimQuoteLinks/jvm`) and `appkit/addons/quotelinks/enabled` (appkit's `jvmMain`; null on mobile), which `LinesPagingSource` calls on each page it loads, in the same trip off the UI thread; `disabled` makes `installedQuoteLinks` null. Core code reaches it only through `QuoteLinksFeature`/`installedQuoteLinks`; only the tractates in the package pay anything (a set lookup otherwise). @@ -170,6 +182,7 @@ See `TAB_SYSTEM_README.md` for complete details. Key points: - `texteffects`: Text rendering effects and styling - `network`: Network utilities and Ktor client setup - `searchkit`: the search without its UI, multiplatform (see "Search on mobile") +- `appkit`: the app without its desktop shell, multiplatform (see "The app without its shell") - `backup`: Backup core, Android-BackupAgent/Transport style: `BackupSource` (what: the app's `UserDataBackup`, user DB + portable prefs), `BackupDestination` (where: `LocalFileDestination`, …), `BackupManager` (the only code linking both) - `backup-drive`: Google Drive `BackupDestination` + `GoogleDriveSync` (OAuth PKCE loopback, hidden `appDataFolder`, append-only with the 10 latest kept as IntelliJ settings sync, never restores without asking, on-quit + daily auto backup paused while a restore is pending) @@ -281,7 +294,7 @@ class MyViewModel( - **Main Entry**: `SeforimApp/src/jvmMain/kotlin/main.kt` - **Features**: `SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/` -- **Resources**: `SeforimApp/src/commonMain/composeResources/` +- **Resources**: `appkit/src/commonMain/composeResources/` - **Desktop Assets**: `SeforimApp/src/jvmMain/assets/` - **Tab System**: See `TAB_SYSTEM_README.md` - **Version Catalog**: `gradle/libs.versions.toml` \ No newline at end of file diff --git a/SeforimApp/addons/siddur/enabled/kotlin/io/github/kdroidfilter/seforimapp/features/siddur/SiddurScreen.kt b/SeforimApp/addons/siddur/enabled/kotlin/io/github/kdroidfilter/seforimapp/features/siddur/SiddurScreen.kt index c8c78bc71..bd56732b6 100644 --- a/SeforimApp/addons/siddur/enabled/kotlin/io/github/kdroidfilter/seforimapp/features/siddur/SiddurScreen.kt +++ b/SeforimApp/addons/siddur/enabled/kotlin/io/github/kdroidfilter/seforimapp/features/siddur/SiddurScreen.kt @@ -91,6 +91,7 @@ import io.github.kdroidfilter.seforimapp.features.home.widgets.luach.moladInfo import io.github.kdroidfilter.seforimapp.features.home.widgets.luach.rememberOpenInLibrary import io.github.kdroidfilter.seforimapp.features.home.widgets.rememberAccentColor import io.github.kdroidfilter.seforimapp.features.onboarding.userprofile.Community +import io.github.kdroidfilter.seforimapp.features.zmanim.data.timeZone import io.github.kdroidfilter.seforimapp.framework.desktop.LocalOpenWindow import io.github.kdroidfilter.seforimapp.framework.di.LocalAppGraph import io.github.kdroidfilter.seforimapp.framework.session.SiddurPersistedState diff --git a/SeforimApp/build.gradle.kts b/SeforimApp/build.gradle.kts index 584739e00..3ed7050a5 100644 --- a/SeforimApp/build.gradle.kts +++ b/SeforimApp/build.gradle.kts @@ -18,7 +18,6 @@ plugins { alias(libs.plugins.buildConfig) alias(libs.plugins.metro) alias(libs.plugins.stability.analyzer) - alias(libs.plugins.sqlDelight) alias(libs.plugins.kover) alias(libs.plugins.nucleus) alias(libs.plugins.structured.coroutines) @@ -43,8 +42,7 @@ sentry { // (settings.gradle.kts); a community build has none and addons/siddur/disabled says so val withSiddur = gradle.extensions.extraProperties["siddurEnabled"] == true -// The verse quote links of the Talmud (open core, temporary until the DB carries them): its package, or -// addons/quotelinks/disabled and the reader shows the lines as they are +// The verse quote links of the Talmud (open core, temporary until the DB carries them): appkit compiles its addon val withQuoteLinks = gradle.extensions.extraProperties["quoteLinksEnabled"] == true kotlin { @@ -141,10 +139,7 @@ kotlin { } if (withSiddur) jvmTest { kotlin.srcDir("addons/siddur/test/kotlin") } - jvmMain { - kotlin.srcDir(if (withQuoteLinks) "addons/quotelinks/enabled/kotlin" else "addons/quotelinks/disabled/kotlin") - if (withQuoteLinks) dependencies { implementation(libs.seforim.quotelinks) } - } + // The quote links' addon itself is appkit's (its jvmMain); its test reads the real DB with the desktop's driver if (withQuoteLinks) jvmTest { kotlin.srcDir("addons/quotelinks/test/kotlin") } jvmTest.dependencies { @@ -181,7 +176,7 @@ kotlin { implementation(libs.slf4j.simple) implementation(libs.split.pane.desktop) implementation(libs.sqlite.driver) - implementation(libs.zstd.jni) + implementation(libs.okio) implementation(libs.ktor.client.okhttp) implementation(libs.lucene.core) implementation(libs.reorderable) @@ -194,6 +189,9 @@ kotlin { // semantic search (open core) comes with it in the official builds implementation(project(":searchkit")) + // The app without its desktop shell (reader, user data, settings, onboarding), shared with the mobile apps + implementation(project(":appkit")) + // SeforimLibrary CLI: lets the desktop binary run headless search commands // (`zayit cli search ...`) by delegating to its runCli() entry point. implementation(libs.seforimlibrary.cli) @@ -320,15 +318,6 @@ nucleus.application { } } -sqldelight { - databases { - create("UserSettingsDb") { - packageName.set("io.github.kdroidfilter.seforimapp.db") - dialect("app.cash.sqldelight:sqlite-3-24-dialect:${libs.versions.sqlDelight.get()}") - } - } -} - tasks.withType().configureEach { mainClass.set("io.github.kdroidfilter.seforimapp.MainKt") } diff --git a/SeforimApp/src/graalvm/reachability-metadata.json b/SeforimApp/src/graalvm/reachability-metadata.json index baeb85df3..2b9521128 100644 --- a/SeforimApp/src/graalvm/reachability-metadata.json +++ b/SeforimApp/src/graalvm/reachability-metadata.json @@ -1343,6 +1343,34 @@ }, { "type": "sun.util.resources.cldr.CalendarData" + }, + { + "type": "com.squareup.zstd.ZstdCompressor", + "jniAccessible": true, + "fields": [ + { + "name": "outputBytesProcessed", + "jniAccessible": true + }, + { + "name": "inputBytesProcessed", + "jniAccessible": true + } + ] + }, + { + "type": "com.squareup.zstd.ZstdDecompressor", + "jniAccessible": true, + "fields": [ + { + "name": "outputBytesProcessed", + "jniAccessible": true + }, + { + "name": "inputBytesProcessed", + "jniAccessible": true + } + ] } ], "foreign": { @@ -2934,6 +2962,9 @@ }, { "glob": "io/github/kdroidfilter/seforim/quotelinks/**" + }, + { + "glob": "jni/*/*zstd-kmp*" } ] } diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/e2e/E2eExtractScenario.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/e2e/E2eExtractScenario.kt new file mode 100644 index 000000000..ccf67a816 --- /dev/null +++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/e2e/E2eExtractScenario.kt @@ -0,0 +1,50 @@ +package io.github.kdroidfilter.seforimapp.core.e2e + +import com.russhwolf.settings.PreferencesSettings +import io.github.kdroidfilter.seforimapp.core.settings.AppSettings +import io.github.kdroidfilter.seforimapp.features.onboarding.extract.ExtractUseCase +import okio.Path.Companion.toOkioPath +import java.io.File +import java.security.MessageDigest +import java.util.HexFormat +import java.util.prefs.Preferences + +/** + * The database bundle's extraction, as the onboarding runs it (`ZAYIT_E2E_SCENARIO=extract`, `ZAYIT_E2E_BUNDLE` naming a + * `.tar.zst` or its `.part01`): into the run's output, with throwaway settings, never the user's databases. Notes the + * extracted database's size and sha256, to compare with the one packed. + */ +object E2eExtractScenario { + private const val SETTINGS_NODE = "io/github/kdroidfilter/seforimapp/e2e-extract" + + suspend fun run(sc: E2eScenario) { + if (E2e.scenario != "extract") return + val bundle = System.getenv("ZAYIT_E2E_BUNDLE") ?: error("ZAYIT_E2E_BUNDLE names no bundle") + val dbDir = File(E2e.outDir, "extracted") + val node = Preferences.userRoot().node(SETTINGS_NODE) + try { + var lastProgress = 0f + val dbPath = + ExtractUseCase(AppSettings(PreferencesSettings(node))) { dbDir.toOkioPath() } + .extractToDatabase(bundle) { lastProgress = it } + val db = File(dbPath) + sc.note("extract: $dbPath, ${db.length()} bytes, sha256 ${sha256(db)}, progress $lastProgress") + dbDir.walkTopDown().filter { it.isFile }.forEach { sc.note("extract: file ${it.relativeTo(dbDir)} ${it.length()}") } + } finally { + node.removeNode() + } + } + + private fun sha256(file: File): String { + val digest = MessageDigest.getInstance("SHA-256") + file.inputStream().use { input -> + val buffer = ByteArray(1 shl 20) + while (true) { + val read = input.read(buffer) + if (read < 0) break + digest.update(buffer, 0, read) + } + } + return HexFormat.of().formatHex(digest.digest()) + } +} diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/e2e/E2eQuoteLinksScenario.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/e2e/E2eQuoteLinksScenario.kt index 3d498b90a..7e29bf9ba 100644 --- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/e2e/E2eQuoteLinksScenario.kt +++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/e2e/E2eQuoteLinksScenario.kt @@ -13,8 +13,8 @@ import androidx.compose.ui.semantics.SemanticsNode import dev.nucleusframework.window.ExperimentalNucleusApi import dev.nucleusframework.window.tao.TaoRootsForTest import io.github.kdroidfilter.seforim.tabs.TabsDestination -import io.github.kdroidfilter.seforimapp.features.bookcontent.quotelinks.installedQuoteLinks import io.github.kdroidfilter.seforimapp.core.presentation.theme.IntUiThemes +import io.github.kdroidfilter.seforimapp.features.bookcontent.quotelinks.installedQuoteLinks import kotlinx.coroutines.delay import kotlinx.coroutines.withTimeoutOrNull import java.util.UUID diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/theme/AccentColor.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/theme/AccentColor.kt deleted file mode 100644 index 7bdfb7774..000000000 --- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/theme/AccentColor.kt +++ /dev/null @@ -1,53 +0,0 @@ -package io.github.kdroidfilter.seforimapp.core.presentation.theme - -import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.Color -import dev.nucleusframework.systemcolor.systemAccentColor -import org.jetbrains.jewel.intui.core.theme.IntUiDarkTheme -import org.jetbrains.jewel.intui.core.theme.IntUiLightTheme - -/** - * Predefined accent color presets for the application theme. - * [System] uses the OS accent color (falls back to Jewel's blue when unavailable). - * [Default] uses Jewel's built-in blue; other entries provide light/dark variants. - */ -enum class AccentColor { - System, - Default, - Teal, - Green, - Gold, - ; - - /** - * Returns the static color for non-System presets. - * For [System], returns the Jewel default blue (use [resolveColor] in composable contexts). - */ - fun forMode(isDark: Boolean): Color = - when (this) { - System, Default -> - if (isDark) { - IntUiDarkTheme.colors.blueOrNull(6) ?: Color(0xFF3574F0) - } else { - IntUiLightTheme.colors.blueOrNull(4) ?: Color(0xFF4682FA) - } - Teal -> if (isDark) Color(0xFF2FC2B6) else Color(0xFF1A998E) - Green -> if (isDark) Color(0xFF5AB869) else Color(0xFF3D9A50) - Gold -> if (isDark) Color(0xFFD4A843) else Color(0xFFBE9117) - } - - /** - * Composable-aware resolution that queries the OS accent color for [System]. - * Falls back to [forMode] when the platform doesn't provide an accent color. - */ - @Composable - fun resolveColor(isDark: Boolean): Color = - when (this) { - System -> systemAccentColor() ?: forMode(isDark) - else -> forMode(isDark) - } - - /** Resolve for display in the settings UI. */ - @Composable - fun displayColor(isDark: Boolean): Color = resolveColor(isDark) -} diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/theme/AccentColors.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/theme/AccentColors.kt new file mode 100644 index 000000000..306fed7ad --- /dev/null +++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/theme/AccentColors.kt @@ -0,0 +1,39 @@ +package io.github.kdroidfilter.seforimapp.core.presentation.theme + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import dev.nucleusframework.systemcolor.systemAccentColor +import org.jetbrains.jewel.intui.core.theme.IntUiDarkTheme +import org.jetbrains.jewel.intui.core.theme.IntUiLightTheme + +/** + * Returns the static color for non-System presets. + * For [AccentColor.System], returns the Jewel default blue (use [resolveColor] in composable contexts). + */ +fun AccentColor.forMode(isDark: Boolean): Color = + when (this) { + AccentColor.System, AccentColor.Default -> + if (isDark) { + IntUiDarkTheme.colors.blueOrNull(6) ?: Color(0xFF3574F0) + } else { + IntUiLightTheme.colors.blueOrNull(4) ?: Color(0xFF4682FA) + } + AccentColor.Teal -> if (isDark) Color(0xFF2FC2B6) else Color(0xFF1A998E) + AccentColor.Green -> if (isDark) Color(0xFF5AB869) else Color(0xFF3D9A50) + AccentColor.Gold -> if (isDark) Color(0xFFD4A843) else Color(0xFFBE9117) + } + +/** + * Composable-aware resolution that queries the OS accent color for [AccentColor.System]. + * Falls back to [forMode] when the platform doesn't provide an accent color. + */ +@Composable +fun AccentColor.resolveColor(isDark: Boolean): Color = + when (this) { + AccentColor.System -> systemAccentColor() ?: forMode(isDark) + else -> forMode(isDark) + } + +/** Resolve for display in the settings UI. */ +@Composable +fun AccentColor.displayColor(isDark: Boolean): Color = resolveColor(isDark) diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/views/HomeView.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/views/HomeView.kt index e20b93f49..2ef8ed450 100644 --- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/views/HomeView.kt +++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/views/HomeView.kt @@ -77,6 +77,7 @@ import io.github.kdroidfilter.seforimapp.core.e2e.E2e import io.github.kdroidfilter.seforimapp.core.presentation.components.rememberPillTextFieldStyle import io.github.kdroidfilter.seforimapp.core.presentation.tabs.LocalTabSelected import io.github.kdroidfilter.seforimapp.core.presentation.theme.AccentColor +import io.github.kdroidfilter.seforimapp.core.presentation.theme.forMode import io.github.kdroidfilter.seforimapp.core.presentation.utils.LocalWindowViewModelStoreOwner import io.github.kdroidfilter.seforimapp.features.bookcontent.BookContentEvent import io.github.kdroidfilter.seforimapp.features.bookcontent.ui.panels.bookcontent.components.CatalogRow diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/booktoc/BookTocPanel.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/booktoc/BookTocPanel.kt index 8346f787b..cd1d1090f 100644 --- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/booktoc/BookTocPanel.kt +++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/booktoc/BookTocPanel.kt @@ -76,7 +76,7 @@ fun BookTocPanel( val search = uiState.toc.search // While searching, the matches of both trees replace them val searching = search != null && search.query.isNotBlank() - val showAlt = uiState.navigation.selectedBook.hasAltStructures && !searching + val showAlt = uiState.navigation.selectedBook?.hasAltStructures == true && !searching Column(modifier = Modifier.fillMaxHeight()) { PaneSearchLayout( search = search, diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/notes/NotesPanel.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/notes/NotesPanel.kt index eab85f020..79ef14515 100644 --- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/notes/NotesPanel.kt +++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/notes/NotesPanel.kt @@ -84,20 +84,6 @@ import java.time.Instant import java.time.ZoneId import java.time.format.DateTimeFormatter -/** - * A pending (not-yet-persisted) note anchored to a resolved character range. Created from the - * "add note" context-menu action, or implicitly as a blank whole-line note when the selected line - * carries none yet. - * - * @property quote The anchored passage, shown as a greyed preview above the editor. - */ -data class NoteDraftAnchor( - val lineId: Long, - val startOffset: Int, - val endOffset: Int, - val quote: String, -) - /** Compact "dd.MM.yy · HH:mm" stamp shown in each note's footer. Locale-neutral on purpose. */ private val NOTE_TIME_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM.yy · HH:mm") diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/home/widgets/HomeWidgetsState.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/home/widgets/HomeWidgetsState.kt index 4ca9399df..cf24c8a3e 100644 --- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/home/widgets/HomeWidgetsState.kt +++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/home/widgets/HomeWidgetsState.kt @@ -20,6 +20,7 @@ import io.github.kdroidfilter.seforimapp.earthwidget.KiddushLevanaLatestOpinion import io.github.kdroidfilter.seforimapp.earthwidget.ZmanimOpinion import io.github.kdroidfilter.seforimapp.features.onboarding.userprofile.Community import io.github.kdroidfilter.seforimapp.features.zmanim.data.ISRAEL_COUNTRY_NAME +import io.github.kdroidfilter.seforimapp.features.zmanim.data.timeZone import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.time.Instant diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/home/widgets/WidgetCard.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/home/widgets/WidgetCard.kt index 4a27e3f37..02e3c98d9 100644 --- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/home/widgets/WidgetCard.kt +++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/home/widgets/WidgetCard.kt @@ -13,6 +13,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp +import io.github.kdroidfilter.seforimapp.core.presentation.theme.resolveColor import io.github.kdroidfilter.seforimapp.framework.di.LocalAppGraph import org.jetbrains.jewel.foundation.theme.JewelTheme import org.jetbrains.jewel.ui.component.CircularProgressIndicator diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/home/widgets/earth/EarthWidget.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/home/widgets/earth/EarthWidget.kt index 002f58c64..e5feea0b6 100644 --- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/home/widgets/earth/EarthWidget.kt +++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/home/widgets/earth/EarthWidget.kt @@ -19,6 +19,7 @@ import io.github.kdroidfilter.seforimapp.features.home.widgets.WidgetCard import io.github.kdroidfilter.seforimapp.features.home.widgets.playMillisHere import io.github.kdroidfilter.seforimapp.features.home.widgets.rememberAccentColor import io.github.kdroidfilter.seforimapp.features.home.widgets.shownDateHere +import io.github.kdroidfilter.seforimapp.features.zmanim.data.timeZone import io.github.kdroidfilter.seforimapp.features.zmanim.data.worldPlaces import io.github.kdroidfilter.seforimapp.framework.platform.PlatformInfo import org.jetbrains.jewel.foundation.theme.JewelTheme diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/extract/ExtractScreen.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/extract/ExtractScreen.kt index fa0cef3d4..5465df48e 100644 --- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/extract/ExtractScreen.kt +++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/extract/ExtractScreen.kt @@ -75,7 +75,7 @@ fun ExtractView( // Error banner with retry if (state.errorMessage != null) { val generic = stringResource(Res.string.onboarding_error_occurred) - val detail = state.errorMessage.takeIf { it.isNotBlank() } + val detail = state.errorMessage?.takeIf { it.isNotBlank() } val message = detail?.let { stringResource(Res.string.onboarding_error_with_detail, it) } ?: generic val retryLabel = stringResource(Res.string.retry_button) DefaultErrorBanner( diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/extract/ExtractUseCase.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/extract/ExtractUseCase.kt deleted file mode 100644 index 6554572d5..000000000 --- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/extract/ExtractUseCase.kt +++ /dev/null @@ -1,314 +0,0 @@ -package io.github.kdroidfilter.seforimapp.features.onboarding.extract - -import com.github.luben.zstd.ZstdInputStream -import io.github.kdroidfilter.seforimapp.core.settings.AppSettings -import io.github.vinceglb.filekit.FileKit -import io.github.vinceglb.filekit.databasesDir -import io.github.vinceglb.filekit.path -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import org.apache.commons.compress.archivers.tar.TarArchiveInputStream -import java.io.BufferedInputStream -import java.io.File -import java.io.FileInputStream -import java.io.FileOutputStream -import java.io.FilterInputStream -import java.io.InputStream -import java.io.OutputStream -import java.io.SequenceInputStream -import java.security.DigestInputStream -import java.security.MessageDigest -import java.util.HexFormat - -class ExtractUseCase( - private val appSettings: AppSettings, -) { - suspend fun extractToDatabase( - sourcePath: String, - onProgress: (Float) -> Unit, - ): String = - withContext(Dispatchers.Default) { - val dbDirV = FileKit.databasesDir - val dbDir = File(dbDirV.path).apply { mkdirs() } - val source = File(sourcePath) - require(source.exists()) { "Selected file not found" } - - onProgress(0f) - val dbFile: File = - withContext(Dispatchers.IO) { - val lower = source.name.lowercase() - when { - BundleParts.isPart(source.name) -> { - val bundle = BundleParts.resolve(source) - val parts = bundle.parts - val result = - extractTarZstFromPartsStreaming(parts, dbDir, bundle.sha256) { mapped -> - onProgress(mapped) - } - // Only cleanup if parts live in our databases directory (downloaded by the app) - val canDelete = - runCatching { - val base = dbDir.canonicalFile - parts.all { it.parentFile?.canonicalFile == base } - }.getOrDefault(false) - if (canDelete) BundleParts.bundleFiles(source).forEach { runCatching { it.delete() } } - result - } - lower.endsWith(".tar.zst") -> { - val result = extractTarZstFile(source, dbDir) { p -> onProgress(p.coerceIn(0f, 1f)) } - runCatching { maybeCleanupSources(source, dbDir) } - result - } - lower.endsWith(".zst") -> { - val baseName = source.name.removeSuffix(".zst") - val targetName = if (baseName.endsWith(".db")) baseName else "$baseName.db" - val targetDb = File(dbDir, targetName) - val result = extractDbZst(source, targetDb) { p -> onProgress(p.coerceIn(0f, 1f)) } - runCatching { maybeCleanupSources(source, dbDir) } - result - } - else -> error("Unsupported file type: ${source.name}") - } - } - onProgress(1f) - appSettings.setDatabasePath(dbFile.absolutePath) - runCatching { maybeCleanupSources(source, dbDir) } - return@withContext dbFile.absolutePath - } - - private fun extractDbZst( - sourceZst: File, - targetDb: File, - onProgress: (Float) -> Unit, - ): File { - class CountingInputStream( - `in`: FileInputStream, - ) : FilterInputStream(`in`) { - var count: Long = 0 - private set - - override fun read( - b: ByteArray, - off: Int, - len: Int, - ): Int { - val r = super.read(b, off, len) - if (r > 0) count += r - return r - } - - override fun read(): Int { - val r = super.read() - if (r >= 0) count += 1 - return r - } - } - - val totalCompressed = sourceZst.length().coerceAtLeast(1L) - FileInputStream(sourceZst).use { fis -> - val cis = CountingInputStream(fis) - ZstdInputStream(cis).use { zin -> - FileOutputStream(targetDb).use { out -> - val buffer = ByteArray(1024 * 1024) - while (true) { - val read = zin.read(buffer) - if (read <= 0) break - out.write(buffer, 0, read) - onProgress(cis.count.toFloat() / totalCompressed.toFloat()) - } - out.fd.sync() - } - } - } - onProgress(1f) - return targetDb - } - - private fun extractTarZstFile( - zstFile: File, - destDir: File, - onProgress: (Float) -> Unit, - ): File { - class CountingInputStream( - `in`: InputStream, - ) : FilterInputStream(`in`) { - var count: Long = 0 - private set - - override fun read( - b: ByteArray, - off: Int, - len: Int, - ): Int { - val r = super.read(b, off, len) - if (r > 0) count += r - return r - } - - override fun read(): Int { - val r = super.read() - if (r >= 0) count += 1 - return r - } - } - - val totalCompressed = zstFile.length().coerceAtLeast(1L) - var extractedDb: File? = null - FileInputStream(zstFile).use { fis -> - CountingInputStream(BufferedInputStream(fis, 1 shl 20)).use { cis -> - ZstdInputStream(cis).use { zIn -> - TarArchiveInputStream(zIn).use { tar -> - while (true) { - val entry = tar.nextEntry ?: break - val name = entry.name - val outFile = File(destDir, name) - if (entry.isDirectory) { - outFile.mkdirs() - } else { - outFile.parentFile?.mkdirs() - FileOutputStream(outFile).use { out -> - val buffer = ByteArray(1024 * 1024) - var remaining = entry.size - while (remaining > 0) { - val toRead = if (remaining >= buffer.size) buffer.size else remaining.toInt() - val read = tar.read(buffer, 0, toRead) - if (read <= 0) break - out.write(buffer, 0, read) - remaining -= read - onProgress(cis.count.toFloat() / totalCompressed.toFloat()) - } - out.fd.sync() - } - if (name.endsWith(".db", ignoreCase = true)) { - extractedDb = outFile - } - } - onProgress(cis.count.toFloat() / totalCompressed.toFloat()) - } - } - } - } - } - return extractedDb ?: error("No .db file found in archive") - } - - /** Extracts the concatenated [parts]; when [expectedSha256] is known, the compressed stream must match it. */ - private fun extractTarZstFromPartsStreaming( - parts: List, - destDir: File, - expectedSha256: String?, - onUiProgress: (Float) -> Unit, - ): File { - require(parts.isNotEmpty()) - - // Counting wrapper to report compressed bytes consumed - class CountingInputStream( - `in`: InputStream, - ) : FilterInputStream(`in`) { - var count: Long = 0 - private set - - override fun read( - b: ByteArray, - off: Int, - len: Int, - ): Int { - val r = super.read(b, off, len) - if (r > 0) count += r - return r - } - - override fun read(): Int { - val r = super.read() - if (r >= 0) count += 1 - return r - } - } - - val totalCompressed = parts.sumOf { it.length().coerceAtLeast(0L) }.coerceAtLeast(1L) - val quarter = (totalCompressed / 4L).coerceAtLeast(1L) - - fun mapProgress(consumed: Long): Float { - val c = consumed.coerceIn(0, totalCompressed) - return if (c <= quarter) { - (c.toFloat() / quarter.toFloat()) * 0.25f - } else { - val rest = (totalCompressed - quarter).coerceAtLeast(1L) - val p = ((c - quarter).toFloat() / rest.toFloat()).coerceIn(0f, 1f) - 0.25f + 0.75f * p - } - } - - val ins = parts.map { BufferedInputStream(FileInputStream(it), 1 shl 20) } - val seq = SequenceInputStream(ins.toEnumeration()) - // Hashing the bytes already read for extraction costs no extra I/O - val digest = expectedSha256?.let { MessageDigest.getInstance("SHA-256") } - - var extractedDb: File? = null - CountingInputStream(digest?.let { DigestInputStream(seq, it) } ?: seq).use { cis -> - ZstdInputStream(cis).use { zIn -> - TarArchiveInputStream(zIn).use { tar -> - while (true) { - val entry = tar.nextEntry ?: break - val name = entry.name - val outFile = File(destDir, name) - if (entry.isDirectory) { - outFile.mkdirs() - } else { - outFile.parentFile?.mkdirs() - FileOutputStream(outFile).use { out -> - val buffer = ByteArray(1024 * 1024) - var remaining = entry.size - while (remaining > 0) { - val toRead = if (remaining >= buffer.size) buffer.size else remaining.toInt() - val read = tar.read(buffer, 0, toRead) - if (read <= 0) break - out.write(buffer, 0, read) - remaining -= read - onUiProgress(mapProgress(cis.count)) - } - out.fd.sync() - } - if (name.endsWith(".db", ignoreCase = true)) { - extractedDb = outFile - } - } - onUiProgress(mapProgress(cis.count)) - } - if (digest != null) { - // The tar ends before the compressed stream does; hash the rest too - cis.transferTo(OutputStream.nullOutputStream()) - if (!HexFormat.of().formatHex(digest.digest()).equals(expectedSha256, ignoreCase = true)) { - extractedDb?.delete() - error("The database bundle is corrupted (sha256 mismatch)") - } - } - } - } - } - onUiProgress(1f) - return extractedDb ?: error("No .db file found in archive") - } - - private fun List.toEnumeration(): java.util.Enumeration = - object : java.util.Enumeration { - private var index = 0 - - override fun hasMoreElements(): Boolean = index < this@toEnumeration.size - - override fun nextElement(): T = this@toEnumeration[index++] - } - - private fun maybeCleanupSources( - source: File, - dbDir: File, - ) { - if (source.parentFile?.canonicalFile == dbDir.canonicalFile) { - if (source.name.endsWith(".tar.zst", true)) { - runCatching { source.delete() } - } else if (BundleParts.isPart(source.name)) { - BundleParts.bundleFiles(source).forEach { runCatching { it.delete() } } - } - } - } -} diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/offline/DatabasePartsPicker.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/offline/DatabasePartsPicker.kt index 14b0d99a8..f58326009 100644 --- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/offline/DatabasePartsPicker.kt +++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/offline/DatabasePartsPicker.kt @@ -7,7 +7,7 @@ import io.github.vinceglb.filekit.dialogs.openFilePicker import io.github.vinceglb.filekit.path import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import java.io.File +import okio.Path.Companion.toPath /** * Prompts for the split database bundle (`.tar.zst.part01` … `.partNN`). @@ -38,5 +38,5 @@ suspend fun pickDatabaseParts(onPart01Picked: (String?) -> Unit = {}): String? { */ suspend fun isCompleteBundle(part01Path: String): Boolean = withContext(Dispatchers.IO) { - runCatching { BundleParts.resolve(File(part01Path)) }.isSuccess + runCatching { BundleParts.resolve(part01Path.toPath()) }.isSuccess } diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/settings/ui/DisplaySettingsScreen.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/settings/ui/DisplaySettingsScreen.kt index 6cbb45d3b..2fffdbb23 100644 --- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/settings/ui/DisplaySettingsScreen.kt +++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/settings/ui/DisplaySettingsScreen.kt @@ -30,6 +30,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import dev.zacsweers.metrox.viewmodel.metroViewModel import io.github.kdroidfilter.seforimapp.core.presentation.theme.AccentColor +import io.github.kdroidfilter.seforimapp.core.presentation.theme.forMode import io.github.kdroidfilter.seforimapp.core.presentation.utils.LocalWindowViewModelStoreOwner import io.github.kdroidfilter.seforimapp.core.settings.AppSettings import io.github.kdroidfilter.seforimapp.earthwidget.WidgetAntiAliasing diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/zmanim/data/PlaceTimeZone.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/zmanim/data/PlaceTimeZone.kt new file mode 100644 index 000000000..a2e5f2846 --- /dev/null +++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/zmanim/data/PlaceTimeZone.kt @@ -0,0 +1,6 @@ +package io.github.kdroidfilter.seforimapp.features.zmanim.data + +import java.util.TimeZone + +/** The city's civil time zone, daylight saving time included. */ +val Place.timeZone: TimeZone get() = TimeZone.getTimeZone(zoneId) diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/database/UserSettingsDatabase.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/database/UserSettingsDatabase.kt index 11c13ff51..ac70faf91 100644 --- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/database/UserSettingsDatabase.kt +++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/database/UserSettingsDatabase.kt @@ -1,18 +1,9 @@ package io.github.kdroidfilter.seforimapp.framework.database import io.github.kdroidfilter.seforimapp.core.e2e.E2e -import io.github.vinceglb.filekit.FileKit -import io.github.vinceglb.filekit.databasesDir -import io.github.vinceglb.filekit.path import java.io.File -private const val SETTINGS_DB_DIR = "settings" -private const val SETTINGS_DB_NAME = "user_settings.db" - -/** Qualifies the shared connection to the user database in the graph. */ -const val USER_SETTINGS_DRIVER = "userSettingsDriver" - -fun getUserSettingsDatabasePath(): String { - val root = (E2e.userDataDir ?: File(FileKit.databasesDir.path, SETTINGS_DB_DIR)).apply { mkdirs() } - return File(root, SETTINGS_DB_NAME).absolutePath -} +/** The user database: the usual one, or the end-to-end harness's own (see [E2e.userDataDir]). */ +fun getUserSettingsDatabasePath(): String = + E2e.userDataDir?.let { dir -> File(dir.apply { mkdirs() }, "user_settings.db").absolutePath } + ?: defaultUserSettingsDatabasePath() diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/desktop/DesktopManager.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/desktop/DesktopManager.kt index 17955bfb5..f7e44da9f 100644 --- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/desktop/DesktopManager.kt +++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/desktop/DesktopManager.kt @@ -14,6 +14,7 @@ import io.github.kdroidfilter.seforim.desktop.VirtualDesktop import io.github.kdroidfilter.seforim.tabs.TabItem import io.github.kdroidfilter.seforim.tabs.TabTitleUpdateManager import io.github.kdroidfilter.seforim.tabs.TabsDestination +import io.github.kdroidfilter.seforim.tabs.TabsResolver import io.github.kdroidfilter.seforim.tabs.TabsViewModel import io.github.kdroidfilter.seforim.tabs.withTabId import io.github.kdroidfilter.seforimapp.core.e2e.E2e @@ -73,7 +74,7 @@ class DesktopManager( defaultDesktopName: String, // The saved session, restored before the first frame (an app composing no window is closed). bootState: DesktopsState? = null, -) { +) : TabsResolver { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) private val defaultDesktopId = UUID.randomUUID().toString() @@ -158,7 +159,7 @@ class DesktopManager( * through this instead of a fixed window reference, so a tab dragged to another window keeps * opening its results in whatever window it lives in now. */ - fun tabsViewModelFor(tabId: String): TabsViewModel? { + override fun tabsViewModelFor(tabId: String): TabsViewModel? { val session = sessionOf(tabId) ?: return null val groupId = session.workspace diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/desktop/DesktopSessionShell.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/desktop/DesktopSessionShell.kt new file mode 100644 index 000000000..4eb6e0baa --- /dev/null +++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/desktop/DesktopSessionShell.kt @@ -0,0 +1,19 @@ +package io.github.kdroidfilter.seforimapp.framework.desktop + +import io.github.kdroidfilter.seforimapp.core.e2e.E2e +import io.github.kdroidfilter.seforimapp.framework.session.DesktopsState +import io.github.kdroidfilter.seforimapp.framework.session.SessionShell +import io.github.kdroidfilter.seforimapp.framework.session.TabThumbnailStore + +/** The session of the desktop's windows and virtual desktops. [desktopManager] is lazy: it is built from the session. */ +class DesktopSessionShell( + private val desktopManager: Lazy, + private val thumbnails: TabThumbnailStore, +) : SessionShell { + // The end-to-end harness must never overwrite or restore the user's session + override val isSessionDisabled: Boolean get() = E2e.enabled + + override fun buildDesktopsState(): DesktopsState = desktopManager.value.buildDesktopsState() + + override fun forgetTabsExcept(tabIds: Set) = thumbnails.prune(tabIds) +} diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/desktop/DesktopTabsHost.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/desktop/DesktopTabsHost.kt new file mode 100644 index 000000000..14a98b946 --- /dev/null +++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/desktop/DesktopTabsHost.kt @@ -0,0 +1,52 @@ +package io.github.kdroidfilter.seforimapp.framework.desktop + +import io.github.kdroidfilter.seforim.tabs.TabItem +import io.github.kdroidfilter.seforim.tabs.TabsDestination +import io.github.kdroidfilter.seforim.tabs.TabsHost + +/** A window's tabs: the group [groupIdOf] of the desktop [sessionOf] (both follow the window across desktops). */ +internal class DesktopTabsHost( + private val sessionOf: () -> DesktopSession, + private val groupIdOf: () -> String, +) : TabsHost { + private val session: DesktopSession get() = sessionOf() + private val groupId: String get() = groupIdOf() + + override fun ids(): List = session.group(groupId)?.ids.orEmpty() + + override fun selectedId(): String? = session.group(groupId)?.selectedId + + override fun item(tabId: String): TabItem? = session.item(tabId) + + override fun isDeclared(tabId: String): Boolean = session.workspace.tab(tabId) != null + + override fun isPinned(tabId: String): Boolean = session.isPinned(tabId) + + override fun setPinned( + tabId: String, + pinned: Boolean, + ) = session.setPinned(tabId, pinned) + + override fun select(tabId: String) = session.workspace.select(tabId) + + override fun close(tabId: String) = session.workspace.close(tabId) + + override fun reorder( + tabId: String, + toIndex: Int, + ) = session.workspace.reorder(tabId, toIndex) + + override fun add( + destination: TabsDestination, + index: Int, + replacing: String?, + pinned: Boolean, + ) { + session.addTab(destination, groupId, index = index, replacing = replacing, pinned = pinned) + } + + override fun updateDestination( + tabId: String, + destination: TabsDestination, + ) = session.updateDestination(tabId, destination) +} diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/desktop/OpenWindow.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/desktop/OpenWindow.kt index a40ecab64..62bab94b9 100644 --- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/desktop/OpenWindow.kt +++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/desktop/OpenWindow.kt @@ -44,7 +44,7 @@ class OpenWindow internal constructor( var groupId: String by mutableStateOf(groupId) private set - val tabsViewModel = TabsViewModel({ this.session }, { this.groupId }) + val tabsViewModel = TabsViewModel(DesktopTabsHost({ this.session }, { this.groupId })) private val _desktopId = MutableStateFlow(session.desktopId) val desktopId: StateFlow = _desktopId.asStateFlow() diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/di/modules/AppCoreBindings.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/di/modules/AppCoreBindings.kt index d66d96175..98fbbdebd 100644 --- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/di/modules/AppCoreBindings.kt +++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/di/modules/AppCoreBindings.kt @@ -1,54 +1,39 @@ package io.github.kdroidfilter.seforimapp.framework.di.modules -import app.cash.sqldelight.db.SqlDriver -import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver -import com.russhwolf.settings.Settings import dev.nucleusframework.core.runtime.AppRestarter.restartApplication import dev.zacsweers.metro.BindingContainer import dev.zacsweers.metro.ContributesTo -import dev.zacsweers.metro.Named import dev.zacsweers.metro.Provides import dev.zacsweers.metro.SingleIn import io.github.kdroidfilter.seforim.tabs.TabTitleUpdateManager +import io.github.kdroidfilter.seforim.tabs.TabsResolver import io.github.kdroidfilter.seforimapp.BuildConfig import io.github.kdroidfilter.seforimapp.backup.BackupManager import io.github.kdroidfilter.seforimapp.backup.drive.DriveSyncConfig import io.github.kdroidfilter.seforimapp.backup.drive.GoogleDriveSync -import io.github.kdroidfilter.seforimapp.core.MainAppState -import io.github.kdroidfilter.seforimapp.core.annotations.HighlightStore -import io.github.kdroidfilter.seforimapp.core.annotations.NoteStore -import io.github.kdroidfilter.seforimapp.core.catalog.CatalogAccess import io.github.kdroidfilter.seforimapp.core.e2e.E2e -import io.github.kdroidfilter.seforimapp.core.favorites.FavoritesStore -import io.github.kdroidfilter.seforimapp.core.history.HistoryStore import io.github.kdroidfilter.seforimapp.core.presentation.utils.UrlOpener -import io.github.kdroidfilter.seforimapp.core.selection.DefaultSelectionContext -import io.github.kdroidfilter.seforimapp.core.selection.SelectionContext import io.github.kdroidfilter.seforimapp.core.settings.AppSettings -import io.github.kdroidfilter.seforimapp.core.settings.CategoryDisplaySettingsStore -import io.github.kdroidfilter.seforimapp.core.shnayimmikra.ShnayimMikraStore -import io.github.kdroidfilter.seforimapp.db.UserSettingsDb -import io.github.kdroidfilter.seforimapp.features.search.SearchHistory import io.github.kdroidfilter.seforimapp.features.search.SearchHomeViewModel import io.github.kdroidfilter.seforimapp.features.search.SearchSessionStore -import io.github.kdroidfilter.seforimapp.features.search.SearchSettings import io.github.kdroidfilter.seforimapp.features.search.SearchTabs import io.github.kdroidfilter.seforimapp.framework.backup.UserDataBackup -import io.github.kdroidfilter.seforimapp.framework.database.CatalogCache +import io.github.kdroidfilter.seforimapp.framework.database.AppDatabases import io.github.kdroidfilter.seforimapp.framework.database.DatabasePathProvider -import io.github.kdroidfilter.seforimapp.framework.database.PersistentSqliteDriver -import io.github.kdroidfilter.seforimapp.framework.database.USER_SETTINGS_DRIVER import io.github.kdroidfilter.seforimapp.framework.database.getUserSettingsDatabasePath import io.github.kdroidfilter.seforimapp.framework.desktop.DesktopManager +import io.github.kdroidfilter.seforimapp.framework.desktop.DesktopSessionShell import io.github.kdroidfilter.seforimapp.framework.di.AppScope import io.github.kdroidfilter.seforimapp.framework.search.AcronymFrequencyCache -import io.github.kdroidfilter.seforimapp.framework.search.DesktopSearchSessionStore import io.github.kdroidfilter.seforimapp.framework.search.DesktopSearchTabs import io.github.kdroidfilter.seforimapp.framework.search.LuceneLookupSearchService import io.github.kdroidfilter.seforimapp.framework.search.RepositorySnippetSourceProvider +import io.github.kdroidfilter.seforimapp.framework.search.TabSearchSessionStore import io.github.kdroidfilter.seforimapp.framework.search.openLuceneIndex import io.github.kdroidfilter.seforimapp.framework.search.withSemanticSearch import io.github.kdroidfilter.seforimapp.framework.session.SessionManager +import io.github.kdroidfilter.seforimapp.framework.session.SessionRestoreState +import io.github.kdroidfilter.seforimapp.framework.session.SessionShell import io.github.kdroidfilter.seforimapp.framework.session.TabPersistedStateStore import io.github.kdroidfilter.seforimapp.framework.session.TabThumbnailStore import io.github.kdroidfilter.seforimapp.framework.update.AppUpdateService @@ -62,7 +47,6 @@ import io.github.vinceglb.filekit.path import io.ktor.client.HttpClient import java.io.File import java.nio.file.Paths -import java.util.Properties @ContributesTo(AppScope::class) @BindingContainer @@ -72,48 +56,10 @@ object AppCoreBindings { @SingleIn(AppScope::class) fun provideHttpClient(): HttpClient = KtorConfig.createHttpClient() + /** The desktop's SQLite connections (the common bindings open the repository and the user database on them). */ @Provides @SingleIn(AppScope::class) - fun provideMainAppState(appSettings: AppSettings): MainAppState = MainAppState(appSettings) - - @Provides - @SingleIn(AppScope::class) - fun provideCatalogAccess(catalogCache: CatalogCache): CatalogAccess = CatalogAccess { catalogCache.getCatalog() } - - @Provides - @SingleIn(AppScope::class) - fun provideSelectionContext(): SelectionContext = DefaultSelectionContext() - - @Provides - @SingleIn(AppScope::class) - fun provideTabPersistedStateStore(): TabPersistedStateStore = TabPersistedStateStore() - - @Provides - @SingleIn(AppScope::class) - fun provideTabTitleUpdateManager(): TabTitleUpdateManager = TabTitleUpdateManager() - - @Provides - @SingleIn(AppScope::class) - fun provideSettings(): Settings = Settings() - - @Provides - @SingleIn(AppScope::class) - @Named(USER_SETTINGS_DRIVER) - fun provideUserSettingsDriver(): SqlDriver { - // Single shared connection to the local user database (separate from the - // read-only books DB). All user stores inject this instance instead of - // opening their own driver. New tables are added transparently for - // existing users via CREATE TABLE IF NOT EXISTS in Schema.create(). - // Wait for a concurrent writer instead of failing: the backup snapshot (VACUUM INTO) runs - // on its own thread, hence its own connection, while the stores keep writing. - val driver = - JdbcSqliteDriver( - "jdbc:sqlite:${getUserSettingsDatabasePath()}", - Properties().apply { setProperty("busy_timeout", USER_DB_BUSY_TIMEOUT_MS.toString()) }, - ) - UserSettingsDb.Schema.create(driver) - return driver - } + fun provideAppDatabases(): AppDatabases = AppDatabases(getUserSettingsDatabasePath()) @Provides @SingleIn(AppScope::class) @@ -140,48 +86,6 @@ object AppCoreBindings { ), ) - @Provides - @SingleIn(AppScope::class) - fun provideUserSettingsDb( - @Named(USER_SETTINGS_DRIVER) driver: SqlDriver, - ): UserSettingsDb = UserSettingsDb(driver) - - @Provides - @SingleIn(AppScope::class) - fun provideCategoryDisplaySettingsStore(database: UserSettingsDb): CategoryDisplaySettingsStore = CategoryDisplaySettingsStore(database) - - @Provides - @SingleIn(AppScope::class) - fun provideHighlightStore(database: UserSettingsDb): HighlightStore = HighlightStore(database) - - @Provides - @SingleIn(AppScope::class) - fun provideNoteStore(database: UserSettingsDb): NoteStore = NoteStore(database) - - @Provides - @SingleIn(AppScope::class) - fun provideHistoryStore(database: UserSettingsDb): HistoryStore = HistoryStore(database) - - @Provides - @SingleIn(AppScope::class) - fun provideFavoritesStore(database: UserSettingsDb): FavoritesStore = FavoritesStore(database) - - @Provides - @SingleIn(AppScope::class) - fun provideShnayimMikraStore(database: UserSettingsDb): ShnayimMikraStore = ShnayimMikraStore(database) - - @Provides - @SingleIn(AppScope::class) - fun provideRepository(databasePathProvider: DatabasePathProvider): SeforimRepository { - val dbPath = databasePathProvider.get() - // Persistent single-connection driver with prepared-statement cache + - // read-tuning PRAGMAs. Replaces `JdbcSqliteDriver` whose ThreadedConnectionManager - // closes the SQLite connection after every non-transactional query (confirmed by - // JFR 2026-04-23: ~70 `NativeDB.prepare_utf8` + `NativeDB._close()` pairs / 20 s). - val driver = PersistentSqliteDriver("jdbc:sqlite:$dbPath") - return SeforimRepository(dbPath, driver) - } - /** * The app's search engine: lexical (BM25 + MagicDictionary) over the text index, fused with dense semantic search * in the official builds ([withSemanticSearch], open core). @@ -200,20 +104,29 @@ object AppCoreBindings { return withSemanticSearch(lexical, lexical.index, repository) } - // What the search screens (searchkit) ask of the desktop around them + // What the reader (appkit) asks of the desktop around it @Provides - @SingleIn(AppScope::class) - fun provideSearchTabs(desktopManager: DesktopManager): SearchTabs = DesktopSearchTabs(desktopManager) + fun provideTabsResolver(desktopManager: DesktopManager): TabsResolver = desktopManager + + @Provides + fun provideSessionRestoreState(sessionManager: SessionManager): SessionRestoreState = sessionManager + // DesktopManager is built from the saved session: the shell only reaches it when saving @Provides @SingleIn(AppScope::class) - fun provideSearchSessionStore(store: TabPersistedStateStore): SearchSessionStore = DesktopSearchSessionStore(store) + fun provideSessionShell( + desktopManager: Lazy, + thumbnails: TabThumbnailStore, + ): SessionShell = DesktopSessionShell(desktopManager, thumbnails) + // What the search screens (searchkit) ask of the desktop around them @Provides - fun provideSearchSettings(appSettings: AppSettings): SearchSettings = appSettings + @SingleIn(AppScope::class) + fun provideSearchTabs(desktopManager: DesktopManager): SearchTabs = DesktopSearchTabs(desktopManager) @Provides - fun provideSearchHistory(historyStore: HistoryStore): SearchHistory = historyStore + @SingleIn(AppScope::class) + fun provideSearchSessionStore(store: TabPersistedStateStore): SearchSessionStore = TabSearchSessionStore(store) @Provides @SingleIn(AppScope::class) @@ -291,7 +204,7 @@ object AppCoreBindings { // created and disposed by DesktopManager. searchHomeViewModelFactory = { observeProfile -> SearchHomeViewModel( - sessionStore = DesktopSearchSessionStore(tabPersistedStateStore), + sessionStore = TabSearchSessionStore(tabPersistedStateStore), repository = repository, lookup = lookup, appSettings = appSettings, @@ -303,8 +216,6 @@ object AppCoreBindings { ) } -private const val USER_DB_BUSY_TIMEOUT_MS = 5_000 - /** The page the browser shows once the Google sign-in returns to the app. */ private fun driveSignInPage(success: Boolean): String { val message = if (success) "זית מחובר לגוגל דרייב." else "החיבור לגוגל דרייב נכשל." diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/search/DesktopSearchTabs.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/search/DesktopSearchTabs.kt new file mode 100644 index 000000000..2f09fafcc --- /dev/null +++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/search/DesktopSearchTabs.kt @@ -0,0 +1,29 @@ +package io.github.kdroidfilter.seforimapp.framework.search + +import io.github.kdroidfilter.seforim.tabs.TabsDestination +import io.github.kdroidfilter.seforimapp.features.search.SearchTabs +import io.github.kdroidfilter.seforimapp.framework.desktop.DesktopManager +import kotlinx.coroutines.flow.Flow + +/** The search's tabs on the desktop: those of the window that holds the searching tab, across virtual desktops. */ +class DesktopSearchTabs( + private val desktopManager: DesktopManager, +) : SearchTabs { + override fun openTab( + fromTabId: String, + destination: TabsDestination, + ) { + desktopManager.tabsViewModelFor(fromTabId)?.openTab(destination) + } + + override fun replace( + tabId: String, + destination: TabsDestination, + ) { + desktopManager.tabsViewModelFor(tabId)?.replaceCurrentTabDestination(destination) + } + + override fun tabExistsFlow(tabId: String): Flow = desktopManager.tabExistsFlow(tabId) + + override fun isTabOpen(tabId: String): Boolean = desktopManager.isTabOpen(tabId) +} diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/main.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/main.kt index 24fd80d73..142bd5dd6 100644 --- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/main.kt +++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/main.kt @@ -19,6 +19,7 @@ import dev.zacsweers.metrox.viewmodel.metroViewModel import io.github.kdroidfilter.seforimapp.core.deeplink.ContentDeepLinkHandler import io.github.kdroidfilter.seforimapp.core.e2e.E2e import io.github.kdroidfilter.seforimapp.core.e2e.E2eAuthorScenario +import io.github.kdroidfilter.seforimapp.core.e2e.E2eExtractScenario import io.github.kdroidfilter.seforimapp.core.e2e.E2eFilamentScenario import io.github.kdroidfilter.seforimapp.core.e2e.E2eMemoryScenario import io.github.kdroidfilter.seforimapp.core.e2e.E2eNotesScenario @@ -465,6 +466,7 @@ fun main(args: Array) { E2eReaderPerfScenario.run(it) E2eShiftSelectScenario.run(it) E2eQuoteLinksScenario.run(it) + E2eExtractScenario.run(it) installedSiddur?.runE2e(it) }) { exitApplication() } } diff --git a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/PostSelectLineTest.kt b/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/PostSelectLineTest.kt index a7a03c2ff..ebfd6edba 100644 --- a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/PostSelectLineTest.kt +++ b/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/PostSelectLineTest.kt @@ -16,7 +16,7 @@ import io.github.kdroidfilter.seforimapp.features.bookcontent.usecases.NotesUseC import io.github.kdroidfilter.seforimapp.features.bookcontent.usecases.TocUseCase import io.github.kdroidfilter.seforimapp.features.search.SearchHomeViewModel import io.github.kdroidfilter.seforimapp.framework.desktop.DesktopManager -import io.github.kdroidfilter.seforimapp.framework.search.DesktopSearchSessionStore +import io.github.kdroidfilter.seforimapp.framework.search.TabSearchSessionStore import io.github.kdroidfilter.seforimapp.framework.session.TabPersistedStateStore import io.github.kdroidfilter.seforimapp.framework.session.TabThumbnailStore import io.github.kdroidfilter.seforimapp.testAppSettings @@ -89,7 +89,7 @@ class PostSelectLineTest { titleUpdateManager = titleUpdateManager, searchHomeViewModelFactory = { SearchHomeViewModel( - DesktopSearchSessionStore(TabPersistedStateStore()), + TabSearchSessionStore(TabPersistedStateStore()), mockk(relaxed = true), mockk(relaxed = true), testAppSettings(), @@ -149,9 +149,9 @@ class PostSelectLineTest { repository = repository, useCaseFactory = useCaseFactory, titleUpdateManager = titleUpdateManager, - desktopManager = desktopManager, + tabsResolver = desktopManager, historyStore = mockk(relaxed = true), - sessionManager = mockk { every { isRestoringSession } returns MutableStateFlow(false) }, + sessionRestore = mockk { every { isRestoringSession } returns MutableStateFlow(false) }, appSettings = testAppSettings(), ) } diff --git a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/search/domain/AuthorScopeSearchTest.kt b/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/search/domain/AuthorScopeSearchTest.kt index fb9e2677d..f13b295ab 100644 --- a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/search/domain/AuthorScopeSearchTest.kt +++ b/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/search/domain/AuthorScopeSearchTest.kt @@ -9,8 +9,8 @@ import io.github.kdroidfilter.seforimapp.db.UserSettingsDb import io.github.kdroidfilter.seforimapp.features.bookcontent.state.StateKeys import io.github.kdroidfilter.seforimapp.features.search.SearchResultViewModel import io.github.kdroidfilter.seforimapp.framework.desktop.DesktopManager -import io.github.kdroidfilter.seforimapp.framework.search.DesktopSearchSessionStore import io.github.kdroidfilter.seforimapp.framework.search.DesktopSearchTabs +import io.github.kdroidfilter.seforimapp.framework.search.TabSearchSessionStore import io.github.kdroidfilter.seforimapp.framework.session.SearchPersistedState import io.github.kdroidfilter.seforimapp.framework.session.TabPersistedStateStore import io.github.kdroidfilter.seforimapp.testAppSettings @@ -53,7 +53,7 @@ class AuthorScopeSearchTest : SearchIntegrationTestBase() { every { desktopManager.tabExistsFlow(any()) } returns flowOf(true) return SearchResultViewModel( savedStateHandle = SavedStateHandle(mapOf(StateKeys.TAB_ID to tabId)), - sessionStore = DesktopSearchSessionStore(store), + sessionStore = TabSearchSessionStore(store), repository = requireRepository(), lucene = requireSearchEngine(), titleUpdateManager = TabTitleUpdateManager(), diff --git a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/settings/SettingsWindowViewModelTest.kt b/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/settings/SettingsWindowViewModelTest.kt index 63d9301ac..da9816ed8 100644 --- a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/settings/SettingsWindowViewModelTest.kt +++ b/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/settings/SettingsWindowViewModelTest.kt @@ -3,7 +3,7 @@ package io.github.kdroidfilter.seforimapp.features.settings import io.github.kdroidfilter.seforim.tabs.TabTitleUpdateManager import io.github.kdroidfilter.seforimapp.features.search.SearchHomeViewModel import io.github.kdroidfilter.seforimapp.framework.desktop.DesktopManager -import io.github.kdroidfilter.seforimapp.framework.search.DesktopSearchSessionStore +import io.github.kdroidfilter.seforimapp.framework.search.TabSearchSessionStore import io.github.kdroidfilter.seforimapp.framework.session.TabPersistedStateStore import io.github.kdroidfilter.seforimapp.framework.session.TabThumbnailStore import io.github.kdroidfilter.seforimapp.testAppSettings @@ -29,7 +29,7 @@ class SettingsWindowViewModelTest { titleUpdateManager = TabTitleUpdateManager(), searchHomeViewModelFactory = { SearchHomeViewModel( - DesktopSearchSessionStore(TabPersistedStateStore()), + TabSearchSessionStore(TabPersistedStateStore()), mockk(relaxed = true), mockk(relaxed = true), testAppSettings(), diff --git a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/framework/desktop/DesktopManagerTest.kt b/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/framework/desktop/DesktopManagerTest.kt index 8d4fa52ff..da096d94d 100644 --- a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/framework/desktop/DesktopManagerTest.kt +++ b/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/framework/desktop/DesktopManagerTest.kt @@ -5,7 +5,7 @@ import io.github.kdroidfilter.seforim.tabs.TabTitleUpdateManager import io.github.kdroidfilter.seforim.tabs.TabType import io.github.kdroidfilter.seforim.tabs.TabsDestination import io.github.kdroidfilter.seforimapp.features.search.SearchHomeViewModel -import io.github.kdroidfilter.seforimapp.framework.search.DesktopSearchSessionStore +import io.github.kdroidfilter.seforimapp.framework.search.TabSearchSessionStore import io.github.kdroidfilter.seforimapp.framework.session.DesktopTabsSnapshot import io.github.kdroidfilter.seforimapp.framework.session.DesktopsState import io.github.kdroidfilter.seforimapp.framework.session.SerializableTabTitle @@ -59,7 +59,7 @@ class DesktopManagerTest { titleUpdateManager = TabTitleUpdateManager(), searchHomeViewModelFactory = { SearchHomeViewModel( - DesktopSearchSessionStore(TabPersistedStateStore()), + TabSearchSessionStore(TabPersistedStateStore()), mockk(relaxed = true), mockk(relaxed = true), testAppSettings(), diff --git a/SeforimApp/addons/quotelinks/disabled/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/quotelinks/InstalledQuoteLinks.kt b/appkit/addons/quotelinks/disabled/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/quotelinks/InstalledQuoteLinks.kt similarity index 69% rename from SeforimApp/addons/quotelinks/disabled/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/quotelinks/InstalledQuoteLinks.kt rename to appkit/addons/quotelinks/disabled/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/quotelinks/InstalledQuoteLinks.kt index da207b233..4a37e37e6 100644 --- a/SeforimApp/addons/quotelinks/disabled/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/quotelinks/InstalledQuoteLinks.kt +++ b/appkit/addons/quotelinks/disabled/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/quotelinks/InstalledQuoteLinks.kt @@ -1,4 +1,4 @@ package io.github.kdroidfilter.seforimapp.features.bookcontent.quotelinks /** A community build: the lines as they are in the DB. */ -val installedQuoteLinks: QuoteLinksFeature? = null +actual val installedQuoteLinks: QuoteLinksFeature? = null diff --git a/SeforimApp/addons/quotelinks/enabled/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/quotelinks/InstalledQuoteLinks.kt b/appkit/addons/quotelinks/enabled/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/quotelinks/InstalledQuoteLinks.kt similarity index 97% rename from SeforimApp/addons/quotelinks/enabled/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/quotelinks/InstalledQuoteLinks.kt rename to appkit/addons/quotelinks/enabled/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/quotelinks/InstalledQuoteLinks.kt index f84a81f0e..409bb871b 100644 --- a/SeforimApp/addons/quotelinks/enabled/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/quotelinks/InstalledQuoteLinks.kt +++ b/appkit/addons/quotelinks/enabled/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/quotelinks/InstalledQuoteLinks.kt @@ -7,7 +7,7 @@ import io.github.kdroidfilter.seforimlibrary.core.models.Line import java.util.concurrent.ConcurrentHashMap /** An official build: the quote links of the SeforimQuoteLinks package. */ -val installedQuoteLinks: QuoteLinksFeature? = TalmudQuoteLinks +actual val installedQuoteLinks: QuoteLinksFeature? = TalmudQuoteLinks internal object TalmudQuoteLinks : QuoteLinksFeature { private const val MAX_BOOKS = 4 diff --git a/appkit/build.gradle.kts b/appkit/build.gradle.kts new file mode 100644 index 000000000..fd33878a0 --- /dev/null +++ b/appkit/build.gradle.kts @@ -0,0 +1,135 @@ +plugins { + alias(libs.plugins.multiplatform) + alias(libs.plugins.android.kmp.library) + alias(libs.plugins.compose.compiler) + alias(libs.plugins.compose) + alias(libs.plugins.kotlinx.serialization) + // The view models and stores contribute their bindings to the app's graph (the desktop's AppGraph) + alias(libs.plugins.metro) + alias(libs.plugins.sqlDelight) + alias(libs.plugins.structured.coroutines) +} + +structuredCoroutines { + useStrictProfile() +} + +val withQuoteLinks = gradle.extensions.extraProperties["quoteLinksEnabled"] == true + +/** + * Zayit without its desktop shell: the reader's state, use cases and view models, the user's data (highlights, notes, + * favorites, history), the settings and the onboarding's logic, for the desktop app and the mobile ones (Android 10+, + * iOS). The screens stay in each app (Jewel on the desktop). + */ +kotlin { + jvmToolchain( + libs.versions.jvmToolchain + .get() + .toInt(), + ) + + jvm() + android { + namespace = "io.github.kdroidfilter.seforimapp.appkit" + compileSdk = 37 + // As searchkit, whose search runs from Android 10 + minSdk = 29 + // The Compose resources (composeResources) packed in the library + androidResources { enable = true } + } + iosArm64() + iosSimulatorArm64() + + sourceSets { + commonMain.dependencies { + api(libs.seforimlibrary.core) + api(libs.seforimlibrary.dao) + api(project(":searchkit")) + api(project(":navigation")) + implementation(project(":htmlparser")) + implementation(project(":logger")) + api(project(":pagination")) + // The 3D views' settings (anti-aliasing), the Earth's and sky's locations and opinions + api(project(":earthwidget")) + + implementation(libs.compose.runtime) + implementation(libs.compose.foundation) + // The app's strings, fonts and pictures (composeResources), shared with the mobile apps + api(libs.compose.components.resources) + implementation(libs.androidx.lifecycle.viewmodel) + implementation(libs.androidx.lifecycle.viewmodel.savedstate) + implementation(libs.metrox.viewmodel) + implementation(libs.androidx.paging.common) + implementation(libs.navigation.compose) + + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.datetime) + implementation(libs.kotlinx.serialization.json) + implementation(libs.kotlinx.serialization.protobuf) + implementation(libs.structured.coroutines.annotations) + implementation(libs.kotlinx.collections.immutable) + implementation(libs.kotlinx.io.core) + // The database bundle's extraction: zstd (native on every platform) and SHA-256 over Okio + implementation(libs.okio) + implementation(libs.zstd.kmp.okio) + implementation(libs.multiplatformSettings) + implementation(libs.filekit.core) + implementation(libs.hebrew.numerals) + implementation(libs.seforimlibrary.cache) + implementation(libs.atomicfu) + } + + // The verse quote links of the Talmud (open core, temporary until the DB carries them): a JVM package, in the + // official builds only; a community build compiles addons/quotelinks/disabled, and mobile has none + jvmMain { + kotlin.srcDir(if (withQuoteLinks) "addons/quotelinks/enabled/kotlin" else "addons/quotelinks/disabled/kotlin") + if (withQuoteLinks) dependencies { implementation(libs.seforim.quotelinks) } + } + + jvmMain.dependencies { implementation(libs.sqlite.driver) } + androidMain.dependencies { implementation(libs.sqldelight.android.driver) } + iosMain.dependencies { implementation(libs.sqldelight.native.driver) } + + commonTest.dependencies { + implementation(kotlin("test")) + implementation(libs.kotlinx.coroutines.test) + implementation(libs.okio.fakefilesystem) + implementation(libs.multiplatformSettings.test) + } + + jvmTest.dependencies { + implementation(libs.mockk) + // The archives the database bundle's packager writes, to read them back + implementation(libs.commons.compress) + implementation(libs.zstd.jni) + } + } +} + +// The app's resources keep the Res class they had in SeforimApp, public for the apps' screens +compose.resources { + publicResClass = true + packageOfResClass = "seforimapp.seforimapp.generated.resources" +} + +sqldelight { + databases { + create("UserSettingsDb") { + packageName.set("io.github.kdroidfilter.seforimapp.db") + dialect("app.cash.sqldelight:sqlite-3-24-dialect:${libs.versions.sqlDelight.get()}") + } + } +} + +// A real database bundle extracted in the iOS simulator (IosExtractBundleTest): -PseforimTestBundle=<.tar.zst or .part01> +// and -PseforimTestBundleSha256=; the simulator sees the host's files +tasks.withType().configureEach { + device.set(providers.gradleProperty("seforim.iosSimulator").orElse("iPhone 17 Pro")) + val bundle = providers.gradleProperty("seforimTestBundle") + val sha256 = providers.gradleProperty("seforimTestBundleSha256") + doFirst { + // The simulator passes the SIMCTL_CHILD_ variables to the test process, without the prefix + bundle.orNull?.let { environment("SIMCTL_CHILD_SEFORIM_TEST_BUNDLE", it) } + sha256.orNull?.let { environment("SIMCTL_CHILD_SEFORIM_TEST_BUNDLE_SHA256", it) } + } +} diff --git a/appkit/src/androidMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/quotelinks/InstalledQuoteLinks.android.kt b/appkit/src/androidMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/quotelinks/InstalledQuoteLinks.android.kt new file mode 100644 index 000000000..4664c2f4b --- /dev/null +++ b/appkit/src/androidMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/quotelinks/InstalledQuoteLinks.android.kt @@ -0,0 +1,4 @@ +package io.github.kdroidfilter.seforimapp.features.bookcontent.quotelinks + +// The quote links package is JVM only +actual val installedQuoteLinks: QuoteLinksFeature? = null diff --git a/appkit/src/androidMain/kotlin/io/github/kdroidfilter/seforimapp/framework/database/AppDatabases.android.kt b/appkit/src/androidMain/kotlin/io/github/kdroidfilter/seforimapp/framework/database/AppDatabases.android.kt new file mode 100644 index 000000000..06542d631 --- /dev/null +++ b/appkit/src/androidMain/kotlin/io/github/kdroidfilter/seforimapp/framework/database/AppDatabases.android.kt @@ -0,0 +1,34 @@ +package io.github.kdroidfilter.seforimapp.framework.database + +import android.content.Context +import app.cash.sqldelight.db.AfterVersion +import app.cash.sqldelight.db.QueryResult +import app.cash.sqldelight.db.SqlDriver +import app.cash.sqldelight.db.SqlSchema +import app.cash.sqldelight.driver.android.AndroidSqliteDriver +import io.github.kdroidfilter.seforimapp.framework.search.openSeforimRepository +import io.github.kdroidfilter.seforimlibrary.dao.repository.SeforimRepository + +/** Android's connections, opened with the app's [context]. */ +actual class AppDatabases( + private val context: Context, + private val userSettingsPath: String = defaultUserSettingsDatabasePath(), +) { + actual fun openSeforimRepository(databasePath: String): SeforimRepository = openSeforimRepository(context, databasePath) + + actual fun openUserSettings(): SqlDriver = AndroidSqliteDriver(CallerCreatedSchema, context, userSettingsPath) +} + +// The caller creates the user tables on each open (CREATE TABLE IF NOT EXISTS), as on the desktop +private object CallerCreatedSchema : SqlSchema> { + override val version: Long = 1 + + override fun create(driver: SqlDriver) = QueryResult.Unit + + override fun migrate( + driver: SqlDriver, + oldVersion: Long, + newVersion: Long, + vararg callbacks: AfterVersion, + ) = QueryResult.Unit +} diff --git a/appkit/src/androidMain/kotlin/io/github/kdroidfilter/seforimapp/framework/platform/HostPlatform.android.kt b/appkit/src/androidMain/kotlin/io/github/kdroidfilter/seforimapp/framework/platform/HostPlatform.android.kt new file mode 100644 index 000000000..d8f091eba --- /dev/null +++ b/appkit/src/androidMain/kotlin/io/github/kdroidfilter/seforimapp/framework/platform/HostPlatform.android.kt @@ -0,0 +1,7 @@ +package io.github.kdroidfilter.seforimapp.framework.platform + +actual val isLinuxDesktop: Boolean = false + +actual val isMacOsDesktop: Boolean = false + +actual fun environmentVariable(name: String): String? = System.getenv(name) diff --git a/SeforimApp/src/commonMain/composeResources/drawable/AppIcon.png b/appkit/src/commonMain/composeResources/drawable/AppIcon.png similarity index 100% rename from SeforimApp/src/commonMain/composeResources/drawable/AppIcon.png rename to appkit/src/commonMain/composeResources/drawable/AppIcon.png diff --git a/SeforimApp/src/commonMain/composeResources/drawable/homepage_wallpaper_dark_large.jpg b/appkit/src/commonMain/composeResources/drawable/homepage_wallpaper_dark_large.jpg similarity index 100% rename from SeforimApp/src/commonMain/composeResources/drawable/homepage_wallpaper_dark_large.jpg rename to appkit/src/commonMain/composeResources/drawable/homepage_wallpaper_dark_large.jpg diff --git a/SeforimApp/src/commonMain/composeResources/drawable/homepage_wallpaper_dark_medium.jpg b/appkit/src/commonMain/composeResources/drawable/homepage_wallpaper_dark_medium.jpg similarity index 100% rename from SeforimApp/src/commonMain/composeResources/drawable/homepage_wallpaper_dark_medium.jpg rename to appkit/src/commonMain/composeResources/drawable/homepage_wallpaper_dark_medium.jpg diff --git a/SeforimApp/src/commonMain/composeResources/drawable/homepage_wallpaper_dark_small.jpg b/appkit/src/commonMain/composeResources/drawable/homepage_wallpaper_dark_small.jpg similarity index 100% rename from SeforimApp/src/commonMain/composeResources/drawable/homepage_wallpaper_dark_small.jpg rename to appkit/src/commonMain/composeResources/drawable/homepage_wallpaper_dark_small.jpg diff --git a/SeforimApp/src/commonMain/composeResources/drawable/homepage_wallpaper_large.jpg b/appkit/src/commonMain/composeResources/drawable/homepage_wallpaper_large.jpg similarity index 100% rename from SeforimApp/src/commonMain/composeResources/drawable/homepage_wallpaper_large.jpg rename to appkit/src/commonMain/composeResources/drawable/homepage_wallpaper_large.jpg diff --git a/SeforimApp/src/commonMain/composeResources/drawable/homepage_wallpaper_medium.jpg b/appkit/src/commonMain/composeResources/drawable/homepage_wallpaper_medium.jpg similarity index 100% rename from SeforimApp/src/commonMain/composeResources/drawable/homepage_wallpaper_medium.jpg rename to appkit/src/commonMain/composeResources/drawable/homepage_wallpaper_medium.jpg diff --git a/SeforimApp/src/commonMain/composeResources/drawable/homepage_wallpaper_small.jpg b/appkit/src/commonMain/composeResources/drawable/homepage_wallpaper_small.jpg similarity index 100% rename from SeforimApp/src/commonMain/composeResources/drawable/homepage_wallpaper_small.jpg rename to appkit/src/commonMain/composeResources/drawable/homepage_wallpaper_small.jpg diff --git a/SeforimApp/src/commonMain/composeResources/drawable/zayit_logo_branch.png b/appkit/src/commonMain/composeResources/drawable/zayit_logo_branch.png similarity index 100% rename from SeforimApp/src/commonMain/composeResources/drawable/zayit_logo_branch.png rename to appkit/src/commonMain/composeResources/drawable/zayit_logo_branch.png diff --git a/SeforimApp/src/commonMain/composeResources/drawable/zayit_new_logo.png b/appkit/src/commonMain/composeResources/drawable/zayit_new_logo.png similarity index 100% rename from SeforimApp/src/commonMain/composeResources/drawable/zayit_new_logo.png rename to appkit/src/commonMain/composeResources/drawable/zayit_new_logo.png diff --git a/SeforimApp/src/commonMain/composeResources/drawable/zayit_new_logo_text.png b/appkit/src/commonMain/composeResources/drawable/zayit_new_logo_text.png similarity index 100% rename from SeforimApp/src/commonMain/composeResources/drawable/zayit_new_logo_text.png rename to appkit/src/commonMain/composeResources/drawable/zayit_new_logo_text.png diff --git a/SeforimApp/src/commonMain/composeResources/drawable/zayit_transparent.png b/appkit/src/commonMain/composeResources/drawable/zayit_transparent.png similarity index 100% rename from SeforimApp/src/commonMain/composeResources/drawable/zayit_transparent.png rename to appkit/src/commonMain/composeResources/drawable/zayit_transparent.png diff --git a/SeforimApp/src/commonMain/composeResources/files/ABOUT.md b/appkit/src/commonMain/composeResources/files/ABOUT.md similarity index 100% rename from SeforimApp/src/commonMain/composeResources/files/ABOUT.md rename to appkit/src/commonMain/composeResources/files/ABOUT.md diff --git a/SeforimApp/src/commonMain/composeResources/files/CONDITIONS.md b/appkit/src/commonMain/composeResources/files/CONDITIONS.md similarity index 100% rename from SeforimApp/src/commonMain/composeResources/files/CONDITIONS.md rename to appkit/src/commonMain/composeResources/files/CONDITIONS.md diff --git a/SeforimApp/src/commonMain/composeResources/files/temple_jerusalem_in_fire.webp b/appkit/src/commonMain/composeResources/files/temple_jerusalem_in_fire.webp similarity index 100% rename from SeforimApp/src/commonMain/composeResources/files/temple_jerusalem_in_fire.webp rename to appkit/src/commonMain/composeResources/files/temple_jerusalem_in_fire.webp diff --git a/SeforimApp/src/commonMain/composeResources/font/AharoniCLM-Bold.otf b/appkit/src/commonMain/composeResources/font/AharoniCLM-Bold.otf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/AharoniCLM-Bold.otf rename to appkit/src/commonMain/composeResources/font/AharoniCLM-Bold.otf diff --git a/SeforimApp/src/commonMain/composeResources/font/AharoniCLM-BoldOblique.otf b/appkit/src/commonMain/composeResources/font/AharoniCLM-BoldOblique.otf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/AharoniCLM-BoldOblique.otf rename to appkit/src/commonMain/composeResources/font/AharoniCLM-BoldOblique.otf diff --git a/SeforimApp/src/commonMain/composeResources/font/AharoniCLM-Book.otf b/appkit/src/commonMain/composeResources/font/AharoniCLM-Book.otf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/AharoniCLM-Book.otf rename to appkit/src/commonMain/composeResources/font/AharoniCLM-Book.otf diff --git a/SeforimApp/src/commonMain/composeResources/font/AharoniCLM-BookOblique.otf b/appkit/src/commonMain/composeResources/font/AharoniCLM-BookOblique.otf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/AharoniCLM-BookOblique.otf rename to appkit/src/commonMain/composeResources/font/AharoniCLM-BookOblique.otf diff --git a/SeforimApp/src/commonMain/composeResources/font/BonaNovaSC-Bold.ttf b/appkit/src/commonMain/composeResources/font/BonaNovaSC-Bold.ttf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/BonaNovaSC-Bold.ttf rename to appkit/src/commonMain/composeResources/font/BonaNovaSC-Bold.ttf diff --git a/SeforimApp/src/commonMain/composeResources/font/BonaNovaSC-Italic.ttf b/appkit/src/commonMain/composeResources/font/BonaNovaSC-Italic.ttf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/BonaNovaSC-Italic.ttf rename to appkit/src/commonMain/composeResources/font/BonaNovaSC-Italic.ttf diff --git a/SeforimApp/src/commonMain/composeResources/font/BonaNovaSC-Regular.ttf b/appkit/src/commonMain/composeResources/font/BonaNovaSC-Regular.ttf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/BonaNovaSC-Regular.ttf rename to appkit/src/commonMain/composeResources/font/BonaNovaSC-Regular.ttf diff --git a/SeforimApp/src/commonMain/composeResources/font/CaladingsCLM.otf b/appkit/src/commonMain/composeResources/font/CaladingsCLM.otf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/CaladingsCLM.otf rename to appkit/src/commonMain/composeResources/font/CaladingsCLM.otf diff --git a/SeforimApp/src/commonMain/composeResources/font/Cardo-Bold.ttf b/appkit/src/commonMain/composeResources/font/Cardo-Bold.ttf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/Cardo-Bold.ttf rename to appkit/src/commonMain/composeResources/font/Cardo-Bold.ttf diff --git a/SeforimApp/src/commonMain/composeResources/font/Cardo-Italic.ttf b/appkit/src/commonMain/composeResources/font/Cardo-Italic.ttf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/Cardo-Italic.ttf rename to appkit/src/commonMain/composeResources/font/Cardo-Italic.ttf diff --git a/SeforimApp/src/commonMain/composeResources/font/Cardo-Regular.ttf b/appkit/src/commonMain/composeResources/font/Cardo-Regular.ttf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/Cardo-Regular.ttf rename to appkit/src/commonMain/composeResources/font/Cardo-Regular.ttf diff --git a/SeforimApp/src/commonMain/composeResources/font/DavidCLM-Bold.otf b/appkit/src/commonMain/composeResources/font/DavidCLM-Bold.otf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/DavidCLM-Bold.otf rename to appkit/src/commonMain/composeResources/font/DavidCLM-Bold.otf diff --git a/SeforimApp/src/commonMain/composeResources/font/DavidCLM-BoldItalic.otf b/appkit/src/commonMain/composeResources/font/DavidCLM-BoldItalic.otf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/DavidCLM-BoldItalic.otf rename to appkit/src/commonMain/composeResources/font/DavidCLM-BoldItalic.otf diff --git a/SeforimApp/src/commonMain/composeResources/font/DavidCLM-Medium.otf b/appkit/src/commonMain/composeResources/font/DavidCLM-Medium.otf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/DavidCLM-Medium.otf rename to appkit/src/commonMain/composeResources/font/DavidCLM-Medium.otf diff --git a/SeforimApp/src/commonMain/composeResources/font/DavidCLM-MediumItalic.otf b/appkit/src/commonMain/composeResources/font/DavidCLM-MediumItalic.otf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/DavidCLM-MediumItalic.otf rename to appkit/src/commonMain/composeResources/font/DavidCLM-MediumItalic.otf diff --git a/SeforimApp/src/commonMain/composeResources/font/DrugulinCLM-Bold.otf b/appkit/src/commonMain/composeResources/font/DrugulinCLM-Bold.otf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/DrugulinCLM-Bold.otf rename to appkit/src/commonMain/composeResources/font/DrugulinCLM-Bold.otf diff --git a/SeforimApp/src/commonMain/composeResources/font/ElliniaCLM-Bold.otf b/appkit/src/commonMain/composeResources/font/ElliniaCLM-Bold.otf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/ElliniaCLM-Bold.otf rename to appkit/src/commonMain/composeResources/font/ElliniaCLM-Bold.otf diff --git a/SeforimApp/src/commonMain/composeResources/font/ElliniaCLM-BoldItalic.otf b/appkit/src/commonMain/composeResources/font/ElliniaCLM-BoldItalic.otf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/ElliniaCLM-BoldItalic.otf rename to appkit/src/commonMain/composeResources/font/ElliniaCLM-BoldItalic.otf diff --git a/SeforimApp/src/commonMain/composeResources/font/ElliniaCLM-Light.otf b/appkit/src/commonMain/composeResources/font/ElliniaCLM-Light.otf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/ElliniaCLM-Light.otf rename to appkit/src/commonMain/composeResources/font/ElliniaCLM-Light.otf diff --git a/SeforimApp/src/commonMain/composeResources/font/ElliniaCLM-LightItalic.otf b/appkit/src/commonMain/composeResources/font/ElliniaCLM-LightItalic.otf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/ElliniaCLM-LightItalic.otf rename to appkit/src/commonMain/composeResources/font/ElliniaCLM-LightItalic.otf diff --git a/SeforimApp/src/commonMain/composeResources/font/FrankRuehlCLM-Bold.otf b/appkit/src/commonMain/composeResources/font/FrankRuehlCLM-Bold.otf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/FrankRuehlCLM-Bold.otf rename to appkit/src/commonMain/composeResources/font/FrankRuehlCLM-Bold.otf diff --git a/SeforimApp/src/commonMain/composeResources/font/FrankRuehlCLM-BoldOblique.otf b/appkit/src/commonMain/composeResources/font/FrankRuehlCLM-BoldOblique.otf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/FrankRuehlCLM-BoldOblique.otf rename to appkit/src/commonMain/composeResources/font/FrankRuehlCLM-BoldOblique.otf diff --git a/SeforimApp/src/commonMain/composeResources/font/FrankRuehlCLM-Medium.otf b/appkit/src/commonMain/composeResources/font/FrankRuehlCLM-Medium.otf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/FrankRuehlCLM-Medium.otf rename to appkit/src/commonMain/composeResources/font/FrankRuehlCLM-Medium.otf diff --git a/SeforimApp/src/commonMain/composeResources/font/FrankRuehlCLM-MediumOblique.otf b/appkit/src/commonMain/composeResources/font/FrankRuehlCLM-MediumOblique.otf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/FrankRuehlCLM-MediumOblique.otf rename to appkit/src/commonMain/composeResources/font/FrankRuehlCLM-MediumOblique.otf diff --git a/SeforimApp/src/commonMain/composeResources/font/HadasimCLM-Bold.otf b/appkit/src/commonMain/composeResources/font/HadasimCLM-Bold.otf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/HadasimCLM-Bold.otf rename to appkit/src/commonMain/composeResources/font/HadasimCLM-Bold.otf diff --git a/SeforimApp/src/commonMain/composeResources/font/HadasimCLM-BoldOblique.otf b/appkit/src/commonMain/composeResources/font/HadasimCLM-BoldOblique.otf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/HadasimCLM-BoldOblique.otf rename to appkit/src/commonMain/composeResources/font/HadasimCLM-BoldOblique.otf diff --git a/SeforimApp/src/commonMain/composeResources/font/HadasimCLM-Regular.otf b/appkit/src/commonMain/composeResources/font/HadasimCLM-Regular.otf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/HadasimCLM-Regular.otf rename to appkit/src/commonMain/composeResources/font/HadasimCLM-Regular.otf diff --git a/SeforimApp/src/commonMain/composeResources/font/HadasimCLM-RegularOblique.otf b/appkit/src/commonMain/composeResources/font/HadasimCLM-RegularOblique.otf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/HadasimCLM-RegularOblique.otf rename to appkit/src/commonMain/composeResources/font/HadasimCLM-RegularOblique.otf diff --git a/SeforimApp/src/commonMain/composeResources/font/KeterAramTsova.ttf b/appkit/src/commonMain/composeResources/font/KeterAramTsova.ttf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/KeterAramTsova.ttf rename to appkit/src/commonMain/composeResources/font/KeterAramTsova.ttf diff --git a/SeforimApp/src/commonMain/composeResources/font/KeterYG-Bold.ttf b/appkit/src/commonMain/composeResources/font/KeterYG-Bold.ttf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/KeterYG-Bold.ttf rename to appkit/src/commonMain/composeResources/font/KeterYG-Bold.ttf diff --git a/SeforimApp/src/commonMain/composeResources/font/KeterYG-BoldOblique.ttf b/appkit/src/commonMain/composeResources/font/KeterYG-BoldOblique.ttf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/KeterYG-BoldOblique.ttf rename to appkit/src/commonMain/composeResources/font/KeterYG-BoldOblique.ttf diff --git a/SeforimApp/src/commonMain/composeResources/font/KeterYG-Medium.ttf b/appkit/src/commonMain/composeResources/font/KeterYG-Medium.ttf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/KeterYG-Medium.ttf rename to appkit/src/commonMain/composeResources/font/KeterYG-Medium.ttf diff --git a/SeforimApp/src/commonMain/composeResources/font/KeterYG-MediumOblique.ttf b/appkit/src/commonMain/composeResources/font/KeterYG-MediumOblique.ttf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/KeterYG-MediumOblique.ttf rename to appkit/src/commonMain/composeResources/font/KeterYG-MediumOblique.ttf diff --git a/SeforimApp/src/commonMain/composeResources/font/LibertinusSerif-Bold.ttf b/appkit/src/commonMain/composeResources/font/LibertinusSerif-Bold.ttf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/LibertinusSerif-Bold.ttf rename to appkit/src/commonMain/composeResources/font/LibertinusSerif-Bold.ttf diff --git a/SeforimApp/src/commonMain/composeResources/font/LibertinusSerif-BoldItalic.ttf b/appkit/src/commonMain/composeResources/font/LibertinusSerif-BoldItalic.ttf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/LibertinusSerif-BoldItalic.ttf rename to appkit/src/commonMain/composeResources/font/LibertinusSerif-BoldItalic.ttf diff --git a/SeforimApp/src/commonMain/composeResources/font/LibertinusSerif-Italic.ttf b/appkit/src/commonMain/composeResources/font/LibertinusSerif-Italic.ttf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/LibertinusSerif-Italic.ttf rename to appkit/src/commonMain/composeResources/font/LibertinusSerif-Italic.ttf diff --git a/SeforimApp/src/commonMain/composeResources/font/LibertinusSerif-Regular.ttf b/appkit/src/commonMain/composeResources/font/LibertinusSerif-Regular.ttf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/LibertinusSerif-Regular.ttf rename to appkit/src/commonMain/composeResources/font/LibertinusSerif-Regular.ttf diff --git a/SeforimApp/src/commonMain/composeResources/font/LibertinusSerif-SemiBold.ttf b/appkit/src/commonMain/composeResources/font/LibertinusSerif-SemiBold.ttf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/LibertinusSerif-SemiBold.ttf rename to appkit/src/commonMain/composeResources/font/LibertinusSerif-SemiBold.ttf diff --git a/SeforimApp/src/commonMain/composeResources/font/LibertinusSerif-SemiBoldItalic.ttf b/appkit/src/commonMain/composeResources/font/LibertinusSerif-SemiBoldItalic.ttf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/LibertinusSerif-SemiBoldItalic.ttf rename to appkit/src/commonMain/composeResources/font/LibertinusSerif-SemiBoldItalic.ttf diff --git a/SeforimApp/src/commonMain/composeResources/font/MiriamCLM-Bold.otf b/appkit/src/commonMain/composeResources/font/MiriamCLM-Bold.otf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/MiriamCLM-Bold.otf rename to appkit/src/commonMain/composeResources/font/MiriamCLM-Bold.otf diff --git a/SeforimApp/src/commonMain/composeResources/font/MiriamCLM-Book.otf b/appkit/src/commonMain/composeResources/font/MiriamCLM-Book.otf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/MiriamCLM-Book.otf rename to appkit/src/commonMain/composeResources/font/MiriamCLM-Book.otf diff --git a/SeforimApp/src/commonMain/composeResources/font/MiriamLibre-VariableFont_wght.ttf b/appkit/src/commonMain/composeResources/font/MiriamLibre-VariableFont_wght.ttf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/MiriamLibre-VariableFont_wght.ttf rename to appkit/src/commonMain/composeResources/font/MiriamLibre-VariableFont_wght.ttf diff --git a/SeforimApp/src/commonMain/composeResources/font/MiriamMonoCLM-Bold.ttf b/appkit/src/commonMain/composeResources/font/MiriamMonoCLM-Bold.ttf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/MiriamMonoCLM-Bold.ttf rename to appkit/src/commonMain/composeResources/font/MiriamMonoCLM-Bold.ttf diff --git a/SeforimApp/src/commonMain/composeResources/font/MiriamMonoCLM-BoldOblique.ttf b/appkit/src/commonMain/composeResources/font/MiriamMonoCLM-BoldOblique.ttf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/MiriamMonoCLM-BoldOblique.ttf rename to appkit/src/commonMain/composeResources/font/MiriamMonoCLM-BoldOblique.ttf diff --git a/SeforimApp/src/commonMain/composeResources/font/MiriamMonoCLM-Book.ttf b/appkit/src/commonMain/composeResources/font/MiriamMonoCLM-Book.ttf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/MiriamMonoCLM-Book.ttf rename to appkit/src/commonMain/composeResources/font/MiriamMonoCLM-Book.ttf diff --git a/SeforimApp/src/commonMain/composeResources/font/MiriamMonoCLM-BookOblique.ttf b/appkit/src/commonMain/composeResources/font/MiriamMonoCLM-BookOblique.ttf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/MiriamMonoCLM-BookOblique.ttf rename to appkit/src/commonMain/composeResources/font/MiriamMonoCLM-BookOblique.ttf diff --git a/SeforimApp/src/commonMain/composeResources/font/NachlieliCLM-Bold.otf b/appkit/src/commonMain/composeResources/font/NachlieliCLM-Bold.otf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/NachlieliCLM-Bold.otf rename to appkit/src/commonMain/composeResources/font/NachlieliCLM-Bold.otf diff --git a/SeforimApp/src/commonMain/composeResources/font/NachlieliCLM-BoldOblique.otf b/appkit/src/commonMain/composeResources/font/NachlieliCLM-BoldOblique.otf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/NachlieliCLM-BoldOblique.otf rename to appkit/src/commonMain/composeResources/font/NachlieliCLM-BoldOblique.otf diff --git a/SeforimApp/src/commonMain/composeResources/font/NachlieliCLM-Light.otf b/appkit/src/commonMain/composeResources/font/NachlieliCLM-Light.otf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/NachlieliCLM-Light.otf rename to appkit/src/commonMain/composeResources/font/NachlieliCLM-Light.otf diff --git a/SeforimApp/src/commonMain/composeResources/font/NachlieliCLM-LightOblique.otf b/appkit/src/commonMain/composeResources/font/NachlieliCLM-LightOblique.otf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/NachlieliCLM-LightOblique.otf rename to appkit/src/commonMain/composeResources/font/NachlieliCLM-LightOblique.otf diff --git a/SeforimApp/src/commonMain/composeResources/font/OpenSans-Italic-VariableFont_wdth,wght.ttf b/appkit/src/commonMain/composeResources/font/OpenSans-Italic-VariableFont_wdth_wght.ttf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/OpenSans-Italic-VariableFont_wdth,wght.ttf rename to appkit/src/commonMain/composeResources/font/OpenSans-Italic-VariableFont_wdth_wght.ttf diff --git a/SeforimApp/src/commonMain/composeResources/font/OpenSans-VariableFont_wdth,wght.ttf b/appkit/src/commonMain/composeResources/font/OpenSans-VariableFont_wdth_wght.ttf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/OpenSans-VariableFont_wdth,wght.ttf rename to appkit/src/commonMain/composeResources/font/OpenSans-VariableFont_wdth_wght.ttf diff --git a/SeforimApp/src/commonMain/composeResources/font/RashiAmiti.ttf b/appkit/src/commonMain/composeResources/font/RashiAmiti.ttf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/RashiAmiti.ttf rename to appkit/src/commonMain/composeResources/font/RashiAmiti.ttf diff --git a/SeforimApp/src/commonMain/composeResources/font/ShofarDemi-Bold.ttf b/appkit/src/commonMain/composeResources/font/ShofarDemi-Bold.ttf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/ShofarDemi-Bold.ttf rename to appkit/src/commonMain/composeResources/font/ShofarDemi-Bold.ttf diff --git a/SeforimApp/src/commonMain/composeResources/font/ShofarDemi-BoldOblique.ttf b/appkit/src/commonMain/composeResources/font/ShofarDemi-BoldOblique.ttf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/ShofarDemi-BoldOblique.ttf rename to appkit/src/commonMain/composeResources/font/ShofarDemi-BoldOblique.ttf diff --git a/SeforimApp/src/commonMain/composeResources/font/ShofarRegular.ttf b/appkit/src/commonMain/composeResources/font/ShofarRegular.ttf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/ShofarRegular.ttf rename to appkit/src/commonMain/composeResources/font/ShofarRegular.ttf diff --git a/SeforimApp/src/commonMain/composeResources/font/ShofarRegularOblique.ttf b/appkit/src/commonMain/composeResources/font/ShofarRegularOblique.ttf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/ShofarRegularOblique.ttf rename to appkit/src/commonMain/composeResources/font/ShofarRegularOblique.ttf diff --git a/SeforimApp/src/commonMain/composeResources/font/SimpleCLM-Bold.ttf b/appkit/src/commonMain/composeResources/font/SimpleCLM-Bold.ttf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/SimpleCLM-Bold.ttf rename to appkit/src/commonMain/composeResources/font/SimpleCLM-Bold.ttf diff --git a/SeforimApp/src/commonMain/composeResources/font/SimpleCLM-BoldOblique.ttf b/appkit/src/commonMain/composeResources/font/SimpleCLM-BoldOblique.ttf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/SimpleCLM-BoldOblique.ttf rename to appkit/src/commonMain/composeResources/font/SimpleCLM-BoldOblique.ttf diff --git a/SeforimApp/src/commonMain/composeResources/font/SimpleCLM-Medium.ttf b/appkit/src/commonMain/composeResources/font/SimpleCLM-Medium.ttf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/SimpleCLM-Medium.ttf rename to appkit/src/commonMain/composeResources/font/SimpleCLM-Medium.ttf diff --git a/SeforimApp/src/commonMain/composeResources/font/SimpleCLM-MediumOblique.ttf b/appkit/src/commonMain/composeResources/font/SimpleCLM-MediumOblique.ttf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/SimpleCLM-MediumOblique.ttf rename to appkit/src/commonMain/composeResources/font/SimpleCLM-MediumOblique.ttf diff --git a/SeforimApp/src/commonMain/composeResources/font/StamAshkenazCLM.ttf b/appkit/src/commonMain/composeResources/font/StamAshkenazCLM.ttf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/StamAshkenazCLM.ttf rename to appkit/src/commonMain/composeResources/font/StamAshkenazCLM.ttf diff --git a/SeforimApp/src/commonMain/composeResources/font/StamSefaradCLM.ttf b/appkit/src/commonMain/composeResources/font/StamSefaradCLM.ttf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/StamSefaradCLM.ttf rename to appkit/src/commonMain/composeResources/font/StamSefaradCLM.ttf diff --git a/SeforimApp/src/commonMain/composeResources/font/TaameyAshkenaz-Bold.ttf b/appkit/src/commonMain/composeResources/font/TaameyAshkenaz-Bold.ttf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/TaameyAshkenaz-Bold.ttf rename to appkit/src/commonMain/composeResources/font/TaameyAshkenaz-Bold.ttf diff --git a/SeforimApp/src/commonMain/composeResources/font/TaameyAshkenaz-BoldOblique.ttf b/appkit/src/commonMain/composeResources/font/TaameyAshkenaz-BoldOblique.ttf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/TaameyAshkenaz-BoldOblique.ttf rename to appkit/src/commonMain/composeResources/font/TaameyAshkenaz-BoldOblique.ttf diff --git a/SeforimApp/src/commonMain/composeResources/font/TaameyAshkenaz-Medium.ttf b/appkit/src/commonMain/composeResources/font/TaameyAshkenaz-Medium.ttf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/TaameyAshkenaz-Medium.ttf rename to appkit/src/commonMain/composeResources/font/TaameyAshkenaz-Medium.ttf diff --git a/SeforimApp/src/commonMain/composeResources/font/TaameyAshkenaz-MediumOblique.ttf b/appkit/src/commonMain/composeResources/font/TaameyAshkenaz-MediumOblique.ttf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/TaameyAshkenaz-MediumOblique.ttf rename to appkit/src/commonMain/composeResources/font/TaameyAshkenaz-MediumOblique.ttf diff --git a/SeforimApp/src/commonMain/composeResources/font/TaameyDavidCLM-Bold.ttf b/appkit/src/commonMain/composeResources/font/TaameyDavidCLM-Bold.ttf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/TaameyDavidCLM-Bold.ttf rename to appkit/src/commonMain/composeResources/font/TaameyDavidCLM-Bold.ttf diff --git a/SeforimApp/src/commonMain/composeResources/font/TaameyDavidCLM-BoldOblique.ttf b/appkit/src/commonMain/composeResources/font/TaameyDavidCLM-BoldOblique.ttf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/TaameyDavidCLM-BoldOblique.ttf rename to appkit/src/commonMain/composeResources/font/TaameyDavidCLM-BoldOblique.ttf diff --git a/SeforimApp/src/commonMain/composeResources/font/TaameyDavidCLM-Medium.ttf b/appkit/src/commonMain/composeResources/font/TaameyDavidCLM-Medium.ttf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/TaameyDavidCLM-Medium.ttf rename to appkit/src/commonMain/composeResources/font/TaameyDavidCLM-Medium.ttf diff --git a/SeforimApp/src/commonMain/composeResources/font/TaameyDavidCLM-MediumOblique.ttf b/appkit/src/commonMain/composeResources/font/TaameyDavidCLM-MediumOblique.ttf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/TaameyDavidCLM-MediumOblique.ttf rename to appkit/src/commonMain/composeResources/font/TaameyDavidCLM-MediumOblique.ttf diff --git a/SeforimApp/src/commonMain/composeResources/font/TaameyFrankCLM-Bold.ttf b/appkit/src/commonMain/composeResources/font/TaameyFrankCLM-Bold.ttf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/TaameyFrankCLM-Bold.ttf rename to appkit/src/commonMain/composeResources/font/TaameyFrankCLM-Bold.ttf diff --git a/SeforimApp/src/commonMain/composeResources/font/TaameyFrankCLM-BoldOblique.ttf b/appkit/src/commonMain/composeResources/font/TaameyFrankCLM-BoldOblique.ttf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/TaameyFrankCLM-BoldOblique.ttf rename to appkit/src/commonMain/composeResources/font/TaameyFrankCLM-BoldOblique.ttf diff --git a/SeforimApp/src/commonMain/composeResources/font/TaameyFrankCLM-Medium.ttf b/appkit/src/commonMain/composeResources/font/TaameyFrankCLM-Medium.ttf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/TaameyFrankCLM-Medium.ttf rename to appkit/src/commonMain/composeResources/font/TaameyFrankCLM-Medium.ttf diff --git a/SeforimApp/src/commonMain/composeResources/font/TaameyFrankCLM-MediumOblique.ttf b/appkit/src/commonMain/composeResources/font/TaameyFrankCLM-MediumOblique.ttf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/TaameyFrankCLM-MediumOblique.ttf rename to appkit/src/commonMain/composeResources/font/TaameyFrankCLM-MediumOblique.ttf diff --git a/SeforimApp/src/commonMain/composeResources/font/Tinos-Bold.ttf b/appkit/src/commonMain/composeResources/font/Tinos-Bold.ttf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/Tinos-Bold.ttf rename to appkit/src/commonMain/composeResources/font/Tinos-Bold.ttf diff --git a/SeforimApp/src/commonMain/composeResources/font/Tinos-BoldItalic.ttf b/appkit/src/commonMain/composeResources/font/Tinos-BoldItalic.ttf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/Tinos-BoldItalic.ttf rename to appkit/src/commonMain/composeResources/font/Tinos-BoldItalic.ttf diff --git a/SeforimApp/src/commonMain/composeResources/font/Tinos-Italic.ttf b/appkit/src/commonMain/composeResources/font/Tinos-Italic.ttf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/Tinos-Italic.ttf rename to appkit/src/commonMain/composeResources/font/Tinos-Italic.ttf diff --git a/SeforimApp/src/commonMain/composeResources/font/Tinos-Regular.ttf b/appkit/src/commonMain/composeResources/font/Tinos-Regular.ttf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/Tinos-Regular.ttf rename to appkit/src/commonMain/composeResources/font/Tinos-Regular.ttf diff --git a/SeforimApp/src/commonMain/composeResources/font/YehudaCLM-Bold.otf b/appkit/src/commonMain/composeResources/font/YehudaCLM-Bold.otf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/YehudaCLM-Bold.otf rename to appkit/src/commonMain/composeResources/font/YehudaCLM-Bold.otf diff --git a/SeforimApp/src/commonMain/composeResources/font/YehudaCLM-Light.otf b/appkit/src/commonMain/composeResources/font/YehudaCLM-Light.otf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/YehudaCLM-Light.otf rename to appkit/src/commonMain/composeResources/font/YehudaCLM-Light.otf diff --git a/SeforimApp/src/commonMain/composeResources/font/frankruhllibre.ttf b/appkit/src/commonMain/composeResources/font/frankruhllibre.ttf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/frankruhllibre.ttf rename to appkit/src/commonMain/composeResources/font/frankruhllibre.ttf diff --git a/SeforimApp/src/commonMain/composeResources/font/notorashihebrew.ttf b/appkit/src/commonMain/composeResources/font/notorashihebrew.ttf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/notorashihebrew.ttf rename to appkit/src/commonMain/composeResources/font/notorashihebrew.ttf diff --git a/SeforimApp/src/commonMain/composeResources/font/notoserifhebrew.ttf b/appkit/src/commonMain/composeResources/font/notoserifhebrew.ttf similarity index 100% rename from SeforimApp/src/commonMain/composeResources/font/notoserifhebrew.ttf rename to appkit/src/commonMain/composeResources/font/notoserifhebrew.ttf diff --git a/SeforimApp/src/commonMain/composeResources/values/strings.xml b/appkit/src/commonMain/composeResources/values/strings.xml similarity index 100% rename from SeforimApp/src/commonMain/composeResources/values/strings.xml rename to appkit/src/commonMain/composeResources/values/strings.xml diff --git a/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforim/tabs/TabsHost.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforim/tabs/TabsHost.kt new file mode 100644 index 000000000..368487c3b --- /dev/null +++ b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforim/tabs/TabsHost.kt @@ -0,0 +1,47 @@ +package io.github.kdroidfilter.seforim.tabs + +/** + * The tabs of one window as the platform holds them: their order, the selection and the pins (the desktop's + * `TabWorkspace`, driven by its strip). [TabsViewModel] mirrors it through `snapshotFlow`, so every read must come + * from snapshot state; every call happens on the UI thread. + */ +interface TabsHost { + /** The window's tab ids, in order (empty once the window is gone). */ + fun ids(): List + + fun selectedId(): String? + + fun item(tabId: String): TabItem? + + /** Whether the window has declared [tabId] (a tab added is only declared on its next frame). */ + fun isDeclared(tabId: String): Boolean + + fun isPinned(tabId: String): Boolean + + fun setPinned( + tabId: String, + pinned: Boolean, + ) + + fun select(tabId: String) + + fun close(tabId: String) + + fun reorder( + tabId: String, + toIndex: Int, + ) + + /** Adds [destination] at [index] of the window, selected; in place of [replacing] when given. */ + fun add( + destination: TabsDestination, + index: Int, + replacing: String? = null, + pinned: Boolean = false, + ) + + fun updateDestination( + tabId: String, + destination: TabsDestination, + ) +} diff --git a/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforim/tabs/TabsResolver.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforim/tabs/TabsResolver.kt new file mode 100644 index 000000000..1382501e3 --- /dev/null +++ b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforim/tabs/TabsResolver.kt @@ -0,0 +1,9 @@ +package io.github.kdroidfilter.seforim.tabs + +/** + * Finds the tabs of the window holding a tab, for a per-tab ViewModel (no composition) to open or replace tabs next to + * its own. The desktop resolves it at call time, across windows and virtual desktops (`DesktopManager`). + */ +fun interface TabsResolver { + fun tabsViewModelFor(tabId: String): TabsViewModel? +} diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforim/tabs/TabsViewModel.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforim/tabs/TabsViewModel.kt similarity index 71% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforim/tabs/TabsViewModel.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforim/tabs/TabsViewModel.kt index 661246ff0..887a083df 100644 --- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforim/tabs/TabsViewModel.kt +++ b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforim/tabs/TabsViewModel.kt @@ -1,11 +1,7 @@ -@file:OptIn(ExperimentalNucleusApi::class) - package io.github.kdroidfilter.seforim.tabs import androidx.compose.runtime.Stable import androidx.compose.runtime.snapshotFlow -import dev.nucleusframework.window.ExperimentalNucleusApi -import io.github.kdroidfilter.seforimapp.framework.desktop.DesktopSession import io.github.santimattius.structured.annotations.StructuredScope import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -22,24 +18,21 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import java.util.UUID +import kotlin.time.Clock +import kotlin.uuid.Uuid /** * The tabs of one window, as the rest of the app sees them. * - * The desktop's `TabWorkspace` owns which tabs this window holds, their order and the selection - * (the stock strip drives it directly: click, ×, drag, tear-off); this class mirrors that into - * [state] and turns the app's navigation calls into workspace operations. Every call must happen on - * the UI thread, like every workspace call. + * The platform's [TabsHost] owns which tabs this window holds, their order and the selection + * (the desktop's stock strip drives it directly: click, ×, drag, tear-off); this class mirrors that into + * [state] and turns the app's navigation calls into host operations. Every call must happen on + * the UI thread, like every host call. */ @Stable -class TabsViewModel internal constructor( - private val sessionOf: () -> DesktopSession, - private val groupIdOf: () -> String, +class TabsViewModel( + private val host: TabsHost, ) { - private val session: DesktopSession get() = sessionOf() - private val groupId: String get() = groupIdOf() - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) private val _state = MutableStateFlow(compute()) @@ -77,34 +70,33 @@ class TabsViewModel internal constructor( scope.launch { snapshotFlow { compute() }.collect { _state.value = it } } } - private fun ids(): List = session.group(groupId)?.ids.orEmpty() + private fun ids(): List = host.ids() private fun compute(): TabsState { - val group = session.group(groupId) ?: return TabsState(emptyList(), 0) - val ids = group.ids + val ids = host.ids() return TabsState( - tabs = ids.mapNotNull(session::item), - selectedTabIndex = ids.indexOf(group.selectedId).coerceAtLeast(0), + tabs = ids.mapNotNull(host::item), + selectedTabIndex = ids.indexOf(host.selectedId()).coerceAtLeast(0), ) } - private fun currentId(): String? = session.group(groupId)?.selectedId + private fun currentId(): String? = host.selectedId() fun onEvent(event: TabsEvents) { val ids = ids() when (event) { - is TabsEvents.OnClose -> ids.getOrNull(event.index)?.let(session.workspace::close) - is TabsEvents.OnSelect -> ids.getOrNull(event.index)?.let(session.workspace::select) - TabsEvents.OnAdd -> session.addTab(freshHome(), groupId, index = 0) - is TabsEvents.OnReorder -> ids.getOrNull(event.fromIndex)?.let { session.workspace.reorder(it, event.toIndex) } + is TabsEvents.OnClose -> ids.getOrNull(event.index)?.let(host::close) + is TabsEvents.OnSelect -> ids.getOrNull(event.index)?.let(host::select) + TabsEvents.OnAdd -> host.add(freshHome(), index = 0) + is TabsEvents.OnReorder -> ids.getOrNull(event.fromIndex)?.let { host.reorder(it, event.toIndex) } // Pinned tabs stay. Chrome-like: with none, the window keeps one fresh Home tab, which // replaces the others once it is in. TabsEvents.CloseAll -> { - if (ids.any(session::isPinned)) { - ids.filterNot(session::isPinned).forEach(session.workspace::close) + if (ids.any(host::isPinned)) { + ids.filterNot(host::isPinned).forEach(host::close) } else { val home = freshHome() - session.addTab(home, groupId, index = 0) + host.add(home, index = 0) closeWhenDeclared(home.tabId, ids) } } @@ -115,13 +107,13 @@ class TabsViewModel internal constructor( } // Chromium's GetTabsClosedByCommand: the bulk closes skip pinned tabs. - private fun List.closeUnpinned() = filterNot(session::isPinned).forEach(session.workspace::close) + private fun List.closeUnpinned() = filterNot(host::isPinned).forEach(host::close) fun setPinned( index: Int, pinned: Boolean, ) { - ids().getOrNull(index)?.let { session.setPinned(it, pinned) } + ids().getOrNull(index)?.let { host.setPinned(it, pinned) } } /** @@ -131,13 +123,13 @@ class TabsViewModel internal constructor( */ fun closeSelectedTab(repeat: Boolean = false) { val tabId = currentId() ?: return - if (session.isPinned(tabId)) { + if (host.isPinned(tabId)) { val hinted = _closePinnedHint.value && hintedTabId == tabId if (!hinted) showClosePinnedHint(tabId) if (!hinted || repeat) return } hideClosePinnedHint() - session.workspace.close(tabId) + host.close(tabId) } private fun showClosePinnedHint( @@ -165,20 +157,20 @@ class TabsViewModel internal constructor( @StructuredScope scope: CoroutineScope = this.scope, ) { scope.launch { - snapshotFlow { session.workspace.tab(tabId) != null }.first { it } - others.forEach(session.workspace::close) + snapshotFlow { host.isDeclared(tabId) }.first { it } + others.forEach(host::close) } } /** A tab opened from another one lands next to it, on the + side of the strip (the current one moves over). */ fun openTab(destination: TabsDestination) { - session.addTab(destination, groupId, index = ids().indexOf(currentId()).coerceAtLeast(0)) + host.add(destination, index = ids().indexOf(currentId()).coerceAtLeast(0)) } /** Navigates the selected tab to [destination], keeping the tab (and its ViewModels). */ fun replaceCurrentTabDestination(destination: TabsDestination) { val tabId = currentId() ?: return - session.updateDestination(tabId, destination.withTabId(tabId)) + host.updateDestination(tabId, destination.withTabId(tabId)) } /** Swaps the selected tab for a new one (fresh ViewModels) at the same place. */ @@ -186,12 +178,11 @@ class TabsViewModel internal constructor( val tabId = currentId() ?: return val index = ids().indexOf(tabId).coerceAtLeast(0) // The pin goes with the tab, as through any navigation (Chrome). - session.addTab( - destination.withTabId(UUID.randomUUID().toString()), - groupId, + host.add( + destination.withTabId(Uuid.random().toString()), index = index, replacing = tabId, - pinned = session.isPinned(tabId), + pinned = host.isPinned(tabId), ) } @@ -199,16 +190,16 @@ class TabsViewModel internal constructor( scope.cancel() } - private fun freshHome(): TabsDestination = TabsDestination.BookContent(bookId = -1, tabId = UUID.randomUUID().toString()) + private fun freshHome(): TabsDestination = TabsDestination.BookContent(bookId = -1, tabId = Uuid.random().toString()) private companion object { const val CLOSE_PINNED_HINT_MS = 3000L } } -internal fun TabsDestination.withTabId(tabId: String): TabsDestination = +fun TabsDestination.withTabId(tabId: String): TabsDestination = when (this) { - is TabsDestination.Home -> TabsDestination.Home(tabId = tabId, version = System.currentTimeMillis()) + is TabsDestination.Home -> TabsDestination.Home(tabId = tabId, version = Clock.System.now().toEpochMilliseconds()) is TabsDestination.Search -> copy(tabId = tabId) is TabsDestination.BookContent -> copy(tabId = tabId) is TabsDestination.History -> copy(tabId = tabId) diff --git a/SeforimApp/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/catalog/CatalogPresets.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/catalog/CatalogPresets.kt similarity index 100% rename from SeforimApp/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/catalog/CatalogPresets.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/catalog/CatalogPresets.kt diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/BookErrorReport.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/core/BookErrorReport.kt similarity index 81% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/BookErrorReport.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/core/BookErrorReport.kt index f86df0db7..41626b083 100644 --- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/BookErrorReport.kt +++ b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/core/BookErrorReport.kt @@ -1,10 +1,9 @@ package io.github.kdroidfilter.seforimapp.core +import io.github.kdroidfilter.seforim.htmlparser.htmlToText import io.github.kdroidfilter.seforimlibrary.core.models.Book import io.github.kdroidfilter.seforimlibrary.core.models.Line import io.github.kdroidfilter.seforimlibrary.dao.repository.SeforimRepository -import org.jsoup.Jsoup -import java.net.URLEncoder private const val SEFARIA_SOURCE = "Sefaria" private const val SEFARIA_CORRECTIONS_EMAIL = "corrections@sefaria.org" @@ -32,7 +31,7 @@ suspend fun bookErrorReportMailto( if (repository.getSourceById(book.sourceId)?.name != SEFARIA_SOURCE) { return mailto(OTZARIA_CORRECTIONS_EMAIL, "טעות ב$reference", body = null) } - val quoted = selectedText.trim().ifEmpty { lines?.first?.let { Jsoup.parse(it.content).text() }.orEmpty() } + val quoted = selectedText.trim().ifEmpty { lines?.first?.let { htmlToText(it.content) }.orEmpty() } val versions = repository .getBookEditions(book.id) @@ -76,5 +75,18 @@ private fun mailto( return "mailto:$email?" + query.joinToString("&") { (key, value) -> "$key=${encode(value)}" } } -// URLEncoder writes spaces as '+', which mail clients keep verbatim in a mailto -private fun encode(value: String): String = URLEncoder.encode(value, Charsets.UTF_8).replace("+", "%20") +// As java.net.URLEncoder (UTF-8), but a space is %20: mail clients keep URLEncoder's '+' verbatim in a mailto +private fun encode(value: String): String = + buildString { + for (byte in value.encodeToByteArray()) { + val code = byte.toInt() and 0xFF + val char = code.toChar() + if (char in 'a'..'z' || char in 'A'..'Z' || char in '0'..'9' || char in ".-*_") { + append(char) + } else { + append('%').append(HEX_DIGITS[code shr 4]).append(HEX_DIGITS[code and 0x0F]) + } + } + } + +private const val HEX_DIGITS = "0123456789ABCDEF" diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/CopyWithSource.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/core/CopyWithSource.kt similarity index 98% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/CopyWithSource.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/core/CopyWithSource.kt index 7cd55e274..a3b817b4f 100644 --- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/CopyWithSource.kt +++ b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/core/CopyWithSource.kt @@ -9,11 +9,11 @@ import androidx.compose.ui.input.key.isMetaPressed import androidx.compose.ui.input.key.isShiftPressed import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.type +import io.github.kdroidfilter.seforim.htmlparser.htmlToText import io.github.kdroidfilter.seforimapp.core.selection.SelectionContext import io.github.kdroidfilter.seforimlibrary.core.models.Book import io.github.kdroidfilter.seforimlibrary.core.models.Line import io.github.kdroidfilter.seforimlibrary.core.text.HebrewTextUtils -import org.jsoup.Jsoup private val WHITESPACE_REGEX = Regex("\\s+") private const val MIN_EDGE_OVERLAP = 8 @@ -33,7 +33,7 @@ private fun normalizeForMatch(text: String): String = .replace(WHITESPACE_REGEX, " ") .trim() -private fun stripHtmlToPlainText(html: String): String = normalizeForMatch(Jsoup.parse(html).text()) +private fun stripHtmlToPlainText(html: String): String = normalizeForMatch(htmlToText(html)) /** * Best-effort matching of a free-form text selection against a list of currently loaded lines. diff --git a/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/core/LruCache.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/core/LruCache.kt new file mode 100644 index 000000000..b775c457c --- /dev/null +++ b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/core/LruCache.kt @@ -0,0 +1,36 @@ +package io.github.kdroidfilter.seforimapp.core + +/** + * A map bounded to [maxSize] entries that evicts the least recently used one, as the JVM's access-order + * `LinkedHashMap` (which common code lacks). Not thread-safe: callers synchronize. + */ +class LruCache( + private val maxSize: Int, + private val onEvict: (V) -> Unit = {}, +) { + // Insertion order, kept as access order by moving each hit to the end + private val entries = LinkedHashMap() + + operator fun get(key: K): V? { + val value = entries.remove(key) ?: return null + entries[key] = value + return value + } + + operator fun set( + key: K, + value: V, + ) { + entries.remove(key) + entries[key] = value + while (entries.size > maxSize) { + val eldest = entries.keys.first() + entries.remove(eldest)?.let(onEvict) + } + } + + inline fun getOrPut( + key: K, + create: () -> V, + ): V = get(key) ?: create().also { set(key, it) } +} diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/MainAppState.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/core/MainAppState.kt similarity index 100% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/MainAppState.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/core/MainAppState.kt diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/annotations/HighlightSelectionResolver.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/core/annotations/HighlightSelectionResolver.kt similarity index 100% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/annotations/HighlightSelectionResolver.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/core/annotations/HighlightSelectionResolver.kt diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/annotations/HighlightStore.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/core/annotations/HighlightStore.kt similarity index 99% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/annotations/HighlightStore.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/core/annotations/HighlightStore.kt index 83d1d3690..af236c33f 100644 --- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/annotations/HighlightStore.kt +++ b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/core/annotations/HighlightStore.kt @@ -6,6 +6,7 @@ import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.text.TextRange import io.github.kdroidfilter.seforimapp.db.UserSettingsDb import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/annotations/NoteStore.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/core/annotations/NoteStore.kt similarity index 99% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/annotations/NoteStore.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/core/annotations/NoteStore.kt index 8263b2bd1..3fa59701a 100644 --- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/annotations/NoteStore.kt +++ b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/core/annotations/NoteStore.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Stable import io.github.kdroidfilter.seforimapp.db.UserSettingsDb import io.github.kdroidfilter.seforimapp.db.User_notes import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/catalog/CatalogAccess.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/core/catalog/CatalogAccess.kt similarity index 93% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/catalog/CatalogAccess.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/core/catalog/CatalogAccess.kt index 49abac307..3b389bd17 100644 --- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/catalog/CatalogAccess.kt +++ b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/core/catalog/CatalogAccess.kt @@ -4,6 +4,9 @@ import io.github.kdroidfilter.seforimapp.catalog.BookRef import io.github.kdroidfilter.seforimapp.catalog.CatalogPresets import io.github.kdroidfilter.seforimlibrary.core.models.CatalogCategory import io.github.kdroidfilter.seforimlibrary.core.models.PrecomputedCatalog +import kotlinx.atomicfu.locks.SynchronizedObject +import kotlinx.atomicfu.locks.synchronized +import kotlin.concurrent.Volatile /** * Runtime access to per-category titles and book lists, sourced from the lib's @@ -17,6 +20,8 @@ import io.github.kdroidfilter.seforimlibrary.core.models.PrecomputedCatalog class CatalogAccess( private val catalogProvider: () -> PrecomputedCatalog?, ) { + private val lock = SynchronizedObject() + private data class Indices( val source: PrecomputedCatalog, val categoryTitles: Map, @@ -33,14 +38,15 @@ class CatalogAccess( } // Synchronized: a lookup racing the warm-up waits for it rather than building the indices a second time - @Synchronized private fun resolve(): Indices? { - val catalog = catalogProvider() ?: return null - val current = indices - if (current != null && current.source === catalog) return current - val built = build(catalog) - indices = built - return built + return synchronized(lock) { + val catalog = catalogProvider() ?: return null + val current = indices + if (current != null && current.source === catalog) return current + val built = build(catalog) + indices = built + return built + } } fun categoryTitle(id: Long): String? = resolve()?.categoryTitles?.get(id) diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/favorites/FavoritesStore.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/core/favorites/FavoritesStore.kt similarity index 99% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/favorites/FavoritesStore.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/core/favorites/FavoritesStore.kt index a2c4495f1..8c0fd17f1 100644 --- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/favorites/FavoritesStore.kt +++ b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/core/favorites/FavoritesStore.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Stable import io.github.kdroidfilter.seforimapp.db.Favorite import io.github.kdroidfilter.seforimapp.db.UserSettingsDb import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/history/HistoryStore.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/core/history/HistoryStore.kt similarity index 99% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/history/HistoryStore.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/core/history/HistoryStore.kt index 98ba82494..a903e11ab 100644 --- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/history/HistoryStore.kt +++ b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/core/history/HistoryStore.kt @@ -6,6 +6,7 @@ import io.github.kdroidfilter.seforimapp.db.UserSettingsDb import io.github.kdroidfilter.seforimapp.db.Visit_history import io.github.kdroidfilter.seforimapp.features.search.SearchHistory import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/history/VisitEntryDestination.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/core/history/VisitEntryDestination.kt similarity index 91% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/history/VisitEntryDestination.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/core/history/VisitEntryDestination.kt index 8ce978e28..6463a8763 100644 --- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/history/VisitEntryDestination.kt +++ b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/core/history/VisitEntryDestination.kt @@ -1,11 +1,11 @@ package io.github.kdroidfilter.seforimapp.core.history import io.github.kdroidfilter.seforim.tabs.TabsDestination -import java.util.UUID +import kotlin.uuid.Uuid /** Where a history entry reopens, in a new tab: the book at its line, or the search in the scope it ran in. */ fun VisitEntry.destination(): TabsDestination? { - val tabId = UUID.randomUUID().toString() + val tabId = Uuid.random().toString() return when (kind) { VisitKind.BOOK -> bookId?.let { TabsDestination.BookContent(bookId = it, tabId = tabId, lineId = lineId) } VisitKind.SEARCH -> diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/components/HardDriveUpload.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/components/HardDriveUpload.kt similarity index 100% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/components/HardDriveUpload.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/components/HardDriveUpload.kt diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/components/ProgressBarController.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/components/ProgressBarController.kt similarity index 100% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/components/ProgressBarController.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/components/ProgressBarController.kt diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/navigation/NoAnimatedComposable.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/navigation/NoAnimatedComposable.kt similarity index 100% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/navigation/NoAnimatedComposable.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/navigation/NoAnimatedComposable.kt diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/tabs/PinnedTabLabel.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/tabs/PinnedTabLabel.kt similarity index 100% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/tabs/PinnedTabLabel.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/tabs/PinnedTabLabel.kt diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/text/Highlighting.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/text/Highlighting.kt similarity index 100% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/text/Highlighting.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/text/Highlighting.kt diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/text/Notes.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/text/Notes.kt similarity index 100% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/text/Notes.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/text/Notes.kt diff --git a/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/theme/AccentColor.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/theme/AccentColor.kt new file mode 100644 index 000000000..26d545c4a --- /dev/null +++ b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/theme/AccentColor.kt @@ -0,0 +1,15 @@ +package io.github.kdroidfilter.seforimapp.core.presentation.theme + +/** + * Predefined accent color presets for the application theme. + * [System] uses the OS accent color (falls back to the theme's blue when unavailable). + * [Default] uses the theme's built-in blue; other entries provide light/dark variants. Each platform resolves them + * to colors (the desktop's Jewel theme in AccentColors.kt). + */ +enum class AccentColor { + System, + Default, + Teal, + Green, + Gold, +} diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/theme/IntUiThemes.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/theme/IntUiThemes.kt similarity index 100% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/theme/IntUiThemes.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/theme/IntUiThemes.kt diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/typography/FontCatalog.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/typography/FontCatalog.kt similarity index 99% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/typography/FontCatalog.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/typography/FontCatalog.kt index 7f64a3027..1c6fb7204 100644 --- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/typography/FontCatalog.kt +++ b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/typography/FontCatalog.kt @@ -5,7 +5,7 @@ import androidx.compose.runtime.Immutable import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight -import io.github.kdroidfilter.seforimapp.framework.platform.PlatformInfo +import io.github.kdroidfilter.seforimapp.framework.platform.isMacOsDesktop import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import org.jetbrains.compose.resources.Font @@ -168,7 +168,7 @@ object FontCatalog { /** On macOS some Hebrew fonts have no bold face: their bold text is scaled up a little to stay visible. */ fun boldScaleFor(code: String): Float = - if (PlatformInfo.isMacOS && code in setOf("notoserifhebrew", "notorashihebrew", "frankruhllibre")) 1.08f else 1.0f + if (isMacOsDesktop && code in setOf("notoserifhebrew", "notorashihebrew", "frankruhllibre")) 1.08f else 1.0f @Composable fun familyFor(code: String): FontFamily = diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/utils/TouchMode.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/utils/TouchMode.kt similarity index 100% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/utils/TouchMode.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/utils/TouchMode.kt diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/utils/WindowViewModelOwner.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/utils/WindowViewModelOwner.kt similarity index 100% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/utils/WindowViewModelOwner.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/utils/WindowViewModelOwner.kt diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/selection/SelectionContext.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/core/selection/SelectionContext.kt similarity index 99% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/selection/SelectionContext.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/core/selection/SelectionContext.kt index 8270e555a..d718f60c8 100644 --- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/selection/SelectionContext.kt +++ b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/core/selection/SelectionContext.kt @@ -5,6 +5,7 @@ import io.github.kdroidfilter.seforimlibrary.core.models.Line import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlin.concurrent.Volatile /** * The book currently active in the foreground tab, paired with the title of its root category diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/settings/AppSettings.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/core/settings/AppSettings.kt similarity index 97% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/settings/AppSettings.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/core/settings/AppSettings.kt index b782d4456..505c9ba95 100644 --- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/settings/AppSettings.kt +++ b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/core/settings/AppSettings.kt @@ -10,7 +10,9 @@ import io.github.kdroidfilter.seforimapp.core.presentation.theme.IntUiThemes import io.github.kdroidfilter.seforimapp.earthwidget.WidgetAntiAliasing import io.github.kdroidfilter.seforimapp.features.search.SearchSettings import io.github.kdroidfilter.seforimapp.framework.di.AppScope -import io.github.kdroidfilter.seforimapp.framework.platform.PlatformInfo +import io.github.kdroidfilter.seforimapp.framework.platform.isLinuxDesktop +import kotlinx.atomicfu.locks.SynchronizedObject +import kotlinx.atomicfu.locks.synchronized import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -18,7 +20,6 @@ import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow -import java.util.concurrent.ConcurrentHashMap /** * Manages application settings and preferences that persist across app restarts. @@ -62,7 +63,8 @@ class AppSettings( val homeWidgetsLayoutFlow: StateFlow = _homeWidgetsLayoutFlow.asStateFlow() // The typed settings (see SettingsGroup), each observed from its first read - private val typedSettings = ConcurrentHashMap>() + private val typedSettingsLock = SynchronizedObject() + private val typedSettings = HashMap>() init { // The Home widgets' options were one opaque string per widget, now typed settings: not carried over @@ -333,9 +335,11 @@ class AppSettings( @Suppress("UNCHECKED_CAST") private fun stateOf(setting: Setting): TypedSettingState = - typedSettings.getOrPut(setting.key) { - val saved = settings.getStringOrNull(setting.key)?.let(setting.codec.decode) - TypedSettingState(setting, MutableStateFlow(saved ?: setting.default)) + synchronized(typedSettingsLock) { + typedSettings.getOrPut(setting.key) { + val saved = settings.getStringOrNull(setting.key)?.let(setting.codec.decode) + TypedSettingState(setting, MutableStateFlow(saved ?: setting.default)) + } } as TypedSettingState private class TypedSettingState( @@ -563,7 +567,7 @@ class AppSettings( _databasePathFlow.value = null _persistSessionFlow.value = true _homeWidgetsLayoutFlow.value = null - typedSettings.values.forEach { it.reset() } + synchronized(typedSettingsLock) { typedSettings.values.toList() }.forEach { it.reset() } _showHomeWallpaperFlow.value = true _compactModeFlow.value = false _antiAliasingFlow.value = WidgetAntiAliasing.OFF @@ -582,7 +586,7 @@ class AppSettings( * NVIDIA's GLES compiler rejects TAA's (the engine then aborts). */ val AVAILABLE_ANTI_ALIASING: List = - WidgetAntiAliasing.entries.filter { it != WidgetAntiAliasing.TAA || !PlatformInfo.isLinux } + WidgetAntiAliasing.entries.filter { it != WidgetAntiAliasing.TAA || !isLinuxDesktop } // Preferences worth carrying to another machine. The database location, the session and // the onboarding state belong to this machine only. diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/settings/CategoryDisplaySettingsStore.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/core/settings/CategoryDisplaySettingsStore.kt similarity index 98% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/settings/CategoryDisplaySettingsStore.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/core/settings/CategoryDisplaySettingsStore.kt index a7e14c9e1..07c698f46 100644 --- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/settings/CategoryDisplaySettingsStore.kt +++ b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/core/settings/CategoryDisplaySettingsStore.kt @@ -3,6 +3,7 @@ package io.github.kdroidfilter.seforimapp.core.settings import io.github.kdroidfilter.seforimapp.core.presentation.text.DiacriticsMode import io.github.kdroidfilter.seforimapp.db.UserSettingsDb import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/settings/Setting.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/core/settings/Setting.kt similarity index 100% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/settings/Setting.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/core/settings/Setting.kt diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/shnayimmikra/ShnayimMikraStore.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/core/shnayimmikra/ShnayimMikraStore.kt similarity index 89% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/shnayimmikra/ShnayimMikraStore.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/core/shnayimmikra/ShnayimMikraStore.kt index 1c891485b..074251d4d 100644 --- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/shnayimmikra/ShnayimMikraStore.kt +++ b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/core/shnayimmikra/ShnayimMikraStore.kt @@ -2,11 +2,13 @@ package io.github.kdroidfilter.seforimapp.core.shnayimmikra import io.github.kdroidfilter.seforimapp.db.UserSettingsDb import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.withContext +import kotlin.time.Clock /** The aliyos read of each parsha, by Hebrew year, in the local user DB; [revision] bumps on every write. */ class ShnayimMikraStore( @@ -37,7 +39,7 @@ class ShnayimMikraStore( ): Unit = withContext(Dispatchers.IO) { if (read) { - queries.markRead(year, parsha, aliya.toLong(), System.currentTimeMillis()) + queries.markRead(year, parsha, aliya.toLong(), Clock.System.now().toEpochMilliseconds()) } else { queries.unmarkRead(year, parsha, aliya.toLong()) } diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/BookContentEvent.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/BookContentEvent.kt similarity index 100% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/BookContentEvent.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/BookContentEvent.kt diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/BookContentViewModel.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/BookContentViewModel.kt similarity index 98% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/BookContentViewModel.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/BookContentViewModel.kt index a5e4eb620..719673c02 100644 --- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/BookContentViewModel.kt +++ b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/BookContentViewModel.kt @@ -29,9 +29,8 @@ import io.github.kdroidfilter.seforimapp.features.bookcontent.usecases.BookConte import io.github.kdroidfilter.seforimapp.features.bookcontent.usecases.altTocIdOfSearchMatch import io.github.kdroidfilter.seforimapp.features.search.domain.BookDetailsLoader import io.github.kdroidfilter.seforimapp.features.search.domain.SearchEntity -import io.github.kdroidfilter.seforimapp.framework.desktop.DesktopManager import io.github.kdroidfilter.seforimapp.framework.di.AppScope -import io.github.kdroidfilter.seforimapp.framework.session.SessionManager +import io.github.kdroidfilter.seforimapp.framework.session.SessionRestoreState import io.github.kdroidfilter.seforimapp.framework.session.TabPersistedStateStore import io.github.kdroidfilter.seforimapp.logger.debugln import io.github.kdroidfilter.seforimlibrary.core.models.Book @@ -39,14 +38,14 @@ import io.github.kdroidfilter.seforimlibrary.core.models.Line import io.github.kdroidfilter.seforimlibrary.dao.repository.SeforimRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.IO import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch -import org.jetbrains.compose.splitpane.ExperimentalSplitPaneApi -import java.util.UUID +import kotlin.time.Clock +import kotlin.uuid.Uuid /** Simplified ViewModel for the book content screen */ -@OptIn(ExperimentalSplitPaneApi::class) @AssistedInject class BookContentViewModel( @Assisted savedStateHandle: SavedStateHandle, @@ -54,9 +53,9 @@ class BookContentViewModel( private val repository: SeforimRepository, private val useCaseFactory: BookContentUseCaseFactory, private val titleUpdateManager: TabTitleUpdateManager, - private val desktopManager: DesktopManager, + private val tabsResolver: TabsResolver, private val historyStore: HistoryStore, - sessionManager: SessionManager, + sessionRestore: SessionRestoreState, private val appSettings: AppSettings, ) : ViewModel() { @AssistedFactory @@ -74,7 +73,7 @@ class BookContentViewModel( // True when this ViewModel was created by the boot session restore: its book was already // recorded when originally opened, so the first load must not re-enter the history. - private val createdDuringSessionRestore = sessionManager.isRestoringSession.value + private val createdDuringSessionRestore = sessionRestore.isRestoringSession.value // Pre-set loading before uiState is initialized to avoid a single-frame Home flash. private val hasBookToLoad: Boolean = @@ -356,7 +355,7 @@ class BookContentViewModel( .debounce(HISTORY_RECORD_DEBOUNCE_MS) .collect { openedBookId -> // Only the tab actually in front records a visit - val hostTabs = desktopManager.tabsViewModelFor(tabId)?.state?.value ?: return@collect + val hostTabs = tabsResolver.tabsViewModelFor(tabId)?.state?.value ?: return@collect val selectedTabId = hostTabs.tabs .getOrNull(hostTabs.selectedTabIndex) @@ -372,7 +371,7 @@ class BookContentViewModel( historyStore.recordBookVisit( bookId = book.id, title = if (tocLabel != null) "${book.title} - $tocLabel" else book.title, - timestamp = System.currentTimeMillis(), + timestamp = Clock.System.now().toEpochMilliseconds(), tocEntryId = toc?.id, lineId = toc?.lineId, ) @@ -482,10 +481,10 @@ class BookContentViewModel( is BookContentEvent.SearchInDatabase -> { val newTabId = - java.util.UUID - .randomUUID() + Uuid + .random() .toString() - desktopManager.tabsViewModelFor(tabId)?.openTab( + tabsResolver.tabsViewModelFor(tabId)?.openTab( TabsDestination.Search( searchQuery = event.query, tabId = newTabId, @@ -675,8 +674,8 @@ class BookContentViewModel( event.lineId?.let { openCommentaryTarget(event.bookId, it) } is BookContentEvent.OpenAuthor -> - desktopManager.tabsViewModelFor(tabId)?.openTab( - TabsDestination.Author(tabId = UUID.randomUUID().toString(), authorId = event.authorId), + tabsResolver.tabsViewModelFor(tabId)?.openTab( + TabsDestination.Author(tabId = Uuid.random().toString(), authorId = event.authorId), ) // Commentaries @@ -772,7 +771,7 @@ class BookContentViewModel( repository.getLine(lineId)?.let { line -> selectLine(line) stateManager.updateContent { - copy(scrollToLineTimestamp = System.currentTimeMillis()) + copy(scrollToLineTimestamp = Clock.System.now().toEpochMilliseconds()) } // Expand TOC to the line's TOC entry so the branch is visible runSuspendCatching { tocUseCase.expandPathToLine(line.id) } @@ -1168,8 +1167,8 @@ class BookContentViewModel( /** Opens a book in a new tab */ private fun openBookInNewTab(book: Book) { val newTabId = - java.util.UUID - .randomUUID() + Uuid + .random() .toString() // Copy only lightweight navigation preferences to the new tab to keep tree context. @@ -1214,7 +1213,7 @@ class BookContentViewModel( } // Navigate directly to book content in the new tab - desktopManager.tabsViewModelFor(tabId)?.openTab( + tabsResolver.tabsViewModelFor(tabId)?.openTab( TabsDestination.BookContent( bookId = book.id, tabId = newTabId, @@ -1229,8 +1228,8 @@ class BookContentViewModel( ) { // Create a new tab and pre-initialize it to avoid initial flashing val newTabId = - java.util.UUID - .randomUUID() + Uuid + .random() .toString() // Seed persisted state so the new tab can restore scroll/anchor deterministically. @@ -1252,7 +1251,7 @@ class BookContentViewModel( ) } - desktopManager.tabsViewModelFor(tabId)?.openTab( + tabsResolver.tabsViewModelFor(tabId)?.openTab( TabsDestination.BookContent( bookId = bookId, tabId = newTabId, diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/quotelinks/QuoteLinksFeature.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/quotelinks/QuoteLinksFeature.kt similarity index 88% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/quotelinks/QuoteLinksFeature.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/quotelinks/QuoteLinksFeature.kt index b73e8a38b..459537899 100644 --- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/quotelinks/QuoteLinksFeature.kt +++ b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/quotelinks/QuoteLinksFeature.kt @@ -26,3 +26,6 @@ interface QuoteLinksFeature { linesByIds: suspend (Collection) -> List, ): List } + +/** The installed quote links: in the official desktop builds only (the package is JVM only), null elsewhere. */ +expect val installedQuoteLinks: QuoteLinksFeature? diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/BookContentState.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/BookContentState.kt similarity index 95% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/BookContentState.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/BookContentState.kt index a587e2712..e63148bc4 100644 --- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/BookContentState.kt +++ b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/BookContentState.kt @@ -6,8 +6,6 @@ import androidx.paging.PagingData import io.github.kdroidfilter.seforimlibrary.core.models.* import io.github.kdroidfilter.seforimlibrary.dao.repository.CommentaryWithText import kotlinx.coroutines.flow.Flow -import org.jetbrains.compose.splitpane.ExperimentalSplitPaneApi -import org.jetbrains.compose.splitpane.SplitPaneState /** * Auxiliary models that are not part of the persistent state @@ -79,7 +77,6 @@ data class AltTocState( */ @Stable data class BookContentState - @OptIn(ExperimentalSplitPaneApi::class) constructor( val tabId: String = "", val navigation: NavigationState = NavigationState(), @@ -265,17 +262,16 @@ data class ContentState( } /** - * Layout state uses SplitPaneState to directly bind with UI panes + * Layout state uses PaneSplitState to directly bind with UI panes */ @Stable data class LayoutState - @OptIn(ExperimentalSplitPaneApi::class) constructor( - val mainSplitState: SplitPaneState = SplitPaneState(initialPositionPercentage = SplitDefaults.MAIN, moveEnabled = true), - val tocSplitState: SplitPaneState = SplitPaneState(initialPositionPercentage = SplitDefaults.TOC, moveEnabled = true), - val notesSplitState: SplitPaneState = SplitPaneState(initialPositionPercentage = SplitDefaults.NOTES, moveEnabled = true), - val contentSplitState: SplitPaneState = SplitPaneState(initialPositionPercentage = SplitDefaults.CONTENT, moveEnabled = true), - val targumSplitState: SplitPaneState = SplitPaneState(initialPositionPercentage = 0.8f, moveEnabled = true), + val mainSplitState: PaneSplitState = PaneSplitState(initialPositionPercentage = SplitDefaults.MAIN, moveEnabled = true), + val tocSplitState: PaneSplitState = PaneSplitState(initialPositionPercentage = SplitDefaults.TOC, moveEnabled = true), + val notesSplitState: PaneSplitState = PaneSplitState(initialPositionPercentage = SplitDefaults.NOTES, moveEnabled = true), + val contentSplitState: PaneSplitState = PaneSplitState(initialPositionPercentage = SplitDefaults.CONTENT, moveEnabled = true), + val targumSplitState: PaneSplitState = PaneSplitState(initialPositionPercentage = 0.8f, moveEnabled = true), val previousPositions: PreviousPositions = PreviousPositions(), ) diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/BookContentStateManager.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/BookContentStateManager.kt similarity index 97% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/BookContentStateManager.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/BookContentStateManager.kt index 376968cfb..d62257cf9 100644 --- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/BookContentStateManager.kt +++ b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/BookContentStateManager.kt @@ -1,4 +1,3 @@ -@file:OptIn(ExperimentalSplitPaneApi::class) package io.github.kdroidfilter.seforimapp.features.bookcontent.state @@ -8,8 +7,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update -import org.jetbrains.compose.splitpane.ExperimentalSplitPaneApi -import org.jetbrains.compose.splitpane.SplitPaneState /** * Gestionnaire d'état centralisé pour BookContent @@ -24,7 +21,6 @@ class BookContentStateManager( /** * Charge l'état initial depuis le store persisté (par tabId) */ - @OptIn(ExperimentalSplitPaneApi::class) private fun loadInitialState(): BookContentState { val persisted: BookContentPersistedState = persistedStore.get(tabId)?.bookContent ?: BookContentPersistedState() @@ -106,27 +102,27 @@ class BookContentStateManager( layout = LayoutState( mainSplitState = - SplitPaneState( + PaneSplitState( initialPositionPercentage = if (navVisible) persisted.mainSplitPosition else 0f, moveEnabled = navVisible, ), tocSplitState = - SplitPaneState( + PaneSplitState( initialPositionPercentage = if (tocVisible) persisted.tocSplitPosition else 0f, moveEnabled = tocVisible, ), notesSplitState = - SplitPaneState( + PaneSplitState( initialPositionPercentage = if (notesVisible) persisted.notesSplitPosition else 0f, moveEnabled = notesVisible, ), contentSplitState = - SplitPaneState( + PaneSplitState( initialPositionPercentage = if (bottomPaneVisible) persisted.contentSplitPosition else 1f, moveEnabled = bottomPaneVisible, ), targumSplitState = - SplitPaneState( + PaneSplitState( initialPositionPercentage = if (targumVisible) persisted.targumSplitPosition else 1f, moveEnabled = targumVisible, ), diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/BookOpenSource.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/BookOpenSource.kt similarity index 100% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/BookOpenSource.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/BookOpenSource.kt diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/BookTabUi.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/BookTabUi.kt similarity index 100% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/BookTabUi.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/BookTabUi.kt diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/CommentatorGroup.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/CommentatorGroup.kt similarity index 100% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/CommentatorGroup.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/CommentatorGroup.kt diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/LineConnectionsSnapshot.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/LineConnectionsSnapshot.kt similarity index 100% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/LineConnectionsSnapshot.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/LineConnectionsSnapshot.kt diff --git a/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/PaneSplitState.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/PaneSplitState.kt new file mode 100644 index 000000000..245d07b8b --- /dev/null +++ b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/PaneSplitState.kt @@ -0,0 +1,20 @@ +package io.github.kdroidfilter.seforimapp.features.bookcontent.state + +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue + +/** + * Where a reader pane's divider sits (a fraction of the space), and whether the user may drag it. Observable, so the UI + * follows its changes; each platform lays its panes out from it (split panes on the desktop). + */ +@Stable +class PaneSplitState( + initialPositionPercentage: Float = 0f, + moveEnabled: Boolean = true, +) { + var positionPercentage by mutableFloatStateOf(initialPositionPercentage) + var moveEnabled by mutableStateOf(moveEnabled) +} diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/SplitDefaults.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/SplitDefaults.kt similarity index 100% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/SplitDefaults.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/SplitDefaults.kt diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/views/BookContentZoomInteraction.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/views/BookContentZoomInteraction.kt similarity index 65% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/views/BookContentZoomInteraction.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/views/BookContentZoomInteraction.kt index 42e5d7050..88b8613af 100644 --- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/views/BookContentZoomInteraction.kt +++ b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/views/BookContentZoomInteraction.kt @@ -2,4 +2,4 @@ package io.github.kdroidfilter.seforimapp.features.bookcontent.ui.panels.bookcon import androidx.compose.runtime.compositionLocalOf -internal val LocalBookContentZoomInProgress = compositionLocalOf { false } +val LocalBookContentZoomInProgress = compositionLocalOf { false } diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/views/BreadcrumbNavigator.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/views/BreadcrumbNavigator.kt similarity index 99% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/views/BreadcrumbNavigator.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/views/BreadcrumbNavigator.kt index 199591574..c413bbaad 100644 --- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/views/BreadcrumbNavigator.kt +++ b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/views/BreadcrumbNavigator.kt @@ -13,6 +13,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlin.time.Clock /** A segment of the book path: a category, the book itself, or one of its TOC headings. */ @Immutable @@ -68,7 +69,7 @@ class BreadcrumbNavigator( // The popup closed from the inside (Escape, a choice): the keyboard focus goes back to the text. // Not on an outside click, which gives the focus to whatever it clicked. private val onReturnFocus: () -> Unit = {}, - private val clock: () -> Long = System::currentTimeMillis, + private val clock: () -> Long = { Clock.System.now().toEpochMilliseconds() }, private val onNavigate: (BreadcrumbNode) -> Unit, ) { private var contextItems: List = initialPath diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/views/ScrollbarMetrics.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/views/ScrollbarMetrics.kt similarity index 95% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/views/ScrollbarMetrics.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/views/ScrollbarMetrics.kt index 61142b80b..9120d9118 100644 --- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/views/ScrollbarMetrics.kt +++ b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/views/ScrollbarMetrics.kt @@ -18,7 +18,7 @@ import kotlin.time.Duration.Companion.milliseconds * Shared between [BookContentView]'s main scrollbar metrics and [LineCommentsView]'s * commentary scrollbar metrics — both pieces measure prose-rendered Hebrew text. */ -internal val CAPACITY_REFERENCE = +val CAPACITY_REFERENCE = ("ועל כן ראוי לנו לומר בדבר הזה ולהבין על מה כוונת המחבר בהזכירו דברים אלו ").repeat(200) /** @@ -27,20 +27,20 @@ internal val CAPACITY_REFERENCE = * sub-pixel jitter from layout settle. Shared between the book and commentary * scrollbars. */ -internal const val RELATCH_VIEWPORT_THRESHOLD = 0.10f +const val RELATCH_VIEWPORT_THRESHOLD = 0.10f /** * Safety net: if the live scroll position never converges to within 0.005 of a dragged * target (e.g. the target rounds to a different last-visible index, or the list shrinks * under us), unpin the thumb after this timeout so it stops floating. */ -internal val PENDING_JUMP_TIMEOUT = 1500.milliseconds +val PENDING_JUMP_TIMEOUT = 1500.milliseconds /** * Visual lines a single item occupies in the rendered text area: `ceil(charCount / capacity)`, * floored at 1 so even an empty line still consumes a slot. Shared by both scrollbars. */ -internal fun visualLinesOf( +fun visualLinesOf( charCount: Int, capacity: Int, ): Int { @@ -59,7 +59,7 @@ internal fun visualLinesOf( * The [charCountAt] selector lets callers back this with either an `IntArray` (book * scrollbar) or a `List` (commentaries scrollbar) without an extra copy. */ -internal fun buildCumulativePixels( +fun buildCumulativePixels( size: Int, capacity: Int, lineHeightPx: Float, @@ -81,7 +81,7 @@ internal fun buildCumulativePixels( * search, no allocation. `cumPx` is monotonically non-decreasing with `cumPx[0] = 0` * and `cumPx[total]` = total content height. */ -internal fun findItemIndexForPixel( +fun findItemIndexForPixel( cumPx: LongArray, total: Int, targetPx: Double, @@ -113,7 +113,7 @@ internal fun findItemIndexForPixel( * [modelIndexAt] maps a lazy-list index to its index in [cumPx], or `-1` for items * outside the modelled domain (loaders, transient paging indices). */ -internal inline fun computeModelScrollRatio( +inline fun computeModelScrollRatio( info: LazyListLayoutInfo, cumPx: LongArray, total: Int, diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/views/SelectionBarLogic.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/views/SelectionBarLogic.kt similarity index 94% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/views/SelectionBarLogic.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/views/SelectionBarLogic.kt index 0ffdfc5ca..eb923669c 100644 --- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/views/SelectionBarLogic.kt +++ b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/views/SelectionBarLogic.kt @@ -8,7 +8,7 @@ package io.github.kdroidfilter.seforimapp.features.bookcontent.ui.panels.bookcon * * Thin bar is only used for secondary lines in a TOC entry selection. */ -internal fun shouldUseThickBar( +fun shouldUseThickBar( lineId: Long, primarySelectedLineId: Long?, isTocEntrySelection: Boolean, @@ -21,7 +21,7 @@ internal fun shouldUseThickBar( * - The next line is also selected * - Both lines use the same bar style (both thick or both thin) */ -internal fun shouldExtendToNext( +fun shouldExtendToNext( isCurrentSelected: Boolean, nextLineId: Long?, selectedLineIds: Set, @@ -43,7 +43,7 @@ internal fun shouldExtendToNext( * When a single line is selected (or the line is the first in a group), * alt headings appear outside the bar to avoid visual coverage. */ -internal fun shouldPlaceAltHeadingsInsideBar( +fun shouldPlaceAltHeadingsInsideBar( isCurrentSelected: Boolean, isPrevSelected: Boolean, hasAltHeadings: Boolean, @@ -54,7 +54,7 @@ internal fun shouldPlaceAltHeadingsInsideBar( * Used for keyboard navigation: only scroll to the newly selected line * if it is not already fully visible. */ -internal fun isLineFullyVisible( +fun isLineFullyVisible( itemOffset: Int?, itemSize: Int?, viewportEnd: Int, @@ -77,7 +77,7 @@ internal fun isLineFullyVisible( * @param firstVisibleItemIndex the first visible item index in the list * @return the target index to scroll to, or null if no visible items */ -internal fun computePageScrollTargetIndex( +fun computePageScrollTargetIndex( forward: Boolean, visibleItemIndices: List, visibleItemEndOffsets: Map, diff --git a/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/notes/NoteDraftAnchor.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/notes/NoteDraftAnchor.kt new file mode 100644 index 000000000..6f1955cc1 --- /dev/null +++ b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/notes/NoteDraftAnchor.kt @@ -0,0 +1,15 @@ +package io.github.kdroidfilter.seforimapp.features.bookcontent.ui.panels.notes + +/** + * A pending (not-yet-persisted) note anchored to a resolved character range. Created from the + * "add note" context-menu action, or implicitly as a blank whole-line note when the selected line + * carries none yet. + * + * @property quote The anchored passage, shown as a greyed preview above the editor. + */ +data class NoteDraftAnchor( + val lineId: Long, + val startOffset: Int, + val endOffset: Int, + val quote: String, +) diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/AltTocUseCase.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/AltTocUseCase.kt similarity index 98% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/AltTocUseCase.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/AltTocUseCase.kt index 3b5399f1d..eeef9c750 100644 --- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/AltTocUseCase.kt +++ b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/AltTocUseCase.kt @@ -1,4 +1,3 @@ -@file:OptIn(ExperimentalSplitPaneApi::class) package io.github.kdroidfilter.seforimapp.features.bookcontent.usecases @@ -11,10 +10,10 @@ import io.github.kdroidfilter.seforimlibrary.core.models.AltTocStructure import io.github.kdroidfilter.seforimlibrary.core.models.Book import io.github.kdroidfilter.seforimlibrary.dao.repository.SeforimRepository import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.ensureActive import kotlinx.coroutines.withContext -import org.jetbrains.compose.splitpane.ExperimentalSplitPaneApi class AltTocUseCase( private val repository: SeforimRepository, diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/BookContentUseCaseFactory.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/BookContentUseCaseFactory.kt similarity index 100% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/BookContentUseCaseFactory.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/BookContentUseCaseFactory.kt diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/BookTreeSearch.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/BookTreeSearch.kt similarity index 100% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/BookTreeSearch.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/BookTreeSearch.kt diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/CategoryAncestry.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/CategoryAncestry.kt similarity index 100% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/CategoryAncestry.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/CategoryAncestry.kt diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/CategoryDisplaySettingsUseCase.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/CategoryDisplaySettingsUseCase.kt similarity index 100% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/CategoryDisplaySettingsUseCase.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/CategoryDisplaySettingsUseCase.kt diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/CommentariesUseCase.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/CommentariesUseCase.kt similarity index 97% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/CommentariesUseCase.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/CommentariesUseCase.kt index 37c27eb57..155125702 100644 --- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/CommentariesUseCase.kt +++ b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/CommentariesUseCase.kt @@ -7,6 +7,7 @@ import androidx.paging.PagingDataEvent import androidx.paging.PagingDataPresenter import androidx.paging.PagingSource import androidx.paging.cachedIn +import io.github.kdroidfilter.seforimapp.core.LruCache import io.github.kdroidfilter.seforimapp.core.coroutines.runSuspendCatching import io.github.kdroidfilter.seforimapp.features.bookcontent.state.BookContentStateManager import io.github.kdroidfilter.seforimapp.features.bookcontent.state.CommentatorGroup @@ -17,6 +18,8 @@ import io.github.kdroidfilter.seforimapp.pagination.LineTargumPagingSource import io.github.kdroidfilter.seforimapp.pagination.MultiLineCommentsPagingSource import io.github.kdroidfilter.seforimapp.pagination.MultiLineLinksPagingSource import io.github.kdroidfilter.seforimapp.pagination.PagingDefaults +import io.github.kdroidfilter.seforimlibrary.cache.ConcurrentCache +import io.github.kdroidfilter.seforimlibrary.cache.concurrentCache import io.github.kdroidfilter.seforimlibrary.core.models.Book import io.github.kdroidfilter.seforimlibrary.core.models.Category import io.github.kdroidfilter.seforimlibrary.core.models.ConnectionType @@ -26,6 +29,8 @@ import io.github.kdroidfilter.seforimlibrary.core.models.TocEntry import io.github.kdroidfilter.seforimlibrary.dao.repository.CommentarySummary import io.github.kdroidfilter.seforimlibrary.dao.repository.CommentaryWithText import io.github.kdroidfilter.seforimlibrary.dao.repository.SeforimRepository +import kotlinx.atomicfu.locks.SynchronizedObject +import kotlinx.atomicfu.locks.synchronized import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -39,8 +44,6 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull -import java.util.IdentityHashMap -import java.util.concurrent.ConcurrentHashMap import kotlin.coroutines.cancellation.CancellationException import kotlin.math.max import kotlin.math.min @@ -56,8 +59,11 @@ class CommentariesUseCase( private val stateManager: BookContentStateManager, private val scope: CoroutineScope, ) { - private val commentatorBookCache: MutableMap = ConcurrentHashMap() - private val defaultTargumCache: MutableMap> = ConcurrentHashMap() + private val commentatorBookCache: ConcurrentCache = concurrentCache() + private val defaultTargumCache: ConcurrentCache> = concurrentCache() + + // Guards the pagers' cache and their warm-up flags + private val lock = SynchronizedObject() // Memoizes the cached pager flows per (kind, line(s), commentator) so the SAME cachedIn flow // is reused whenever the same commentator column is requested again (re-selecting a line, @@ -73,34 +79,28 @@ class CommentariesUseCase( var warmed: Boolean = false, ) - private val pagerFlowCache = - object : LinkedHashMap(16, 0.75f, true) { - override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean = - (size > MAX_CACHED_PAGERS).also { if (it) eldest?.value?.job?.cancel() } - } + private val pagerFlowCache = LruCache(MAX_CACHED_PAGERS) { it.job.cancel() } // Read-through cache of commentator GROUPS per base line. Survives tab switches (the use // case lives in viewModelScope), so returning to a tab no longer re-queries the DB for the // groups. Bounded (access-order LRU). - private val lineConnectionsCache = - object : LinkedHashMap(16, 0.75f, true) { - override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean = - size > MAX_CACHED_LINE_CONNECTIONS - } + private val lineConnectionsCache = LruCache(MAX_CACHED_LINE_CONNECTIONS) + private val lineConnectionsLock = SynchronizedObject() private fun cachedPager( key: String, create: (CoroutineScope) -> Flow>, ): Flow> = cachedPagerEntry(key, create).flow - @Synchronized private fun cachedPagerEntry( key: String, create: (CoroutineScope) -> Flow>, ): CachedPager = - pagerFlowCache.getOrPut(key) { - val job = SupervisorJob(scope.coroutineContext[Job]) - CachedPager(create(CoroutineScope(scope.coroutineContext + job)), job) + synchronized(lock) { + pagerFlowCache.getOrPut(key) { + val job = SupervisorJob(scope.coroutineContext[Job]) + CachedPager(create(CoroutineScope(scope.coroutineContext + job)), job) + } } /** @@ -108,7 +108,7 @@ class CommentariesUseCase( * shows its items in its very first frame (LazyPagingItems starts from the cached pages). */ private suspend fun warm(entry: CachedPager) { - synchronized(this) { + synchronized(lock) { if (entry.warmed) return entry.warmed = true } @@ -122,7 +122,7 @@ class CommentariesUseCase( presenter.loadStateFlow.first { states -> states != null && states.refresh !is LoadState.Loading } collector.cancel() } - } ?: synchronized(this) { entry.warmed = false } + } ?: synchronized(lock) { entry.warmed = false } } private data class BaseLineResolution( @@ -914,11 +914,11 @@ class CommentariesUseCase( // 1. canonicalRank() — hand-curated author/title → birth year table. // 2. earliest pub_date year — first known printing. // 3. Int.MAX_VALUE — pushes undated entries to the tail. - // Memoized: the sorts below ask it on every comparison, and it normalizes and scans names. - val years = IdentityHashMap() + // Memoized per book (one entry each): the sorts below ask it on every comparison, and it normalizes and scans names. + val years = HashMap() fun entryYear(entry: CommentatorEntry): Int = - years.getOrPut(entry) { + years.getOrPut(entry.bookId) { canonicalRank(entry.book, entry.displayName) ?: entry.book?.pubDates?.let { extractEarliestYear(it) } ?: Int.MAX_VALUE @@ -1044,7 +1044,7 @@ class CommentariesUseCase( // Read-through cache: only query the DB for lines we have not resolved yet. val cached = LinkedHashMap() val missing = ArrayList() - synchronized(lineConnectionsCache) { + synchronized(lineConnectionsLock) { distinctIds.forEach { id -> val hit = lineConnectionsCache[id] if (hit != null) cached[id] = hit else missing.add(id) @@ -1053,7 +1053,7 @@ class CommentariesUseCase( if (missing.isEmpty()) return cached fun storeAndMerge(loaded: Map): Map { - synchronized(lineConnectionsCache) { loaded.forEach { (id, snap) -> lineConnectionsCache[id] = snap } } + synchronized(lineConnectionsLock) { loaded.forEach { (id, snap) -> lineConnectionsCache[id] = snap } } return cached + loaded } diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/ContentUseCase.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/ContentUseCase.kt similarity index 99% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/ContentUseCase.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/ContentUseCase.kt index 00aa80900..ceeea7da4 100644 --- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/ContentUseCase.kt +++ b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/ContentUseCase.kt @@ -1,4 +1,3 @@ -@file:OptIn(ExperimentalSplitPaneApi::class) package io.github.kdroidfilter.seforimapp.features.bookcontent.usecases @@ -15,7 +14,7 @@ import io.github.kdroidfilter.seforimlibrary.core.models.Line import io.github.kdroidfilter.seforimlibrary.dao.repository.LineSelectionRepository import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first -import org.jetbrains.compose.splitpane.ExperimentalSplitPaneApi +import kotlin.time.Clock /** * UseCase pour gérer le contenu du livre et la navigation dans les lignes @@ -195,7 +194,7 @@ class ContentUseCase( } if (scroll) { - val now = System.currentTimeMillis() + val now = Clock.System.now().toEpochMilliseconds() stateManager.updateContent { copy( selectedLines = selectedLines, diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/NavigationUseCase.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/NavigationUseCase.kt similarity index 98% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/NavigationUseCase.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/NavigationUseCase.kt index 8a2f2ea3d..8f9d76f5a 100644 --- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/NavigationUseCase.kt +++ b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/NavigationUseCase.kt @@ -1,4 +1,3 @@ -@file:OptIn(ExperimentalSplitPaneApi::class) package io.github.kdroidfilter.seforimapp.features.bookcontent.usecases @@ -14,9 +13,9 @@ import io.github.kdroidfilter.seforimlibrary.core.models.Book import io.github.kdroidfilter.seforimlibrary.core.models.Category import io.github.kdroidfilter.seforimlibrary.dao.repository.SeforimRepository import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.flow.first import kotlinx.coroutines.withContext -import org.jetbrains.compose.splitpane.ExperimentalSplitPaneApi private const val BOOK_SEARCH_LIMIT = 120 diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/NotesUseCase.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/NotesUseCase.kt similarity index 94% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/NotesUseCase.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/NotesUseCase.kt index f725a227f..2cd211f67 100644 --- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/NotesUseCase.kt +++ b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/NotesUseCase.kt @@ -1,9 +1,7 @@ -@file:OptIn(ExperimentalSplitPaneApi::class) package io.github.kdroidfilter.seforimapp.features.bookcontent.usecases import io.github.kdroidfilter.seforimapp.features.bookcontent.state.BookContentStateManager -import org.jetbrains.compose.splitpane.ExperimentalSplitPaneApi /** * UseCase for the Notes side pane visibility and scroll state. The notes themselves are owned by diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/PaneSearch.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/PaneSearch.kt similarity index 100% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/PaneSearch.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/PaneSearch.kt diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/TocSearch.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/TocSearch.kt similarity index 92% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/TocSearch.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/TocSearch.kt index 8268c4396..75ce82b38 100644 --- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/TocSearch.kt +++ b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/TocSearch.kt @@ -24,14 +24,14 @@ private fun isInWordQuote( text[i + 1].isLetter() /** Splits a normalized query into its words, dropping punctuation. */ -internal fun tocQueryTokens(query: String): List { +fun tocQueryTokens(query: String): List { val normalized = normalizeQueryForHebrew(query) val joined = buildString { normalized.forEachIndexed { i, c -> if (!isInWordQuote(normalized, i)) append(c) } } return joined.split(NON_WORD).filter { it.isNotEmpty() } } /** A TOC title that matched every query token: highlight ranges in the original text, and how many tokens were whole words. */ -internal data class TocTextMatch( +data class TocTextMatch( val ranges: List, val exactWords: Int, ) @@ -42,7 +42,7 @@ internal data class TocTextMatch( * first, so "סימן א" highlights "א" rather than the first word starting with alef, and a word * serves one token only: "סימן ס" does not match "סימן ג". */ -internal fun matchTocText( +fun matchTocText( text: String, tokens: List, ): TocTextMatch? { @@ -82,7 +82,7 @@ internal fun matchTocText( * Filters the full TOC of a book ([entries], in book order) down to the entries matching * [query] plus their ancestors, as a tree ready to render fully expanded. */ -internal fun buildTocFilter( +fun buildTocFilter( entries: List, query: String, ): TocFilterResult { @@ -138,7 +138,7 @@ internal fun buildTocFilter( } /** The TOC search filter of [this], over the TOC, extended with [alt], over the alt TOC. */ -internal fun TocFilterResult.withAlt(alt: TocFilterResult): TocFilterResult = +fun TocFilterResult.withAlt(alt: TocFilterResult): TocFilterResult = copy( altRoots = alt.roots, children = children + alt.children, @@ -149,12 +149,12 @@ internal fun TocFilterResult.withAlt(alt: TocFilterResult): TocFilterResult = ) /** The alt TOC entry a TOC search match id stands for, if it is one (see [toSearchTocEntry]). */ -internal fun altTocIdOfSearchMatch(matchId: Long): Long? = if (matchId < 0) -matchId else null +fun altTocIdOfSearchMatch(matchId: Long): Long? = if (matchId < 0) -matchId else null /** [this] as an entry of the TOC search, under its negated id so it cannot collide with a TOC entry. */ -internal fun AltTocEntry.toSearchTocEntry(bookId: Long): TocEntry = toTocEntry(bookId).copy(id = -id, parentId = parentId?.let { -it }) +fun AltTocEntry.toSearchTocEntry(bookId: Long): TocEntry = toTocEntry(bookId).copy(id = -id, parentId = parentId?.let { -it }) -internal fun AltTocEntry.toTocEntry(bookId: Long): TocEntry = +fun AltTocEntry.toTocEntry(bookId: Long): TocEntry = TocEntry( id = id, bookId = bookId, diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/TocUseCase.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/TocUseCase.kt similarity index 99% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/TocUseCase.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/TocUseCase.kt index 7d466bdec..67b070368 100644 --- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/TocUseCase.kt +++ b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/TocUseCase.kt @@ -1,4 +1,3 @@ -@file:OptIn(ExperimentalSplitPaneApi::class) package io.github.kdroidfilter.seforimapp.features.bookcontent.usecases @@ -17,7 +16,6 @@ import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.first import kotlinx.coroutines.withContext -import org.jetbrains.compose.splitpane.ExperimentalSplitPaneApi /** * UseCase pour gérer la table des matières (TOC) diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/database/update/navigation/DatabaseUpdateDestination.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/database/update/navigation/DatabaseUpdateDestination.kt similarity index 100% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/database/update/navigation/DatabaseUpdateDestination.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/database/update/navigation/DatabaseUpdateDestination.kt diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/database/update/navigation/DatabaseUpdateProgressBarState.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/database/update/navigation/DatabaseUpdateProgressBarState.kt similarity index 100% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/database/update/navigation/DatabaseUpdateProgressBarState.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/database/update/navigation/DatabaseUpdateProgressBarState.kt diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/home/widgets/HomeUserLocationViewModel.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/home/widgets/HomeUserLocationViewModel.kt similarity index 100% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/home/widgets/HomeUserLocationViewModel.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/home/widgets/HomeUserLocationViewModel.kt diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/home/widgets/WidgetSettings.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/home/widgets/WidgetSettings.kt similarity index 100% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/home/widgets/WidgetSettings.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/home/widgets/WidgetSettings.kt diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/data/OnboardingProcessRepository.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/data/OnboardingProcessRepository.kt similarity index 100% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/data/OnboardingProcessRepository.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/data/OnboardingProcessRepository.kt diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/diskspace/AvailableDiskSpaceEvents.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/diskspace/AvailableDiskSpaceEvents.kt similarity index 100% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/diskspace/AvailableDiskSpaceEvents.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/diskspace/AvailableDiskSpaceEvents.kt diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/diskspace/AvailableDiskSpaceState.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/diskspace/AvailableDiskSpaceState.kt similarity index 100% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/diskspace/AvailableDiskSpaceState.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/diskspace/AvailableDiskSpaceState.kt diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/download/DownloadEvents.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/download/DownloadEvents.kt similarity index 100% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/download/DownloadEvents.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/download/DownloadEvents.kt diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/download/DownloadState.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/download/DownloadState.kt similarity index 100% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/download/DownloadState.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/download/DownloadState.kt diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/extract/BundleManifest.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/extract/BundleManifest.kt similarity index 100% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/extract/BundleManifest.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/extract/BundleManifest.kt diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/extract/BundleParts.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/extract/BundleParts.kt similarity index 76% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/extract/BundleParts.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/extract/BundleParts.kt index a152f8fdc..6a1940736 100644 --- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/extract/BundleParts.kt +++ b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/extract/BundleParts.kt @@ -1,6 +1,8 @@ package io.github.kdroidfilter.seforimapp.features.onboarding.extract -import java.io.File +import okio.FileSystem +import okio.Path +import okio.SYSTEM /** * Naming of the split database bundle: `.tar.zst.partNN`, numbered from 01, described by @@ -53,7 +55,7 @@ object BundleParts { /** Parts of a bundle in concatenation order, with the bundle's sha256 when its manifest is known. */ class Resolved( - val parts: List, + val parts: List, val sha256: String?, ) @@ -65,19 +67,22 @@ object BundleParts { * unknown format, or left over from another release) parts are discovered by name and checked * with [requireComplete]. */ - fun resolve(anyPart: File): Resolved { + fun resolve( + anyPart: Path, + fileSystem: FileSystem = FileSystem.SYSTEM, + ): Resolved { val bundleName = bundleNameOf(anyPart.name) ?: error("Not a bundle part: ${anyPart.name}") - val dir = anyPart.absoluteFile.parentFile ?: error("Invalid part path: $anyPart") + val dir = fileSystem.canonicalize(anyPart).parent ?: error("Invalid part path: $anyPart") val manifest = - File(dir, manifestNameFor(bundleName)) - .takeIf { it.isFile } - ?.let { BundleManifest.parseOrNull(it.readText()) } - val manifestParts = manifest?.parts?.map { File(dir, it.file) } - if (manifestParts != null && manifestParts.zip(manifest.parts).all { (file, part) -> file.length() == part.size }) { + (dir / manifestNameFor(bundleName)) + .takeIf { fileSystem.metadataOrNull(it)?.isRegularFile == true } + ?.let { path -> BundleManifest.parseOrNull(fileSystem.read(path) { readUtf8() }) } + val manifestParts = manifest?.parts?.map { dir / it.file } + if (manifestParts != null && manifestParts.zip(manifest.parts).all { (file, part) -> fileSystem.sizeOf(file) == part.size }) { return Resolved(manifestParts, manifest.sha256) } - val parts = legacyPartNames(dir.list().orEmpty().toList(), bundleName).map { File(dir, it) } - requireComplete(parts.map { it.length() }) + val parts = legacyPartNames(fileSystem.list(dir).map { it.name }, bundleName).map { dir / it } + requireComplete(parts.map(fileSystem::sizeOf)) return Resolved(parts, sha256 = null) } @@ -94,11 +99,14 @@ object BundleParts { } /** Files belonging to the bundle [anyPart] is part of (parts and manifest), for cleanup. */ - fun bundleFiles(anyPart: File): List { + fun bundleFiles( + anyPart: Path, + fileSystem: FileSystem = FileSystem.SYSTEM, + ): List { val bundleName = bundleNameOf(anyPart.name) ?: return emptyList() - val dir = anyPart.absoluteFile.parentFile ?: return emptyList() - return dir - .listFiles() + val dir = runCatching { fileSystem.canonicalize(anyPart).parent }.getOrNull() ?: return emptyList() + return fileSystem + .listOrNull(dir) .orEmpty() .filter { file -> file.name.equals(manifestNameFor(bundleName), ignoreCase = true) || @@ -111,7 +119,10 @@ object BundleParts { require(numbers.isNotEmpty()) { "No bundle part found" } val sorted = numbers.sorted() val missing = (1..sorted.last()).firstOrNull { it !in numbers } - require(missing == null) { "Missing bundle part %02d".format(missing) } + require(missing == null) { "Missing bundle part ${missing.toString().padStart(2, '0')}" } return sorted } } + +/** The size of the file at [path] (0 when unknown, as java.io.File's length). */ +internal fun FileSystem.sizeOf(path: Path): Long = metadataOrNull(path)?.size ?: 0L diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/extract/ExtractEvents.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/extract/ExtractEvents.kt similarity index 100% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/extract/ExtractEvents.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/extract/ExtractEvents.kt diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/extract/ExtractState.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/extract/ExtractState.kt similarity index 100% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/extract/ExtractState.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/extract/ExtractState.kt diff --git a/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/extract/ExtractUseCase.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/extract/ExtractUseCase.kt new file mode 100644 index 000000000..61b7f388b --- /dev/null +++ b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/extract/ExtractUseCase.kt @@ -0,0 +1,263 @@ +package io.github.kdroidfilter.seforimapp.features.onboarding.extract + +import com.squareup.zstd.okio.zstdDecompress +import io.github.kdroidfilter.seforimapp.core.settings.AppSettings +import io.github.vinceglb.filekit.FileKit +import io.github.vinceglb.filekit.databasesDir +import io.github.vinceglb.filekit.path +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.withContext +import okio.Buffer +import okio.FileSystem +import okio.ForwardingSource +import okio.HashingSource +import okio.Path +import okio.Path.Companion.toPath +import okio.SYSTEM +import okio.Source +import okio.Timeout +import okio.blackholeSink +import okio.buffer +import okio.use + +class ExtractUseCase( + private val appSettings: AppSettings, + private val fileSystem: FileSystem = FileSystem.SYSTEM, + private val databasesDir: () -> Path = { FileKit.databasesDir.path.toPath() }, +) { + suspend fun extractToDatabase( + sourcePath: String, + onProgress: (Float) -> Unit, + ): String = + withContext(Dispatchers.Default) { + val dbDir = databasesDir().also(fileSystem::createDirectories) + val source = sourcePath.toPath() + require(fileSystem.exists(source)) { "Selected file not found" } + + onProgress(0f) + val dbFile: Path = + withContext(Dispatchers.IO) { + val lower = source.name.lowercase() + when { + BundleParts.isPart(source.name) -> { + val bundle = BundleParts.resolve(source, fileSystem) + val parts = bundle.parts + val result = + extractTarZstFromPartsStreaming(parts, dbDir, bundle.sha256) { mapped -> + onProgress(mapped) + } + // Only cleanup if parts live in our databases directory (downloaded by the app) + val canDelete = + runCatching { + val base = fileSystem.canonicalize(dbDir) + parts.all { fileSystem.canonicalize(it).parent == base } + }.getOrDefault(false) + if (canDelete) BundleParts.bundleFiles(source, fileSystem).forEach { runCatching { fileSystem.delete(it) } } + result + } + lower.endsWith(".tar.zst") -> { + val result = extractTarZstFile(source, dbDir) { p -> onProgress(p.coerceIn(0f, 1f)) } + runCatching { maybeCleanupSources(source, dbDir) } + result + } + lower.endsWith(".zst") -> { + val baseName = source.name.removeSuffix(".zst") + val targetName = if (baseName.endsWith(".db")) baseName else "$baseName.db" + val targetDb = dbDir / targetName + val result = extractDbZst(source, targetDb) { p -> onProgress(p.coerceIn(0f, 1f)) } + runCatching { maybeCleanupSources(source, dbDir) } + result + } + else -> error("Unsupported file type: ${source.name}") + } + } + onProgress(1f) + appSettings.setDatabasePath(dbFile.toString()) + runCatching { maybeCleanupSources(source, dbDir) } + return@withContext dbFile.toString() + } + + private fun extractDbZst( + sourceZst: Path, + targetDb: Path, + onProgress: (Float) -> Unit, + ): Path { + val totalCompressed = fileSystem.sizeOf(sourceZst).coerceAtLeast(1L) + val counting = CountingSource(fileSystem.source(sourceZst)) + counting.zstdDecompress().use { decompressed -> + writeFile(targetDb, decompressed) { onProgress(counting.count.toFloat() / totalCompressed.toFloat()) } + } + onProgress(1f) + return targetDb + } + + private fun extractTarZstFile( + zstFile: Path, + destDir: Path, + onProgress: (Float) -> Unit, + ): Path { + val totalCompressed = fileSystem.sizeOf(zstFile).coerceAtLeast(1L) + val counting = CountingSource(fileSystem.source(zstFile)) + return counting.zstdDecompress().use { decompressed -> + extractTar(decompressed, destDir) { onProgress(counting.count.toFloat() / totalCompressed.toFloat()) } + } + } + + /** Extracts the concatenated [parts]; when [expectedSha256] is known, the compressed stream must match it. */ + private fun extractTarZstFromPartsStreaming( + parts: List, + destDir: Path, + expectedSha256: String?, + onUiProgress: (Float) -> Unit, + ): Path { + require(parts.isNotEmpty()) + + val totalCompressed = parts.sumOf { fileSystem.sizeOf(it).coerceAtLeast(0L) }.coerceAtLeast(1L) + val quarter = (totalCompressed / 4L).coerceAtLeast(1L) + + fun mapProgress(consumed: Long): Float { + val c = consumed.coerceIn(0, totalCompressed) + return if (c <= quarter) { + (c.toFloat() / quarter.toFloat()) * 0.25f + } else { + val rest = (totalCompressed - quarter).coerceAtLeast(1L) + val p = ((c - quarter).toFloat() / rest.toFloat()).coerceIn(0f, 1f) + 0.25f + 0.75f * p + } + } + + val counting = CountingSource(ConcatenatedSource(parts.map(fileSystem::source))) + // Hashing the bytes already read for extraction costs no extra I/O + val hashing = expectedSha256?.let { HashingSource.sha256(counting) } + val compressed: Source = hashing ?: counting + + val extractedDb = + compressed.zstdDecompress().use { decompressed -> + val db = extractTar(decompressed, destDir) { onUiProgress(mapProgress(counting.count)) } + if (hashing != null) { + // The tar ends before the compressed stream does; hash the rest too + blackholeSink().buffer().use { it.writeAll(hashing) } + if (!hashing.hash.hex().equals(expectedSha256, ignoreCase = true)) { + fileSystem.delete(db, mustExist = false) + error("The database bundle is corrupted (sha256 mismatch)") + } + } + db + } + onUiProgress(1f) + return extractedDb + } + + /** Writes the tar read from [archive] under [destDir]; returns the database it holds. */ + private fun extractTar( + archive: Source, + destDir: Path, + onChunk: () -> Unit, + ): Path { + var extractedDb: Path? = null + val tar = TarReader(archive.buffer()) + while (true) { + val entry = tar.nextEntry() ?: break + val outFile = destDir.resolve(safeEntryName(entry.name), normalize = true) + when (entry.type) { + TarEntry.Type.DIRECTORY -> fileSystem.createDirectories(outFile) + TarEntry.Type.FILE -> { + outFile.parent?.let(fileSystem::createDirectories) + writeFile(outFile, tar.content, onChunk) + if (entry.name.endsWith(".db", ignoreCase = true)) extractedDb = outFile + } + // The bundle holds no links nor special files + TarEntry.Type.OTHER -> Unit + } + onChunk() + } + return extractedDb ?: error("No .db file found in archive") + } + + /** [content] into the file at [path], synced to the disk; [onChunk] after each chunk. */ + private fun writeFile( + path: Path, + content: Source, + onChunk: () -> Unit, + ) { + fileSystem.openReadWrite(path).use { handle -> + handle.resize(0L) + handle.sink().use { out -> + val buffer = Buffer() + while (true) { + val read = content.read(buffer, CHUNK_BYTES) + if (read == -1L) break + out.write(buffer, read) + onChunk() + } + } + handle.flush() + } + } + + private fun maybeCleanupSources( + source: Path, + dbDir: Path, + ) { + if (fileSystem.canonicalize(source).parent == fileSystem.canonicalize(dbDir)) { + if (source.name.endsWith(".tar.zst", true)) { + runCatching { fileSystem.delete(source) } + } else if (BundleParts.isPart(source.name)) { + BundleParts.bundleFiles(source, fileSystem).forEach { runCatching { fileSystem.delete(it) } } + } + } + } + + private companion object { + const val CHUNK_BYTES = 1L shl 20 + + /** An entry's name as a path inside the destination: never absolute, never climbing out of it. */ + fun safeEntryName(name: String): String { + require(!name.startsWith("/") && name.split('/', '\\').none { it == ".." }) { + "Unsafe path in the database bundle: $name" + } + return name + } + } +} + +/** Counts the bytes read through it: the compressed bytes consumed, for the progress. */ +private class CountingSource( + delegate: Source, +) : ForwardingSource(delegate) { + var count: Long = 0 + private set + + override fun read( + sink: Buffer, + byteCount: Long, + ): Long { + val read = super.read(sink, byteCount) + if (read > 0) count += read + return read + } +} + +/** [sources] read one after the other, as the parts of a split file. */ +private class ConcatenatedSource( + private val sources: List, +) : Source { + private var index = 0 + + override fun read( + sink: Buffer, + byteCount: Long, + ): Long { + while (index < sources.size) { + val read = sources[index].read(sink, byteCount) + if (read != -1L) return read + index++ + } + return -1L + } + + override fun timeout(): Timeout = Timeout.NONE + + override fun close() = sources.forEach { runCatching { it.close() } } +} diff --git a/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/extract/TarReader.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/extract/TarReader.kt new file mode 100644 index 000000000..446020f0a --- /dev/null +++ b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/extract/TarReader.kt @@ -0,0 +1,193 @@ +package io.github.kdroidfilter.seforimapp.features.onboarding.extract + +import okio.Buffer +import okio.BufferedSource +import okio.EOFException +import okio.IOException +import okio.Source +import okio.Timeout + +/** An entry of a tar archive: its [name] (a relative path, `/`-separated) and the [size] of its content. */ +class TarEntry( + val name: String, + val size: Long, + val type: Type, +) { + enum class Type { FILE, DIRECTORY, OTHER } +} + +/** + * Reads a tar archive as a stream: ustar entries, with POSIX (PAX) extended headers for long paths and sizes over 8 GiB + * (what the database bundle's packager writes) and GNU long names. Call [nextEntry], then read its content from + * [content]; what isn't read is skipped by the next call. Links and special files come as [TarEntry.Type.OTHER]. + */ +class TarReader( + private val source: BufferedSource, +) { + // What is left of the current entry's content, then the padding to the next 512-byte block + private var remaining = 0L + private var padding = 0L + + /** The current entry's content: at most its remaining bytes, then the end of the stream. */ + val content: Source = + object : Source { + override fun read( + sink: Buffer, + byteCount: Long, + ): Long { + if (remaining == 0L) return -1L + val read = source.read(sink, minOf(byteCount, remaining)) + if (read == -1L) throw EOFException("Truncated tar entry") + remaining -= read + return read + } + + override fun timeout(): Timeout = source.timeout() + + override fun close() = Unit + } + + /** The next entry, or null at the end of the archive. */ + fun nextEntry(): TarEntry? { + var paxPath: String? = null + var paxSize: Long? = null + var gnuLongName: String? = null + while (true) { + source.skip(remaining + padding) + remaining = 0L + padding = 0L + // An archive ends with zero blocks; tolerate one cut right after its last entry + if (source.exhausted()) return null + val header = source.readByteArray(BLOCK_SIZE.toLong()) + if (header.all { it == ZERO }) return null + requireValidChecksum(header) + + val size = parseNumber(header, SIZE_OFFSET, SIZE_LENGTH) + remaining = size + padding = (BLOCK_SIZE - size % BLOCK_SIZE) % BLOCK_SIZE + when (val typeFlag = header[TYPE_OFFSET].toInt().toChar()) { + // A POSIX extended header: records for the next entry + PAX_HEADER -> { + val records = parsePaxRecords(readContent(size)) + records["path"]?.let { paxPath = it } + records["size"]?.let { paxSize = it.toLongOrNull() ?: throw IOException("Invalid tar size: $it") } + } + GNU_LONG_NAME -> gnuLongName = readContent(size).decodeToString().trimEnd(NUL) + // Global extended headers and GNU long link names change nothing we read + PAX_GLOBAL_HEADER, GNU_LONG_LINK -> Unit + else -> { + val entrySize = paxSize ?: size + remaining = entrySize + padding = (BLOCK_SIZE - entrySize % BLOCK_SIZE) % BLOCK_SIZE + val name = paxPath ?: gnuLongName ?: headerName(header) + val type = + when { + typeFlag == DIRECTORY || (typeFlag == REGULAR_OLD && name.endsWith('/')) -> TarEntry.Type.DIRECTORY + typeFlag == REGULAR || typeFlag == REGULAR_OLD || typeFlag == CONTIGUOUS -> TarEntry.Type.FILE + else -> TarEntry.Type.OTHER + } + return TarEntry(name, entrySize, type) + } + } + } + } + + // The content of an extended header, which is small + private fun readContent(size: Long): ByteArray { + if (size > MAX_EXTENDED_HEADER) throw IOException("Tar extended header too large: $size") + val bytes = source.readByteArray(size) + remaining = 0L + return bytes + } + + private companion object { + const val BLOCK_SIZE = 512 + const val ZERO: Byte = 0 + const val NUL = '\u0000' + const val MAX_EXTENDED_HEADER = 1L shl 20 + + const val NAME_OFFSET = 0 + const val NAME_LENGTH = 100 + const val SIZE_OFFSET = 124 + const val SIZE_LENGTH = 12 + const val CHECKSUM_OFFSET = 148 + const val CHECKSUM_LENGTH = 8 + const val TYPE_OFFSET = 156 + const val MAGIC_OFFSET = 257 + const val PREFIX_OFFSET = 345 + const val PREFIX_LENGTH = 155 + + const val REGULAR = '0' + const val REGULAR_OLD = NUL + const val DIRECTORY = '5' + const val CONTIGUOUS = '7' + const val PAX_HEADER = 'x' + const val PAX_GLOBAL_HEADER = 'g' + const val GNU_LONG_NAME = 'L' + const val GNU_LONG_LINK = 'K' + + /** The header's name, behind its ustar prefix when it has one. */ + fun headerName(header: ByteArray): String { + val name = string(header, NAME_OFFSET, NAME_LENGTH) + val isUstar = string(header, MAGIC_OFFSET, 5) == "ustar" + val prefix = if (isUstar) string(header, PREFIX_OFFSET, PREFIX_LENGTH) else "" + return if (prefix.isEmpty()) name else "$prefix/$name" + } + + fun string( + header: ByteArray, + offset: Int, + length: Int, + ): String { + var end = offset + while (end < offset + length && header[end] != ZERO) end++ + return header.decodeToString(offset, end) + } + + /** A numeric field: octal digits, or big-endian base-256 when its first byte has the high bit (GNU). */ + fun parseNumber( + header: ByteArray, + offset: Int, + length: Int, + ): Long { + val first = header[offset].toInt() + if (first and 0x80 != 0) { + var value = (first and 0x7F).toLong() + for (i in offset + 1 until offset + length) value = (value shl 8) or (header[i].toLong() and 0xFF) + return value + } + val digits = string(header, offset, length).trim() + if (digits.isEmpty()) return 0L + return digits.toLongOrNull(8) ?: throw IOException("Invalid tar number: $digits") + } + + /** The header's checksum: the sum of its bytes, the checksum field counted as spaces. */ + fun requireValidChecksum(header: ByteArray) { + val expected = parseNumber(header, CHECKSUM_OFFSET, CHECKSUM_LENGTH) + var sum = 0L + for (i in header.indices) { + val inChecksum = i >= CHECKSUM_OFFSET && i < CHECKSUM_OFFSET + CHECKSUM_LENGTH + sum += if (inChecksum) ' '.code.toLong() else (header[i].toLong() and 0xFF) + } + if (sum != expected) throw IOException("Corrupted tar header (checksum)") + } + + /** POSIX records: ` =\n`, the length counting the whole record in bytes. */ + fun parsePaxRecords(bytes: ByteArray): Map { + val records = HashMap() + var position = 0 + while (position < bytes.size) { + var space = position + while (space < bytes.size && bytes[space] != ' '.code.toByte()) space++ + val length = bytes.decodeToString(position, space).toIntOrNull() ?: break + val end = position + length + if (length <= 0 || end > bytes.size) break + val record = bytes.decodeToString(space + 1, end - 1) + val equals = record.indexOf('=') + if (equals > 0) records[record.substring(0, equals)] = record.substring(equals + 1) + position = end + } + return records + } + } +} diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/navigation/OnBoardingDestination.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/navigation/OnBoardingDestination.kt similarity index 100% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/navigation/OnBoardingDestination.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/navigation/OnBoardingDestination.kt diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/navigation/ProgressBarState.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/navigation/ProgressBarState.kt similarity index 100% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/navigation/ProgressBarState.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/navigation/ProgressBarState.kt diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/region/RegionConfigEvents.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/region/RegionConfigEvents.kt similarity index 100% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/region/RegionConfigEvents.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/region/RegionConfigEvents.kt diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/region/RegionConfigState.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/region/RegionConfigState.kt similarity index 100% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/region/RegionConfigState.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/region/RegionConfigState.kt diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/region/RegionConfigUseCase.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/region/RegionConfigUseCase.kt similarity index 100% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/region/RegionConfigUseCase.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/region/RegionConfigUseCase.kt diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/region/RegionConfigViewModel.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/region/RegionConfigViewModel.kt similarity index 100% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/region/RegionConfigViewModel.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/region/RegionConfigViewModel.kt diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/typeofinstall/TypeOfInstallationEvents.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/typeofinstall/TypeOfInstallationEvents.kt similarity index 100% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/typeofinstall/TypeOfInstallationEvents.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/typeofinstall/TypeOfInstallationEvents.kt diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/typeofinstall/TypeOfInstallationState.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/typeofinstall/TypeOfInstallationState.kt similarity index 100% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/typeofinstall/TypeOfInstallationState.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/typeofinstall/TypeOfInstallationState.kt diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/typeofinstall/TypeOfInstallationViewModel.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/typeofinstall/TypeOfInstallationViewModel.kt similarity index 100% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/typeofinstall/TypeOfInstallationViewModel.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/typeofinstall/TypeOfInstallationViewModel.kt diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/userprofile/Community.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/userprofile/Community.kt similarity index 100% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/userprofile/Community.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/userprofile/Community.kt diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/userprofile/UserProfileEvents.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/userprofile/UserProfileEvents.kt similarity index 100% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/userprofile/UserProfileEvents.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/userprofile/UserProfileEvents.kt diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/userprofile/UserProfileState.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/userprofile/UserProfileState.kt similarity index 100% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/userprofile/UserProfileState.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/userprofile/UserProfileState.kt diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/userprofile/UserProfileUseCase.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/userprofile/UserProfileUseCase.kt similarity index 100% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/userprofile/UserProfileUseCase.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/userprofile/UserProfileUseCase.kt diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/userprofile/UserProfileViewModel.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/userprofile/UserProfileViewModel.kt similarity index 100% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/userprofile/UserProfileViewModel.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/userprofile/UserProfileViewModel.kt diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/settings/SettingsWindowEvents.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/settings/SettingsWindowEvents.kt similarity index 100% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/settings/SettingsWindowEvents.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/settings/SettingsWindowEvents.kt diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/settings/SettingsWindowState.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/settings/SettingsWindowState.kt similarity index 100% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/settings/SettingsWindowState.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/settings/SettingsWindowState.kt diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/settings/data/DataSettingsState.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/settings/data/DataSettingsState.kt similarity index 100% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/settings/data/DataSettingsState.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/settings/data/DataSettingsState.kt diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/settings/dbupdate/DbDeltaUpdateEvents.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/settings/dbupdate/DbDeltaUpdateEvents.kt similarity index 100% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/settings/dbupdate/DbDeltaUpdateEvents.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/settings/dbupdate/DbDeltaUpdateEvents.kt diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/settings/dbupdate/DbDeltaUpdateState.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/settings/dbupdate/DbDeltaUpdateState.kt similarity index 100% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/settings/dbupdate/DbDeltaUpdateState.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/settings/dbupdate/DbDeltaUpdateState.kt diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/settings/display/DisplaySettingsEvents.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/settings/display/DisplaySettingsEvents.kt similarity index 100% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/settings/display/DisplaySettingsEvents.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/settings/display/DisplaySettingsEvents.kt diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/settings/display/DisplaySettingsState.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/settings/display/DisplaySettingsState.kt similarity index 100% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/settings/display/DisplaySettingsState.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/settings/display/DisplaySettingsState.kt diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/settings/display/DisplaySettingsViewModel.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/settings/display/DisplaySettingsViewModel.kt similarity index 100% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/settings/display/DisplaySettingsViewModel.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/settings/display/DisplaySettingsViewModel.kt diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/settings/fonts/FontsSettingsEvents.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/settings/fonts/FontsSettingsEvents.kt similarity index 100% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/settings/fonts/FontsSettingsEvents.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/settings/fonts/FontsSettingsEvents.kt diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/settings/fonts/FontsSettingsState.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/settings/fonts/FontsSettingsState.kt similarity index 100% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/settings/fonts/FontsSettingsState.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/settings/fonts/FontsSettingsState.kt diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/settings/fonts/FontsSettingsViewModel.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/settings/fonts/FontsSettingsViewModel.kt similarity index 100% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/settings/fonts/FontsSettingsViewModel.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/settings/fonts/FontsSettingsViewModel.kt diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/settings/general/GeneralSettingsEvents.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/settings/general/GeneralSettingsEvents.kt similarity index 100% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/settings/general/GeneralSettingsEvents.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/settings/general/GeneralSettingsEvents.kt diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/settings/general/GeneralSettingsState.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/settings/general/GeneralSettingsState.kt similarity index 100% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/settings/general/GeneralSettingsState.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/settings/general/GeneralSettingsState.kt diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/settings/general/GeneralSettingsViewModel.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/settings/general/GeneralSettingsViewModel.kt similarity index 100% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/settings/general/GeneralSettingsViewModel.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/settings/general/GeneralSettingsViewModel.kt diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/settings/navigation/SettingsDestination.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/settings/navigation/SettingsDestination.kt similarity index 100% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/settings/navigation/SettingsDestination.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/settings/navigation/SettingsDestination.kt diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/siddur/OpenSiddur.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/siddur/OpenSiddur.kt similarity index 84% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/siddur/OpenSiddur.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/siddur/OpenSiddur.kt index cb948a9d3..e627a8a63 100644 --- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/siddur/OpenSiddur.kt +++ b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/siddur/OpenSiddur.kt @@ -3,7 +3,7 @@ package io.github.kdroidfilter.seforimapp.features.siddur import io.github.kdroidfilter.seforim.tabs.TabsDestination import io.github.kdroidfilter.seforim.tabs.TabsEvents import io.github.kdroidfilter.seforim.tabs.TabsViewModel -import java.util.UUID +import kotlin.uuid.Uuid /** Brings the window's siddur tab forward, or opens one on the tefila of the hour. */ fun openSiddurTab(tabsViewModel: TabsViewModel) { @@ -13,6 +13,6 @@ fun openSiddurTab(tabsViewModel: TabsViewModel) { if (existing >= 0) { tabsViewModel.onEvent(TabsEvents.OnSelect(existing)) } else { - tabsViewModel.openTab(TabsDestination.Siddur(tabId = UUID.randomUUID().toString())) + tabsViewModel.openTab(TabsDestination.Siddur(tabId = Uuid.random().toString())) } } diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/zmanim/data/CandleLighting.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/zmanim/data/CandleLighting.kt similarity index 100% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/zmanim/data/CandleLighting.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/zmanim/data/CandleLighting.kt diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/zmanim/data/Cities.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/zmanim/data/Cities.kt similarity index 98% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/zmanim/data/Cities.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/zmanim/data/Cities.kt index 03e0dd837..764557412 100644 --- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/zmanim/data/Cities.kt +++ b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/zmanim/data/Cities.kt @@ -1,17 +1,12 @@ package io.github.kdroidfilter.seforimapp.features.zmanim.data -import java.util.TimeZone - // Simple value holder for each city's coordinates and IANA time zone data class Place( val lat: Double, val lng: Double, val elevation: Double, val zoneId: String, -) { - /** The city's civil time zone, daylight saving time included. */ - val timeZone: TimeZone get() = TimeZone.getTimeZone(zoneId) -} +) /** Country key used to identify Israel (vs. Diaspora / Chutz LaAretz). */ const val ISRAEL_COUNTRY_NAME = "ישראל" diff --git a/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/framework/database/AppDatabases.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/framework/database/AppDatabases.kt new file mode 100644 index 000000000..122973a31 --- /dev/null +++ b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/framework/database/AppDatabases.kt @@ -0,0 +1,37 @@ +package io.github.kdroidfilter.seforimapp.framework.database + +import app.cash.sqldelight.db.SqlDriver +import io.github.kdroidfilter.seforimlibrary.dao.repository.SeforimRepository +import io.github.vinceglb.filekit.FileKit +import io.github.vinceglb.filekit.databasesDir +import io.github.vinceglb.filekit.path +import kotlinx.io.files.Path +import kotlinx.io.files.SystemFileSystem + +/** Qualifies the shared connection to the user database in the graph. */ +const val USER_SETTINGS_DRIVER = "userSettingsDriver" + +private const val SETTINGS_DB_DIR = "settings" +private const val SETTINGS_DB_NAME = "user_settings.db" + +/** The user database's usual place: databases/settings/user_settings.db in the app's data (its folder created). */ +fun defaultUserSettingsDatabasePath(): String { + val dir = Path(FileKit.databasesDir.path, SETTINGS_DB_DIR) + SystemFileSystem.createDirectories(dir) + return Path(dir, SETTINGS_DB_NAME).toString() +} + +/** + * The app's SQLite connections, as each platform opens them; the app's graph provides it (on Android, with its + * context). The common bindings (AppKitBindings) build the repository and the user stores on them. + */ +expect class AppDatabases { + /** The books' database at [databasePath], downloaded complete: nothing created nor migrated. */ + fun openSeforimRepository(databasePath: String): SeforimRepository + + /** + * The single shared connection to the user database, which waits for a concurrent writer rather than failing; its + * schema is created by the caller. + */ + fun openUserSettings(): SqlDriver +} diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/database/CatalogCache.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/framework/database/CatalogCache.kt similarity index 79% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/database/CatalogCache.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/framework/database/CatalogCache.kt index d8c9aab15..5d9f11ff8 100644 --- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/database/CatalogCache.kt +++ b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/framework/database/CatalogCache.kt @@ -14,8 +14,11 @@ import io.github.kdroidfilter.seforimlibrary.core.models.extractCategoryChildren import io.github.kdroidfilter.seforimlibrary.core.models.extractRootCategories import io.github.kdroidfilter.seforimlibrary.dao.CatalogLoader import io.github.kdroidfilter.seforimlibrary.dao.repository.SeforimRepository +import kotlinx.atomicfu.locks.SynchronizedObject +import kotlinx.atomicfu.locks.synchronized import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import kotlin.concurrent.Volatile /** * App-scoped holder for the precomputed catalog and its extracted data. @@ -27,6 +30,8 @@ import kotlinx.coroutines.withContext class CatalogCache( private val databasePathProvider: DatabasePathProvider, ) { + private val lock = SynchronizedObject() + private var _catalog: PrecomputedCatalog? = null // Cached extracted data - computed once from the catalog @@ -42,53 +47,57 @@ class CatalogCache( * Gets the cached catalog, loading it if necessary. * Returns null if the catalog file doesn't exist or can't be loaded. */ - @Synchronized fun getCatalog(): PrecomputedCatalog? { - if (_catalog == null) { - _catalog = loadCatalog() + return synchronized(lock) { + if (_catalog == null) { + _catalog = loadCatalog() + } + return _catalog } - return _catalog } /** * Gets the cached root categories, extracting them from the catalog if necessary. * Returns null if the catalog is not available. */ - @Synchronized fun getRootCategories(): List? { - if (_rootCategories == null) { - _rootCategories = getCatalog()?.extractRootCategories() + return synchronized(lock) { + if (_rootCategories == null) { + _rootCategories = getCatalog()?.extractRootCategories() + } + return _rootCategories } - return _rootCategories } /** * Gets the cached category children map, extracting it from the catalog if necessary. * Returns null if the catalog is not available. */ - @Synchronized fun getCategoryChildren(): Map>? { - if (_categoryChildren == null) { - _categoryChildren = getCatalog()?.extractCategoryChildren() + return synchronized(lock) { + if (_categoryChildren == null) { + _categoryChildren = getCatalog()?.extractCategoryChildren() + } + return _categoryChildren } - return _categoryChildren } /** * Flat id → Category index built once from roots + children. Used by features that need * O(1) parent walks (e.g. resolving the root category of a book) without hitting the DB. */ - @Synchronized fun getCategoriesById(): Map? { - if (_categoriesById == null) { - val roots = getRootCategories() ?: return null - val children = getCategoryChildren() ?: return null - val map = HashMap(roots.size + children.values.sumOf { it.size }) - roots.forEach { map[it.id] = it } - children.values.forEach { list -> list.forEach { map[it.id] = it } } - _categoriesById = map + return synchronized(lock) { + if (_categoriesById == null) { + val roots = getRootCategories() ?: return null + val children = getCategoryChildren() ?: return null + val map = HashMap(roots.size + children.values.sumOf { it.size }) + roots.forEach { map[it.id] = it } + children.values.forEach { list -> list.forEach { map[it.id] = it } } + _categoriesById = map + } + return _categoriesById } - return _categoriesById } /** @@ -120,12 +129,13 @@ class CatalogCache( * Gets all books from the catalog, cached after first extraction. * Returns null if the catalog is not available. */ - @Synchronized fun getAllBooks(): Set? { - if (_allBooks == null) { - _allBooks = getCatalog()?.extractAllBooks() + return synchronized(lock) { + if (_allBooks == null) { + _allBooks = getCatalog()?.extractAllBooks() + } + return _allBooks } - return _allBooks } /** diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/database/DatabasePathProvider.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/framework/database/DatabasePathProvider.kt similarity index 74% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/database/DatabasePathProvider.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/framework/database/DatabasePathProvider.kt index 63efa9c6c..fdcf830c5 100644 --- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/database/DatabasePathProvider.kt +++ b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/framework/database/DatabasePathProvider.kt @@ -4,11 +4,16 @@ import dev.zacsweers.metro.Inject import dev.zacsweers.metro.SingleIn import io.github.kdroidfilter.seforimapp.core.settings.AppSettings import io.github.kdroidfilter.seforimapp.framework.di.AppScope +import io.github.kdroidfilter.seforimapp.framework.platform.environmentVariable import io.github.kdroidfilter.seforimapp.logger.infoln import io.github.vinceglb.filekit.FileKit +import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.absolutePath import io.github.vinceglb.filekit.databasesDir -import io.github.vinceglb.filekit.path -import java.io.File +import io.github.vinceglb.filekit.exists +import kotlinx.atomicfu.locks.SynchronizedObject +import kotlinx.atomicfu.locks.synchronized +import kotlin.concurrent.Volatile private const val DEFAULT_DB_NAME = "seforim.db" @@ -25,25 +30,27 @@ private const val DEFAULT_DB_NAME = "seforim.db" class DatabasePathProvider( private val appSettings: AppSettings, ) { + private val lock = SynchronizedObject() + @Volatile private var cached: String? = null /** The database path; throws [IllegalStateException] when the file does not exist. */ fun get(): String { cached?.let { return it } - return synchronized(this) { + return synchronized(lock) { cached ?: resolve().also { cached = it } } } /** Clears the cached path so the next [get] re-resolves it. */ fun reset() { - synchronized(this) { cached = null } + synchronized(lock) { cached = null } } private fun resolve(): String { // 1) Prefer an explicit environment variable override if provided - val envDbPath = System.getenv("SEFORIMAPP_DATABASE_PATH")?.takeIf { it.isNotBlank() } + val envDbPath = environmentVariable("SEFORIMAPP_DATABASE_PATH")?.takeIf { it.isNotBlank() } // 2) Try AppSettings (but fix if it points to lexical.db which is wrong) val rawSettingsPath = appSettings.getDatabasePath() @@ -57,15 +64,15 @@ class DatabasePathProvider( } // 3) Fallback to default location - val defaultDbPath = File(FileKit.databasesDir.path, DEFAULT_DB_NAME).absolutePath + val defaultDbPath = PlatformFile(FileKit.databasesDir, DEFAULT_DB_NAME).absolutePath() val dbPath = envDbPath ?: settingsPath ?: defaultDbPath - infoln { "[DatabaseUtils] Database path resolved: $dbPath (exists: ${File(dbPath).exists()})" } + val exists = PlatformFile(dbPath).exists() + infoln { "[DatabaseUtils] Database path resolved: $dbPath (exists: $exists)" } // Check if the database file exists - val dbFile = File(dbPath) - if (!dbFile.exists()) { + if (!exists) { throw IllegalStateException("Database file not found at $dbPath") } diff --git a/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/framework/di/AppKitBindings.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/framework/di/AppKitBindings.kt new file mode 100644 index 000000000..68aac1231 --- /dev/null +++ b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/framework/di/AppKitBindings.kt @@ -0,0 +1,116 @@ +package io.github.kdroidfilter.seforimapp.framework.di + +import app.cash.sqldelight.db.SqlDriver +import com.russhwolf.settings.Settings +import dev.zacsweers.metro.BindingContainer +import dev.zacsweers.metro.ContributesTo +import dev.zacsweers.metro.Named +import dev.zacsweers.metro.Provides +import dev.zacsweers.metro.SingleIn +import io.github.kdroidfilter.seforim.tabs.TabTitleUpdateManager +import io.github.kdroidfilter.seforimapp.core.MainAppState +import io.github.kdroidfilter.seforimapp.core.annotations.HighlightStore +import io.github.kdroidfilter.seforimapp.core.annotations.NoteStore +import io.github.kdroidfilter.seforimapp.core.catalog.CatalogAccess +import io.github.kdroidfilter.seforimapp.core.favorites.FavoritesStore +import io.github.kdroidfilter.seforimapp.core.history.HistoryStore +import io.github.kdroidfilter.seforimapp.core.selection.DefaultSelectionContext +import io.github.kdroidfilter.seforimapp.core.selection.SelectionContext +import io.github.kdroidfilter.seforimapp.core.settings.AppSettings +import io.github.kdroidfilter.seforimapp.core.settings.CategoryDisplaySettingsStore +import io.github.kdroidfilter.seforimapp.core.shnayimmikra.ShnayimMikraStore +import io.github.kdroidfilter.seforimapp.db.UserSettingsDb +import io.github.kdroidfilter.seforimapp.features.search.SearchHistory +import io.github.kdroidfilter.seforimapp.features.search.SearchSettings +import io.github.kdroidfilter.seforimapp.framework.database.AppDatabases +import io.github.kdroidfilter.seforimapp.framework.database.CatalogCache +import io.github.kdroidfilter.seforimapp.framework.database.DatabasePathProvider +import io.github.kdroidfilter.seforimapp.framework.database.USER_SETTINGS_DRIVER +import io.github.kdroidfilter.seforimapp.framework.session.TabPersistedStateStore +import io.github.kdroidfilter.seforimlibrary.dao.repository.SeforimRepository + +/** + * What every app's graph shares: the settings, the user's data, the books' repository and the app state. Each app + * provides [AppDatabases] (its SQLite connections) and the ports of its shell (tabs, session, search tabs). + */ +@ContributesTo(AppScope::class) +@BindingContainer +object AppKitBindings { + @Provides + @SingleIn(AppScope::class) + fun provideSettings(): Settings = Settings() + + @Provides + @SingleIn(AppScope::class) + fun provideMainAppState(appSettings: AppSettings): MainAppState = MainAppState(appSettings) + + @Provides + @SingleIn(AppScope::class) + fun provideCatalogAccess(catalogCache: CatalogCache): CatalogAccess = CatalogAccess { catalogCache.getCatalog() } + + @Provides + @SingleIn(AppScope::class) + fun provideSelectionContext(): SelectionContext = DefaultSelectionContext() + + @Provides + @SingleIn(AppScope::class) + fun provideTabPersistedStateStore(): TabPersistedStateStore = TabPersistedStateStore() + + @Provides + @SingleIn(AppScope::class) + fun provideTabTitleUpdateManager(): TabTitleUpdateManager = TabTitleUpdateManager() + + /** + * Single shared connection to the local user database: every user store injects this instance instead of opening + * its own. New tables reach existing users through CREATE TABLE IF NOT EXISTS in Schema.create(), on each open. + */ + @Provides + @SingleIn(AppScope::class) + @Named(USER_SETTINGS_DRIVER) + fun provideUserSettingsDriver(databases: AppDatabases): SqlDriver = + databases.openUserSettings().also { UserSettingsDb.Schema.create(it) } + + @Provides + @SingleIn(AppScope::class) + fun provideUserSettingsDb( + @Named(USER_SETTINGS_DRIVER) driver: SqlDriver, + ): UserSettingsDb = UserSettingsDb(driver) + + @Provides + @SingleIn(AppScope::class) + fun provideCategoryDisplaySettingsStore(database: UserSettingsDb): CategoryDisplaySettingsStore = CategoryDisplaySettingsStore(database) + + @Provides + @SingleIn(AppScope::class) + fun provideHighlightStore(database: UserSettingsDb): HighlightStore = HighlightStore(database) + + @Provides + @SingleIn(AppScope::class) + fun provideNoteStore(database: UserSettingsDb): NoteStore = NoteStore(database) + + @Provides + @SingleIn(AppScope::class) + fun provideHistoryStore(database: UserSettingsDb): HistoryStore = HistoryStore(database) + + @Provides + @SingleIn(AppScope::class) + fun provideFavoritesStore(database: UserSettingsDb): FavoritesStore = FavoritesStore(database) + + @Provides + @SingleIn(AppScope::class) + fun provideShnayimMikraStore(database: UserSettingsDb): ShnayimMikraStore = ShnayimMikraStore(database) + + @Provides + @SingleIn(AppScope::class) + fun provideRepository( + databasePathProvider: DatabasePathProvider, + databases: AppDatabases, + ): SeforimRepository = databases.openSeforimRepository(databasePathProvider.get()) + + // What the search screens (searchkit) ask of the app's settings and history + @Provides + fun provideSearchSettings(appSettings: AppSettings): SearchSettings = appSettings + + @Provides + fun provideSearchHistory(historyStore: HistoryStore): SearchHistory = historyStore +} diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/di/AppMetroViewModelFactory.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/framework/di/AppMetroViewModelFactory.kt similarity index 100% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/di/AppMetroViewModelFactory.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/framework/di/AppMetroViewModelFactory.kt diff --git a/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/framework/platform/HostPlatform.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/framework/platform/HostPlatform.kt new file mode 100644 index 000000000..2f22b3d2c --- /dev/null +++ b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/framework/platform/HostPlatform.kt @@ -0,0 +1,10 @@ +package io.github.kdroidfilter.seforimapp.framework.platform + +/** True on a Linux desktop (not Android), whose OpenGL drivers limit the 3D views (see AppSettings). */ +expect val isLinuxDesktop: Boolean + +/** True on macOS (not iOS), where some Hebrew fonts lack a bold face (see FontCatalog). */ +expect val isMacOsDesktop: Boolean + +/** An environment variable of the process, or null (none on mobile in practice). */ +expect fun environmentVariable(name: String): String? diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/search/DesktopSearchHost.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/framework/search/TabSearchSessionStore.kt similarity index 59% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/search/DesktopSearchHost.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/framework/search/TabSearchSessionStore.kt index a843422ec..28474dee4 100644 --- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/search/DesktopSearchHost.kt +++ b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/framework/search/TabSearchSessionStore.kt @@ -1,38 +1,11 @@ package io.github.kdroidfilter.seforimapp.framework.search -import io.github.kdroidfilter.seforim.tabs.TabsDestination import io.github.kdroidfilter.seforimapp.features.search.SearchSessionStore -import io.github.kdroidfilter.seforimapp.features.search.SearchTabs -import io.github.kdroidfilter.seforimapp.framework.desktop.DesktopManager import io.github.kdroidfilter.seforimapp.framework.session.SearchPersistedState import io.github.kdroidfilter.seforimapp.framework.session.TabPersistedStateStore -import kotlinx.coroutines.flow.Flow -/** The search's tabs on the desktop: those of the window that holds the searching tab, across virtual desktops. */ -class DesktopSearchTabs( - private val desktopManager: DesktopManager, -) : SearchTabs { - override fun openTab( - fromTabId: String, - destination: TabsDestination, - ) { - desktopManager.tabsViewModelFor(fromTabId)?.openTab(destination) - } - - override fun replace( - tabId: String, - destination: TabsDestination, - ) { - desktopManager.tabsViewModelFor(tabId)?.replaceCurrentTabDestination(destination) - } - - override fun tabExistsFlow(tabId: String): Flow = desktopManager.tabExistsFlow(tabId) - - override fun isTabOpen(tabId: String): Boolean = desktopManager.isTabOpen(tabId) -} - -/** The search's state in the desktop session: the per-tab state the session saves at close. */ -class DesktopSearchSessionStore( +/** The search's state in the session: the per-tab state the session saves at close. */ +class TabSearchSessionStore( private val store: TabPersistedStateStore, ) : SearchSessionStore { override fun search(tabId: String): SearchPersistedState? = store.get(tabId)?.search diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/session/SessionManager.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/framework/session/SessionManager.kt similarity index 89% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/session/SessionManager.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/framework/session/SessionManager.kt index 140350370..22d9729df 100644 --- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/session/SessionManager.kt +++ b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/framework/session/SessionManager.kt @@ -7,9 +7,7 @@ import dev.zacsweers.metro.SingleIn import io.github.kdroidfilter.seforim.desktop.VirtualDesktop import io.github.kdroidfilter.seforim.tabs.TabType import io.github.kdroidfilter.seforim.tabs.TabsDestination -import io.github.kdroidfilter.seforimapp.core.e2e.E2e import io.github.kdroidfilter.seforimapp.core.settings.AppSettings -import io.github.kdroidfilter.seforimapp.framework.desktop.DesktopManager import io.github.kdroidfilter.seforimapp.framework.di.AppScope import io.github.kdroidfilter.seforimapp.logger.debugln import io.github.kdroidfilter.seforimlibrary.dao.repository.SeforimRepository @@ -17,6 +15,7 @@ import io.github.vinceglb.filekit.FileKit import io.github.vinceglb.filekit.databasesDir import io.github.vinceglb.filekit.path import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay @@ -25,9 +24,12 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext +import kotlinx.io.buffered +import kotlinx.io.files.Path +import kotlinx.io.files.SystemFileSystem +import kotlinx.io.readByteArray import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.protobuf.ProtoBuf -import java.io.File /** * Persists and restores the navigation session (open tabs + per-tab persisted UI state) when enabled. @@ -35,29 +37,34 @@ import java.io.File * Now supports multiple virtual desktops via [DesktopsState]. * Migrates transparently from the legacy single-desktop [SavedSessionV2] format. * - * [desktopManager] is lazy: [DesktopManager] itself is built from [loadBootState]. + * [shell] gives the desktops to save; it must not need them before [saveIfEnabled] (the desktop's are built from + * [loadBootState]). */ @Inject @SingleIn(AppScope::class) class SessionManager( - private val desktopManager: Lazy, - private val tabThumbnailStore: TabThumbnailStore, + private val shell: SessionShell, private val appSettings: AppSettings, -) { +) : SessionRestoreState { private val proto = ProtoBuf // Guarded: outside the app (unit tests) FileKit and the settings are not initialized. private val _isRestoringSession = MutableStateFlow(runCatching { hasSavedSessionToRestore() }.getOrDefault(false)) - val isRestoringSession: StateFlow = _isRestoringSession + override val isRestoringSession: StateFlow = _isRestoringSession - private fun sessionDir(): File { - val root = File(FileKit.databasesDir.path, "session").apply { mkdirs() } - return root - } + private fun sessionDir(): Path = Path(FileKit.databasesDir.path, "session").also { SystemFileSystem.createDirectories(it) } + + private fun legacySessionFile(): Path = Path(sessionDir(), "session_v2.pb") + + private fun desktopsFile(): Path = Path(sessionDir(), "desktops_v1.pb") + + private fun Path.exists(): Boolean = SystemFileSystem.exists(this) + + private fun Path.readBytes(): ByteArray = SystemFileSystem.source(this).buffered().use { it.readByteArray() } - private fun legacySessionFile(): File = File(sessionDir(), "session_v2.pb") + private fun Path.writeBytes(bytes: ByteArray) = SystemFileSystem.sink(this).buffered().use { it.write(bytes) } - private fun desktopsFile(): File = File(sessionDir(), "desktops_v1.pb") + private fun Path.delete() = SystemFileSystem.delete(this, mustExist = false) private fun hasSavedSessionToRestore(): Boolean = appSettings.isPersistSessionEnabled() && (desktopsFile().exists() || legacySessionFile().exists()) @@ -65,11 +72,11 @@ class SessionManager( /** Saves the current session snapshot if the user enabled persistence in settings. */ fun saveIfEnabled() { // The end-to-end harness must never overwrite the user's session. - if (E2e.enabled || !appSettings.isPersistSessionEnabled()) return + if (shell.isSessionDisabled || !appSettings.isPersistSessionEnabled()) return - val desktopsState = desktopManager.value.buildDesktopsState() + val desktopsState = shell.buildDesktopsState() // The hover-card pictures of tabs no desktop holds any more. - tabThumbnailStore.prune( + shell.forgetTabsExcept( desktopsState.snapshots.values .flatMap { snapshot -> snapshot.effectiveWindows().flatMap { it.destinations } } .mapTo(HashSet()) { it.tabId }, @@ -99,7 +106,7 @@ class SessionManager( * an application composing no window at all is closed at once. Null when disabled or absent. */ fun loadBootState(repository: SeforimRepository): DesktopsState? { - if (E2e.enabled || !appSettings.isPersistSessionEnabled()) return null + if (shell.isSessionDisabled || !appSettings.isPersistSessionEnabled()) return null val state = loadDesktopsState()?.takeIf { it.desktops.isNotEmpty() } ?: return null // Tabs saved without a title (book names come from the DB) are named before they are shown. return runCatching { runBlocking { enrichMissingTabTitles(state, repository) } }.getOrDefault(state) diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/session/SessionModels.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/framework/session/SessionModels.kt similarity index 100% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/session/SessionModels.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/framework/session/SessionModels.kt diff --git a/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/framework/session/SessionRestoreState.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/framework/session/SessionRestoreState.kt new file mode 100644 index 000000000..c7a4e353e --- /dev/null +++ b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/framework/session/SessionRestoreState.kt @@ -0,0 +1,8 @@ +package io.github.kdroidfilter.seforimapp.framework.session + +import kotlinx.coroutines.flow.StateFlow + +/** Whether the app is reopening the tabs of its last session (the desktop's `SessionManager`). */ +interface SessionRestoreState { + val isRestoringSession: StateFlow +} diff --git a/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/framework/session/SessionShell.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/framework/session/SessionShell.kt new file mode 100644 index 000000000..db74d9e0a --- /dev/null +++ b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/framework/session/SessionShell.kt @@ -0,0 +1,13 @@ +package io.github.kdroidfilter.seforimapp.framework.session + +/** What [SessionManager] asks of the app's shell (the desktop's windows and virtual desktops). */ +interface SessionShell { + /** True when the session must be neither saved nor restored (the desktop's end-to-end harness). */ + val isSessionDisabled: Boolean + + /** The desktops, their windows and tabs as they are now, to save. */ + fun buildDesktopsState(): DesktopsState + + /** Drops what the shell keeps for tabs (hover-card pictures) that no saved desktop holds any more. */ + fun forgetTabsExcept(tabIds: Set) +} diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/session/TabPersistedStateStore.kt b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/framework/session/TabPersistedStateStore.kt similarity index 52% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/session/TabPersistedStateStore.kt rename to appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/framework/session/TabPersistedStateStore.kt index d42dcdfac..6382b8391 100644 --- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/session/TabPersistedStateStore.kt +++ b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/framework/session/TabPersistedStateStore.kt @@ -1,6 +1,7 @@ package io.github.kdroidfilter.seforimapp.framework.session -import java.util.concurrent.ConcurrentHashMap +import kotlinx.atomicfu.locks.SynchronizedObject +import kotlinx.atomicfu.locks.synchronized /** * In-memory store for per-tab persisted UI state. @@ -10,49 +11,48 @@ import java.util.concurrent.ConcurrentHashMap * does not need to know about individual keys. */ class TabPersistedStateStore { - private val states = ConcurrentHashMap() + private val lock = SynchronizedObject() + private val states = HashMap() - fun get(tabId: String): TabPersistedState? = states[tabId] + fun get(tabId: String): TabPersistedState? = synchronized(lock) { states[tabId] } - fun getOrCreate(tabId: String): TabPersistedState = states.getOrPut(tabId) { TabPersistedState() } + fun getOrCreate(tabId: String): TabPersistedState = synchronized(lock) { states.getOrPut(tabId) { TabPersistedState() } } fun set( tabId: String, state: TabPersistedState, ) { - states[tabId] = state + synchronized(lock) { states[tabId] = state } } fun update( tabId: String, transform: (TabPersistedState) -> TabPersistedState, ) { - states.compute(tabId) { _, current -> - transform(current ?: TabPersistedState()) - } + synchronized(lock) { states[tabId] = transform(states[tabId] ?: TabPersistedState()) } } fun remove(tabId: String) { - states.remove(tabId) + synchronized(lock) { states.remove(tabId) } } fun removeAll(tabIds: Collection) { - tabIds.forEach { states.remove(it) } + synchronized(lock) { tabIds.forEach { states.remove(it) } } } /** Loads a desktop's saved states without touching entries of other open desktops. */ fun putAll(snapshot: Map) { - states.putAll(snapshot) + synchronized(lock) { states.putAll(snapshot) } } fun clearAll() { - states.clear() + synchronized(lock) { states.clear() } } - fun snapshot(): Map = states.toMap() + fun snapshot(): Map = synchronized(lock) { states.toMap() } fun restore(snapshot: Map) { - synchronized(states) { + synchronized(lock) { states.clear() states.putAll(snapshot) } diff --git a/SeforimApp/src/commonMain/sqldelight/io/github/kdroidfilter/seforimapp/db/CategoryDisplaySettings.sq b/appkit/src/commonMain/sqldelight/io/github/kdroidfilter/seforimapp/db/CategoryDisplaySettings.sq similarity index 100% rename from SeforimApp/src/commonMain/sqldelight/io/github/kdroidfilter/seforimapp/db/CategoryDisplaySettings.sq rename to appkit/src/commonMain/sqldelight/io/github/kdroidfilter/seforimapp/db/CategoryDisplaySettings.sq diff --git a/SeforimApp/src/commonMain/sqldelight/io/github/kdroidfilter/seforimapp/db/Favorites.sq b/appkit/src/commonMain/sqldelight/io/github/kdroidfilter/seforimapp/db/Favorites.sq similarity index 100% rename from SeforimApp/src/commonMain/sqldelight/io/github/kdroidfilter/seforimapp/db/Favorites.sq rename to appkit/src/commonMain/sqldelight/io/github/kdroidfilter/seforimapp/db/Favorites.sq diff --git a/SeforimApp/src/commonMain/sqldelight/io/github/kdroidfilter/seforimapp/db/ShnayimMikra.sq b/appkit/src/commonMain/sqldelight/io/github/kdroidfilter/seforimapp/db/ShnayimMikra.sq similarity index 100% rename from SeforimApp/src/commonMain/sqldelight/io/github/kdroidfilter/seforimapp/db/ShnayimMikra.sq rename to appkit/src/commonMain/sqldelight/io/github/kdroidfilter/seforimapp/db/ShnayimMikra.sq diff --git a/SeforimApp/src/commonMain/sqldelight/io/github/kdroidfilter/seforimapp/db/UserHighlights.sq b/appkit/src/commonMain/sqldelight/io/github/kdroidfilter/seforimapp/db/UserHighlights.sq similarity index 100% rename from SeforimApp/src/commonMain/sqldelight/io/github/kdroidfilter/seforimapp/db/UserHighlights.sq rename to appkit/src/commonMain/sqldelight/io/github/kdroidfilter/seforimapp/db/UserHighlights.sq diff --git a/SeforimApp/src/commonMain/sqldelight/io/github/kdroidfilter/seforimapp/db/UserNotes.sq b/appkit/src/commonMain/sqldelight/io/github/kdroidfilter/seforimapp/db/UserNotes.sq similarity index 100% rename from SeforimApp/src/commonMain/sqldelight/io/github/kdroidfilter/seforimapp/db/UserNotes.sq rename to appkit/src/commonMain/sqldelight/io/github/kdroidfilter/seforimapp/db/UserNotes.sq diff --git a/SeforimApp/src/commonMain/sqldelight/io/github/kdroidfilter/seforimapp/db/VisitHistory.sq b/appkit/src/commonMain/sqldelight/io/github/kdroidfilter/seforimapp/db/VisitHistory.sq similarity index 100% rename from SeforimApp/src/commonMain/sqldelight/io/github/kdroidfilter/seforimapp/db/VisitHistory.sq rename to appkit/src/commonMain/sqldelight/io/github/kdroidfilter/seforimapp/db/VisitHistory.sq diff --git a/appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforim/tabs/TabsViewModelTest.kt b/appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforim/tabs/TabsViewModelTest.kt new file mode 100644 index 000000000..8dcee0494 --- /dev/null +++ b/appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforim/tabs/TabsViewModelTest.kt @@ -0,0 +1,146 @@ +package io.github.kdroidfilter.seforim.tabs + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +/** A window's tabs held in memory: what the desktop's workspace does, minus the windows. */ +private class FakeTabsHost( + vararg ids: String, +) : TabsHost { + val tabs = ids.map { tab(TabsDestination.BookContent(bookId = 1, tabId = it)) }.toMutableList() + var selected: String? = ids.firstOrNull() + + override fun ids(): List = tabs.map { it.destination.tabId } + + override fun selectedId(): String? = selected + + override fun item(tabId: String): TabItem? = tabs.firstOrNull { it.destination.tabId == tabId } + + override fun isDeclared(tabId: String): Boolean = item(tabId) != null + + override fun isPinned(tabId: String): Boolean = item(tabId)?.pinned == true + + override fun setPinned( + tabId: String, + pinned: Boolean, + ) { + val index = ids().indexOf(tabId) + tabs[index] = tabs[index].copy(pinned = pinned) + } + + override fun select(tabId: String) { + selected = tabId + } + + override fun close(tabId: String) { + tabs.removeAll { it.destination.tabId == tabId } + if (selected == tabId) selected = ids().firstOrNull() + } + + override fun reorder( + tabId: String, + toIndex: Int, + ) { + val item = item(tabId) ?: return + tabs.remove(item) + tabs.add(toIndex, item) + } + + override fun add( + destination: TabsDestination, + index: Int, + replacing: String?, + pinned: Boolean, + ) { + replacing?.let(::close) + tabs.add(index.coerceAtMost(tabs.size), tab(destination).copy(pinned = pinned)) + selected = destination.tabId + } + + override fun updateDestination( + tabId: String, + destination: TabsDestination, + ) { + val index = ids().indexOf(tabId) + tabs[index] = tabs[index].copy(destination = destination) + } + + private fun tab(destination: TabsDestination) = TabItem(id = 0, title = destination.tabId, destination = destination) +} + +@OptIn(ExperimentalCoroutinesApi::class) +class TabsViewModelTest { + @BeforeTest + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `close others keeps the pinned tabs`() { + val host = FakeTabsHost("a", "b", "c", "d") + host.setPinned("a", true) + val viewModel = TabsViewModel(host) + + viewModel.onEvent(TabsEvents.CloseOthers(index = 2)) + + assertEquals(listOf("a", "c"), host.ids()) + viewModel.dispose() + } + + @Test + fun `a tab opened from another lands at its place and is selected`() { + val host = FakeTabsHost("a", "b", "c") + host.select("b") + val viewModel = TabsViewModel(host) + + viewModel.openTab(TabsDestination.BookContent(bookId = 2, tabId = "new")) + + assertEquals(listOf("a", "new", "b", "c"), host.ids()) + assertEquals("new", host.selectedId()) + viewModel.dispose() + } + + @Test + fun `replacing the current tab gives it a new id and keeps its pin`() { + val host = FakeTabsHost("a", "b") + host.setPinned("a", true) + val viewModel = TabsViewModel(host) + + viewModel.replaceCurrentTabWithNewTabId(TabsDestination.BookContent(bookId = 3, tabId = "a")) + + val newId = host.ids().first() + assertNotEquals("a", newId) + assertTrue(host.isPinned(newId)) + assertEquals(listOf(newId, "b"), host.ids()) + viewModel.dispose() + } + + @Test + fun `a pinned tab closes on the second close only`() { + val host = FakeTabsHost("a", "b") + host.setPinned("a", true) + val viewModel = TabsViewModel(host) + + viewModel.closeSelectedTab() + assertEquals(listOf("a", "b"), host.ids()) + assertTrue(viewModel.closePinnedHint.value) + + viewModel.closeSelectedTab() + assertEquals(listOf("b"), host.ids()) + viewModel.dispose() + } +} diff --git a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/catalog/CatalogPresetsTest.kt b/appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/catalog/CatalogPresetsTest.kt similarity index 100% rename from SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/catalog/CatalogPresetsTest.kt rename to appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/catalog/CatalogPresetsTest.kt diff --git a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/core/CopyWithSourceTest.kt b/appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/core/CopyWithSourceTest.kt similarity index 100% rename from SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/core/CopyWithSourceTest.kt rename to appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/core/CopyWithSourceTest.kt diff --git a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/core/annotations/HighlightSelectionResolverTest.kt b/appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/core/annotations/HighlightSelectionResolverTest.kt similarity index 100% rename from SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/core/annotations/HighlightSelectionResolverTest.kt rename to appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/core/annotations/HighlightSelectionResolverTest.kt diff --git a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/tabs/PinnedTabLabelTest.kt b/appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/tabs/PinnedTabLabelTest.kt similarity index 100% rename from SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/tabs/PinnedTabLabelTest.kt rename to appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/tabs/PinnedTabLabelTest.kt diff --git a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/typography/FontCatalogTest.kt b/appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/typography/FontCatalogTest.kt similarity index 100% rename from SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/typography/FontCatalogTest.kt rename to appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/typography/FontCatalogTest.kt diff --git a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/core/selection/SelectionContextTest.kt b/appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/core/selection/SelectionContextTest.kt similarity index 100% rename from SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/core/selection/SelectionContextTest.kt rename to appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/core/selection/SelectionContextTest.kt diff --git a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/BookContentEventTest.kt b/appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/BookContentEventTest.kt similarity index 100% rename from SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/BookContentEventTest.kt rename to appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/BookContentEventTest.kt diff --git a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/BookContentViewModelTest.kt b/appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/BookContentViewModelTest.kt similarity index 100% rename from SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/BookContentViewModelTest.kt rename to appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/BookContentViewModelTest.kt diff --git a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/BookContentStateTest.kt b/appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/BookContentStateTest.kt similarity index 100% rename from SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/BookContentStateTest.kt rename to appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/BookContentStateTest.kt diff --git a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/BookOpenSourceTest.kt b/appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/BookOpenSourceTest.kt similarity index 100% rename from SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/BookOpenSourceTest.kt rename to appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/BookOpenSourceTest.kt diff --git a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/CommentatorGroupTest.kt b/appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/CommentatorGroupTest.kt similarity index 100% rename from SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/CommentatorGroupTest.kt rename to appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/CommentatorGroupTest.kt diff --git a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/LineConnectionsSnapshotTest.kt b/appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/LineConnectionsSnapshotTest.kt similarity index 100% rename from SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/LineConnectionsSnapshotTest.kt rename to appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/LineConnectionsSnapshotTest.kt diff --git a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/SplitDefaultsTest.kt b/appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/SplitDefaultsTest.kt similarity index 100% rename from SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/SplitDefaultsTest.kt rename to appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/SplitDefaultsTest.kt diff --git a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/views/BreadcrumbNavigatorTest.kt b/appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/views/BreadcrumbNavigatorTest.kt similarity index 97% rename from SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/views/BreadcrumbNavigatorTest.kt rename to appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/views/BreadcrumbNavigatorTest.kt index 835d50a69..30ba55f63 100644 --- a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/views/BreadcrumbNavigatorTest.kt +++ b/appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/views/BreadcrumbNavigatorTest.kt @@ -110,7 +110,7 @@ class BreadcrumbNavigatorTest { } @Test - fun `outside click keeps the drilled-down bar, escape restores the path`() = + fun `outside click keeps the drilled-down bar escape restores the path`() = runTest { val nav = navigator(mutableListOf()) nav.showPopup(0) @@ -226,7 +226,7 @@ class BreadcrumbNavigatorTest { } @Test - fun `after drilling down, clicking the new anchor closes the popup`() = + fun `after drilling down clicking the new anchor closes the popup`() = runTest { val nav = navigator(mutableListOf()) nav.onSegmentClick(mishna.key) @@ -321,7 +321,7 @@ class BreadcrumbNavigatorTest { } @Test - fun `book popup replaces a title root by its children, as the path does`() = + fun `book popup replaces a title root by its children as the path does`() = runTest { val titleRoot = TocEntry(id = 1, bookId = 10, text = "משנה ברכות", level = 0, hasChildren = true) val intro = TocEntry(id = 2, bookId = 10, text = "מבוא", level = 0) @@ -337,7 +337,7 @@ class BreadcrumbNavigatorTest { } @Test - fun `book popup keeps a title root without children, a line of its own`() = + fun `book popup keeps a title root without children a line of its own`() = runTest { val titleRoot = TocEntry(id = 1, bookId = 10, text = "משנה ברכות", level = 0, lineId = 1) val provider = @@ -352,7 +352,7 @@ class BreadcrumbNavigatorTest { } @Test - fun `escape and choices hand the focus back, an outside click doesn't`() = + fun `escape and choices hand the focus back an outside click doesn't`() = runTest { var returns = 0 val nav = BreadcrumbNavigator(backgroundScope, provider, initialPath = path, onReturnFocus = { returns++ }) {} diff --git a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/BookContentUseCaseFactoryTest.kt b/appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/BookContentUseCaseFactoryTest.kt similarity index 100% rename from SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/BookContentUseCaseFactoryTest.kt rename to appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/BookContentUseCaseFactoryTest.kt diff --git a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/BookTreeSearchTest.kt b/appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/BookTreeSearchTest.kt similarity index 100% rename from SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/BookTreeSearchTest.kt rename to appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/BookTreeSearchTest.kt diff --git a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/CommentatorNameTest.kt b/appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/CommentatorNameTest.kt similarity index 95% rename from SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/CommentatorNameTest.kt rename to appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/CommentatorNameTest.kt index bb3807075..ecf9a656a 100644 --- a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/CommentatorNameTest.kt +++ b/appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/CommentatorNameTest.kt @@ -19,7 +19,7 @@ class CommentatorNameTest { } @Test - fun `leaves a longer title, another book, alone`() { + fun `leaves a longer title another book alone`() { assertEquals("מתנות כהונה על שמות רבה", sanitizeCommentatorName("מתנות כהונה על שמות רבה", "שמות")) assertEquals("רש״י על שמותיהם", sanitizeCommentatorName("רש״י על שמותיהם", "שמות")) } diff --git a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/PaneSearchTest.kt b/appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/PaneSearchTest.kt similarity index 100% rename from SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/PaneSearchTest.kt rename to appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/PaneSearchTest.kt diff --git a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/TocSearchTest.kt b/appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/TocSearchTest.kt similarity index 97% rename from SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/TocSearchTest.kt rename to appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/TocSearchTest.kt index c5636a494..4f48ad72e 100644 --- a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/TocSearchTest.kt +++ b/appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/TocSearchTest.kt @@ -28,7 +28,7 @@ class TocSearchTest { ) @Test - fun `tokens must each start a word, ignoring gershayim`() { + fun `tokens must each start a word ignoring gershayim`() { assertNotNull(matchTocText("סימן קכ״א", listOf("קכא"))) assertNotNull(matchTocText("סימן קכ״א", listOf("סי", "קכ"))) assertNull(matchTocText("סימן קכ״א", listOf("ימן"))) @@ -80,7 +80,7 @@ class TocSearchTest { } @Test - fun `best match prefers whole words, then display order`() { + fun `best match prefers whole words then display order`() { assertEquals(7L, buildTocFilter(toc, "קכ").bestMatchId) assertEquals(3L, buildTocFilter(toc, "סימן א").bestMatchId) } diff --git a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/database/update/navigation/DatabaseUpdateDestinationTest.kt b/appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/database/update/navigation/DatabaseUpdateDestinationTest.kt similarity index 100% rename from SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/database/update/navigation/DatabaseUpdateDestinationTest.kt rename to appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/database/update/navigation/DatabaseUpdateDestinationTest.kt diff --git a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/database/update/navigation/DatabaseUpdateProgressBarStateTest.kt b/appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/database/update/navigation/DatabaseUpdateProgressBarStateTest.kt similarity index 100% rename from SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/database/update/navigation/DatabaseUpdateProgressBarStateTest.kt rename to appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/database/update/navigation/DatabaseUpdateProgressBarStateTest.kt diff --git a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/diskspace/AvailableDiskSpaceEventsTest.kt b/appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/diskspace/AvailableDiskSpaceEventsTest.kt similarity index 100% rename from SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/diskspace/AvailableDiskSpaceEventsTest.kt rename to appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/diskspace/AvailableDiskSpaceEventsTest.kt diff --git a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/diskspace/AvailableDiskSpaceStateTest.kt b/appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/diskspace/AvailableDiskSpaceStateTest.kt similarity index 100% rename from SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/diskspace/AvailableDiskSpaceStateTest.kt rename to appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/diskspace/AvailableDiskSpaceStateTest.kt diff --git a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/download/DownloadEventsTest.kt b/appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/download/DownloadEventsTest.kt similarity index 100% rename from SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/download/DownloadEventsTest.kt rename to appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/download/DownloadEventsTest.kt diff --git a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/download/DownloadStateTest.kt b/appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/download/DownloadStateTest.kt similarity index 100% rename from SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/download/DownloadStateTest.kt rename to appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/download/DownloadStateTest.kt diff --git a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/extract/BundlePartsTest.kt b/appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/extract/BundlePartsTest.kt similarity index 72% rename from SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/extract/BundlePartsTest.kt rename to appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/extract/BundlePartsTest.kt index 3a59b4242..06d3036fc 100644 --- a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/extract/BundlePartsTest.kt +++ b/appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/extract/BundlePartsTest.kt @@ -1,8 +1,7 @@ package io.github.kdroidfilter.seforimapp.features.onboarding.extract -import java.io.File -import java.nio.file.Files -import kotlin.test.AfterTest +import okio.Path.Companion.toPath +import okio.fakefilesystem.FakeFileSystem import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -10,18 +9,19 @@ import kotlin.test.assertNull import kotlin.test.assertTrue class BundlePartsTest { - private val dir = Files.createTempDirectory("bundle-parts").toFile() + private val fileSystem = FakeFileSystem() + private val dir = "/bundle-parts".toPath().also { fileSystem.createDirectories(it) } - @AfterTest - fun cleanup() { - dir.deleteRecursively() - } + private fun partName( + number: Int, + base: String = BundleParts.BUNDLE_NAME, + ) = "$base.part${number.toString().padStart(2, '0')}" private fun part( number: Int, size: Int, base: String = BundleParts.BUNDLE_NAME, - ) = File(dir, "$base.part%02d".format(number)).apply { writeBytes(ByteArray(size)) } + ) = fileSystem.write(dir / partName(number, base)) { write(ByteArray(size)) } private fun manifest( vararg sizes: Long, @@ -29,15 +29,17 @@ class BundlePartsTest { ) { val parts = sizes.mapIndexed { i, size -> - """{ "file": "${BundleParts.BUNDLE_NAME}.part%02d", "size": $size, "sha256": "x" }""".format(i + 1) + """{ "file": "${partName(i + 1)}", "size": $size, "sha256": "x" }""" } - File(dir, BundleParts.manifestNameFor(BundleParts.BUNDLE_NAME)).writeText( - """{ "formatVersion": $formatVersion, "file": "${BundleParts.BUNDLE_NAME}", "size": ${sizes.sum()}, "sha256": "bundle-sha", + fileSystem.write(dir / BundleParts.manifestNameFor(BundleParts.BUNDLE_NAME)) { + writeUtf8( + """{ "formatVersion": $formatVersion, "file": "${BundleParts.BUNDLE_NAME}", "size": ${sizes.sum()}, "sha256": "bundle-sha", "parts": [${parts.joinToString()}], "futureField": true }""", - ) + ) + } } - private fun resolve() = BundleParts.resolve(File(dir, "${BundleParts.BUNDLE_NAME}.part01")) + private fun resolve() = BundleParts.resolve(dir / partName(1), fileSystem) private fun resolveNames() = resolve().parts.map { it.name.substringAfterLast('.') } @@ -51,7 +53,7 @@ class BundlePartsTest { } @Test - fun `legacy part names keep only the bundle, in order`() { + fun `legacy part names keep only the bundle in order`() { val names = BundleParts.legacyPartNames( listOf("seforim_bundle.tar.zst.part02", "lucene.tar.zst.part01", "seforim_bundle.tar.zst.part01", "catalog.pb"), @@ -70,12 +72,12 @@ class BundlePartsTest { } @Test - fun `without manifest rejects a gap, a lone part and a missing last part`() { + fun `without manifest rejects a gap a lone part and a missing last part`() { part(1, 4) assertFailsWith { resolveNames() } part(3, 2) assertFailsWith { resolveNames() } - File(dir, "${BundleParts.BUNDLE_NAME}.part03").delete() + fileSystem.delete(dir / partName(3)) part(2, 4) assertFailsWith { resolveNames() } } @@ -119,9 +121,9 @@ class BundlePartsTest { part(1, 4) part(2, 1) manifest(4, 1) - File(dir, "catalog.pb").writeText("x") + fileSystem.write(dir / "catalog.pb") { writeUtf8("x") } - val names = BundleParts.bundleFiles(File(dir, "${BundleParts.BUNDLE_NAME}.part01")).map { it.name }.toSet() + val names = BundleParts.bundleFiles(dir / partName(1), fileSystem).map { it.name }.toSet() assertEquals(3, names.size) assertTrue(BundleParts.manifestNameFor(BundleParts.BUNDLE_NAME) in names) diff --git a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/extract/ExtractEventsTest.kt b/appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/extract/ExtractEventsTest.kt similarity index 100% rename from SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/extract/ExtractEventsTest.kt rename to appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/extract/ExtractEventsTest.kt diff --git a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/extract/ExtractStateTest.kt b/appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/extract/ExtractStateTest.kt similarity index 100% rename from SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/extract/ExtractStateTest.kt rename to appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/extract/ExtractStateTest.kt diff --git a/appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/extract/ExtractUseCaseTest.kt b/appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/extract/ExtractUseCaseTest.kt new file mode 100644 index 000000000..8f2df7103 --- /dev/null +++ b/appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/extract/ExtractUseCaseTest.kt @@ -0,0 +1,129 @@ +package io.github.kdroidfilter.seforimapp.features.onboarding.extract + +import com.russhwolf.settings.MapSettings +import com.squareup.zstd.okio.zstdCompress +import io.github.kdroidfilter.seforimapp.core.settings.AppSettings +import kotlinx.coroutines.test.runTest +import okio.Buffer +import okio.ByteString.Companion.toByteString +import okio.Path +import okio.Path.Companion.toPath +import okio.buffer +import okio.fakefilesystem.FakeFileSystem +import okio.use +import kotlin.random.Random +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ExtractUseCaseTest { + private val fileSystem = FakeFileSystem() + private val dbDir = "/data/databases".toPath() + private val downloads = "/downloads".toPath().also { fileSystem.createDirectories(it) } + private val appSettings = AppSettings(MapSettings()) + private val useCase = ExtractUseCase(appSettings, fileSystem) { dbDir } + + // Pseudo-random, so the bundle stays big enough to be split + private val database = Random(42).nextBytes(300_000) + private val segment = ByteArray(5_000) { (it % 7).toByte() } + + private fun bundle(vararg extra: Pair): ByteArray { + val tar = + TarWriter().apply { + entry("seforim.db.lucene/", type = '5') + entry("seforim.db.lucene/_0.cfs", segment) + entry("seforim.db", database) + extra.forEach { (name, content) -> entry(name, content) } + end() + } + val compressed = Buffer() + (compressed as okio.Sink).zstdCompress().buffer().use { it.writeAll(tar.buffer) } + return compressed.readByteArray() + } + + /** [bytes] split in parts of [partSize] in [dir], with their manifest when [withManifest]. */ + private fun writeParts( + bytes: ByteArray, + dir: Path, + partSize: Int, + withManifest: Boolean = true, + sha256: String = bytes.toByteString().sha256().hex(), + ): Path { + val chunks = bytes.toList().chunked(partSize).map { it.toByteArray() } + val names = chunks.indices.map { "${BundleParts.BUNDLE_NAME}.part${(it + 1).toString().padStart(2, '0')}" } + chunks.zip(names).forEach { (chunk, name) -> fileSystem.write(dir / name) { write(chunk) } } + if (withManifest) { + val parts = chunks.zip(names).joinToString { (chunk, name) -> """{ "file": "$name", "size": ${chunk.size}, "sha256": "x" }""" } + fileSystem.write(dir / BundleParts.manifestNameFor(BundleParts.BUNDLE_NAME)) { + writeUtf8( + """{ "formatVersion": 1, "file": "${BundleParts.BUNDLE_NAME}", "size": ${bytes.size}, "sha256": "$sha256", "parts": [$parts] }""", + ) + } + } + return dir / names.first() + } + + @Test + fun `a split bundle is extracted checked and its parts deleted`() = + runTest { + val bytes = bundle() + val part01 = writeParts(bytes, dbDir.also { fileSystem.createDirectories(it) }, partSize = bytes.size / 3 + 1) + val progress = mutableListOf() + + val dbPath = useCase.extractToDatabase(part01.toString()) { progress += it } + + assertEquals((dbDir / "seforim.db").toString(), dbPath) + assertEquals(dbPath, appSettings.getDatabasePath()) + assertContentEquals(database, fileSystem.read(dbDir / "seforim.db") { readByteArray() }) + assertContentEquals(segment, fileSystem.read(dbDir / "seforim.db.lucene" / "_0.cfs") { readByteArray() }) + assertEquals(1f, progress.last()) + assertTrue(progress.zipWithNext().all { (a, b) -> a <= b }) + // Downloaded by the app into its databases directory: the parts and their manifest go + assertFalse(fileSystem.list(dbDir).any { BundleParts.isPart(it.name) || BundleParts.isManifest(it.name) }) + } + + @Test + fun `parts picked elsewhere are kept`() = + runTest { + val part01 = writeParts(bundle(), downloads, partSize = 120_000, withManifest = false) + + useCase.extractToDatabase(part01.toString()) {} + + assertTrue(fileSystem.exists(part01)) + } + + @Test + fun `a bundle not matching its manifest's sha256 is refused`() = + runTest { + val part01 = writeParts(bundle(), downloads, partSize = 120_000, sha256 = "00".repeat(32)) + + assertFailsWith { useCase.extractToDatabase(part01.toString()) {} } + assertFalse(fileSystem.exists(dbDir / "seforim.db")) + } + + @Test + fun `an entry climbing out of the databases directory is refused`() = + runTest { + val archive = downloads / "evil.tar.zst" + fileSystem.write(archive) { write(bundle("../outside.txt" to "x".encodeToByteArray())) } + + assertFailsWith { useCase.extractToDatabase(archive.toString()) {} } + assertFalse(fileSystem.exists("/data/outside.txt".toPath())) + } + + @Test + fun `a compressed database alone is decompressed`() = + runTest { + val compressed = Buffer() + (compressed as okio.Sink).zstdCompress().buffer().use { it.write(database) } + val source = downloads / "seforim.db.zst" + fileSystem.write(source) { writeAll(compressed) } + + val dbPath = useCase.extractToDatabase(source.toString()) {} + + assertContentEquals(database, fileSystem.read(dbPath.toPath()) { readByteArray() }) + } +} diff --git a/appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/extract/TarReaderTest.kt b/appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/extract/TarReaderTest.kt new file mode 100644 index 000000000..872b256d0 --- /dev/null +++ b/appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/extract/TarReaderTest.kt @@ -0,0 +1,129 @@ +package io.github.kdroidfilter.seforimapp.features.onboarding.extract + +import okio.Buffer +import okio.IOException +import okio.buffer +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull + +class TarReaderTest { + private fun TarReader.readContent(): String = content.buffer().readUtf8() + + @Test + fun `files and directories in order with their content`() { + val tar = + TarWriter().apply { + entry("index/", type = '5') + entry("index/a.txt", "alpha".encodeToByteArray()) + entry("seforim.db", ByteArray(1000) { 7 }) + end() + } + val reader = TarReader(tar.buffer) + + val dir = reader.nextEntry()!! + assertEquals("index/", dir.name) + assertEquals(TarEntry.Type.DIRECTORY, dir.type) + val file = reader.nextEntry()!! + assertEquals("index/a.txt", file.name) + assertEquals(TarEntry.Type.FILE, file.type) + assertEquals("alpha", reader.readContent()) + val db = reader.nextEntry()!! + assertEquals(1000L, db.size) + assertEquals( + 1000L, + reader.content + .buffer() + .readByteArray() + .size + .toLong(), + ) + assertNull(reader.nextEntry()) + } + + @Test + fun `content left unread is skipped by the next entry`() { + val tar = + TarWriter().apply { + entry("big.bin", ByteArray(3000) { 1 }) + entry("next.txt", "after".encodeToByteArray()) + end() + } + val reader = TarReader(tar.buffer) + + reader.nextEntry() + assertEquals("next.txt", reader.nextEntry()!!.name) + assertEquals("after", reader.readContent()) + } + + @Test + fun `a POSIX extended header gives the next entry its path and size`() { + val longName = "seforim.db.lucene/" + "segment_".repeat(20) + "1.cfs" + val tar = + TarWriter().apply { + pax("path" to longName, "size" to "5") + entry(longName.take(100), "12345".encodeToByteArray()) + entry("short.txt", "x".encodeToByteArray()) + end() + } + val reader = TarReader(tar.buffer) + + val entry = reader.nextEntry()!! + assertEquals(longName, entry.name) + assertEquals(5L, entry.size) + assertEquals("12345", reader.readContent()) + // The records only apply to the entry right after them + assertEquals("short.txt", reader.nextEntry()!!.name) + } + + @Test + fun `a GNU long name and a ustar prefix make the full path`() { + val longName = "dir/" + "n".repeat(150) + val tar = + TarWriter().apply { + entry("././@LongLink", (longName + "\u0000").encodeToByteArray(), type = 'L') + entry(longName.take(100), "a".encodeToByteArray()) + entry("file.txt", "b".encodeToByteArray(), prefix = "some/prefix") + end() + } + val reader = TarReader(tar.buffer) + + assertEquals(longName, reader.nextEntry()!!.name) + assertEquals("some/prefix/file.txt", reader.nextEntry()!!.name) + } + + @Test + fun `a size in base-256 is read whole`() { + val size = ByteArray(12) + size[0] = 0x80.toByte() + size[11] = 4 + val tar = + TarWriter().apply { + entry("four.bin", byteArrayOf(1, 2, 3, 4), sizeField = size) + end() + } + val reader = TarReader(tar.buffer) + + assertEquals(4L, reader.nextEntry()!!.size) + } + + @Test + fun `an archive cut after its last entry ends cleanly`() { + val tar = TarWriter().apply { entry("only.txt", "z".encodeToByteArray()) } + val reader = TarReader(tar.buffer) + + reader.nextEntry() + assertNull(reader.nextEntry()) + } + + @Test + fun `a corrupted header is refused`() { + val tar = TarWriter().apply { entry("a.txt", "a".encodeToByteArray()) } + val bytes = tar.buffer.readByteArray() + bytes[10] = 'X'.code.toByte() + val reader = TarReader(Buffer().write(bytes)) + + assertFailsWith { reader.nextEntry() } + } +} diff --git a/appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/extract/TarWriter.kt b/appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/extract/TarWriter.kt new file mode 100644 index 000000000..e78a80938 --- /dev/null +++ b/appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/extract/TarWriter.kt @@ -0,0 +1,50 @@ +package io.github.kdroidfilter.seforimapp.features.onboarding.extract + +import okio.Buffer + +/** Writes tar archives block by block, as a packager would. */ +internal class TarWriter { + val buffer = Buffer() + + fun entry( + name: String, + content: ByteArray = ByteArray(0), + type: Char = '0', + prefix: String = "", + sizeField: ByteArray? = null, + ) { + val header = ByteArray(512) + name.encodeToByteArray().copyInto(header, 0) + octal(content.size.toLong(), 12).copyInto(header, 124) + sizeField?.copyInto(header, 124) + header[156] = type.code.toByte() + "ustar\u000000".encodeToByteArray().copyInto(header, 257) + prefix.encodeToByteArray().copyInto(header, 345) + " ".repeat(8).encodeToByteArray().copyInto(header, 148) + val checksum = header.sumOf { it.toLong() and 0xFF } + octal(checksum, 7).copyInto(header, 148) + buffer.write(header) + buffer.write(content) + buffer.write(ByteArray(((512 - content.size % 512) % 512))) + } + + fun pax(vararg records: Pair) { + val body = + records.joinToString("") { (key, value) -> + val rest = " $key=$value\n" + var length = rest.length + 1 + while ("$length$rest".encodeToByteArray().size != length) length++ + "$length$rest" + } + entry("PaxHeader", body.encodeToByteArray(), type = 'x') + } + + fun end() { + buffer.write(ByteArray(1024)) + } + + private fun octal( + value: Long, + digits: Int, + ): ByteArray = (value.toString(8).padStart(digits - 1, '0') + "\u0000").encodeToByteArray() +} diff --git a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/navigation/OnBoardingDestinationTest.kt b/appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/navigation/OnBoardingDestinationTest.kt similarity index 100% rename from SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/navigation/OnBoardingDestinationTest.kt rename to appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/navigation/OnBoardingDestinationTest.kt diff --git a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/navigation/ProgressBarStateTest.kt b/appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/navigation/ProgressBarStateTest.kt similarity index 100% rename from SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/navigation/ProgressBarStateTest.kt rename to appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/navigation/ProgressBarStateTest.kt diff --git a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/region/RegionConfigEventsTest.kt b/appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/region/RegionConfigEventsTest.kt similarity index 100% rename from SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/region/RegionConfigEventsTest.kt rename to appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/region/RegionConfigEventsTest.kt diff --git a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/region/RegionConfigStateTest.kt b/appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/region/RegionConfigStateTest.kt similarity index 100% rename from SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/region/RegionConfigStateTest.kt rename to appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/region/RegionConfigStateTest.kt diff --git a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/region/RegionConfigUseCaseTest.kt b/appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/region/RegionConfigUseCaseTest.kt similarity index 100% rename from SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/region/RegionConfigUseCaseTest.kt rename to appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/region/RegionConfigUseCaseTest.kt diff --git a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/typeofinstall/TypeOfInstallationEventsTest.kt b/appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/typeofinstall/TypeOfInstallationEventsTest.kt similarity index 100% rename from SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/typeofinstall/TypeOfInstallationEventsTest.kt rename to appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/typeofinstall/TypeOfInstallationEventsTest.kt diff --git a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/typeofinstall/TypeOfInstallationStateTest.kt b/appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/typeofinstall/TypeOfInstallationStateTest.kt similarity index 100% rename from SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/typeofinstall/TypeOfInstallationStateTest.kt rename to appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/typeofinstall/TypeOfInstallationStateTest.kt diff --git a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/userprofile/CommunityTest.kt b/appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/userprofile/CommunityTest.kt similarity index 100% rename from SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/userprofile/CommunityTest.kt rename to appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/userprofile/CommunityTest.kt diff --git a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/userprofile/UserProfileEventsTest.kt b/appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/userprofile/UserProfileEventsTest.kt similarity index 100% rename from SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/userprofile/UserProfileEventsTest.kt rename to appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/userprofile/UserProfileEventsTest.kt diff --git a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/userprofile/UserProfileStateTest.kt b/appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/userprofile/UserProfileStateTest.kt similarity index 100% rename from SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/userprofile/UserProfileStateTest.kt rename to appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/userprofile/UserProfileStateTest.kt diff --git a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/userprofile/UserProfileUseCaseTest.kt b/appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/userprofile/UserProfileUseCaseTest.kt similarity index 100% rename from SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/userprofile/UserProfileUseCaseTest.kt rename to appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/userprofile/UserProfileUseCaseTest.kt diff --git a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/settings/SettingsWindowEventsTest.kt b/appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/settings/SettingsWindowEventsTest.kt similarity index 92% rename from SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/settings/SettingsWindowEventsTest.kt rename to appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/settings/SettingsWindowEventsTest.kt index 4e2eecf1b..e8a01e9bd 100644 --- a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/settings/SettingsWindowEventsTest.kt +++ b/appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/settings/SettingsWindowEventsTest.kt @@ -3,6 +3,7 @@ package io.github.kdroidfilter.seforimapp.features.settings import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertIs +import kotlin.test.assertTrue class SettingsWindowEventsTest { @Test @@ -25,6 +26,6 @@ class SettingsWindowEventsTest { fun `OnOpen and OnClose are different`() { val open = SettingsWindowEvents.OnOpen val close = SettingsWindowEvents.OnClose - assert(open != close) + assertTrue(open != close) } } diff --git a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/settings/SettingsWindowStateTest.kt b/appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/settings/SettingsWindowStateTest.kt similarity index 100% rename from SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/settings/SettingsWindowStateTest.kt rename to appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/settings/SettingsWindowStateTest.kt diff --git a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/settings/fonts/FontsSettingsEventsTest.kt b/appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/settings/fonts/FontsSettingsEventsTest.kt similarity index 100% rename from SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/settings/fonts/FontsSettingsEventsTest.kt rename to appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/settings/fonts/FontsSettingsEventsTest.kt diff --git a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/settings/fonts/FontsSettingsStateTest.kt b/appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/settings/fonts/FontsSettingsStateTest.kt similarity index 89% rename from SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/settings/fonts/FontsSettingsStateTest.kt rename to appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/settings/fonts/FontsSettingsStateTest.kt index d975bddc4..d418d5d61 100644 --- a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/settings/fonts/FontsSettingsStateTest.kt +++ b/appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/settings/fonts/FontsSettingsStateTest.kt @@ -3,6 +3,7 @@ package io.github.kdroidfilter.seforimapp.features.settings.fonts import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull +import kotlin.test.assertTrue class FontsSettingsStateTest { @Test @@ -59,16 +60,16 @@ class FontsSettingsStateTest { val state3 = FontsSettingsState(bookFontCode = "font2") assertEquals(state1, state2) - assert(state1 != state3) + assertTrue(state1 != state3) } @Test fun `default fonts are non-empty strings`() { val state = FontsSettingsState() - assert(state.bookFontCode.isNotEmpty()) - assert(state.commentaryFontCode.isNotEmpty()) - assert(state.targumFontCode.isNotEmpty()) - assert(state.sourceFontCode.isNotEmpty()) + assertTrue(state.bookFontCode.isNotEmpty()) + assertTrue(state.commentaryFontCode.isNotEmpty()) + assertTrue(state.targumFontCode.isNotEmpty()) + assertTrue(state.sourceFontCode.isNotEmpty()) } } diff --git a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/settings/general/GeneralSettingsEventsTest.kt b/appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/settings/general/GeneralSettingsEventsTest.kt similarity index 100% rename from SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/settings/general/GeneralSettingsEventsTest.kt rename to appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/settings/general/GeneralSettingsEventsTest.kt diff --git a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/settings/general/GeneralSettingsStateTest.kt b/appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/settings/general/GeneralSettingsStateTest.kt similarity index 100% rename from SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/settings/general/GeneralSettingsStateTest.kt rename to appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/settings/general/GeneralSettingsStateTest.kt diff --git a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/settings/navigation/SettingsDestinationTest.kt b/appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/settings/navigation/SettingsDestinationTest.kt similarity index 100% rename from SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/settings/navigation/SettingsDestinationTest.kt rename to appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/settings/navigation/SettingsDestinationTest.kt diff --git a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/framework/session/SessionModelsTest.kt b/appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/framework/session/SessionModelsTest.kt similarity index 97% rename from SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/framework/session/SessionModelsTest.kt rename to appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/framework/session/SessionModelsTest.kt index a650d9fc6..6e14579e9 100644 --- a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/framework/session/SessionModelsTest.kt +++ b/appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/framework/session/SessionModelsTest.kt @@ -151,7 +151,7 @@ class SessionModelsTest { @OptIn(ExperimentalSerializationApi::class) @Test - fun `a tab title saved before pinning reads back unpinned, and a pin round-trips`() { + fun `a tab title saved before pinning reads back unpinned and a pin round-trips`() { val legacy = ProtoBuf.encodeToByteArray(LegacyTabTitle("Book", TabType.BOOK)) assertEquals(SerializableTabTitle("Book", TabType.BOOK), ProtoBuf.decodeFromByteArray(legacy)) @@ -171,7 +171,7 @@ class SessionModelsTest { } @Test - fun `a search scoped to an author keeps it, and a book scope drops it`() { + fun `a search scoped to an author keeps it and a book scope drops it`() { val author = SearchPersistedState().withScope(SearchScope.Author(150L)) assertEquals(SearchScope.Author(150L), ProtoBuf.decodeFromByteArray(ProtoBuf.encodeToByteArray(author)).scope) assertEquals(SearchScope.Book(3L), author.withScope(SearchScope.Book(3L)).scope) diff --git a/appkit/src/iosMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/quotelinks/InstalledQuoteLinks.ios.kt b/appkit/src/iosMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/quotelinks/InstalledQuoteLinks.ios.kt new file mode 100644 index 000000000..4664c2f4b --- /dev/null +++ b/appkit/src/iosMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/quotelinks/InstalledQuoteLinks.ios.kt @@ -0,0 +1,4 @@ +package io.github.kdroidfilter.seforimapp.features.bookcontent.quotelinks + +// The quote links package is JVM only +actual val installedQuoteLinks: QuoteLinksFeature? = null diff --git a/appkit/src/iosMain/kotlin/io/github/kdroidfilter/seforimapp/framework/database/AppDatabases.ios.kt b/appkit/src/iosMain/kotlin/io/github/kdroidfilter/seforimapp/framework/database/AppDatabases.ios.kt new file mode 100644 index 000000000..44f1e7313 --- /dev/null +++ b/appkit/src/iosMain/kotlin/io/github/kdroidfilter/seforimapp/framework/database/AppDatabases.ios.kt @@ -0,0 +1,27 @@ +package io.github.kdroidfilter.seforimapp.framework.database + +import app.cash.sqldelight.db.SqlDriver +import app.cash.sqldelight.driver.native.NativeSqliteDriver +import co.touchlab.sqliter.DatabaseConfiguration +import io.github.kdroidfilter.seforimapp.framework.search.openSeforimRepository +import io.github.kdroidfilter.seforimlibrary.dao.repository.SeforimRepository + +/** iOS's connections. */ +actual class AppDatabases( + private val userSettingsPath: String = defaultUserSettingsDatabasePath(), +) { + actual fun openSeforimRepository(databasePath: String): SeforimRepository = openSeforimRepository(databasePath) + + // The caller creates the user tables on each open (CREATE TABLE IF NOT EXISTS), as on the desktop; SQLiter waits + // for a concurrent writer (its busy timeout) + actual fun openUserSettings(): SqlDriver = + NativeSqliteDriver( + DatabaseConfiguration( + name = userSettingsPath.substringAfterLast('/'), + version = 1, + create = {}, + upgrade = { _, _, _ -> }, + extendedConfig = DatabaseConfiguration.Extended(basePath = userSettingsPath.substringBeforeLast('/')), + ), + ) +} diff --git a/appkit/src/iosMain/kotlin/io/github/kdroidfilter/seforimapp/framework/platform/HostPlatform.ios.kt b/appkit/src/iosMain/kotlin/io/github/kdroidfilter/seforimapp/framework/platform/HostPlatform.ios.kt new file mode 100644 index 000000000..1a8099282 --- /dev/null +++ b/appkit/src/iosMain/kotlin/io/github/kdroidfilter/seforimapp/framework/platform/HostPlatform.ios.kt @@ -0,0 +1,12 @@ +package io.github.kdroidfilter.seforimapp.framework.platform + +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.toKString +import platform.posix.getenv + +actual val isLinuxDesktop: Boolean = false + +actual val isMacOsDesktop: Boolean = false + +@OptIn(ExperimentalForeignApi::class) +actual fun environmentVariable(name: String): String? = getenv(name)?.toKString() diff --git a/appkit/src/iosSimulatorArm64Test/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/extract/IosExtractBundleTest.kt b/appkit/src/iosSimulatorArm64Test/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/extract/IosExtractBundleTest.kt new file mode 100644 index 000000000..82f8bba8a --- /dev/null +++ b/appkit/src/iosSimulatorArm64Test/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/extract/IosExtractBundleTest.kt @@ -0,0 +1,53 @@ +package io.github.kdroidfilter.seforimapp.features.onboarding.extract + +import com.russhwolf.settings.MapSettings +import io.github.kdroidfilter.seforimapp.core.settings.AppSettings +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.toKString +import kotlinx.coroutines.test.runTest +import okio.FileSystem +import okio.HashingSource +import okio.Path.Companion.toPath +import okio.blackholeSink +import okio.buffer +import okio.use +import platform.Foundation.NSTemporaryDirectory +import platform.posix.getenv +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.Duration.Companion.minutes +import kotlin.time.TimeSource + +/** + * A real database bundle extracted on the iOS simulator's file system, as the onboarding does: `-PseforimTestBundle=` + * a `.tar.zst` or its `.part01`, with the extracted database's expected sha256 in `-PseforimTestBundleSha256=`. + * Skipped without them. + */ +class IosExtractBundleTest { + @OptIn(ExperimentalForeignApi::class) + private fun env(name: String): String? = getenv(name)?.toKString()?.takeIf { it.isNotBlank() } + + @Test + fun extractsARealBundleOnTheFileSystem() = + runTest(timeout = 10.minutes) { + val bundle = env("SEFORIM_TEST_BUNDLE") ?: return@runTest println("IosExtractBundleTest: no bundle, skipped") + val expectedSha256 = env("SEFORIM_TEST_BUNDLE_SHA256") + val fileSystem = FileSystem.SYSTEM + val dbDir = (NSTemporaryDirectory() + "extract-test").toPath() + fileSystem.deleteRecursively(dbDir, mustExist = false) + + val start = TimeSource.Monotonic.markNow() + val dbPath = ExtractUseCase(AppSettings(MapSettings()), fileSystem) { dbDir }.extractToDatabase(bundle) {} + val elapsed = start.elapsedNow() + + val size = fileSystem.metadata(dbPath.toPath()).size + val sha256 = + HashingSource.sha256(fileSystem.source(dbPath.toPath())).use { hashing -> + hashing.buffer().readAll(blackholeSink()) + hashing.hash.hex() + } + println("IosExtractBundleTest: $dbPath, $size bytes, sha256 $sha256, extracted in $elapsed") + expectedSha256?.let { assertEquals(it, sha256) } + fileSystem.deleteRecursively(dbDir) + } +} diff --git a/appkit/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/database/AppDatabases.jvm.kt b/appkit/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/database/AppDatabases.jvm.kt new file mode 100644 index 000000000..e45589e6b --- /dev/null +++ b/appkit/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/database/AppDatabases.jvm.kt @@ -0,0 +1,29 @@ +package io.github.kdroidfilter.seforimapp.framework.database + +import app.cash.sqldelight.db.SqlDriver +import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver +import io.github.kdroidfilter.seforimlibrary.dao.repository.SeforimRepository +import java.util.Properties + +/** The desktop's connections; [userSettingsPath] is the user database's (elsewhere in the end-to-end harness). */ +actual class AppDatabases( + private val userSettingsPath: String = defaultUserSettingsDatabasePath(), +) { + // Persistent single-connection driver with prepared-statement cache + read-tuning PRAGMAs. Replaces + // `JdbcSqliteDriver` whose ThreadedConnectionManager closes the SQLite connection after every non-transactional + // query (confirmed by JFR 2026-04-23: ~70 `NativeDB.prepare_utf8` + `NativeDB._close()` pairs / 20 s). + actual fun openSeforimRepository(databasePath: String): SeforimRepository = + SeforimRepository(databasePath, PersistentSqliteDriver("jdbc:sqlite:$databasePath")) + + // Separate from the read-only books DB. Waits for a concurrent writer instead of failing: the backup snapshot + // (VACUUM INTO) runs on its own thread, hence its own connection, while the stores keep writing. + actual fun openUserSettings(): SqlDriver = + JdbcSqliteDriver( + "jdbc:sqlite:$userSettingsPath", + Properties().apply { setProperty("busy_timeout", USER_DB_BUSY_TIMEOUT_MS.toString()) }, + ) + + private companion object { + const val USER_DB_BUSY_TIMEOUT_MS = 5_000 + } +} diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/database/PersistentSqliteDriver.kt b/appkit/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/database/PersistentSqliteDriver.kt similarity index 100% rename from SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/database/PersistentSqliteDriver.kt rename to appkit/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/database/PersistentSqliteDriver.kt diff --git a/appkit/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/platform/HostPlatform.jvm.kt b/appkit/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/platform/HostPlatform.jvm.kt new file mode 100644 index 000000000..306679372 --- /dev/null +++ b/appkit/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/platform/HostPlatform.jvm.kt @@ -0,0 +1,9 @@ +package io.github.kdroidfilter.seforimapp.framework.platform + +private val osName = System.getProperty("os.name").orEmpty() + +actual val isLinuxDesktop: Boolean = osName.startsWith("Linux", ignoreCase = true) + +actual val isMacOsDesktop: Boolean = osName.startsWith("Mac", ignoreCase = true) + +actual fun environmentVariable(name: String): String? = System.getenv(name) diff --git a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/core/catalog/CatalogAccessTest.kt b/appkit/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/core/catalog/CatalogAccessTest.kt similarity index 100% rename from SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/core/catalog/CatalogAccessTest.kt rename to appkit/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/core/catalog/CatalogAccessTest.kt diff --git a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/core/history/HistoryStoreTest.kt b/appkit/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/core/history/HistoryStoreTest.kt similarity index 100% rename from SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/core/history/HistoryStoreTest.kt rename to appkit/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/core/history/HistoryStoreTest.kt diff --git a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/theme/IntUiThemesTest.kt b/appkit/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/theme/IntUiThemesTest.kt similarity index 100% rename from SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/theme/IntUiThemesTest.kt rename to appkit/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/theme/IntUiThemesTest.kt diff --git a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/core/settings/SettingTest.kt b/appkit/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/core/settings/SettingTest.kt similarity index 100% rename from SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/core/settings/SettingTest.kt rename to appkit/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/core/settings/SettingTest.kt diff --git a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/core/shnayimmikra/ShnayimMikraStoreTest.kt b/appkit/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/core/shnayimmikra/ShnayimMikraStoreTest.kt similarity index 100% rename from SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/core/shnayimmikra/ShnayimMikraStoreTest.kt rename to appkit/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/core/shnayimmikra/ShnayimMikraStoreTest.kt diff --git a/appkit/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/extract/ExtractPackagerBundleTest.kt b/appkit/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/extract/ExtractPackagerBundleTest.kt new file mode 100644 index 000000000..1b92a9b6e --- /dev/null +++ b/appkit/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/extract/ExtractPackagerBundleTest.kt @@ -0,0 +1,57 @@ +package io.github.kdroidfilter.seforimapp.features.onboarding.extract + +import com.github.luben.zstd.ZstdOutputStream +import com.russhwolf.settings.MapSettings +import io.github.kdroidfilter.seforimapp.core.settings.AppSettings +import kotlinx.coroutines.test.runTest +import okio.Path.Companion.toOkioPath +import org.apache.commons.compress.archivers.tar.TarArchiveEntry +import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream +import java.io.File +import java.nio.file.Files +import kotlin.random.Random +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertContentEquals + +/** A bundle written as SeforimLibrary's packager does (zstd-jni with its checksum and workers, POSIX tar). */ +class ExtractPackagerBundleTest { + private val dir = Files.createTempDirectory("packager-bundle").toFile() + + @AfterTest + fun cleanup() { + dir.deleteRecursively() + } + + @Test + fun `extracts a bundle written by the packager`() = + runTest { + val database = Random(7).nextBytes(2_000_000) + val longName = "seforim.db.lucene/" + "b".repeat(130) + ".cfs" + val segment = Random(8).nextBytes(10_000) + val bundle = File(dir, BundleParts.BUNDLE_NAME) + ZstdOutputStream(bundle.outputStream(), 3).use { zstd -> + zstd.setChecksum(true) + zstd.setWorkers(2) + TarArchiveOutputStream(zstd).use { tar -> + tar.setLongFileMode(TarArchiveOutputStream.LONGFILE_POSIX) + tar.setBigNumberMode(TarArchiveOutputStream.BIGNUMBER_POSIX) + tar.putArchiveEntry(TarArchiveEntry("seforim.db.lucene/")) + tar.closeArchiveEntry() + listOf(longName to segment, "seforim.db" to database).forEach { (name, content) -> + tar.putArchiveEntry(TarArchiveEntry(name).apply { size = content.size.toLong() }) + tar.write(content) + tar.closeArchiveEntry() + } + tar.finish() + } + } + val dbDir = File(dir, "databases") + val useCase = ExtractUseCase(AppSettings(MapSettings())) { dbDir.toOkioPath() } + + val dbPath = useCase.extractToDatabase(bundle.path) {} + + assertContentEquals(database, File(dbPath).readBytes()) + assertContentEquals(segment, File(dbDir, longName).readBytes()) + } +} diff --git a/appkit/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/extract/TarReaderCompatTest.kt b/appkit/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/extract/TarReaderCompatTest.kt new file mode 100644 index 000000000..8b88002bc --- /dev/null +++ b/appkit/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/extract/TarReaderCompatTest.kt @@ -0,0 +1,52 @@ +package io.github.kdroidfilter.seforimapp.features.onboarding.extract + +import okio.Buffer +import okio.buffer +import org.apache.commons.compress.archivers.tar.TarArchiveEntry +import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream +import java.io.ByteArrayOutputStream +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** The archives commons-compress writes as the database bundle's packager does (POSIX long names and sizes). */ +class TarReaderCompatTest { + @Test + fun `reads what the bundle packager writes`() { + val longName = "seforim.db.lucene/" + "a".repeat(120) + ".cfs" + val files = linkedMapOf("seforim.db" to ByteArray(70_000) { (it % 251).toByte() }, longName to "long".toByteArray()) + val bytes = + ByteArrayOutputStream().also { out -> + TarArchiveOutputStream(out).use { tar -> + tar.setLongFileMode(TarArchiveOutputStream.LONGFILE_POSIX) + tar.setBigNumberMode(TarArchiveOutputStream.BIGNUMBER_POSIX) + tar.putArchiveEntry(TarArchiveEntry("seforim.db.lucene/")) + tar.closeArchiveEntry() + files.forEach { (name, content) -> + tar.putArchiveEntry(TarArchiveEntry(name).apply { size = content.size.toLong() }) + tar.write(content) + tar.closeArchiveEntry() + } + tar.finish() + } + } + val reader = TarReader(Buffer().write(bytes.toByteArray())) + + val dir = reader.nextEntry()!! + assertEquals("seforim.db.lucene/", dir.name) + assertEquals(TarEntry.Type.DIRECTORY, dir.type) + files.forEach { (name, content) -> + val entry = reader.nextEntry()!! + assertEquals(name, entry.name) + assertEquals(TarEntry.Type.FILE, entry.type) + assertEquals( + content.toList(), + reader.content + .buffer() + .readByteArray() + .toList(), + ) + } + assertNull(reader.nextEntry()) + } +} diff --git a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/zmanim/data/CitiesTest.kt b/appkit/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/zmanim/data/CitiesTest.kt similarity index 100% rename from SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/zmanim/data/CitiesTest.kt rename to appkit/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/zmanim/data/CitiesTest.kt diff --git a/cataloggen/build.gradle.kts b/cataloggen/build.gradle.kts index 334da9db8..bbe2715ca 100644 --- a/cataloggen/build.gradle.kts +++ b/cataloggen/build.gradle.kts @@ -27,7 +27,7 @@ val defaultDbPath = .absolutePath val defaultOutputDir = rootProject.layout.projectDirectory - .dir("SeforimApp/src/commonMain/kotlin") + .dir("appkit/src/commonMain/kotlin") .asFile .absolutePath diff --git a/earthwidget/build.gradle.kts b/earthwidget/build.gradle.kts index aa4163752..2bd3a39f0 100644 --- a/earthwidget/build.gradle.kts +++ b/earthwidget/build.gradle.kts @@ -2,6 +2,7 @@ import io.github.kdroidfilter.buildsrc.Versioning plugins { alias(libs.plugins.multiplatform) + alias(libs.plugins.android.kmp.library) alias(libs.plugins.compose.compiler) alias(libs.plugins.compose) } @@ -16,6 +17,15 @@ kotlin { ) jvm() + // Filament KMP renders on Android and iOS too: the 3D widgets of the mobile apps + android { + namespace = "io.github.kdroidfilter.seforimapp.earthwidget" + compileSdk = 37 + minSdk = 29 + androidResources { enable = true } + } + iosArm64() + iosSimulatorArm64() sourceSets { commonMain.dependencies { @@ -24,6 +34,8 @@ kotlin { implementation(libs.compose.foundation) implementation(libs.compose.components.resources) implementation(libs.filament.compose) + implementation(libs.kotlinx.datetime) + implementation(libs.kosherkotlin) } jvmMain.dependencies { @@ -32,7 +44,6 @@ kotlin { implementation(compose.desktop.currentOs) { exclude(group = "org.jetbrains.compose.material") } - implementation(libs.kosherkotlin) } jvmTest.dependencies { diff --git a/earthwidget/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/earthwidget/AngleMath.kt b/earthwidget/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/earthwidget/AngleMath.kt new file mode 100644 index 000000000..69fe8f59a --- /dev/null +++ b/earthwidget/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/earthwidget/AngleMath.kt @@ -0,0 +1,8 @@ +package io.github.kdroidfilter.seforimapp.earthwidget + +import kotlin.math.PI + +// As java.lang.Math's, which has no common counterpart +internal fun toRadians(degrees: Double): Double = degrees * (PI / 180.0) + +internal fun toDegrees(radians: Double): Double = radians * (180.0 / PI) diff --git a/earthwidget/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/earthwidget/EarthEphemeris.kt b/earthwidget/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/earthwidget/EarthEphemeris.kt index e0693720d..ab8ed1d09 100644 --- a/earthwidget/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/earthwidget/EarthEphemeris.kt +++ b/earthwidget/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/earthwidget/EarthEphemeris.kt @@ -127,8 +127,8 @@ internal fun computeMoonHorizontalPosition( ) return HorizontalPosition( - azimuthFromNorthDeg = normalizeAngleDeg(Math.toDegrees(azimuthRad)), - elevationDeg = Math.toDegrees(elevationRad), + azimuthFromNorthDeg = normalizeAngleDeg(toDegrees(azimuthRad)), + elevationDeg = toDegrees(elevationRad), ) } @@ -187,7 +187,7 @@ internal fun computeMoonLightFromPhaseInternal( val viewDir = Vec3f(viewDirX, viewDirY, viewDirZ).normalized() val normalizedPhase = ((phaseAngleDegrees % 360f) + 360f) % 360f val thetaDegrees = 180f - normalizedPhase - val thetaRad = Math.toRadians(thetaDegrees.toDouble()) + val thetaRad = toRadians(thetaDegrees.toDouble()) var right = cross(Vec3f.WORLD_UP, viewDir) if (right.length() <= EPSILON) { @@ -202,8 +202,8 @@ internal fun computeMoonLightFromPhaseInternal( val sunDir = rotateTowards(viewDir, up, cosT, sinT) return LightDirection( - lightDegrees = Math.toDegrees(atan2(sunDir.x.toDouble(), sunDir.z.toDouble())).toFloat(), - sunElevationDegrees = Math.toDegrees(asin(sunDir.y.toDouble().coerceIn(-1.0, 1.0))).toFloat(), + lightDegrees = toDegrees(atan2(sunDir.x.toDouble(), sunDir.z.toDouble())).toFloat(), + sunElevationDegrees = toDegrees(asin(sunDir.y.toDouble().coerceIn(-1.0, 1.0))).toFloat(), ) } @@ -226,7 +226,7 @@ internal fun computeMoonLightFromPhaseWithObserverUp( val normalizedPhase = ((phaseAngleDegrees % 360f) + 360f) % 360f val thetaDegrees = 180f - normalizedPhase - val thetaRad = Math.toRadians(thetaDegrees.toDouble()) + val thetaRad = toRadians(thetaDegrees.toDouble()) val baseUp = projectOntoViewPlane(viewDir, observerUp) @@ -266,7 +266,7 @@ internal fun computeMoonLightFromPhaseWithObserverUp( val sunDir = rotateTowards(viewDir, planeUp, cosT, sinT) return LightDirection( - lightDegrees = Math.toDegrees(atan2(sunDir.x.toDouble(), sunDir.z.toDouble())).toFloat(), - sunElevationDegrees = Math.toDegrees(asin(sunDir.y.toDouble().coerceIn(-1.0, 1.0))).toFloat(), + lightDegrees = toDegrees(atan2(sunDir.x.toDouble(), sunDir.z.toDouble())).toFloat(), + sunElevationDegrees = toDegrees(asin(sunDir.y.toDouble().coerceIn(-1.0, 1.0))).toFloat(), ) } diff --git a/earthwidget/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/earthwidget/EarthWidgetView.kt b/earthwidget/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/earthwidget/EarthWidgetView.kt index c93f1369a..8e4583365 100644 --- a/earthwidget/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/earthwidget/EarthWidgetView.kt +++ b/earthwidget/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/earthwidget/EarthWidgetView.kt @@ -47,6 +47,7 @@ import kotlinx.coroutines.delay import kotlin.math.PI import kotlin.math.roundToInt import kotlin.math.sqrt +import kotlin.time.Clock // ============================================================================ // SHARED ANIMATION SPECS @@ -159,7 +160,7 @@ fun EarthWidgetScene( val animatedViewYaw by animateFloatAsState(viewYawDegrees, viewSpec, label = "viewYaw") val animatedViewPitch by animateFloatAsState(viewPitchDegrees, viewSpec, label = "viewPitch") val animatedViewZoom by animateFloatAsState(viewZoom, viewSpec, label = "viewZoom") - val skyJulianDay = julianDay ?: (System.currentTimeMillis() / 86_400_000.0 + 2_440_587.5) + val skyJulianDay = julianDay ?: (Clock.System.now().toEpochMilliseconds() / 86_400_000.0 + 2_440_587.5) val animatedSidereal = rememberSmoothAnimatedAngle( targetValue = (greenwichMeanSiderealTimeRad(skyJulianDay) * 180.0 / PI).toFloat(), diff --git a/earthwidget/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/earthwidget/SkyAtmosphere.kt b/earthwidget/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/earthwidget/SkyAtmosphere.kt index c73c9e5fc..3d1a3c409 100644 --- a/earthwidget/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/earthwidget/SkyAtmosphere.kt +++ b/earthwidget/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/earthwidget/SkyAtmosphere.kt @@ -148,7 +148,7 @@ internal fun twilightGlow( sx: Double, sz: Double, ): DoubleArray { - val el = Math.toDegrees(kotlin.math.asin(sunY.coerceIn(-1.0, 1.0))) + val el = toDegrees(kotlin.math.asin(sunY.coerceIn(-1.0, 1.0))) if (el > 4.0 || el < -24.0) return DoubleArray(3) val fade = ((4.0 - el) / 6.0).coerceIn(0.0, 1.0) val level = 2.0e-4 * exp(0.855 * (minOf(el, 0.0) + 6.0)) * fade @@ -225,7 +225,7 @@ internal fun scotopic(zenith: Double): Double { /** Atmospheric refraction (Saemundsson), degrees, at apparent-ish elevation [h] degrees. */ internal fun refractionDeg(h: Double): Double { val e = max(h, -1.5) - return 1.02 / tan(Math.toRadians(e + 10.3 / (e + 5.11))) / 60.0 + return 1.02 / tan(toRadians(e + 10.3 / (e + 5.11))) / 60.0 } /** Air mass (Rozenberg) toward elevation sine [sinAlt]. */ diff --git a/earthwidget/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/earthwidget/SolarPositionCalculator.kt b/earthwidget/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/earthwidget/SolarPositionCalculator.kt index 7b4bc8174..bcba37642 100644 --- a/earthwidget/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/earthwidget/SolarPositionCalculator.kt +++ b/earthwidget/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/earthwidget/SolarPositionCalculator.kt @@ -1,8 +1,8 @@ package io.github.kdroidfilter.seforimapp.earthwidget -import java.util.Calendar -import java.util.Date -import java.util.TimeZone +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime import kotlin.math.acos import kotlin.math.asin import kotlin.math.atan2 @@ -10,6 +10,7 @@ import kotlin.math.cos import kotlin.math.floor import kotlin.math.sin import kotlin.math.tan +import kotlin.time.Instant /** * Solar azimuth/elevation at a specific location. @@ -30,7 +31,7 @@ internal data class SolarPosition( * - `sunElevationDegrees`: elevation above the XZ plane (positive = +Y). */ internal fun computeSunLightDirectionForEarth( - referenceTime: Date, + referenceTime: Instant, latitude: Double, longitude: Double, earthRotationDegrees: Float, @@ -58,7 +59,7 @@ internal fun computeSunLightDirectionForEarth( * This avoids discontinuities caused by mixing local calendar fields with an algorithm that expects UTC. */ internal fun computeSolarPositionNoaaUtc( - referenceTime: Date, + referenceTime: Instant, latitude: Double, longitude: Double, ): SolarPosition { @@ -77,7 +78,7 @@ internal fun computeSolarPositionNoaaUtc( 0.016708634 - julianCenturies * (0.000042037 + 0.0000001267 * julianCenturies) - val meanAnomalyRad = Math.toRadians(meanAnomaly) + val meanAnomalyRad = toRadians(meanAnomaly) val sinM = sin(meanAnomalyRad) val sin2M = sin(2.0 * meanAnomalyRad) val sin3M = sin(3.0 * meanAnomalyRad) @@ -89,7 +90,7 @@ internal fun computeSolarPositionNoaaUtc( val trueLongitude = meanLongitude + equationOfCenter val omega = 125.04 - 1934.136 * julianCenturies - val apparentLongitude = trueLongitude - 0.00569 - 0.00478 * sin(Math.toRadians(omega)) + val apparentLongitude = trueLongitude - 0.00569 - 0.00478 * sin(toRadians(omega)) val meanObliquity = 23.0 + ( @@ -97,10 +98,10 @@ internal fun computeSolarPositionNoaaUtc( (21.448 - julianCenturies * (46.815 + julianCenturies * (0.00059 - julianCenturies * 0.001813))) / 60.0 ) ) / 60.0 - val obliquityCorrection = meanObliquity + 0.00256 * cos(Math.toRadians(omega)) + val obliquityCorrection = meanObliquity + 0.00256 * cos(toRadians(omega)) - val obliquityRad = Math.toRadians(obliquityCorrection) - val apparentLongitudeRad = Math.toRadians(apparentLongitude) + val obliquityRad = toRadians(obliquityCorrection) + val apparentLongitudeRad = toRadians(apparentLongitude) val sinDeclination = (sin(obliquityRad) * sin(apparentLongitudeRad)).coerceIn(-1.0, 1.0) val declinationRad = asin(sinDeclination) @@ -108,11 +109,11 @@ internal fun computeSolarPositionNoaaUtc( val y = tan(obliquityRad / 2.0) val y2 = y * y - val meanLongitudeRad = Math.toRadians(meanLongitude) + val meanLongitudeRad = toRadians(meanLongitude) val equationOfTimeMinutes = 4.0 * - Math.toDegrees( + toDegrees( y2 * sin(2.0 * meanLongitudeRad) - 2.0 * earthOrbitEccentricity * sinM + 4.0 * earthOrbitEccentricity * y2 * sinM * cos(2.0 * meanLongitudeRad) - @@ -126,21 +127,21 @@ internal fun computeSolarPositionNoaaUtc( var hourAngleDegrees = trueSolarTimeMinutes / 4.0 - 180.0 if (hourAngleDegrees < -180.0) hourAngleDegrees += 360.0 - val hourAngleRad = Math.toRadians(hourAngleDegrees) - val latRad = Math.toRadians(latitude) + val hourAngleRad = toRadians(hourAngleDegrees) + val latRad = toRadians(latitude) val sinLat = sin(latRad) val cosLat = cos(latRad) val cosZenith = (sinLat * sinDeclination + cosLat * cosDeclination * cos(hourAngleRad)).coerceIn(-1.0, 1.0) val zenithRad = acos(cosZenith) - val elevationDegrees = 90.0 - Math.toDegrees(zenithRad) + val elevationDegrees = 90.0 - toDegrees(zenithRad) val azimuthRad = atan2( sin(hourAngleRad), cos(hourAngleRad) * sinLat - tan(declinationRad) * cosLat, ) - val azimuthDegreesFromNorth = normalizeDegrees360(Math.toDegrees(azimuthRad) + 180.0) + val azimuthDegreesFromNorth = normalizeDegrees360(toDegrees(azimuthRad) + 180.0) return SolarPosition( azimuthDegreesFromNorth = azimuthDegreesFromNorth, @@ -149,17 +150,17 @@ internal fun computeSolarPositionNoaaUtc( } /** - * Converts a [Date] instant to Julian Day number (UTC), including fractional day. + * Converts an instant to Julian Day number (UTC), including fractional day. */ -internal fun computeJulianDayUtc(date: Date): Double { - val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC")).apply { time = date } - var year = cal.get(Calendar.YEAR) - var month = cal.get(Calendar.MONTH) + 1 - val day = cal.get(Calendar.DAY_OF_MONTH) - val hour = cal.get(Calendar.HOUR_OF_DAY) - val minute = cal.get(Calendar.MINUTE) - val second = cal.get(Calendar.SECOND) - val millisecond = cal.get(Calendar.MILLISECOND) +internal fun computeJulianDayUtc(instant: Instant): Double { + val utc = instant.utc() + var year = utc.year + var month = utc.month.ordinal + 1 + val day = utc.day + val hour = utc.hour + val minute = utc.minute + val second = utc.second + val millisecond = utc.nanosecond / 1_000_000 if (month <= 2) { year -= 1 @@ -185,15 +186,13 @@ internal fun computeJulianDayUtc(date: Date): Double { 1524.5 } -private fun utcMinutesOfDay(date: Date): Double { - val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC")).apply { time = date } - val hour = cal.get(Calendar.HOUR_OF_DAY) - val minute = cal.get(Calendar.MINUTE) - val second = cal.get(Calendar.SECOND) - val millisecond = cal.get(Calendar.MILLISECOND) - return hour * 60.0 + minute + second / 60.0 + millisecond / 60_000.0 +private fun utcMinutesOfDay(instant: Instant): Double { + val utc = instant.utc() + return utc.hour * 60.0 + utc.minute + utc.second / 60.0 + (utc.nanosecond / 1_000_000) / 60_000.0 } +private fun Instant.utc(): LocalDateTime = toLocalDateTime(TimeZone.UTC) + private fun normalizeDegrees360(degrees: Double): Double { val mod = degrees % 360.0 return if (mod < 0.0) mod + 360.0 else mod @@ -215,10 +214,10 @@ private fun computeSunDirectionWorld( earthRotationDegrees: Float, earthTiltDegrees: Float, ): LightDirection { - val latRad = Math.toRadians(latitude) - val lonRad = Math.toRadians(longitude) - val azRad = Math.toRadians(azimuthDegrees) - val elRad = Math.toRadians(elevationDegrees.toDouble()) + val latRad = toRadians(latitude) + val lonRad = toRadians(longitude) + val azRad = toRadians(azimuthDegrees) + val elRad = toRadians(elevationDegrees.toDouble()) val sinLat = sin(latRad) val cosLat = cos(latRad) @@ -259,8 +258,8 @@ private fun computeSunDirectionWorld( val worldDir = earthToWorld(earthDir, earthRotationDegrees, earthTiltDegrees).normalized() return LightDirection( - lightDegrees = Math.toDegrees(atan2(worldDir.x, worldDir.z)).toFloat(), - sunElevationDegrees = Math.toDegrees(asin(worldDir.y.coerceIn(-1.0, 1.0))).toFloat(), + lightDegrees = toDegrees(atan2(worldDir.x, worldDir.z)).toFloat(), + sunElevationDegrees = toDegrees(asin(worldDir.y.coerceIn(-1.0, 1.0))).toFloat(), ) } @@ -273,14 +272,14 @@ private fun earthToWorld( earthRotationDegrees: Float, earthTiltDegrees: Float, ): Vec3d { - val yawRad = Math.toRadians(-earthRotationDegrees.toDouble()) + val yawRad = toRadians(-earthRotationDegrees.toDouble()) val cosYaw = cos(yawRad) val sinYaw = sin(yawRad) val x1 = earthDir.x * cosYaw + earthDir.z * sinYaw val z1 = -earthDir.x * sinYaw + earthDir.z * cosYaw val y1 = earthDir.y - val tiltRad = Math.toRadians(-earthTiltDegrees.toDouble()) + val tiltRad = toRadians(-earthTiltDegrees.toDouble()) val cosTilt = cos(tiltRad) val sinTilt = sin(tiltRad) val x2 = x1 * cosTilt - y1 * sinTilt diff --git a/earthwidget/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/earthwidget/SolarPositionCalculator.jvm.kt b/earthwidget/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/earthwidget/SolarPositionCalculator.jvm.kt new file mode 100644 index 000000000..63edbd66c --- /dev/null +++ b/earthwidget/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/earthwidget/SolarPositionCalculator.jvm.kt @@ -0,0 +1,23 @@ +package io.github.kdroidfilter.seforimapp.earthwidget + +import java.util.Date +import kotlin.time.Instant + +// The desktop views keep their java.util.Date instants +private fun Date.asInstant(): Instant = Instant.fromEpochMilliseconds(time) + +internal fun computeSunLightDirectionForEarth( + referenceTime: Date, + latitude: Double, + longitude: Double, + earthRotationDegrees: Float, + earthTiltDegrees: Float, +): LightDirection = computeSunLightDirectionForEarth(referenceTime.asInstant(), latitude, longitude, earthRotationDegrees, earthTiltDegrees) + +internal fun computeSolarPositionNoaaUtc( + referenceTime: Date, + latitude: Double, + longitude: Double, +): SolarPosition = computeSolarPositionNoaaUtc(referenceTime.asInstant(), latitude, longitude) + +internal fun computeJulianDayUtc(date: Date): Double = computeJulianDayUtc(date.asInstant()) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a6cf0f9f2..cd8505bc4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -47,6 +47,8 @@ atomicfu = "0.33.0" kotlinx-io = "0.9.1" seforimQuoteLinks = "0.1.0" zstdJni = "1.5.7-10" +zstdKmp = "0.4.0" +okio = "3.16.0" metro = "1.4.5" lucene = "10.5.2" ktlint = "14.2.0" @@ -120,6 +122,7 @@ navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-co kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } multiplatformSettings = { module = "com.russhwolf:multiplatform-settings-no-arg", version.ref = "multiplatformSettings" } +multiplatformSettings-test = { module = "com.russhwolf:multiplatform-settings-test", version.ref = "multiplatformSettings" } kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" } materialKolor = { module = "com.materialkolor:material-kolor", version.ref = "materialKolor" } foundation-desktop = { module = "org.jetbrains.compose.foundation:foundation-desktop", version.ref = "compose" } @@ -163,6 +166,9 @@ androidx-lifecycle-viewmodel-savedstate = { module = "org.jetbrains.androidx.lif metrox-viewmodel = { module = "dev.zacsweers.metro:metrox-viewmodel", version.ref = "metro" } seforim-quotelinks = { module = "io.github.kdroidfilter:seforim-quotelinks", version.ref = "seforimQuoteLinks" } zstd-jni = { module = "com.github.luben:zstd-jni", version.ref = "zstdJni" } +zstd-kmp-okio = { module = "com.squareup.zstd:zstd-kmp-okio", version.ref = "zstdKmp" } +okio = { module = "com.squareup.okio:okio", version.ref = "okio" } +okio-fakefilesystem = { module = "com.squareup.okio:okio-fakefilesystem", version.ref = "okio" } lucene-core = { module = "org.apache.lucene:lucene-core", version.ref = "lucene" } lucene-analysis-common = { module = "org.apache.lucene:lucene-analysis-common", version.ref = "lucene" } lucene-queryparser = { module = "org.apache.lucene:lucene-queryparser", version.ref = "lucene" } diff --git a/htmlparser/src/androidJvmMain/kotlin/io/github/kdroidfilter/seforim/htmlparser/HtmlDom.androidJvm.kt b/htmlparser/src/androidJvmMain/kotlin/io/github/kdroidfilter/seforim/htmlparser/HtmlDom.androidJvm.kt index 887a3806f..85f4a517e 100644 --- a/htmlparser/src/androidJvmMain/kotlin/io/github/kdroidfilter/seforim/htmlparser/HtmlDom.androidJvm.kt +++ b/htmlparser/src/androidJvmMain/kotlin/io/github/kdroidfilter/seforim/htmlparser/HtmlDom.androidJvm.kt @@ -24,3 +24,5 @@ private fun toHtmlNode(node: Node): HtmlNode? = ) else -> null } + +actual fun htmlToText(html: String): String = Jsoup.parse(html).text() diff --git a/htmlparser/src/commonMain/kotlin/io/github/kdroidfilter/seforim/htmlparser/HtmlDom.kt b/htmlparser/src/commonMain/kotlin/io/github/kdroidfilter/seforim/htmlparser/HtmlDom.kt index 1b89effe8..cc841aad8 100644 --- a/htmlparser/src/commonMain/kotlin/io/github/kdroidfilter/seforim/htmlparser/HtmlDom.kt +++ b/htmlparser/src/commonMain/kotlin/io/github/kdroidfilter/seforim/htmlparser/HtmlDom.kt @@ -26,3 +26,6 @@ internal class HtmlElement( /** The nodes of [html]'s body, parsed as a browser would. */ internal expect fun parseHtmlBody(html: String): List + +/** All the text of [html], whitespace normalized as a browser shows it (jsoup's Document.text()). */ +expect fun htmlToText(html: String): String diff --git a/htmlparser/src/iosMain/kotlin/io/github/kdroidfilter/seforim/htmlparser/HtmlDom.ios.kt b/htmlparser/src/iosMain/kotlin/io/github/kdroidfilter/seforim/htmlparser/HtmlDom.ios.kt index 371b015f8..fe7ac340e 100644 --- a/htmlparser/src/iosMain/kotlin/io/github/kdroidfilter/seforim/htmlparser/HtmlDom.ios.kt +++ b/htmlparser/src/iosMain/kotlin/io/github/kdroidfilter/seforim/htmlparser/HtmlDom.ios.kt @@ -25,3 +25,5 @@ private fun toHtmlNode(node: Node): HtmlNode? = ) else -> null } + +actual fun htmlToText(html: String): String = Ksoup.parse(html).text() diff --git a/pagination/build.gradle.kts b/pagination/build.gradle.kts index f1bd30ee7..8e6576afb 100644 --- a/pagination/build.gradle.kts +++ b/pagination/build.gradle.kts @@ -2,6 +2,7 @@ import org.jetbrains.compose.reload.gradle.ComposeHotRun plugins { alias(libs.plugins.multiplatform) + alias(libs.plugins.android.kmp.library) alias(libs.plugins.compose.compiler) alias(libs.plugins.compose) alias(libs.plugins.kotlinx.serialization) @@ -15,6 +16,14 @@ kotlin { ) jvm() + // The reader's paging sources, shared with the mobile apps (appkit) + android { + namespace = "io.github.kdroidfilter.seforimapp.pagination" + compileSdk = 37 + minSdk = 29 + } + iosArm64() + iosSimulatorArm64() sourceSets { commonMain.dependencies { diff --git a/pagination/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/pagination/LinesPagingSource.kt b/pagination/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/pagination/LinesPagingSource.kt index ad797e97a..15bc9efec 100644 --- a/pagination/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/pagination/LinesPagingSource.kt +++ b/pagination/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/pagination/LinesPagingSource.kt @@ -31,7 +31,7 @@ class LinesPagingSource( return try { val pageNumber = params.key ?: 0 - debugln { "[LinesPagingSource] Loading page $pageNumber, type: ${params.javaClass.simpleName}" } + debugln { "[LinesPagingSource] Loading page $pageNumber, type: ${params::class.simpleName}" } // Determine the range to load based on the load type val (startIndex, loadSize) = diff --git a/scripts/fonts/fix_noto_meteg.py b/scripts/fonts/fix_noto_meteg.py index 2b363814a..22ea1de98 100644 --- a/scripts/fonts/fix_noto_meteg.py +++ b/scripts/fonts/fix_noto_meteg.py @@ -15,7 +15,7 @@ from fontTools.ttLib import TTFont from fontTools.ttLib.tables import otTables -FONT_DIR = Path(__file__).resolve().parents[2] / "SeforimApp/src/commonMain/composeResources/font" +FONT_DIR = Path(__file__).resolve().parents[2] / "appkit/src/commonMain/composeResources/font" FONTS = ["notoserifhebrew.ttf", "notorashihebrew.ttf"] METEG = "uni05BD" diff --git a/scripts/fonts/fix_paseq.py b/scripts/fonts/fix_paseq.py index fe6228453..a5099af35 100644 --- a/scripts/fonts/fix_paseq.py +++ b/scripts/fonts/fix_paseq.py @@ -20,7 +20,7 @@ from fontTools.pens.ttGlyphPen import TTGlyphPen from fontTools.ttLib import TTFont -FONT_DIR = Path(__file__).resolve().parents[2] / "SeforimApp/src/commonMain/composeResources/font" +FONT_DIR = Path(__file__).resolve().parents[2] / "appkit/src/commonMain/composeResources/font" @dataclass(frozen=True) diff --git a/settings.gradle.kts b/settings.gradle.kts index 114818d07..26b5d2fc5 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -139,6 +139,7 @@ plugins { include(":SeforimApp") include(":searchkit") +include(":appkit") include(":searchkit-check") include(":cataloggen") include(":widgetprocessor")