From a35a5becc6ec27c2a6ef33583a49ef200cd7a5e5 Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Sun, 18 Oct 2026 01:56:37 +0300 Subject: [PATCH 1/9] feat(appkit): the app's logic without its desktop shell, for the mobile apps A new KMP module, appkit (JVM, Android 10+, iOS), takes from SeforimApp what doesn't need the desktop: the reader's state, use cases and paging, the user's data (highlights, notes, favorites, history, the UserSettingsDb schema), the settings (AppSettings and the settings view models), the onboarding's states and view models, the catalog and the cities. Packages are unchanged. - earthwidget and pagination get Android and iOS targets (Filament KMP renders there too); the solar position runs on kotlinx-datetime, the desktop views keep their java.util.Date overloads. - java.* replaced by common APIs: atomicfu locks, SeforimLibrary's :cache, LruCache, kotlin.time, FileKit paths. - Reader pane splits: PaneSplitState instead of compose-splitpane's desktop-only SplitPaneState. - AccentColor: the enum is common, its Jewel colors stay on the desktop (AccentColors.kt). - The quote links addon moves to appkit's jvmMain (null on mobile); its DB test stays in SeforimApp. - The tests of the moved code move with it: commonTest (run on the JVM and the iOS simulator) or jvmTest. Signed-off-by: Elie Gambache --- .../features/siddur/SiddurScreen.kt | 1 + SeforimApp/build.gradle.kts | 21 +--- .../core/e2e/E2eQuoteLinksScenario.kt | 2 +- .../core/presentation/theme/AccentColor.kt | 53 ---------- .../core/presentation/theme/AccentColors.kt | 39 +++++++ .../ui/panels/bookcontent/views/HomeView.kt | 1 + .../ui/panels/booktoc/BookTocPanel.kt | 2 +- .../bookcontent/ui/panels/notes/NotesPanel.kt | 14 --- .../features/home/widgets/HomeWidgetsState.kt | 1 + .../features/home/widgets/WidgetCard.kt | 1 + .../home/widgets/earth/EarthWidget.kt | 1 + .../onboarding/extract/ExtractScreen.kt | 2 +- .../settings/ui/DisplaySettingsScreen.kt | 1 + .../features/zmanim/data/PlaceTimeZone.kt | 6 ++ .../quotelinks/InstalledQuoteLinks.kt | 2 +- .../quotelinks/InstalledQuoteLinks.kt | 2 +- appkit/build.gradle.kts | 100 ++++++++++++++++++ .../quotelinks/InstalledQuoteLinks.android.kt | 4 + .../platform/HostPlatform.android.kt | 5 + .../seforimapp/catalog/CatalogPresets.kt | 0 .../kdroidfilter/seforimapp/core/LruCache.kt | 36 +++++++ .../seforimapp/core/MainAppState.kt | 0 .../annotations/HighlightSelectionResolver.kt | 0 .../core/annotations/HighlightStore.kt | 1 + .../seforimapp/core/annotations/NoteStore.kt | 1 + .../seforimapp/core/catalog/CatalogAccess.kt | 20 ++-- .../core/favorites/FavoritesStore.kt | 1 + .../seforimapp/core/history/HistoryStore.kt | 1 + .../components/HardDriveUpload.kt | 0 .../components/ProgressBarController.kt | 0 .../core/presentation/text/Highlighting.kt | 0 .../core/presentation/text/Notes.kt | 0 .../core/presentation/theme/AccentColor.kt | 15 +++ .../core/presentation/theme/IntUiThemes.kt | 0 .../core/presentation/utils/TouchMode.kt | 0 .../core/selection/SelectionContext.kt | 1 + .../seforimapp/core/settings/AppSettings.kt | 20 ++-- .../settings/CategoryDisplaySettingsStore.kt | 1 + .../seforimapp/core/settings/Setting.kt | 0 .../features/bookcontent/BookContentEvent.kt | 0 .../quotelinks/QuoteLinksFeature.kt | 3 + .../bookcontent/state/BookContentState.kt | 16 ++- .../state/BookContentStateManager.kt | 14 +-- .../bookcontent/state/BookOpenSource.kt | 0 .../features/bookcontent/state/BookTabUi.kt | 0 .../bookcontent/state/CommentatorGroup.kt | 0 .../state/LineConnectionsSnapshot.kt | 0 .../bookcontent/state/PaneSplitState.kt | 20 ++++ .../bookcontent/state/SplitDefaults.kt | 0 .../views/BookContentZoomInteraction.kt | 2 +- .../bookcontent/views/BreadcrumbNavigator.kt | 3 +- .../bookcontent/views/ScrollbarMetrics.kt | 14 +-- .../bookcontent/views/SelectionBarLogic.kt | 10 +- .../ui/panels/notes/NoteDraftAnchor.kt | 15 +++ .../bookcontent/usecases/AltTocUseCase.kt | 3 +- .../usecases/BookContentUseCaseFactory.kt | 0 .../bookcontent/usecases/BookTreeSearch.kt | 0 .../bookcontent/usecases/CategoryAncestry.kt | 0 .../CategoryDisplaySettingsUseCase.kt | 0 .../usecases/CommentariesUseCase.kt | 50 ++++----- .../bookcontent/usecases/ContentUseCase.kt | 5 +- .../bookcontent/usecases/NavigationUseCase.kt | 3 +- .../bookcontent/usecases/NotesUseCase.kt | 2 - .../bookcontent/usecases/PaneSearch.kt | 0 .../bookcontent/usecases/TocSearch.kt | 16 +-- .../bookcontent/usecases/TocUseCase.kt | 2 - .../navigation/DatabaseUpdateDestination.kt | 0 .../DatabaseUpdateProgressBarState.kt | 0 .../home/widgets/HomeUserLocationViewModel.kt | 0 .../features/home/widgets/WidgetSettings.kt | 0 .../data/OnboardingProcessRepository.kt | 0 .../diskspace/AvailableDiskSpaceEvents.kt | 0 .../diskspace/AvailableDiskSpaceState.kt | 0 .../onboarding/download/DownloadEvents.kt | 0 .../onboarding/download/DownloadState.kt | 0 .../onboarding/extract/BundleManifest.kt | 0 .../onboarding/extract/ExtractEvents.kt | 0 .../onboarding/extract/ExtractState.kt | 0 .../navigation/OnBoardingDestination.kt | 0 .../onboarding/navigation/ProgressBarState.kt | 0 .../onboarding/region/RegionConfigEvents.kt | 0 .../onboarding/region/RegionConfigState.kt | 0 .../onboarding/region/RegionConfigUseCase.kt | 0 .../region/RegionConfigViewModel.kt | 0 .../typeofinstall/TypeOfInstallationEvents.kt | 0 .../typeofinstall/TypeOfInstallationState.kt | 0 .../TypeOfInstallationViewModel.kt | 0 .../onboarding/userprofile/Community.kt | 0 .../userprofile/UserProfileEvents.kt | 0 .../userprofile/UserProfileState.kt | 0 .../userprofile/UserProfileUseCase.kt | 0 .../userprofile/UserProfileViewModel.kt | 0 .../features/settings/SettingsWindowEvents.kt | 0 .../features/settings/SettingsWindowState.kt | 0 .../settings/data/DataSettingsState.kt | 0 .../settings/dbupdate/DbDeltaUpdateEvents.kt | 0 .../settings/dbupdate/DbDeltaUpdateState.kt | 0 .../settings/display/DisplaySettingsEvents.kt | 0 .../settings/display/DisplaySettingsState.kt | 0 .../display/DisplaySettingsViewModel.kt | 0 .../settings/fonts/FontsSettingsEvents.kt | 0 .../settings/fonts/FontsSettingsState.kt | 0 .../settings/fonts/FontsSettingsViewModel.kt | 0 .../settings/general/GeneralSettingsEvents.kt | 0 .../settings/general/GeneralSettingsState.kt | 0 .../general/GeneralSettingsViewModel.kt | 0 .../navigation/SettingsDestination.kt | 0 .../features/zmanim/data/CandleLighting.kt | 0 .../seforimapp/features/zmanim/data/Cities.kt | 7 +- .../framework/database/CatalogCache.kt | 60 ++++++----- .../database/DatabasePathProvider.kt | 25 +++-- .../framework/di/AppMetroViewModelFactory.kt | 0 .../framework/platform/HostPlatform.kt | 7 ++ .../framework/session/SessionModels.kt | 0 .../session/TabPersistedStateStore.kt | 28 ++--- .../seforimapp/db/CategoryDisplaySettings.sq | 0 .../kdroidfilter/seforimapp/db/Favorites.sq | 0 .../seforimapp/db/ShnayimMikra.sq | 0 .../seforimapp/db/UserHighlights.sq | 0 .../kdroidfilter/seforimapp/db/UserNotes.sq | 0 .../seforimapp/db/VisitHistory.sq | 0 .../seforimapp/catalog/CatalogPresetsTest.kt | 0 .../HighlightSelectionResolverTest.kt | 0 .../core/selection/SelectionContextTest.kt | 0 .../bookcontent/BookContentEventTest.kt | 0 .../bookcontent/state/BookContentStateTest.kt | 0 .../bookcontent/state/BookOpenSourceTest.kt | 0 .../bookcontent/state/CommentatorGroupTest.kt | 0 .../state/LineConnectionsSnapshotTest.kt | 0 .../bookcontent/state/SplitDefaultsTest.kt | 0 .../views/BreadcrumbNavigatorTest.kt | 10 +- .../usecases/BookContentUseCaseFactoryTest.kt | 0 .../usecases/BookTreeSearchTest.kt | 0 .../usecases/CommentatorNameTest.kt | 2 +- .../bookcontent/usecases/PaneSearchTest.kt | 0 .../bookcontent/usecases/TocSearchTest.kt | 4 +- .../DatabaseUpdateDestinationTest.kt | 0 .../DatabaseUpdateProgressBarStateTest.kt | 0 .../diskspace/AvailableDiskSpaceEventsTest.kt | 0 .../diskspace/AvailableDiskSpaceStateTest.kt | 0 .../onboarding/download/DownloadEventsTest.kt | 0 .../onboarding/download/DownloadStateTest.kt | 0 .../onboarding/extract/ExtractEventsTest.kt | 0 .../onboarding/extract/ExtractStateTest.kt | 0 .../navigation/OnBoardingDestinationTest.kt | 0 .../navigation/ProgressBarStateTest.kt | 0 .../region/RegionConfigEventsTest.kt | 0 .../region/RegionConfigStateTest.kt | 0 .../region/RegionConfigUseCaseTest.kt | 0 .../TypeOfInstallationEventsTest.kt | 0 .../TypeOfInstallationStateTest.kt | 0 .../onboarding/userprofile/CommunityTest.kt | 0 .../userprofile/UserProfileEventsTest.kt | 0 .../userprofile/UserProfileStateTest.kt | 0 .../userprofile/UserProfileUseCaseTest.kt | 0 .../settings/SettingsWindowEventsTest.kt | 3 +- .../settings/SettingsWindowStateTest.kt | 0 .../settings/fonts/FontsSettingsEventsTest.kt | 0 .../settings/fonts/FontsSettingsStateTest.kt | 11 +- .../general/GeneralSettingsEventsTest.kt | 0 .../general/GeneralSettingsStateTest.kt | 0 .../navigation/SettingsDestinationTest.kt | 0 .../framework/session/SessionModelsTest.kt | 4 +- .../quotelinks/InstalledQuoteLinks.ios.kt | 4 + .../framework/platform/HostPlatform.ios.kt | 10 ++ .../framework/platform/HostPlatform.jvm.kt | 5 + .../core/catalog/CatalogAccessTest.kt | 0 .../core/history/HistoryStoreTest.kt | 0 .../presentation/theme/IntUiThemesTest.kt | 0 .../seforimapp/core/settings/SettingTest.kt | 0 .../features/zmanim/data/CitiesTest.kt | 0 cataloggen/build.gradle.kts | 2 +- earthwidget/build.gradle.kts | 13 ++- .../seforimapp/earthwidget/AngleMath.kt | 8 ++ .../seforimapp/earthwidget/EarthEphemeris.kt | 16 +-- .../seforimapp/earthwidget/EarthWidgetView.kt | 3 +- .../seforimapp/earthwidget/SkyAtmosphere.kt | 4 +- .../earthwidget/SolarPositionCalculator.kt | 81 +++++++------- .../SolarPositionCalculator.jvm.kt | 23 ++++ pagination/build.gradle.kts | 9 ++ .../pagination/LinesPagingSource.kt | 2 +- settings.gradle.kts | 1 + 182 files changed, 570 insertions(+), 305 deletions(-) delete mode 100644 SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/theme/AccentColor.kt create mode 100644 SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/theme/AccentColors.kt create mode 100644 SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/zmanim/data/PlaceTimeZone.kt rename {SeforimApp => appkit}/addons/quotelinks/disabled/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/quotelinks/InstalledQuoteLinks.kt (69%) rename {SeforimApp => appkit}/addons/quotelinks/enabled/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/quotelinks/InstalledQuoteLinks.kt (97%) create mode 100644 appkit/build.gradle.kts create mode 100644 appkit/src/androidMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/quotelinks/InstalledQuoteLinks.android.kt create mode 100644 appkit/src/androidMain/kotlin/io/github/kdroidfilter/seforimapp/framework/platform/HostPlatform.android.kt rename {SeforimApp => appkit}/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/catalog/CatalogPresets.kt (100%) create mode 100644 appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/core/LruCache.kt rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/core/MainAppState.kt (100%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/core/annotations/HighlightSelectionResolver.kt (100%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/core/annotations/HighlightStore.kt (99%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/core/annotations/NoteStore.kt (99%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/core/catalog/CatalogAccess.kt (93%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/core/favorites/FavoritesStore.kt (99%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/core/history/HistoryStore.kt (99%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/components/HardDriveUpload.kt (100%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/components/ProgressBarController.kt (100%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/text/Highlighting.kt (100%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/text/Notes.kt (100%) create mode 100644 appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/theme/AccentColor.kt rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/theme/IntUiThemes.kt (100%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/utils/TouchMode.kt (100%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/core/selection/SelectionContext.kt (99%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/core/settings/AppSettings.kt (97%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/core/settings/CategoryDisplaySettingsStore.kt (98%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/core/settings/Setting.kt (100%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/BookContentEvent.kt (100%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/quotelinks/QuoteLinksFeature.kt (88%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/BookContentState.kt (95%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/BookContentStateManager.kt (97%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/BookOpenSource.kt (100%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/BookTabUi.kt (100%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/CommentatorGroup.kt (100%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/LineConnectionsSnapshot.kt (100%) create mode 100644 appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/PaneSplitState.kt rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/SplitDefaults.kt (100%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/views/BookContentZoomInteraction.kt (65%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/views/BreadcrumbNavigator.kt (99%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/views/ScrollbarMetrics.kt (95%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/views/SelectionBarLogic.kt (94%) create mode 100644 appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/notes/NoteDraftAnchor.kt rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/AltTocUseCase.kt (98%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/BookContentUseCaseFactory.kt (100%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/BookTreeSearch.kt (100%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/CategoryAncestry.kt (100%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/CategoryDisplaySettingsUseCase.kt (100%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/CommentariesUseCase.kt (97%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/ContentUseCase.kt (99%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/NavigationUseCase.kt (98%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/NotesUseCase.kt (94%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/PaneSearch.kt (100%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/TocSearch.kt (92%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/TocUseCase.kt (99%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/features/database/update/navigation/DatabaseUpdateDestination.kt (100%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/features/database/update/navigation/DatabaseUpdateProgressBarState.kt (100%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/features/home/widgets/HomeUserLocationViewModel.kt (100%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/features/home/widgets/WidgetSettings.kt (100%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/data/OnboardingProcessRepository.kt (100%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/diskspace/AvailableDiskSpaceEvents.kt (100%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/diskspace/AvailableDiskSpaceState.kt (100%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/download/DownloadEvents.kt (100%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/download/DownloadState.kt (100%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/extract/BundleManifest.kt (100%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/extract/ExtractEvents.kt (100%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/extract/ExtractState.kt (100%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/navigation/OnBoardingDestination.kt (100%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/navigation/ProgressBarState.kt (100%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/region/RegionConfigEvents.kt (100%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/region/RegionConfigState.kt (100%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/region/RegionConfigUseCase.kt (100%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/region/RegionConfigViewModel.kt (100%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/typeofinstall/TypeOfInstallationEvents.kt (100%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/typeofinstall/TypeOfInstallationState.kt (100%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/typeofinstall/TypeOfInstallationViewModel.kt (100%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/userprofile/Community.kt (100%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/userprofile/UserProfileEvents.kt (100%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/userprofile/UserProfileState.kt (100%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/userprofile/UserProfileUseCase.kt (100%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/userprofile/UserProfileViewModel.kt (100%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/features/settings/SettingsWindowEvents.kt (100%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/features/settings/SettingsWindowState.kt (100%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/features/settings/data/DataSettingsState.kt (100%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/features/settings/dbupdate/DbDeltaUpdateEvents.kt (100%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/features/settings/dbupdate/DbDeltaUpdateState.kt (100%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/features/settings/display/DisplaySettingsEvents.kt (100%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/features/settings/display/DisplaySettingsState.kt (100%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/features/settings/display/DisplaySettingsViewModel.kt (100%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/features/settings/fonts/FontsSettingsEvents.kt (100%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/features/settings/fonts/FontsSettingsState.kt (100%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/features/settings/fonts/FontsSettingsViewModel.kt (100%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/features/settings/general/GeneralSettingsEvents.kt (100%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/features/settings/general/GeneralSettingsState.kt (100%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/features/settings/general/GeneralSettingsViewModel.kt (100%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/features/settings/navigation/SettingsDestination.kt (100%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/features/zmanim/data/CandleLighting.kt (100%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/features/zmanim/data/Cities.kt (98%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/framework/database/CatalogCache.kt (79%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/framework/database/DatabasePathProvider.kt (74%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/framework/di/AppMetroViewModelFactory.kt (100%) create mode 100644 appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/framework/platform/HostPlatform.kt rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/framework/session/SessionModels.kt (100%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/framework/session/TabPersistedStateStore.kt (52%) rename {SeforimApp => appkit}/src/commonMain/sqldelight/io/github/kdroidfilter/seforimapp/db/CategoryDisplaySettings.sq (100%) rename {SeforimApp => appkit}/src/commonMain/sqldelight/io/github/kdroidfilter/seforimapp/db/Favorites.sq (100%) rename {SeforimApp => appkit}/src/commonMain/sqldelight/io/github/kdroidfilter/seforimapp/db/ShnayimMikra.sq (100%) rename {SeforimApp => appkit}/src/commonMain/sqldelight/io/github/kdroidfilter/seforimapp/db/UserHighlights.sq (100%) rename {SeforimApp => appkit}/src/commonMain/sqldelight/io/github/kdroidfilter/seforimapp/db/UserNotes.sq (100%) rename {SeforimApp => appkit}/src/commonMain/sqldelight/io/github/kdroidfilter/seforimapp/db/VisitHistory.sq (100%) rename {SeforimApp/src/jvmTest => appkit/src/commonTest}/kotlin/io/github/kdroidfilter/seforimapp/catalog/CatalogPresetsTest.kt (100%) rename {SeforimApp/src/jvmTest => appkit/src/commonTest}/kotlin/io/github/kdroidfilter/seforimapp/core/annotations/HighlightSelectionResolverTest.kt (100%) rename {SeforimApp/src/jvmTest => appkit/src/commonTest}/kotlin/io/github/kdroidfilter/seforimapp/core/selection/SelectionContextTest.kt (100%) rename {SeforimApp/src/jvmTest => appkit/src/commonTest}/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/BookContentEventTest.kt (100%) rename {SeforimApp/src/jvmTest => appkit/src/commonTest}/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/BookContentStateTest.kt (100%) rename {SeforimApp/src/jvmTest => appkit/src/commonTest}/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/BookOpenSourceTest.kt (100%) rename {SeforimApp/src/jvmTest => appkit/src/commonTest}/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/CommentatorGroupTest.kt (100%) rename {SeforimApp/src/jvmTest => appkit/src/commonTest}/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/LineConnectionsSnapshotTest.kt (100%) rename {SeforimApp/src/jvmTest => appkit/src/commonTest}/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/state/SplitDefaultsTest.kt (100%) rename {SeforimApp/src/jvmTest => appkit/src/commonTest}/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/views/BreadcrumbNavigatorTest.kt (97%) rename {SeforimApp/src/jvmTest => appkit/src/commonTest}/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/BookContentUseCaseFactoryTest.kt (100%) rename {SeforimApp/src/jvmTest => appkit/src/commonTest}/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/BookTreeSearchTest.kt (100%) rename {SeforimApp/src/jvmTest => appkit/src/commonTest}/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/CommentatorNameTest.kt (95%) rename {SeforimApp/src/jvmTest => appkit/src/commonTest}/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/PaneSearchTest.kt (100%) rename {SeforimApp/src/jvmTest => appkit/src/commonTest}/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/TocSearchTest.kt (97%) rename {SeforimApp/src/jvmTest => appkit/src/commonTest}/kotlin/io/github/kdroidfilter/seforimapp/features/database/update/navigation/DatabaseUpdateDestinationTest.kt (100%) rename {SeforimApp/src/jvmTest => appkit/src/commonTest}/kotlin/io/github/kdroidfilter/seforimapp/features/database/update/navigation/DatabaseUpdateProgressBarStateTest.kt (100%) rename {SeforimApp/src/jvmTest => appkit/src/commonTest}/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/diskspace/AvailableDiskSpaceEventsTest.kt (100%) rename {SeforimApp/src/jvmTest => appkit/src/commonTest}/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/diskspace/AvailableDiskSpaceStateTest.kt (100%) rename {SeforimApp/src/jvmTest => appkit/src/commonTest}/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/download/DownloadEventsTest.kt (100%) rename {SeforimApp/src/jvmTest => appkit/src/commonTest}/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/download/DownloadStateTest.kt (100%) rename {SeforimApp/src/jvmTest => appkit/src/commonTest}/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/extract/ExtractEventsTest.kt (100%) rename {SeforimApp/src/jvmTest => appkit/src/commonTest}/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/extract/ExtractStateTest.kt (100%) rename {SeforimApp/src/jvmTest => appkit/src/commonTest}/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/navigation/OnBoardingDestinationTest.kt (100%) rename {SeforimApp/src/jvmTest => appkit/src/commonTest}/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/navigation/ProgressBarStateTest.kt (100%) rename {SeforimApp/src/jvmTest => appkit/src/commonTest}/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/region/RegionConfigEventsTest.kt (100%) rename {SeforimApp/src/jvmTest => appkit/src/commonTest}/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/region/RegionConfigStateTest.kt (100%) rename {SeforimApp/src/jvmTest => appkit/src/commonTest}/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/region/RegionConfigUseCaseTest.kt (100%) rename {SeforimApp/src/jvmTest => appkit/src/commonTest}/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/typeofinstall/TypeOfInstallationEventsTest.kt (100%) rename {SeforimApp/src/jvmTest => appkit/src/commonTest}/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/typeofinstall/TypeOfInstallationStateTest.kt (100%) rename {SeforimApp/src/jvmTest => appkit/src/commonTest}/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/userprofile/CommunityTest.kt (100%) rename {SeforimApp/src/jvmTest => appkit/src/commonTest}/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/userprofile/UserProfileEventsTest.kt (100%) rename {SeforimApp/src/jvmTest => appkit/src/commonTest}/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/userprofile/UserProfileStateTest.kt (100%) rename {SeforimApp/src/jvmTest => appkit/src/commonTest}/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/userprofile/UserProfileUseCaseTest.kt (100%) rename {SeforimApp/src/jvmTest => appkit/src/commonTest}/kotlin/io/github/kdroidfilter/seforimapp/features/settings/SettingsWindowEventsTest.kt (92%) rename {SeforimApp/src/jvmTest => appkit/src/commonTest}/kotlin/io/github/kdroidfilter/seforimapp/features/settings/SettingsWindowStateTest.kt (100%) rename {SeforimApp/src/jvmTest => appkit/src/commonTest}/kotlin/io/github/kdroidfilter/seforimapp/features/settings/fonts/FontsSettingsEventsTest.kt (100%) rename {SeforimApp/src/jvmTest => appkit/src/commonTest}/kotlin/io/github/kdroidfilter/seforimapp/features/settings/fonts/FontsSettingsStateTest.kt (89%) rename {SeforimApp/src/jvmTest => appkit/src/commonTest}/kotlin/io/github/kdroidfilter/seforimapp/features/settings/general/GeneralSettingsEventsTest.kt (100%) rename {SeforimApp/src/jvmTest => appkit/src/commonTest}/kotlin/io/github/kdroidfilter/seforimapp/features/settings/general/GeneralSettingsStateTest.kt (100%) rename {SeforimApp/src/jvmTest => appkit/src/commonTest}/kotlin/io/github/kdroidfilter/seforimapp/features/settings/navigation/SettingsDestinationTest.kt (100%) rename {SeforimApp/src/jvmTest => appkit/src/commonTest}/kotlin/io/github/kdroidfilter/seforimapp/framework/session/SessionModelsTest.kt (97%) create mode 100644 appkit/src/iosMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/quotelinks/InstalledQuoteLinks.ios.kt create mode 100644 appkit/src/iosMain/kotlin/io/github/kdroidfilter/seforimapp/framework/platform/HostPlatform.ios.kt create mode 100644 appkit/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/platform/HostPlatform.jvm.kt rename {SeforimApp => appkit}/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/core/catalog/CatalogAccessTest.kt (100%) rename {SeforimApp => appkit}/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/core/history/HistoryStoreTest.kt (100%) rename {SeforimApp => appkit}/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/theme/IntUiThemesTest.kt (100%) rename {SeforimApp => appkit}/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/core/settings/SettingTest.kt (100%) rename {SeforimApp => appkit}/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/zmanim/data/CitiesTest.kt (100%) create mode 100644 earthwidget/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/earthwidget/AngleMath.kt create mode 100644 earthwidget/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/earthwidget/SolarPositionCalculator.jvm.kt 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..9551be5a0 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 { @@ -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/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/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/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..f6c03380f --- /dev/null +++ b/appkit/build.gradle.kts @@ -0,0 +1,100 @@ +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 + } + 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) + implementation(libs.androidx.lifecycle.viewmodel) + implementation(libs.androidx.lifecycle.viewmodel.savedstate) + implementation(libs.metrox.viewmodel) + implementation(libs.androidx.paging.common) + + 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.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) + } + + jvmTest.dependencies { implementation(libs.mockk) } + } +} + +sqldelight { + databases { + create("UserSettingsDb") { + packageName.set("io.github.kdroidfilter.seforimapp.db") + dialect("app.cash.sqldelight:sqlite-3-24-dialect:${libs.versions.sqlDelight.get()}") + } + } +} 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/platform/HostPlatform.android.kt b/appkit/src/androidMain/kotlin/io/github/kdroidfilter/seforimapp/framework/platform/HostPlatform.android.kt new file mode 100644 index 000000000..115bb8ddd --- /dev/null +++ b/appkit/src/androidMain/kotlin/io/github/kdroidfilter/seforimapp/framework/platform/HostPlatform.android.kt @@ -0,0 +1,5 @@ +package io.github.kdroidfilter.seforimapp.framework.platform + +actual val isLinuxDesktop: Boolean = false + +actual fun environmentVariable(name: String): String? = System.getenv(name) 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/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/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/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/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/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/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/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/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/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/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/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/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..a9e900a8c --- /dev/null +++ b/appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/framework/platform/HostPlatform.kt @@ -0,0 +1,7 @@ +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 + +/** 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/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/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/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/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/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/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/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/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/platform/HostPlatform.ios.kt b/appkit/src/iosMain/kotlin/io/github/kdroidfilter/seforimapp/framework/platform/HostPlatform.ios.kt new file mode 100644 index 000000000..65cfab6e2 --- /dev/null +++ b/appkit/src/iosMain/kotlin/io/github/kdroidfilter/seforimapp/framework/platform/HostPlatform.ios.kt @@ -0,0 +1,10 @@ +package io.github.kdroidfilter.seforimapp.framework.platform + +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.toKString +import platform.posix.getenv + +actual val isLinuxDesktop: Boolean = false + +@OptIn(ExperimentalForeignApi::class) +actual fun environmentVariable(name: String): String? = getenv(name)?.toKString() 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..ca8941ec3 --- /dev/null +++ b/appkit/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/platform/HostPlatform.jvm.kt @@ -0,0 +1,5 @@ +package io.github.kdroidfilter.seforimapp.framework.platform + +actual val isLinuxDesktop: Boolean = System.getProperty("os.name").orEmpty().startsWith("Linux", 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/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/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/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") From 670b762a26d03cdd1706f3f9e6356c006f85d954 Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Sun, 18 Oct 2026 02:06:42 +0300 Subject: [PATCH 2/9] feat(appkit): BookContentViewModel and TabsViewModel in common code - TabsViewModel drives a TabsHost port, the window's tabs as the platform holds them; the desktop's DesktopTabsHost maps it onto its TabWorkspace. A common test covers it on a fake host (JVM and iOS). - BookContentViewModel reaches the desktop through two ports: TabsResolver (DesktopManager) to open tabs next to its own, SessionRestoreState (SessionManager) to know whether the session is being restored. - pinnedTabLabel moves with it. Signed-off-by: Elie Gambache --- .../framework/desktop/DesktopManager.kt | 5 +- .../framework/desktop/DesktopTabsHost.kt | 52 +++++++ .../framework/desktop/OpenWindow.kt | 2 +- .../framework/di/modules/AppCoreBindings.kt | 9 ++ .../framework/session/SessionManager.kt | 4 +- .../bookcontent/PostSelectLineTest.kt | 4 +- .../kdroidfilter/seforim/tabs/TabsHost.kt | 47 ++++++ .../kdroidfilter/seforim/tabs/TabsResolver.kt | 9 ++ .../seforim/tabs/TabsViewModel.kt | 77 ++++----- .../core/presentation/tabs/PinnedTabLabel.kt | 0 .../bookcontent/BookContentViewModel.kt | 43 +++--- .../framework/session/SessionRestoreState.kt | 8 + .../seforim/tabs/TabsViewModelTest.kt | 146 ++++++++++++++++++ 13 files changed, 334 insertions(+), 72 deletions(-) create mode 100644 SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/desktop/DesktopTabsHost.kt create mode 100644 appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforim/tabs/TabsHost.kt create mode 100644 appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforim/tabs/TabsResolver.kt rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforim/tabs/TabsViewModel.kt (71%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/tabs/PinnedTabLabel.kt (100%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/BookContentViewModel.kt (98%) create mode 100644 appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/framework/session/SessionRestoreState.kt create mode 100644 appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforim/tabs/TabsViewModelTest.kt 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/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..ea3ac6ae2 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 @@ -10,6 +10,7 @@ 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 @@ -49,6 +50,7 @@ import io.github.kdroidfilter.seforimapp.framework.search.RepositorySnippetSourc 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.TabPersistedStateStore import io.github.kdroidfilter.seforimapp.framework.session.TabThumbnailStore import io.github.kdroidfilter.seforimapp.framework.update.AppUpdateService @@ -200,6 +202,13 @@ object AppCoreBindings { return withSemanticSearch(lexical, lexical.index, repository) } + // What the reader (appkit) asks of the desktop around it + @Provides + fun provideTabsResolver(desktopManager: DesktopManager): TabsResolver = desktopManager + + @Provides + fun provideSessionRestoreState(sessionManager: SessionManager): SessionRestoreState = sessionManager + // What the search screens (searchkit) ask of the desktop around them @Provides @SingleIn(AppScope::class) diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/session/SessionManager.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/session/SessionManager.kt index 140350370..30a3e9155 100644 --- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/session/SessionManager.kt +++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/session/SessionManager.kt @@ -43,12 +43,12 @@ class SessionManager( private val desktopManager: Lazy, private val tabThumbnailStore: TabThumbnailStore, 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() } 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..6aecba211 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 @@ -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/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/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/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/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/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() + } +} From 42dfba169778d7953c8ffad5830d865080e268dc Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Sun, 18 Oct 2026 02:14:41 +0300 Subject: [PATCH 3/9] feat(appkit): SessionManager in common code SessionManager (saving, restoring and migrating the session, naming its tabs) moves to appkit, on kotlinx-io files under FileKit's databases directory as before. It reaches the shell through a SessionShell port: the desktops to save and the tabs' pictures to drop (the desktop's DesktopSessionShell, on DesktopManager and TabThumbnailStore), and the end-to-end harness that turns it off. DesktopManager stays on the desktop: it is the shell itself (windows, Nucleus tab workspaces, geometry, drag and drop between windows), behind the ports it implements (TabsResolver, SessionShell's desktops). Signed-off-by: Elie Gambache --- .../framework/desktop/DesktopSessionShell.kt | 19 +++++++++ .../framework/di/modules/AppCoreBindings.kt | 10 +++++ appkit/build.gradle.kts | 1 + .../framework/session/SessionManager.kt | 39 +++++++++++-------- .../framework/session/SessionShell.kt | 13 +++++++ 5 files changed, 66 insertions(+), 16 deletions(-) create mode 100644 SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/desktop/DesktopSessionShell.kt rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/framework/session/SessionManager.kt (90%) create mode 100644 appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/framework/session/SessionShell.kt 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/di/modules/AppCoreBindings.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/di/modules/AppCoreBindings.kt index ea3ac6ae2..b140c5e99 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 @@ -41,6 +41,7 @@ import io.github.kdroidfilter.seforimapp.framework.database.PersistentSqliteDriv 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 @@ -51,6 +52,7 @@ 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 @@ -209,6 +211,14 @@ object AppCoreBindings { @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 provideSessionShell( + desktopManager: Lazy, + thumbnails: TabThumbnailStore, + ): SessionShell = DesktopSessionShell(desktopManager, thumbnails) + // What the search screens (searchkit) ask of the desktop around them @Provides @SingleIn(AppScope::class) diff --git a/appkit/build.gradle.kts b/appkit/build.gradle.kts index f6c03380f..5f974878e 100644 --- a/appkit/build.gradle.kts +++ b/appkit/build.gradle.kts @@ -63,6 +63,7 @@ kotlin { implementation(libs.kotlinx.serialization.protobuf) implementation(libs.structured.coroutines.annotations) implementation(libs.kotlinx.collections.immutable) + implementation(libs.kotlinx.io.core) implementation(libs.multiplatformSettings) implementation(libs.filekit.core) implementation(libs.hebrew.numerals) 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 90% 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 30a3e9155..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,13 +37,13 @@ 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 @@ -50,14 +52,19 @@ class SessionManager( private val _isRestoringSession = MutableStateFlow(runCatching { hasSavedSessionToRestore() }.getOrDefault(false)) 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/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) +} From 08a7e9c7939d8b49f7e092f5d0ea95e339e2385e Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Sun, 18 Oct 2026 02:24:58 +0300 Subject: [PATCH 4/9] feat(appkit): the common bindings in a binding container, SQLite per platform - AppKitBindings (appkit, contributed to AppScope): the settings, the app state, the catalog access, the user database and its stores, the books' repository and the search's settings and history, for every app's graph. - AppDatabases (expect/actual) opens the SQLite connections: on the desktop, PersistentSqliteDriver for the books and JDBC with a busy timeout for the user database; on Android and iOS, searchkit's drivers for the books and their own for the user database. The user tables are created on each open in common code, as before. - ShnayimMikraStore moves to appkit; the desktop's AppCoreBindings keeps its own (HTTP, Drive, search engine, delta updates, thumbnails, updater, DesktopManager and the shell's ports) and provides AppDatabases. Signed-off-by: Elie Gambache --- .../database/UserSettingsDatabase.kt | 17 +-- .../framework/di/modules/AppCoreBindings.kt | 114 +---------------- .../database/AppDatabases.android.kt | 34 +++++ .../core/shnayimmikra/ShnayimMikraStore.kt | 4 +- .../framework/database/AppDatabases.kt | 37 ++++++ .../seforimapp/framework/di/AppKitBindings.kt | 116 ++++++++++++++++++ .../framework/database/AppDatabases.ios.kt | 27 ++++ .../framework/database/AppDatabases.jvm.kt | 29 +++++ .../database/PersistentSqliteDriver.kt | 0 9 files changed, 253 insertions(+), 125 deletions(-) create mode 100644 appkit/src/androidMain/kotlin/io/github/kdroidfilter/seforimapp/framework/database/AppDatabases.android.kt rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/core/shnayimmikra/ShnayimMikraStore.kt (89%) create mode 100644 appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/framework/database/AppDatabases.kt create mode 100644 appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/framework/di/AppKitBindings.kt create mode 100644 appkit/src/iosMain/kotlin/io/github/kdroidfilter/seforimapp/framework/database/AppDatabases.ios.kt create mode 100644 appkit/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/database/AppDatabases.jvm.kt rename {SeforimApp => appkit}/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/database/PersistentSqliteDriver.kt (100%) 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/di/modules/AppCoreBindings.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/di/modules/AppCoreBindings.kt index b140c5e99..c022e98c4 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,12 +1,8 @@ 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 @@ -15,30 +11,15 @@ 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 @@ -66,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 @@ -76,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) @@ -144,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). @@ -228,12 +128,6 @@ object AppCoreBindings { @SingleIn(AppScope::class) fun provideSearchSessionStore(store: TabPersistedStateStore): SearchSessionStore = DesktopSearchSessionStore(store) - @Provides - fun provideSearchSettings(appSettings: AppSettings): SearchSettings = appSettings - - @Provides - fun provideSearchHistory(historyStore: HistoryStore): SearchHistory = historyStore - @Provides @SingleIn(AppScope::class) fun provideAcronymFrequencyCache(databasePathProvider: DatabasePathProvider): AcronymFrequencyCache = @@ -322,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/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/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/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/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/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/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 From eab4b3fe6d98c35a085d956bb942d6236f6bc7f2 Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Sun, 18 Oct 2026 02:57:41 +0300 Subject: [PATCH 5/9] feat(appkit): copy with source, error reports, history entries and the search's session store in common code - CopyWithSource and BookErrorReport move to appkit, on htmlparser's new htmlToText (jsoup's Document.text() on the JVM and Android, Ksoup's on iOS) and a common percent-encoding of the mailto. - VisitEntry.destination() and openSiddurTab: kotlin.uuid. - DesktopSearchSessionStore, which only reads the common session, becomes appkit's TabSearchSessionStore. - Their tests move with them. Signed-off-by: Elie Gambache --- .../framework/di/modules/AppCoreBindings.kt | 6 ++-- .../framework/search/DesktopSearchTabs.kt | 29 +++++++++++++++++ .../bookcontent/PostSelectLineTest.kt | 4 +-- .../search/domain/AuthorScopeSearchTest.kt | 4 +-- .../settings/SettingsWindowViewModelTest.kt | 4 +-- .../framework/desktop/DesktopManagerTest.kt | 4 +-- .../seforimapp/core/BookErrorReport.kt | 22 ++++++++++--- .../seforimapp/core/CopyWithSource.kt | 4 +-- .../core/history/VisitEntryDestination.kt | 4 +-- .../seforimapp/features/siddur/OpenSiddur.kt | 4 +-- .../framework/search/TabSearchSessionStore.kt | 31 ++----------------- .../seforimapp/core/CopyWithSourceTest.kt | 0 .../presentation/tabs/PinnedTabLabelTest.kt | 0 .../bookcontent/BookContentViewModelTest.kt | 0 .../shnayimmikra/ShnayimMikraStoreTest.kt | 0 .../seforim/htmlparser/HtmlDom.androidJvm.kt | 2 ++ .../seforim/htmlparser/HtmlDom.kt | 3 ++ .../seforim/htmlparser/HtmlDom.ios.kt | 2 ++ 18 files changed, 72 insertions(+), 51 deletions(-) create mode 100644 SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/search/DesktopSearchTabs.kt rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/core/BookErrorReport.kt (81%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/core/CopyWithSource.kt (98%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/core/history/VisitEntryDestination.kt (91%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/features/siddur/OpenSiddur.kt (84%) rename SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/search/DesktopSearchHost.kt => appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/framework/search/TabSearchSessionStore.kt (59%) rename {SeforimApp/src/jvmTest => appkit/src/commonTest}/kotlin/io/github/kdroidfilter/seforimapp/core/CopyWithSourceTest.kt (100%) rename {SeforimApp/src/jvmTest => appkit/src/commonTest}/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/tabs/PinnedTabLabelTest.kt (100%) rename {SeforimApp/src/jvmTest => appkit/src/commonTest}/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/BookContentViewModelTest.kt (100%) rename {SeforimApp => appkit}/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/core/shnayimmikra/ShnayimMikraStoreTest.kt (100%) 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 c022e98c4..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 @@ -25,10 +25,10 @@ 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 @@ -126,7 +126,7 @@ object AppCoreBindings { @Provides @SingleIn(AppScope::class) - fun provideSearchSessionStore(store: TabPersistedStateStore): SearchSessionStore = DesktopSearchSessionStore(store) + fun provideSearchSessionStore(store: TabPersistedStateStore): SearchSessionStore = TabSearchSessionStore(store) @Provides @SingleIn(AppScope::class) @@ -204,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, 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/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/PostSelectLineTest.kt b/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/PostSelectLineTest.kt index 6aecba211..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(), 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/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/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/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/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/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/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/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/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/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() From debcd43837c96741d271040c60068aec0165ed08 Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Sun, 18 Oct 2026 03:06:42 +0300 Subject: [PATCH 6/9] feat(appkit): the Compose resources and the foundation-only composables in common code - The strings, fonts, pictures and files (composeResources) move to appkit, whose Res keeps its package (seforimapp.seforimapp.generated.resources), public for the apps' screens; Android packs them. The two OpenSans variable fonts lose the comma in their names, which Kotlin/Native refuses in an accessor (unused in code). - FontCatalog (on isMacOsDesktop), the window ViewModel store owner and the navigation helper move to appkit, with FontCatalog's tests. - CLAUDE.md describes appkit; the docs and font scripts point at the resources' new place. Signed-off-by: Elie Gambache --- AGENTS.md | 2 +- CLAUDE.md | 17 +++++++++++++++-- appkit/build.gradle.kts | 11 +++++++++++ .../framework/platform/HostPlatform.android.kt | 2 ++ .../composeResources/drawable/AppIcon.png | Bin .../drawable/homepage_wallpaper_dark_large.jpg | Bin .../homepage_wallpaper_dark_medium.jpg | Bin .../drawable/homepage_wallpaper_dark_small.jpg | Bin .../drawable/homepage_wallpaper_large.jpg | Bin .../drawable/homepage_wallpaper_medium.jpg | Bin .../drawable/homepage_wallpaper_small.jpg | Bin .../drawable/zayit_logo_branch.png | Bin .../drawable/zayit_new_logo.png | Bin .../drawable/zayit_new_logo_text.png | Bin .../drawable/zayit_transparent.png | Bin .../commonMain/composeResources/files/ABOUT.md | 0 .../composeResources/files/CONDITIONS.md | 0 .../files/temple_jerusalem_in_fire.webp | Bin .../composeResources/font/AharoniCLM-Bold.otf | Bin .../font/AharoniCLM-BoldOblique.otf | Bin .../composeResources/font/AharoniCLM-Book.otf | Bin .../font/AharoniCLM-BookOblique.otf | Bin .../composeResources/font/BonaNovaSC-Bold.ttf | Bin .../font/BonaNovaSC-Italic.ttf | Bin .../font/BonaNovaSC-Regular.ttf | Bin .../composeResources/font/CaladingsCLM.otf | Bin .../composeResources/font/Cardo-Bold.ttf | Bin .../composeResources/font/Cardo-Italic.ttf | Bin .../composeResources/font/Cardo-Regular.ttf | Bin .../composeResources/font/DavidCLM-Bold.otf | Bin .../font/DavidCLM-BoldItalic.otf | Bin .../composeResources/font/DavidCLM-Medium.otf | Bin .../font/DavidCLM-MediumItalic.otf | Bin .../composeResources/font/DrugulinCLM-Bold.otf | Bin .../composeResources/font/ElliniaCLM-Bold.otf | Bin .../font/ElliniaCLM-BoldItalic.otf | Bin .../composeResources/font/ElliniaCLM-Light.otf | Bin .../font/ElliniaCLM-LightItalic.otf | Bin .../font/FrankRuehlCLM-Bold.otf | Bin .../font/FrankRuehlCLM-BoldOblique.otf | Bin .../font/FrankRuehlCLM-Medium.otf | Bin .../font/FrankRuehlCLM-MediumOblique.otf | Bin .../composeResources/font/HadasimCLM-Bold.otf | Bin .../font/HadasimCLM-BoldOblique.otf | Bin .../font/HadasimCLM-Regular.otf | Bin .../font/HadasimCLM-RegularOblique.otf | Bin .../composeResources/font/KeterAramTsova.ttf | Bin .../composeResources/font/KeterYG-Bold.ttf | Bin .../font/KeterYG-BoldOblique.ttf | Bin .../composeResources/font/KeterYG-Medium.ttf | Bin .../font/KeterYG-MediumOblique.ttf | Bin .../font/LibertinusSerif-Bold.ttf | Bin .../font/LibertinusSerif-BoldItalic.ttf | Bin .../font/LibertinusSerif-Italic.ttf | Bin .../font/LibertinusSerif-Regular.ttf | Bin .../font/LibertinusSerif-SemiBold.ttf | Bin .../font/LibertinusSerif-SemiBoldItalic.ttf | Bin .../composeResources/font/MiriamCLM-Bold.otf | Bin .../composeResources/font/MiriamCLM-Book.otf | Bin .../font/MiriamLibre-VariableFont_wght.ttf | Bin .../font/MiriamMonoCLM-Bold.ttf | Bin .../font/MiriamMonoCLM-BoldOblique.ttf | Bin .../font/MiriamMonoCLM-Book.ttf | Bin .../font/MiriamMonoCLM-BookOblique.ttf | Bin .../font/NachlieliCLM-Bold.otf | Bin .../font/NachlieliCLM-BoldOblique.otf | Bin .../font/NachlieliCLM-Light.otf | Bin .../font/NachlieliCLM-LightOblique.otf | Bin .../OpenSans-Italic-VariableFont_wdth_wght.ttf | Bin .../font/OpenSans-VariableFont_wdth_wght.ttf | Bin .../composeResources/font/RashiAmiti.ttf | Bin .../composeResources/font/ShofarDemi-Bold.ttf | Bin .../font/ShofarDemi-BoldOblique.ttf | Bin .../composeResources/font/ShofarRegular.ttf | Bin .../font/ShofarRegularOblique.ttf | Bin .../composeResources/font/SimpleCLM-Bold.ttf | Bin .../font/SimpleCLM-BoldOblique.ttf | Bin .../composeResources/font/SimpleCLM-Medium.ttf | Bin .../font/SimpleCLM-MediumOblique.ttf | Bin .../composeResources/font/StamAshkenazCLM.ttf | Bin .../composeResources/font/StamSefaradCLM.ttf | Bin .../font/TaameyAshkenaz-Bold.ttf | Bin .../font/TaameyAshkenaz-BoldOblique.ttf | Bin .../font/TaameyAshkenaz-Medium.ttf | Bin .../font/TaameyAshkenaz-MediumOblique.ttf | Bin .../font/TaameyDavidCLM-Bold.ttf | Bin .../font/TaameyDavidCLM-BoldOblique.ttf | Bin .../font/TaameyDavidCLM-Medium.ttf | Bin .../font/TaameyDavidCLM-MediumOblique.ttf | Bin .../font/TaameyFrankCLM-Bold.ttf | Bin .../font/TaameyFrankCLM-BoldOblique.ttf | Bin .../font/TaameyFrankCLM-Medium.ttf | Bin .../font/TaameyFrankCLM-MediumOblique.ttf | Bin .../composeResources/font/Tinos-Bold.ttf | Bin .../composeResources/font/Tinos-BoldItalic.ttf | Bin .../composeResources/font/Tinos-Italic.ttf | Bin .../composeResources/font/Tinos-Regular.ttf | Bin .../composeResources/font/YehudaCLM-Bold.otf | Bin .../composeResources/font/YehudaCLM-Light.otf | Bin .../composeResources/font/frankruhllibre.ttf | Bin .../composeResources/font/notorashihebrew.ttf | Bin .../composeResources/font/notoserifhebrew.ttf | Bin .../composeResources/values/strings.xml | 0 .../navigation/NoAnimatedComposable.kt | 0 .../presentation/typography/FontCatalog.kt | 4 ++-- .../presentation/utils/WindowViewModelOwner.kt | 0 .../framework/platform/HostPlatform.kt | 3 +++ .../presentation/typography/FontCatalogTest.kt | 0 .../framework/platform/HostPlatform.ios.kt | 2 ++ .../framework/platform/HostPlatform.jvm.kt | 6 +++++- scripts/fonts/fix_noto_meteg.py | 2 +- scripts/fonts/fix_paseq.py | 2 +- 112 files changed, 43 insertions(+), 8 deletions(-) rename {SeforimApp => appkit}/src/commonMain/composeResources/drawable/AppIcon.png (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/drawable/homepage_wallpaper_dark_large.jpg (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/drawable/homepage_wallpaper_dark_medium.jpg (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/drawable/homepage_wallpaper_dark_small.jpg (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/drawable/homepage_wallpaper_large.jpg (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/drawable/homepage_wallpaper_medium.jpg (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/drawable/homepage_wallpaper_small.jpg (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/drawable/zayit_logo_branch.png (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/drawable/zayit_new_logo.png (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/drawable/zayit_new_logo_text.png (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/drawable/zayit_transparent.png (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/files/ABOUT.md (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/files/CONDITIONS.md (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/files/temple_jerusalem_in_fire.webp (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/font/AharoniCLM-Bold.otf (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/font/AharoniCLM-BoldOblique.otf (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/font/AharoniCLM-Book.otf (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/font/AharoniCLM-BookOblique.otf (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/font/BonaNovaSC-Bold.ttf (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/font/BonaNovaSC-Italic.ttf (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/font/BonaNovaSC-Regular.ttf (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/font/CaladingsCLM.otf (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/font/Cardo-Bold.ttf (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/font/Cardo-Italic.ttf (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/font/Cardo-Regular.ttf (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/font/DavidCLM-Bold.otf (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/font/DavidCLM-BoldItalic.otf (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/font/DavidCLM-Medium.otf (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/font/DavidCLM-MediumItalic.otf (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/font/DrugulinCLM-Bold.otf (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/font/ElliniaCLM-Bold.otf (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/font/ElliniaCLM-BoldItalic.otf (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/font/ElliniaCLM-Light.otf (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/font/ElliniaCLM-LightItalic.otf (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/font/FrankRuehlCLM-Bold.otf (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/font/FrankRuehlCLM-BoldOblique.otf (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/font/FrankRuehlCLM-Medium.otf (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/font/FrankRuehlCLM-MediumOblique.otf (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/font/HadasimCLM-Bold.otf (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/font/HadasimCLM-BoldOblique.otf (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/font/HadasimCLM-Regular.otf (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/font/HadasimCLM-RegularOblique.otf (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/font/KeterAramTsova.ttf (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/font/KeterYG-Bold.ttf (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/font/KeterYG-BoldOblique.ttf (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/font/KeterYG-Medium.ttf (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/font/KeterYG-MediumOblique.ttf (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/font/LibertinusSerif-Bold.ttf (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/font/LibertinusSerif-BoldItalic.ttf (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/font/LibertinusSerif-Italic.ttf (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/font/LibertinusSerif-Regular.ttf (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/font/LibertinusSerif-SemiBold.ttf (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/font/LibertinusSerif-SemiBoldItalic.ttf (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/font/MiriamCLM-Bold.otf (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/font/MiriamCLM-Book.otf (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/font/MiriamLibre-VariableFont_wght.ttf (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/font/MiriamMonoCLM-Bold.ttf (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/font/MiriamMonoCLM-BoldOblique.ttf (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/font/MiriamMonoCLM-Book.ttf (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/font/MiriamMonoCLM-BookOblique.ttf (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/font/NachlieliCLM-Bold.otf (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/font/NachlieliCLM-BoldOblique.otf (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/font/NachlieliCLM-Light.otf (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/font/NachlieliCLM-LightOblique.otf (100%) rename SeforimApp/src/commonMain/composeResources/font/OpenSans-Italic-VariableFont_wdth,wght.ttf => appkit/src/commonMain/composeResources/font/OpenSans-Italic-VariableFont_wdth_wght.ttf (100%) rename SeforimApp/src/commonMain/composeResources/font/OpenSans-VariableFont_wdth,wght.ttf => appkit/src/commonMain/composeResources/font/OpenSans-VariableFont_wdth_wght.ttf (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/font/RashiAmiti.ttf (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/font/ShofarDemi-Bold.ttf (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/font/ShofarDemi-BoldOblique.ttf (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/font/ShofarRegular.ttf (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/font/ShofarRegularOblique.ttf (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/font/SimpleCLM-Bold.ttf (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/font/SimpleCLM-BoldOblique.ttf (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/font/SimpleCLM-Medium.ttf (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/font/SimpleCLM-MediumOblique.ttf (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/font/StamAshkenazCLM.ttf (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/font/StamSefaradCLM.ttf (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/font/TaameyAshkenaz-Bold.ttf (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/font/TaameyAshkenaz-BoldOblique.ttf (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/font/TaameyAshkenaz-Medium.ttf (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/font/TaameyAshkenaz-MediumOblique.ttf (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/font/TaameyDavidCLM-Bold.ttf (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/font/TaameyDavidCLM-BoldOblique.ttf (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/font/TaameyDavidCLM-Medium.ttf (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/font/TaameyDavidCLM-MediumOblique.ttf (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/font/TaameyFrankCLM-Bold.ttf (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/font/TaameyFrankCLM-BoldOblique.ttf (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/font/TaameyFrankCLM-Medium.ttf (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/font/TaameyFrankCLM-MediumOblique.ttf (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/font/Tinos-Bold.ttf (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/font/Tinos-BoldItalic.ttf (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/font/Tinos-Italic.ttf (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/font/Tinos-Regular.ttf (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/font/YehudaCLM-Bold.otf (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/font/YehudaCLM-Light.otf (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/font/frankruhllibre.ttf (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/font/notorashihebrew.ttf (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/font/notoserifhebrew.ttf (100%) rename {SeforimApp => appkit}/src/commonMain/composeResources/values/strings.xml (100%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/navigation/NoAnimatedComposable.kt (100%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/typography/FontCatalog.kt (99%) rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/utils/WindowViewModelOwner.kt (100%) rename {SeforimApp/src/jvmTest => appkit/src/commonTest}/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/typography/FontCatalogTest.kt (100%) 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/appkit/build.gradle.kts b/appkit/build.gradle.kts index 5f974878e..c5c56293c 100644 --- a/appkit/build.gradle.kts +++ b/appkit/build.gradle.kts @@ -34,6 +34,8 @@ kotlin { 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() @@ -52,10 +54,13 @@ kotlin { 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) @@ -91,6 +96,12 @@ kotlin { } } +// 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") { 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 index 115bb8ddd..d8f091eba 100644 --- 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 @@ -2,4 +2,6 @@ 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/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/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/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/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 index a9e900a8c..2f22b3d2c 100644 --- 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 @@ -3,5 +3,8 @@ 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/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/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 index 65cfab6e2..1a8099282 100644 --- 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 @@ -6,5 +6,7 @@ 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/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 index ca8941ec3..306679372 100644 --- 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 @@ -1,5 +1,9 @@ package io.github.kdroidfilter.seforimapp.framework.platform -actual val isLinuxDesktop: Boolean = System.getProperty("os.name").orEmpty().startsWith("Linux", ignoreCase = true) +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/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) From c487a55a89d050ffdcfa39fb881a4d50b799e149 Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Sun, 18 Oct 2026 04:24:52 +0300 Subject: [PATCH 7/9] feat(appkit): the database bundle's extraction in common code, on zstd-kmp and our own tar reader - ExtractUseCase and BundleParts move to appkit on Okio: zstd-kmp decompresses (JNI on the desktop, native on Android and iOS), a common TarReader reads the archive (ustar, POSIX extended headers for long paths and big sizes as the packager writes them, GNU long names, base-256 sizes, header checksums) and Okio's HashingSource checks the bundle's sha256. An entry climbing out of the databases directory is refused. - zstd-jni leaves SeforimApp; its GraalVM metadata gains zstd-kmp's library and the fields its JNI code sets. - Tests: the tar reader and the extraction in common code (JVM and iOS simulator), BundleParts on a fake file system, and a bundle written as the packager does (zstd-jni, commons-compress) extracted on the JVM. Signed-off-by: Elie Gambache --- SeforimApp/build.gradle.kts | 2 +- .../src/graalvm/reachability-metadata.json | 31 ++ .../onboarding/extract/ExtractUseCase.kt | 314 ------------------ .../onboarding/offline/DatabasePartsPicker.kt | 4 +- appkit/build.gradle.kts | 12 +- .../onboarding/extract/BundleParts.kt | 43 ++- .../onboarding/extract/ExtractUseCase.kt | 263 +++++++++++++++ .../features/onboarding/extract/TarReader.kt | 193 +++++++++++ .../onboarding/extract/BundlePartsTest.kt | 40 +-- .../onboarding/extract/ExtractUseCaseTest.kt | 129 +++++++ .../onboarding/extract/TarReaderTest.kt | 129 +++++++ .../features/onboarding/extract/TarWriter.kt | 50 +++ .../extract/ExtractPackagerBundleTest.kt | 57 ++++ .../onboarding/extract/TarReaderCompatTest.kt | 52 +++ gradle/libs.versions.toml | 6 + 15 files changed, 972 insertions(+), 353 deletions(-) delete mode 100644 SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/extract/ExtractUseCase.kt rename {SeforimApp/src/jvmMain => appkit/src/commonMain}/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/extract/BundleParts.kt (76%) create mode 100644 appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/extract/ExtractUseCase.kt create mode 100644 appkit/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/extract/TarReader.kt rename {SeforimApp/src/jvmTest => appkit/src/commonTest}/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/extract/BundlePartsTest.kt (72%) create mode 100644 appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/extract/ExtractUseCaseTest.kt create mode 100644 appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/extract/TarReaderTest.kt create mode 100644 appkit/src/commonTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/extract/TarWriter.kt create mode 100644 appkit/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/extract/ExtractPackagerBundleTest.kt create mode 100644 appkit/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/extract/TarReaderCompatTest.kt diff --git a/SeforimApp/build.gradle.kts b/SeforimApp/build.gradle.kts index 9551be5a0..3ed7050a5 100644 --- a/SeforimApp/build.gradle.kts +++ b/SeforimApp/build.gradle.kts @@ -176,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) 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/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/appkit/build.gradle.kts b/appkit/build.gradle.kts index c5c56293c..5897147e8 100644 --- a/appkit/build.gradle.kts +++ b/appkit/build.gradle.kts @@ -69,6 +69,9 @@ kotlin { 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) @@ -90,9 +93,16 @@ kotlin { commonTest.dependencies { implementation(kotlin("test")) implementation(libs.kotlinx.coroutines.test) + implementation(libs.okio.fakefilesystem) + implementation(libs.multiplatformSettings.test) } - jvmTest.dependencies { implementation(libs.mockk) } + 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) + } } } 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/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/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/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/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/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" } From 263482d87653ff95b4372bcfd78d29290867ba32 Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Sun, 18 Oct 2026 07:43:37 +0300 Subject: [PATCH 8/9] test(e2e): the database bundle's extraction, in the app as built (native image included) ZAYIT_E2E_SCENARIO=extract with ZAYIT_E2E_BUNDLE naming a .tar.zst or its .part01 runs the onboarding's ExtractUseCase into the run's output, with throwaway settings, and notes the extracted database's size and sha256. On the GraalVM native image (macOS arm64), a 157 MB bundle in 3 parts written as the packager does (POSIX tar, zstd -T2 with checksum, manifest with its sha256) extracts to the expected database, and so does the single .tar.zst. Signed-off-by: Elie Gambache --- .../seforimapp/core/e2e/E2eExtractScenario.kt | 50 +++++++++++++++++++ .../io/github/kdroidfilter/seforimapp/main.kt | 2 + 2 files changed, 52 insertions(+) create mode 100644 SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/e2e/E2eExtractScenario.kt 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/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() } } From 5d292d1b965af55ecb0c169a3e2fae39f0252938 Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Sun, 18 Oct 2026 07:48:59 +0300 Subject: [PATCH 9/9] test(appkit): a real database bundle extracted on the iOS simulator's file system IosExtractBundleTest runs ExtractUseCase on FileSystem.SYSTEM in the simulator, with -PseforimTestBundle (a .tar.zst or its .part01) and -PseforimTestBundleSha256 (the extracted database's); skipped without them. The 157 MB bundle in 3 parts written as the packager does extracts to the expected database in 2.6 s, and so does the single .tar.zst. Signed-off-by: Elie Gambache --- appkit/build.gradle.kts | 13 +++++ .../extract/IosExtractBundleTest.kt | 53 +++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 appkit/src/iosSimulatorArm64Test/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/extract/IosExtractBundleTest.kt diff --git a/appkit/build.gradle.kts b/appkit/build.gradle.kts index 5897147e8..fd33878a0 100644 --- a/appkit/build.gradle.kts +++ b/appkit/build.gradle.kts @@ -120,3 +120,16 @@ sqldelight { } } } + +// 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/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) + } +}