From 51289f1266924d822eb741e7ea91274b4c035717 Mon Sep 17 00:00:00 2001 From: Fabian Freund Date: Fri, 22 May 2026 18:10:22 +0200 Subject: [PATCH] Add Supa account and search changes --- .gitignore | 2 +- .vscode/launch.json | 7 +- apps/weblibre/android/app/build.gradle | 4 +- apps/weblibre/android/app/proguard-rules.pro | 48 +- .../drawable-hdpi/ic_launcher_foreground.png | Bin 11775 -> 12658 bytes .../drawable-hdpi/ic_launcher_monochrome.png | Bin 11775 -> 12658 bytes .../drawable-mdpi/ic_launcher_foreground.png | Bin 7820 -> 8546 bytes .../drawable-mdpi/ic_launcher_monochrome.png | Bin 7820 -> 8546 bytes .../drawable-xhdpi/ic_launcher_foreground.png | Bin 16813 -> 17941 bytes .../drawable-xhdpi/ic_launcher_monochrome.png | Bin 16813 -> 17941 bytes .../ic_launcher_foreground.png | Bin 26146 -> 27944 bytes .../ic_launcher_monochrome.png | Bin 26146 -> 27944 bytes .../ic_launcher_foreground.png | Bin 36561 -> 38568 bytes .../ic_launcher_monochrome.png | Bin 36561 -> 38568 bytes .../alpha/res/mipmap-hdpi/launcher_icon.png | Bin 4652 -> 5098 bytes .../alpha/res/mipmap-mdpi/launcher_icon.png | Bin 2690 -> 2968 bytes .../alpha/res/mipmap-xhdpi/launcher_icon.png | Bin 6522 -> 7035 bytes .../alpha/res/mipmap-xxhdpi/launcher_icon.png | Bin 10593 -> 11396 bytes .../res/mipmap-xxxhdpi/launcher_icon.png | Bin 14326 -> 15619 bytes .../android/app/src/main/AndroidManifest.xml | 19 +- .../kotlin/eu/weblibre/gecko/MyApplication.kt | 7 + apps/weblibre/assets/icon/icon_alpha.png | Bin 54060 -> 54795 bytes apps/weblibre/assets/icon/icon_alpha.svg | 34 +- apps/weblibre/build.yaml | 5 + .../drift_schemas/tabs/drift_schema_v10.json | 646 ++++ .../drift_schemas/tabs/drift_schema_v11.json | 646 ++++ .../drift_schemas/tabs/drift_schema_v12.json | 1119 ++++++ .../drift_schemas/tabs/drift_schema_v13.json | 1139 ++++++ .../drift_schemas/user/drift_schema_v4.json | 408 +++ .../drift_schemas/user/drift_schema_v5.json | 441 +++ apps/weblibre/lib/core/filesystem.dart | 4 +- .../lib/core/providers/device_info.dart | 16 +- .../lib/core/providers/device_info.g.dart | 2 +- apps/weblibre/lib/core/routing/routes.dart | 1 + apps/weblibre/lib/core/routing/routes.g.dart | 26 + .../lib/core/routing/routes.settings.dart | 11 + .../data/database/converters/icon_data.dart | 4 +- .../database/functions/url_functions.dart | 115 + .../lib/domain/entities/equatable_image.dart | 36 +- .../domain/services/app_initialization.dart | 4 + .../domain/services/app_initialization.g.dart | 2 +- .../lib/domain/services/favicon_resolver.dart | 94 + .../lib/domain/services/generic_website.dart | 475 ++- .../domain/services/generic_website.g.dart | 90 +- .../account/data/account_secure_store.dart | 66 + .../account/data/account_secure_store.g.dart | 58 + .../data/models/account_auth_state.dart | 68 + .../data/models/account_auth_state.g.dart | 126 + .../data/models/account_persisted_data.dart | 60 + .../data/models/account_persisted_data.g.dart | 147 + .../data/models/persisted_session.dart | 58 + .../data/models/persisted_session.g.dart | 23 + .../data/models/settings_sync_envelope.dart | 61 + .../data/models/settings_sync_envelope.g.dart | 46 + .../data/models/subscription_status.dart | 95 + .../repositories/account_sync_repository.dart | 181 + .../account_sync_repository.g.dart | 92 + .../account/data/supabase_config.dart | 43 + .../domain/repositories/account_auth.dart | 408 +++ .../domain/repositories/account_auth.g.dart | 57 + .../repositories/subscription_repository.dart | 54 + .../subscription_repository.g.dart | 57 + .../services/account_callback_handler.dart | 68 + .../services/account_callback_handler.g.dart | 68 + .../services/handoff_redeem_client.dart | 113 + .../services/handoff_redeem_client.g.dart | 58 + .../domain/services/prefs_js_reader.dart | 83 + .../domain/services/prefs_sync_service.dart | 99 + .../domain/services/prefs_sync_service.g.dart | 62 + .../services/settings_sync_service.dart | 93 + .../services/settings_sync_service.g.dart | 63 + .../services/sync_document_service.dart | 39 + .../features/account/domain/utils/pkce.dart | 62 + .../account/domain/utils/user_js_parser.dart | 461 +++ .../domain/utils/user_js_serializer.dart | 79 + .../screens/account_settings.dart | 223 ++ .../widgets/account_auth_status_card.dart | 255 ++ .../widgets/subscription_card.dart | 308 ++ .../widgets/sync_document_dialogs.dart | 207 ++ .../widgets/sync_document_list_section.dart | 382 ++ .../presentation/widgets/sync_setup_card.dart | 267 ++ .../screens/addon_listing_details.dart | 78 +- .../presentation/screens/addon_manager.dart | 71 +- .../bangs/data/database/database.drift.dart | 44 + .../bangs/data/models/bang_group.dart | 3 +- .../bangs/data/models/web_search_bang.dart | 24 + .../bangs/domain/providers/search.dart | 6 + .../bangs/domain/providers/search.g.dart | 2 +- .../bangs/domain/repositories/data.dart | 12 +- .../bangs/domain/repositories/data.g.dart | 2 +- .../bangs/domain/repositories/sync.dart | 21 +- .../bangs/domain/repositories/sync.g.dart | 2 +- .../bangs/presentation/screens/search.dart | 49 +- .../domain/entities/browser_icon.dart | 7 +- .../geckoview/domain/providers/tab_state.dart | 31 +- .../domain/providers/tab_state.g.dart | 2 +- .../providers/web_extensions_state.dart | 11 +- .../providers/web_extensions_state.g.dart | 2 +- .../geckoview/domain/repositories/tab.dart | 64 +- .../geckoview/domain/repositories/tab.g.dart | 2 +- .../select_bookmark_folder_dialog.dart | 19 +- .../features/browser/domain/providers.dart | 11 +- .../features/browser/domain/providers.g.dart | 2 +- .../browser/domain/services/browser_data.dart | 2 + .../domain/services/browser_data.g.dart | 2 +- .../presentation/toolbar_button_registry.dart | 11 +- .../widgets/contextual_bar_buttons.dart | 13 +- .../presentation/dialogs/keep_tab_dialog.dart | 6 +- .../dialogs/select_folder_dialog.dart | 17 +- .../site_settings_badge_provider.dart | 12 + .../site_settings_badge_provider.g.dart | 2 +- .../browser/presentation/screens/browser.dart | 56 +- .../presentation/widgets/browser_home.dart | 18 +- .../widgets/browser_menu_sheet.dart | 133 +- .../browser_modules/app_bar_title.dart | 106 +- .../browser_modules/bottom_app_bar.dart | 173 +- .../widgets/browser_modules/browser_view.dart | 22 + .../widgets/certificate_tile.dart | 19 + .../widgets/menu_item_buttons.dart | 25 +- .../widgets/share_bottom_sheet.dart | 20 +- .../presentation/widgets/sheets/view_tab.dart | 17 +- .../presentation/widgets/tab_icon.dart | 10 + .../presentation/widgets/tab_menu.dart | 29 +- .../widgets/tab_view/tab_preview.dart | 29 +- .../widgets/tab_view/tab_tree_view.dart | 29 +- .../candidates/open_in_container.dart | 3 +- .../controllers/find_in_page.dart | 9 +- .../controllers/find_in_page.g.dart | 6 +- .../presentation/widgets/find_in_page.dart | 16 + .../history/domain/repositories/history.dart | 67 + .../domain/repositories/history.g.dart | 2 +- .../dialogs/open_shared_content.dart | 1 + .../dialogs/tracking_details_dialog.dart | 132 +- .../screens/unshortener_settings.dart | 73 +- .../screens/url_cleaner_settings.dart | 102 +- .../features/pwa/domain/providers.dart | 17 + .../features/pwa/domain/providers.g.dart | 4 +- .../domain/providers/combined_history.dart | 149 + .../domain/providers/combined_history.g.dart | 81 + .../domain/providers/search_autofocus.dart | 38 + .../domain/providers/search_autofocus.g.dart | 64 + .../domain/providers/search_module_order.dart | 10 +- .../providers/search_module_order.g.dart | 6 + .../domain/providers/search_modules_view.dart | 50 +- .../search/presentation/screens/search.dart | 410 ++- .../presentation/widgets/bang_chip_strip.dart | 141 + .../empty_state/frequent_bangs_section.dart | 163 + .../empty_state/recent_searches_section.dart | 66 + .../empty_state/recent_tabs_section.dart | 15 +- .../presentation/widgets/search_field.dart | 9 +- .../combined_history_suggestions.dart | 178 + .../full_search_suggestions.dart | 251 -- .../search_modules/history_row_icon.dart | 66 + .../search_modules/history_suggestions.dart | 69 +- .../local_history_suggestions.dart | 175 + .../search_modules/search_module_header.dart | 12 +- .../search_modules/search_module_section.dart | 31 + .../search_providers_section.dart | 63 + .../search_modules/search_query_chips.dart | 146 + .../search_term_suggestions_section.dart | 82 + .../widgets/smart_bang_selector.dart | 72 +- .../tabs/data/database/daos/capture_tab.dart | 68 + .../data/database/daos/capture_tab.drift.dart | 14 + .../tabs/data/database/daos/container.dart | 60 + .../tabs/data/database/daos/history.dart | 136 + .../data/database/daos/history.drift.dart | 14 + .../features/tabs/data/database/daos/tab.dart | 21 +- .../features/tabs/data/database/database.dart | 100 +- .../tabs/data/database/database.drift.dart | 136 +- .../tabs/data/database/database.steps.dart | 924 +++++ .../tabs/data/database/definitions.drift | 361 +- .../tabs/data/database/definitions.drift.dart | 3134 +++++++++++++++- .../tabs/data/models/container_data.dart | 19 +- .../tabs/data/models/container_data.g.dart | 30 + .../data/models/history_query_result.dart | 66 + .../features/tabs/data/providers.dart | 2 + .../features/tabs/data/providers.g.dart | 2 +- .../tabs/domain/repositories/container.dart | 148 +- .../tabs/domain/repositories/container.g.dart | 2 +- .../domain/repositories/history_search.dart | 216 ++ .../domain/repositories/history_search.g.dart | 94 + .../domain/services/local_index_pruner.dart | 127 + .../domain/services/local_index_pruner.g.dart | 87 + .../services/local_index_settings_sync.dart | 85 + .../services/local_index_settings_sync.g.dart | 96 + .../presentation/screens/container_edit.dart | 478 ++- .../presentation/screens/container_list.dart | 419 ++- .../screens/container_selection.dart | 362 +- .../widgets/color_picker_dialog.dart | 4 +- .../widgets/compact_container_selector.dart | 50 +- .../widgets/container_chip_content.dart | 71 + .../presentation/widgets/container_chips.dart | 332 +- .../widgets/container_icon_picker_sheet.dart | 234 ++ .../widgets/container_list_tile.dart | 11 +- .../widgets/material_color_picker.dart | 23 +- .../widgets/tab_drag_container_target.dart | 31 +- .../features/tabs/utils/container_colors.dart | 130 +- .../features/tabs/utils/container_icons.dart | 57 + .../data/database/database.drift.dart | 44 + .../geckoview/utils/image_helper.dart | 137 + .../presentation/pages/welcome.dart | 9 +- .../quotes/data/database/database.drift.dart | 44 + .../features/search/domain/fts_tokenizer.dart | 6 +- .../data/models/search_credits_status.dart | 62 + .../data/models/web_search_settings.dart | 80 + .../data/models/web_search_settings.g.dart | 165 + .../data/search_backend_config.dart | 41 + .../search_credits/data/token_stash.dart | 77 + .../search_token_issuance_controller.dart | 238 ++ .../search_token_issuance_controller.g.dart | 118 + .../search_credits/domain/providers.dart | 52 + .../search_credits/domain/providers.g.dart | 140 + .../domain/providers/proxy_client.dart | 82 + .../domain/providers/proxy_client.g.dart | 169 + .../search_credits_repository.dart | 57 + .../search_credits_repository.g.dart | 58 + .../search_token_stash_repository.dart | 41 + .../search_token_stash_repository.g.dart | 102 + .../repositories/web_search_settings.dart | 66 + .../repositories/web_search_settings.g.dart | 106 + .../widgets/search_credits_section.dart | 202 ++ .../dialogs/log_details_dialog.dart | 109 +- .../screens/addon_collection.dart | 213 +- .../screens/advanced_settings.dart | 141 +- .../presentation/screens/bang_settings.dart | 105 +- .../screens/browsing_settings.dart | 300 +- .../screens/contextual_toolbar_settings.dart | 271 +- .../screens/custom_tracking_protection.dart | 86 +- .../presentation/screens/doh_settings.dart | 25 +- .../screens/error_logs_screen.dart | 106 +- .../screens/experimental_settings.dart | 59 +- .../screens/extensions_settings.dart | 74 +- .../screens/fingerprint_settings.dart | 287 +- .../screens/general_settings.dart | 125 +- .../presentation/screens/locale_settings.dart | 262 +- .../screens/privacy_security_settings.dart | 359 +- .../presentation/screens/search_settings.dart | 308 +- .../presentation/screens/settings.dart | 505 +-- .../screens/toolbar_layout_settings.dart | 46 +- .../tracking_protection_exceptions.dart | 125 +- .../screens/ublock_filter_lists.dart | 99 +- .../screens/web_content_settings.dart | 124 +- .../screens/web_engine_hardening.dart | 249 +- .../screens/web_engine_hardening_group.dart | 355 +- .../widgets/default_search_selector.dart | 68 +- .../widgets/settings_content_card.dart | 51 + .../presentation/widgets/settings_detail.dart | 362 ++ .../widgets/toolbar_layout_content.dart | 125 +- .../presentation/widgets/toolbar_preview.dart | 18 +- .../domain/services/sharing_intent.dart | 80 +- .../domain/services/sharing_intent.g.dart | 109 +- .../data/database/database.drift.dart | 44 + .../widgets/small_web_history_list.dart | 95 +- .../widgets/small_web_menu_sheet.dart | 457 +-- .../widgets/small_web_mode_chips.dart | 93 +- .../widgets/wander_console_sheet.dart | 111 +- .../presentation/screens/sync_settings.dart | 96 +- .../widgets/tor_notification.dart | 6 +- .../user/data/database/daos/cache.dart | 20 + .../data/database/daos/search_tokens.dart | 100 + .../database/daos/search_tokens.drift.dart | 13 + .../features/user/data/database/database.dart | 19 +- .../user/data/database/database.drift.dart | 71 +- .../user/data/database/database.steps.dart | 273 +- .../user/data/database/definitions.drift | 11 + .../user/data/database/definitions.drift.dart | 519 +++ .../features/user/data/icon_cache_marker.dart | 48 + .../user/data/models/general_settings.dart | 26 +- .../user/data/models/general_settings.g.dart | 48 + .../lib/features/user/data/providers.dart | 8 + .../lib/features/user/data/providers.g.dart | 2 +- .../lib/features/user/domain/providers.dart | 8 + .../lib/features/user/domain/providers.g.dart | 75 + .../user/domain/repositories/cache.dart | 43 +- .../user/domain/repositories/cache.g.dart | 2 +- .../domain/repositories/general_settings.dart | 12 + .../repositories/general_settings.g.dart | 2 +- .../data/database/database.drift.dart | 44 + .../web_search/data/locale_options.dart | 151 + .../sandbox_capture_controller.dart | 690 ++++ .../sandbox_capture_controller.g.dart | 472 +++ .../domain/controllers/search_controller.dart | 1234 +++++++ .../controllers/search_controller.g.dart | 134 + .../domain/entities/captured_page_state.dart | 62 + .../entities/captured_page_state.g.dart | 184 + .../domain/entities/fetch_method.dart | 67 + .../services/capture_artifact_downloader.dart | 213 ++ .../capture_artifact_downloader.g.dart | 58 + .../domain/services/capture_server.dart | 631 ++++ .../domain/services/capture_server.g.dart | 51 + .../services/sandbox_capture_store.dart | 57 + .../dialogs/fetch_method_dialog.dart | 208 ++ .../presentation/open_in_new_tab.dart | 142 + .../presentation/screens/page_preview.dart | 94 + .../widgets/page_metadata_chips.dart | 55 + .../widgets/route_through_tor_toggle.dart | 187 + .../presentation/widgets/search_field.dart | 80 + .../widgets/search_filter_chips.dart | 416 +++ .../widgets/search_mode_selector.dart | 113 + .../widgets/search_result_card.dart | 527 +++ .../widgets/search_result_metadata_chips.dart | 407 +++ .../widgets/web_search_infobox_card.dart | 398 +++ .../widgets/web_search_results_section.dart | 305 ++ apps/weblibre/lib/main.dart | 22 + apps/weblibre/lib/presentation/main_app.dart | 38 +- .../widgets/auto_suggest_text_field.dart | 7 +- .../widgets/inline_count_badge.dart | 62 + .../widgets/selectable_chips.dart | 62 +- .../lib/presentation/widgets/url_icon.dart | 94 +- .../presentation/widgets/url_list_tile.dart | 13 +- .../widgets/website_title_tile.dart | 23 + apps/weblibre/lib/utils/url_canonical.dart | 103 + apps/weblibre/pubspec.yaml | 21 +- .../domain/services/generic_website_test.dart | 244 ++ .../test/drift/tabs/generated/schema.dart | 14 +- .../test/drift/tabs/generated/schema_v10.dart | 1893 ++++++++++ .../test/drift/tabs/generated/schema_v11.dart | 1893 ++++++++++ .../test/drift/tabs/generated/schema_v12.dart | 3092 ++++++++++++++++ .../test/drift/tabs/generated/schema_v13.dart | 3168 +++++++++++++++++ .../test/drift/user/generated/schema.dart | 8 +- .../test/drift/user/generated/schema_v4.dart | 1449 ++++++++ .../test/drift/user/generated/schema_v5.dart | 1493 ++++++++ .../domain/services/prefs_js_reader_test.dart | 93 + .../domain/utils/user_js_parser_test.dart | 137 + .../domain/utils/user_js_serializer_test.dart | 72 + .../widgets/subscription_card_test.dart | 101 + .../widgets/frequent_bangs_section_test.dart | 173 + .../geckoview/utils/image_helper_test.dart | 53 + .../widgets/settings_detail_test.dart | 81 + .../controllers/search_controller_test.dart | 719 ++++ .../domain/services/capture_server_test.dart | 271 ++ .../screens/page_preview_test.dart | 122 + .../widgets/search_result_card_test.dart | 122 + .../features/web_search/test_harness.dart | 81 + .../presentation/widgets/url_icon_test.dart | 168 + .../test/utils/url_canonical_test.dart | 62 + .../extensions/sandbox_capture/background.js | 28 + .../extensions/sandbox_capture/manifest.json | 20 + .../BaseBrowserFragment.kt | 8 +- .../EngineProvider.kt | 3 + .../ExternalAppBrowserFragment.kt | 59 +- .../FlutterMozillaComponentsPlugin.kt | 4 +- .../GeckoPlatformView.kt | 3 +- .../GlobalComponents.kt | 27 + .../api/GeckoBrowserApiImpl.kt | 2 + .../api/GeckoHistoryApiImpl.kt | 231 ++ .../api/GeckoIconsApiImpl.kt | 39 +- .../components/Core.kt | 4 + .../feature/SandboxCaptureFeature.kt | 177 + .../feature/SandboxCaptureRegistry.kt | 109 + .../interceptor/AppRequestInterceptor.kt | 73 + .../middleware/SandboxCaptureMiddleware.kt | 53 + .../pigeons/Gecko.g.kt | 1154 ++++-- .../PrivateTabsNotificationService.kt | 74 + .../widget/BackGestureFilterFrameLayout.kt | 128 + .../res/drawable/mdi_icon_domino_mask.xml | 9 + .../src/main/res/layout/fragment_browser.xml | 10 +- .../android/src/main/res/values/strings.xml | 3 + .../lib/flutter_mozilla_components.dart | 6 + .../src/domain/services/gecko_history.dart | 68 + .../lib/src/pigeons/gecko.g.dart | 1103 ++++-- .../pigeons/gecko.dart | 183 + .../weblibre/flutter_tor/FlutterTorPlugin.kt | 57 +- .../flutter_tor/PluggableTransportManager.kt | 72 +- .../eu/weblibre/flutter_tor/PortManager.kt | 32 - .../eu/weblibre/flutter_tor/TorConfig.kt | 81 +- .../eu/weblibre/flutter_tor/TorManager.kt | 409 ++- .../eu/weblibre/flutter_tor/TorService.kt | 35 +- .../SimpleIntentReceiverPlugin.kt | 55 +- .../pigeons/Intent.g.kt | 37 + .../lib/src/intent_receiver.dart | 58 +- .../lib/src/pigeons/intent.g.dart | 37 + .../pigeons/intent.dart | 9 + pubspec.yaml | 2 +- 374 files changed, 54061 insertions(+), 5013 deletions(-) create mode 100644 apps/weblibre/drift_schemas/tabs/drift_schema_v10.json create mode 100644 apps/weblibre/drift_schemas/tabs/drift_schema_v11.json create mode 100644 apps/weblibre/drift_schemas/tabs/drift_schema_v12.json create mode 100644 apps/weblibre/drift_schemas/tabs/drift_schema_v13.json create mode 100644 apps/weblibre/drift_schemas/user/drift_schema_v4.json create mode 100644 apps/weblibre/drift_schemas/user/drift_schema_v5.json create mode 100644 apps/weblibre/lib/data/database/functions/url_functions.dart create mode 100644 apps/weblibre/lib/domain/services/favicon_resolver.dart create mode 100644 apps/weblibre/lib/features/account/data/account_secure_store.dart create mode 100644 apps/weblibre/lib/features/account/data/account_secure_store.g.dart create mode 100644 apps/weblibre/lib/features/account/data/models/account_auth_state.dart create mode 100644 apps/weblibre/lib/features/account/data/models/account_auth_state.g.dart create mode 100644 apps/weblibre/lib/features/account/data/models/account_persisted_data.dart create mode 100644 apps/weblibre/lib/features/account/data/models/account_persisted_data.g.dart create mode 100644 apps/weblibre/lib/features/account/data/models/persisted_session.dart create mode 100644 apps/weblibre/lib/features/account/data/models/persisted_session.g.dart create mode 100644 apps/weblibre/lib/features/account/data/models/settings_sync_envelope.dart create mode 100644 apps/weblibre/lib/features/account/data/models/settings_sync_envelope.g.dart create mode 100644 apps/weblibre/lib/features/account/data/models/subscription_status.dart create mode 100644 apps/weblibre/lib/features/account/data/repositories/account_sync_repository.dart create mode 100644 apps/weblibre/lib/features/account/data/repositories/account_sync_repository.g.dart create mode 100644 apps/weblibre/lib/features/account/data/supabase_config.dart create mode 100644 apps/weblibre/lib/features/account/domain/repositories/account_auth.dart create mode 100644 apps/weblibre/lib/features/account/domain/repositories/account_auth.g.dart create mode 100644 apps/weblibre/lib/features/account/domain/repositories/subscription_repository.dart create mode 100644 apps/weblibre/lib/features/account/domain/repositories/subscription_repository.g.dart create mode 100644 apps/weblibre/lib/features/account/domain/services/account_callback_handler.dart create mode 100644 apps/weblibre/lib/features/account/domain/services/account_callback_handler.g.dart create mode 100644 apps/weblibre/lib/features/account/domain/services/handoff_redeem_client.dart create mode 100644 apps/weblibre/lib/features/account/domain/services/handoff_redeem_client.g.dart create mode 100644 apps/weblibre/lib/features/account/domain/services/prefs_js_reader.dart create mode 100644 apps/weblibre/lib/features/account/domain/services/prefs_sync_service.dart create mode 100644 apps/weblibre/lib/features/account/domain/services/prefs_sync_service.g.dart create mode 100644 apps/weblibre/lib/features/account/domain/services/settings_sync_service.dart create mode 100644 apps/weblibre/lib/features/account/domain/services/settings_sync_service.g.dart create mode 100644 apps/weblibre/lib/features/account/domain/services/sync_document_service.dart create mode 100644 apps/weblibre/lib/features/account/domain/utils/pkce.dart create mode 100644 apps/weblibre/lib/features/account/domain/utils/user_js_parser.dart create mode 100644 apps/weblibre/lib/features/account/domain/utils/user_js_serializer.dart create mode 100644 apps/weblibre/lib/features/account/presentation/screens/account_settings.dart create mode 100644 apps/weblibre/lib/features/account/presentation/widgets/account_auth_status_card.dart create mode 100644 apps/weblibre/lib/features/account/presentation/widgets/subscription_card.dart create mode 100644 apps/weblibre/lib/features/account/presentation/widgets/sync_document_dialogs.dart create mode 100644 apps/weblibre/lib/features/account/presentation/widgets/sync_document_list_section.dart create mode 100644 apps/weblibre/lib/features/account/presentation/widgets/sync_setup_card.dart create mode 100644 apps/weblibre/lib/features/bangs/data/models/web_search_bang.dart create mode 100644 apps/weblibre/lib/features/geckoview/features/search/domain/providers/combined_history.dart create mode 100644 apps/weblibre/lib/features/geckoview/features/search/domain/providers/combined_history.g.dart create mode 100644 apps/weblibre/lib/features/geckoview/features/search/domain/providers/search_autofocus.dart create mode 100644 apps/weblibre/lib/features/geckoview/features/search/domain/providers/search_autofocus.g.dart create mode 100644 apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/bang_chip_strip.dart create mode 100644 apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/empty_state/frequent_bangs_section.dart create mode 100644 apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/empty_state/recent_searches_section.dart create mode 100644 apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/search_modules/combined_history_suggestions.dart delete mode 100644 apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/search_modules/full_search_suggestions.dart create mode 100644 apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/search_modules/history_row_icon.dart create mode 100644 apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/search_modules/local_history_suggestions.dart create mode 100644 apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/search_modules/search_providers_section.dart create mode 100644 apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/search_modules/search_query_chips.dart create mode 100644 apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/search_modules/search_term_suggestions_section.dart create mode 100644 apps/weblibre/lib/features/geckoview/features/tabs/data/database/daos/capture_tab.dart create mode 100644 apps/weblibre/lib/features/geckoview/features/tabs/data/database/daos/capture_tab.drift.dart create mode 100644 apps/weblibre/lib/features/geckoview/features/tabs/data/database/daos/history.dart create mode 100644 apps/weblibre/lib/features/geckoview/features/tabs/data/database/daos/history.drift.dart create mode 100644 apps/weblibre/lib/features/geckoview/features/tabs/data/models/history_query_result.dart create mode 100644 apps/weblibre/lib/features/geckoview/features/tabs/domain/repositories/history_search.dart create mode 100644 apps/weblibre/lib/features/geckoview/features/tabs/domain/repositories/history_search.g.dart create mode 100644 apps/weblibre/lib/features/geckoview/features/tabs/domain/services/local_index_pruner.dart create mode 100644 apps/weblibre/lib/features/geckoview/features/tabs/domain/services/local_index_pruner.g.dart create mode 100644 apps/weblibre/lib/features/geckoview/features/tabs/domain/services/local_index_settings_sync.dart create mode 100644 apps/weblibre/lib/features/geckoview/features/tabs/domain/services/local_index_settings_sync.g.dart create mode 100644 apps/weblibre/lib/features/geckoview/features/tabs/presentation/widgets/container_chip_content.dart create mode 100644 apps/weblibre/lib/features/geckoview/features/tabs/presentation/widgets/container_icon_picker_sheet.dart create mode 100644 apps/weblibre/lib/features/geckoview/features/tabs/utils/container_icons.dart create mode 100644 apps/weblibre/lib/features/search_credits/data/models/search_credits_status.dart create mode 100644 apps/weblibre/lib/features/search_credits/data/models/web_search_settings.dart create mode 100644 apps/weblibre/lib/features/search_credits/data/models/web_search_settings.g.dart create mode 100644 apps/weblibre/lib/features/search_credits/data/search_backend_config.dart create mode 100644 apps/weblibre/lib/features/search_credits/data/token_stash.dart create mode 100644 apps/weblibre/lib/features/search_credits/domain/controllers/search_token_issuance_controller.dart create mode 100644 apps/weblibre/lib/features/search_credits/domain/controllers/search_token_issuance_controller.g.dart create mode 100644 apps/weblibre/lib/features/search_credits/domain/providers.dart create mode 100644 apps/weblibre/lib/features/search_credits/domain/providers.g.dart create mode 100644 apps/weblibre/lib/features/search_credits/domain/providers/proxy_client.dart create mode 100644 apps/weblibre/lib/features/search_credits/domain/providers/proxy_client.g.dart create mode 100644 apps/weblibre/lib/features/search_credits/domain/repositories/search_credits_repository.dart create mode 100644 apps/weblibre/lib/features/search_credits/domain/repositories/search_credits_repository.g.dart create mode 100644 apps/weblibre/lib/features/search_credits/domain/repositories/search_token_stash_repository.dart create mode 100644 apps/weblibre/lib/features/search_credits/domain/repositories/search_token_stash_repository.g.dart create mode 100644 apps/weblibre/lib/features/search_credits/domain/repositories/web_search_settings.dart create mode 100644 apps/weblibre/lib/features/search_credits/domain/repositories/web_search_settings.g.dart create mode 100644 apps/weblibre/lib/features/search_credits/presentation/widgets/search_credits_section.dart create mode 100644 apps/weblibre/lib/features/settings/presentation/widgets/settings_content_card.dart create mode 100644 apps/weblibre/lib/features/settings/presentation/widgets/settings_detail.dart create mode 100644 apps/weblibre/lib/features/user/data/database/daos/search_tokens.dart create mode 100644 apps/weblibre/lib/features/user/data/database/daos/search_tokens.drift.dart create mode 100644 apps/weblibre/lib/features/user/data/icon_cache_marker.dart create mode 100644 apps/weblibre/lib/features/web_search/data/locale_options.dart create mode 100644 apps/weblibre/lib/features/web_search/domain/controllers/sandbox_capture_controller.dart create mode 100644 apps/weblibre/lib/features/web_search/domain/controllers/sandbox_capture_controller.g.dart create mode 100644 apps/weblibre/lib/features/web_search/domain/controllers/search_controller.dart create mode 100644 apps/weblibre/lib/features/web_search/domain/controllers/search_controller.g.dart create mode 100644 apps/weblibre/lib/features/web_search/domain/entities/captured_page_state.dart create mode 100644 apps/weblibre/lib/features/web_search/domain/entities/captured_page_state.g.dart create mode 100644 apps/weblibre/lib/features/web_search/domain/entities/fetch_method.dart create mode 100644 apps/weblibre/lib/features/web_search/domain/services/capture_artifact_downloader.dart create mode 100644 apps/weblibre/lib/features/web_search/domain/services/capture_artifact_downloader.g.dart create mode 100644 apps/weblibre/lib/features/web_search/domain/services/capture_server.dart create mode 100644 apps/weblibre/lib/features/web_search/domain/services/capture_server.g.dart create mode 100644 apps/weblibre/lib/features/web_search/domain/services/sandbox_capture_store.dart create mode 100644 apps/weblibre/lib/features/web_search/presentation/dialogs/fetch_method_dialog.dart create mode 100644 apps/weblibre/lib/features/web_search/presentation/open_in_new_tab.dart create mode 100644 apps/weblibre/lib/features/web_search/presentation/screens/page_preview.dart create mode 100644 apps/weblibre/lib/features/web_search/presentation/widgets/page_metadata_chips.dart create mode 100644 apps/weblibre/lib/features/web_search/presentation/widgets/route_through_tor_toggle.dart create mode 100644 apps/weblibre/lib/features/web_search/presentation/widgets/search_field.dart create mode 100644 apps/weblibre/lib/features/web_search/presentation/widgets/search_filter_chips.dart create mode 100644 apps/weblibre/lib/features/web_search/presentation/widgets/search_mode_selector.dart create mode 100644 apps/weblibre/lib/features/web_search/presentation/widgets/search_result_card.dart create mode 100644 apps/weblibre/lib/features/web_search/presentation/widgets/search_result_metadata_chips.dart create mode 100644 apps/weblibre/lib/features/web_search/presentation/widgets/web_search_infobox_card.dart create mode 100644 apps/weblibre/lib/features/web_search/presentation/widgets/web_search_results_section.dart create mode 100644 apps/weblibre/lib/presentation/widgets/inline_count_badge.dart create mode 100644 apps/weblibre/lib/utils/url_canonical.dart create mode 100644 apps/weblibre/test/domain/services/generic_website_test.dart create mode 100644 apps/weblibre/test/drift/tabs/generated/schema_v10.dart create mode 100644 apps/weblibre/test/drift/tabs/generated/schema_v11.dart create mode 100644 apps/weblibre/test/drift/tabs/generated/schema_v12.dart create mode 100644 apps/weblibre/test/drift/tabs/generated/schema_v13.dart create mode 100644 apps/weblibre/test/drift/user/generated/schema_v4.dart create mode 100644 apps/weblibre/test/drift/user/generated/schema_v5.dart create mode 100644 apps/weblibre/test/features/account/domain/services/prefs_js_reader_test.dart create mode 100644 apps/weblibre/test/features/account/domain/utils/user_js_parser_test.dart create mode 100644 apps/weblibre/test/features/account/domain/utils/user_js_serializer_test.dart create mode 100644 apps/weblibre/test/features/account/presentation/widgets/subscription_card_test.dart create mode 100644 apps/weblibre/test/features/geckoview/features/search/presentation/widgets/frequent_bangs_section_test.dart create mode 100644 apps/weblibre/test/features/geckoview/utils/image_helper_test.dart create mode 100644 apps/weblibre/test/features/settings/presentation/widgets/settings_detail_test.dart create mode 100644 apps/weblibre/test/features/web_search/domain/controllers/search_controller_test.dart create mode 100644 apps/weblibre/test/features/web_search/domain/services/capture_server_test.dart create mode 100644 apps/weblibre/test/features/web_search/presentation/screens/page_preview_test.dart create mode 100644 apps/weblibre/test/features/web_search/presentation/widgets/search_result_card_test.dart create mode 100644 apps/weblibre/test/features/web_search/test_harness.dart create mode 100644 apps/weblibre/test/presentation/widgets/url_icon_test.dart create mode 100644 apps/weblibre/test/utils/url_canonical_test.dart create mode 100644 packages/flutter_mozilla_components/android/src/main/assets/extensions/sandbox_capture/background.js create mode 100644 packages/flutter_mozilla_components/android/src/main/assets/extensions/sandbox_capture/manifest.json create mode 100644 packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/feature/SandboxCaptureFeature.kt create mode 100644 packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/feature/SandboxCaptureRegistry.kt create mode 100644 packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/middleware/SandboxCaptureMiddleware.kt create mode 100644 packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/services/PrivateTabsNotificationService.kt create mode 100644 packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/widget/BackGestureFilterFrameLayout.kt create mode 100644 packages/flutter_mozilla_components/android/src/main/res/drawable/mdi_icon_domino_mask.xml delete mode 100644 packages/flutter_tor/android/src/main/kotlin/eu/weblibre/flutter_tor/PortManager.kt diff --git a/.gitignore b/.gitignore index 793c0cab..892ec1b7 100644 --- a/.gitignore +++ b/.gitignore @@ -17,4 +17,4 @@ plans/ .env .temp/ certs/ -codenomad/ \ No newline at end of file +codenomad/ diff --git a/.vscode/launch.json b/.vscode/launch.json index 026e41ce..8ce77e7d 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -13,7 +13,12 @@ "--device-user", "0", "--flavor", - "alpha" + "alpha", + "--dart-define", + "ACCOUNT_BACKEND_ORIGIN=https://account.weblibre.eu/", + "--dart-define", + "SEARCH_BACKEND_ORIGIN=https://search.weblibre.eu/", + "SEARCH_BACKEND_ORIGIN_TOR=http://eyipgwt32zaejr2xwblaswp2ur4qikapofunbqus5dklvf7jxkncirad.onion", ] }, { diff --git a/apps/weblibre/android/app/build.gradle b/apps/weblibre/android/app/build.gradle index f7948ebb..e11f95da 100644 --- a/apps/weblibre/android/app/build.gradle +++ b/apps/weblibre/android/app/build.gradle @@ -87,7 +87,9 @@ android { // Signing with the debug keys for now, so `flutter run --release` works. signingConfig = signingConfigs.release - proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + shrinkResources = !project.hasProperty("disableOptimization") + minifyEnabled = !project.hasProperty("disableOptimization") + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' matchingFallbacks = ['release'] // Use on the "release" build type in dependencies (AARs) } } diff --git a/apps/weblibre/android/app/proguard-rules.pro b/apps/weblibre/android/app/proguard-rules.pro index 12037847..3ccf0e29 100644 --- a/apps/weblibre/android/app/proguard-rules.pro +++ b/apps/weblibre/android/app/proguard-rules.pro @@ -1,43 +1,17 @@ -dontobfuscate #################################################################################################### -# Android and GeckoView built-ins +# GeckoView built-ins #################################################################################################### --dontwarn android.** --dontwarn androidx.** --dontwarn com.google.** -dontwarn org.mozilla.geckoview.** -# Raptor now writes a *-config.yaml file to specify Gecko runtime settings (e.g. the profile dir). This -# file gets deserialized into a DebugConfig object, which is why we need to keep this class -# and its members. --keep class org.mozilla.gecko.util.DebugConfig { *; } - #################################################################################################### -# kotlinx.coroutines: use the fast service loader to init MainDispatcherLoader by including a rule -# to rewrite this property to return true: -# https://github.com/Kotlin/kotlinx.coroutines/blob/8c98180f177bbe4b26f1ed9685a9280fea648b9c/kotlinx-coroutines-core/jvm/src/internal/MainDispatchers.kt#L19 -# -# R8 is expected to optimize the default implementation to avoid a performance issue but a bug in R8 -# as bundled with AGP v7.0.0 causes this optimization to fail so we use the fast service loader instead. See: -# https://github.com/mozilla-mobile/focus-android/issues/5102#issuecomment-897854121 -# -# The fast service loader appears to be as performant as the R8 optimization so it's not worth the -# churn to later remove this workaround. If needed, the upstream fix is being handled in -# https://issuetracker.google.com/issues/196302685 -#################################################################################################### --assumenosideeffects class kotlinx.coroutines.internal.MainDispatcherLoader { - boolean FAST_SERVICE_LOADER_ENABLED return true; -} - -#################################################################################################### -# REMOVE all Log messages except warnings and errors +# Remove debug logs from release builds #################################################################################################### -assumenosideeffects class android.util.Log { public static boolean isLoggable(java.lang.String, int); public static int v(...); - public static int i(...); public static int d(...); } @@ -48,23 +22,7 @@ -keep class mozilla.appservices.** { *; } #################################################################################################### -# ViewModels -#################################################################################################### - --keep class org.mozilla.fenix.**ViewModel { *; } - -# Keep Android Lifecycle methods -# https://bugzilla.mozilla.org/show_bug.cgi?id=1596302 --keep class androidx.lifecycle.** { *; } - --dontwarn java.beans.BeanInfo --dontwarn java.beans.FeatureDescriptor --dontwarn java.beans.IntrospectionException --dontwarn java.beans.Introspector --dontwarn java.beans.PropertyDescriptor - -#################################################################################################### -# Checker Framework +# Dependency annotations referenced from bundled ExoPlayer classes #################################################################################################### -dontwarn org.checkerframework.checker.nullness.qual.EnsuresNonNull diff --git a/apps/weblibre/android/app/src/alpha/res/drawable-hdpi/ic_launcher_foreground.png b/apps/weblibre/android/app/src/alpha/res/drawable-hdpi/ic_launcher_foreground.png index f44368015cb54dd75cee83dd3bb03c2dc37dcef3..6184c81496afb8cd3f7b71706f2e208f6e6dedf4 100644 GIT binary patch literal 12658 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 literal 11775 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 EFR`NR9RL6T diff --git a/apps/weblibre/android/app/src/alpha/res/drawable-hdpi/ic_launcher_monochrome.png b/apps/weblibre/android/app/src/alpha/res/drawable-hdpi/ic_launcher_monochrome.png index f44368015cb54dd75cee83dd3bb03c2dc37dcef3..6184c81496afb8cd3f7b71706f2e208f6e6dedf4 100644 GIT binary patch literal 12658 zcmbVzWmjBZtT$2|${@vUrnp;S@Z#96~M->7kourhCOWJl8pXmy@{bnRr5MWs6biFX%5( zs1s*ykjuCK#Eav+GlU>fz8Xlzmw;y+XA7k3BhwL8g7DP_=Kx$xk@$dnL+H+8V~HPj zLNh)x4WV&JYyL-ALG`~P>?Q-weK{!=K25P%MZV|b@GL#^vvEF^6tl#5dr5y^=yjlp^6S~as`oABi;J=I zeLE3(zn`vs=YB+dZsXcvZFu@)`|EPc>&qi0q3jMX*88}PZWPKV5s_EeB-heS?XHRJxR0>Yk?Z%Yb=6Xb&)ujDYK@ACrmy|{B;^^gt(x%j-MJ``<&U_> zY1^4@)AM!fuB~>kp|A%=yFV|V#p=HM^T~36_hlT)6FhGR?*7aAP^y-Jm#CK)Y~TNB zM&!?ugY*}(vduSrpB-r?o`)Rr`V$LsFJK}6`Uwr63K|<5i|6Jv%ZF5q(|qq5v0s7d zWfM0@w5krm_IZY+SGkz@Cb=SHI_sfbXAIOghr!F(Kx7Hzzj;9!hBFMM_D= zKVbWDSRJ_I=E7eolNPTH4%FtZJ#d#Zqp)v3!(DNi-=AKylANhA(DT1Mq|q|;yKRyz zacH^35bh$a>bOG%O#`4j!pYO=yK=9NO#Zj&y5=u-q*lGF-~A9u?AkB)asV*1{fS&H zc>4RtY?LYIifEXwY;keB=gHk9*DD(!DwEDeKNViL(H2ICBUvyYJl6|hC#4b(cDkct z#3Xf>p@yM`HQ%=Lsi~{0*M$^Y)i>T&x~rQX95)sC+kIiktib%0O0zd>KV>UZRjQtz zrb)z`?hZXA)joy#s+E?NEuOh5_6TJPiP*;$2e!vVJ)Dj!n$eJsamrF~eH%g!A84(% z8iy^YbeXs)L7u9ewxvD<{tD_&oV6bzlZ%zCUOpuUWfv6HtW;m3hg2e`{d|)}7~;n) z?5;X8!i(r>*06jn`?*NX4kTXJ{d4o}GfYq-fTo|Ho0rmxaPeeZal!RtOzRRet}%8U zzKZ!mvsJ_Lm29^yPcr4F+A?Ws5Vdj49R#{3bwzuKQx|5m5aDzOz}b2eGfEd06@|Vh zEtDWB1oC3WJ8}1ED=lz%1z>^S7$(iyFW3%&RZCbuWyWT6&;6v`w{F_sR`TM^N|3$68A{s4<#bt7xwablVc53D|F7QRO$(<1 zQ%;6RWegq0VcHSJ7yzA=lvG6wK!6zwtHhzABfq}%{VHOFR+}FtVz1qpjn44EE!Ozt zUT6lqc^!;8DnC?H7mBLNOpvc#)w8g$5MfZ2ZXRk$YHQUe^{3P@B=2W&QM{Pyk9P?6 z7I}*ixoo}Fb=Wo9_%sinY+7>Z2YoS-#mT+MYvXI19d?A#bk^z2v7->^;pr5Kox~Dzo^d zaOY4Y^c;TUfXwt4FI~F`8HXR-J~Xi2;p=S|$lJ?%H&h2C&0JcgDPh+!Omh)@AQSO_ zl8Be7=F1|Lm*+{bNDk|#XsS-BG;9-htM{YiO-~@>BEk%B5b6jiUyb2YU|R+M)?_ld z&%*(k9DO7pu)|`8o@g&nkQ~#prKo8t&HaH^xcdml9k}vG>akW$6yJ*fRQbbf9v>IS z1M?N$>mUX~7lbXrCfADeOqG)qz&H;sytJdM(`9T5FMhf{ZtXgAEDm(YZ=HHqb!$d* zX2#syT&&2uH(L;cZ{P#9b)19-4==88;@)lC#QmssJ+>-eO^aumot<4CmY~T_vmY}< z-gEQyeG2LGsT+W>*iRjNIIT(Z6aZmk6_U5v?{MO1d%g zItqAPi9Lct`2Ah~GXgv_@?O~#%* zPC6t+F$@Bd$lt_B7Ik$6#GyIF0e(BBvoHN9FVbueW1tcO5!egmiZru}^)#MAi-YsS>@uFNBMZoVIFh#7&Y( zT(_DiBz_#_Y(AjS)Y!-~<{u!6TMX^=!?I!WV4ex3$=$)?-o`Edk~yN-syOg|@&%Yj z`bj)UuFgQe6z1CrdNq^)sXo^``3^&ig7c{e(Zk%(!-AjmA%{Efew0=_j66^5q&({E z_uZFwe>T!-t$28NIHgt@*}kk>6f#}? z{`sSwEVdNpc1nUSsKyogb0|OzC`G$%M@zBXmzX{yn)aDFkv>{Zu4+D2V-1Ac-&liZ zNOqOmH|DXmRR*F)_`pO{i;=ptg`xNX^`Sj)Ui$PoUO6WA`6EH})fF>u_PAVBzIAvw z9o4XU-C8`D-Eom(%ncwaQ!6wTZHN*n@h(?Wk&K*VKcMk5@$>ruJQV9P;r{Pr?Y-E`}N-ZjsBb3h^tbH_{gV|kQ zBF8PrWs(vVR33vo^F{Ju%l@wLQL7i)J@_$EMhY!)%$y64+3c87g1+BRL8>*AV(9K? zx}*10c@$B44QyMS8gDD*`)$De-AZ?6kl-_2DdWI?+KPCbpd(m_Y!h(vPcIqdwcX+*j(SMIi$~ZYJE7o6~T|DAx&#aeFCM*cw{k^zkl@Yw1|k1KEG2?x~X3ESZ#G%jDI-`?I99+#a`4&|)@ zX2ed8k8M!H2$njM(VZNdiy|=@Bg2X;97Wk$8L4YUh~zT{uam_79o-OS#S|$gx^<;m zuGfl-e$}(R4^S)fUHuby>r*3%96u9p7B!YphqMJPV$s;Vuh}aMmSdF2?#PUh&6m>u z&Hffv)}pnU!31(^)yD=dbX_j}fB+4j}SL=w7qTVMD_{{(N z$38zKoFlZkQKsO7riNP6FOR4hQdw-hs5^wal?~o&{3&B+;M!x%_$lD`g2qd<%%5vn zyiH8>u&wrAM@x4;KCSLZ2ro!{d;hF&&1*;4Ehj0WN9wKOl^R_``bTuNQAh>eGJy&l zP{yDOZxr`l(x(p=!~**)wixfkGDgO+Z8VO2GE*;wo`dIrLVO1HO;{4qcrIjk`MP zKIT1tBd1Xv`xJ~J0a8JTQ^Gc-+@ku^-xiXeQA6p(D#Omt7ig^5Z{DJSq#gzf@{M&5 zLXE`9Q$yuzh$lMzsp7OG&G=zkw~5H<%!;wOr00HABzNH%y5+8KEJ7!)9i%8L*R_EsjpQmj}t+Vp2w4*-l z{Yr2+>(|#QLE!^+<5l{E^q$gBYvAYAzO=LXh^JLW_+en(^r2F2W<`qq6st918Q^uO z7)af>cB*$l9{@3Qde!0l{0zs|nduGWi=06mg?^wq(d9%G&n=VC;oWrY6%xj@$|Jcx zUsaRAXAI4_uI^HnPiU&>yq?gX%{`6#KSwaJ@ALiQyBzu#4(tzL=iUjx$3&3DSe^!(S0pm(zQe z2eT*(HzHCJ66eAtb5-E`1>LN%@Ys3NY{y<2 zayVzLm1KmcQ$S@_31+G7%=r`W+J}Ihwi1x#DRg6xDTc@+yxKn&H*D^I#Y}*lx$3$DoN9wFLu&M zanJtdZ~B_r3@Kg+L#8+}EyzRy_s(0l(lwo|l$6|U)vwif=e#ZKM>)FOw{7tY7a;=j zmjzX`RDh_>Du?(DE#-+=VCUZ1$I?$s7iEgpCo5l3!+c?Krs=6^?Ecx}P!3ci{F^Y) zeR0M@=9gZwcvVp!AD>?1Wft!*72=9`+=?D)A7-n(t-+BQ!J2pul_u>Gg`cEE^C*(n ziqwDCh_V*3`>YD%{&ZU9{YtPp$U)FeE@Ek>SJAskl%z-!iOT=zJGTDnV`XVx>%7&D za3As{@qhzNij<=QoTC&OqlYo#4CtTC^j|89K{`4*4lxp^rklR^RacCs6ZUKy3OT$7 ziP3T~pvAim_>kw0^hE=db;|of466pZM3!Q)zc!=9Ac-HM$$o}^z3cRjX%oA@G!eQ$ z`b$_S#ws4fi9NWy*UHRz9hz|?ffHAu)CA5oFfDxxTe}iSwxKdd)fI0Uh|uuMl$CB` zQYQxe5aDN9q3P%t@OGWp?^gT{acNF|s0AzYKu41%NNQ$G+mR-5Gz>M&qIwlR2+w1pF%L^$ zaPn2)EHzxRWz|TeO>iu~6cLj@&Xp@PA7Ae}#vom;N}pH?H`WSu&J*@)QCjxq&Zlic zZ4~a$QIrQ*)`im2h;Cc&LG>o4zTFmH&hRV7*5(6UYDBS;0Myb#4#sO8HHdzir~m2f z$jgs0LNTfgfdL>W#}S(&eotY+#S%eRiS-Y*|7z@T0;o5w7=>8;1gHy)_R;mgcn>u0#100=cszF#}aG2?_po*truv-ECZ0&K#X)qW#{@$)A6#mKcr<$O#@kO>+-2F}M7#0Es$t`0HdoEGBF)BI z+;Y=aDP$b1IrGT8GZz3=(}>lAs-J{@{R8!ZcA{kgSS+ppBJy)lZj$iMANaq!pIwCU zRR7(yD1El=#`bk%;gXl+4s>+1=Vii)+R`Bff0iIZgLTtvYi*$rW0iFBNi2U~vNW#X zWFe;ze9l6|l}o7g+Z_K)cYqj7cVp*mw1*MDSVf&}KlO^@zA! zqEW(h>r5URdA6h@BKg1Pmb5n1sv(VEhxA;t_FONC&7nFE7)gUpod_9xT1hDs)0UX5 zlc;j&Ov!~$KM33JdfBu%jh_aT1vV~O$?Cbhbisx1Gp}>OC(@l5lD_4HixJ#m3k~0& zCu5tBAJn+XoOJN(sF$q40+K`NApW>4jCv456}K7=@+m`9kN50NSqL*xiVze z81VDyBGLW{`r>v!cbeLK4AdR5i;T_GAjb+@)KSrZ^+<*eVHeGK0lZFe&~nqB72RUO zBtxh=d`ICV0!)X)vGCO0|E1+d3AT_J1Xy>qg`4xv;89rZYpPl`@cH`Uioo>QX8frd zKC<9!-M{nc60gyQYf*p;Ku*gkVkh1vNPY8pNft&lZH&Z1U|IQ!6FD0BfjhH($oRXz zq_`nMqywv$V;^Y!$oU^dv|X3u<(0wT)7PUWL;It4#HfWK)U&!(W}*b~lB!IS7i{W9 z@W%Gce{It0*(Spq0Me_2nIUN(e(P!if2Ev^+xB5ZsEmj~zH2~O(lJe>q*{<>@RG|k z#8|Y2HbSmyaf=mudwZWSYUbw@{t@yV*`%?gk~;0-qc5^uHVgJ%slAtFF39* zh(vu+Fd1jsMJCou{-!WNvQFa*prdUTu-Rb>MGe2d8vl5t6fA9t?&o_&fseze~r9{|1uawAtm9QurJS-i*5F{3Aq8lE7Im&I$Ii zYP>zQ`>N{&4fmA<=IDharv)-E4w2jfGy(efArJxml1@>o`%H`iI0k!^5?q-Rdk2mQG=&PNA7L*>}i?nf34A9Kda(9 zur5}$3q{TFlse7A`aK`M2)u!7+(!tm?j-?iM1y)1Vf3kTDy6D5292_R4nd3ITMYfK zztmiv?KW|DnEpmf$=uW80%>WPXU#YVbZ*%Kwt1IDsd1l6OWKaxji$PG0&R!PpOaPS zxh*x0P+e;u;Br8l9ab!NY=XfT z#?6%;NkH`0wDaL-igD8l?O%pm*K2reipe)Ge_R*R#swpEkHx2@tjp+0Hv+PGGR|oNr~kxYo2;&` z=FH82;|*=0djo2{3?*u^g2n_P#II=dDmOh&R8E=7?t;Y==#n(gwmf~jH8LjQQf7fJ ze}y|J2cayrlPPo;h0C83H34qS1Xx&Di!TlB`Q9RuXZZbR0xXt>Nq$2Gi-%ca^kj-o zg6{jn>Ts213u9htt0j^o5%*dJcxcOQZx~<+mHmX6ZPSUvSM?f1&O}?)x+P!8vN|`u z!qE*?J~c#&z~5;PgsYrNBNvAmuj}Z%``)&qvghX<{Cz&o1cQ(aYnJKb6!&TEZNvKL zq0w~)5h-6Oql!?02BQPD&^LYtX~^i}iQm=L&Qz%>c*$Dva9BL=bF>$1j4XR4SsQhl z9_yz>*1u&d4jH8aZR(*yG9VDcmh9VpuMR{V-o^yf<(_^Dm|rENN>s@qs7X$Q96gTg zkA9%2n{cae%oXZ;%KNz0* z48${S@1aVBWa{`{C-&yhGRpi1^~HqI7*EYY?g5|{xJ)rM5_JTT7@CfQ8W>0S40Tws z)!|(1&1`TE3Q#Kln~dqXC^h;v<@sg$L@yu5Wwax>Pxoa#@!R912FdAfU+PQ` z6xj`Ld|loJ;+mVoy!n}&bnCXWietnMY>ZI#8e4QK9WhaWfxi-6SrKNFvOY_yu!}Me7L&1V` z{iZL}cxeiuU;!WT;smtl$trVZ=2P)Zv_xc((*8og^<1+KSel$NQ^8Cc>Cb~}eBwnj z>>vYAyx<9jKSGV~YxQ0N(vpaOb7(1&$H{bJTu9e#v#O3Zy{GI5L%K=NiC)DQ3>x~uVArJTFLn?E* zIBf!Pl?1a4Q;9K1s^7k;L_xWo=nx7qm~w4W)oKgx-;)IrYr%*pNN$W*{A1r&bsn3O zQAJ@e{YbIdxG71`O)bW8<;!KWSgZZ#Ntc^uS>G;Bk6-*DhM=ib0fdjv#rkSt$MD%D zg76tSHjh922B0#f%dpkv3xT>ft*sa}9ki3_)O$%c&QgZwZM|mrktRg+!{Xy$>|lJu zg*iqntF|(v-foYc&8b>C%scO5C}HWpoG=+?M(Srh-H0$)lPFtvM^nn?{3PZTNFphM zlU(6)GuXt{m74`Srao+8?O9cPcrJE6$>^8sKx73iBXi|&g9*NFsfm2rqP2{e6~_KrFtj*5fe;y4eu`4SUvvb zopV_R+6D;`k>!QwUtLQXT}WDYczCX^gSg}&?M)nop|elYNU&@Z$1_qYBS#wM+{bYL zEx(U4AdMi4%pOp)p{Sh8gc;t`+F>(naLId_92kdYw; ztk=`i@A*=$5JLB3qZ#B{Had!oLP_()AmVe?TRol*W3$BWJXRPr>(eI%R`sN=pXgjx zwUUbQPJAI;`&Hkwvf-mE=;l%#pJ2B* zd+}^TV|;%^A;p+)Xdh=($-&98bgf9v*lbVISzrd1>7K9LX^RSDea4lO?%D;+m+5P7 z?{Oyok1?&a5qBR>)!|9?A5{)s$0tr5A~Q6OkydPW$5K*a^^3YB$BZ5;L$f`#?P4*L zIkFH2Eg-c&zBR|`20`AK zgf`?EYOa=f#G?kVGz%l5#L|u+FmT=15S&+({r1TQh>TeIft-@|!n>m)G@~SegUftS z=1TUb3G<67J+F*YLd19cY%qv3ny#hoEwXbN*bV7H;?gpMKM%4zrmy5xU4B!~#>Re~ zj&qvVQ&z(H(3^IkyI}9|*-w^E4|WntR1SRs6CgHGF9}lNKZK^#`^aKMi*dZ`omK^x zh#lZCZp2N*YSBst8>|bwAB7tX-eCaL0E`dokLT4=rFC-IW)LyKUyDP^taZqv#|ceC z{J3N0p<{j|De@hjrN59qI8$!Gj|_GEtrp%{Z0C;QGHo9mK<7q_0Xrt+HrEoEvLB)ghU@MWV$ z>=i6Q!F9bow3Fj*dSnOVnm>&Hj`~LCA}dk@${E*I3Eh9txOdLQE2<}7eencRseNye zVd@_8MD1oDhp?xF&mcObKqRc*b(C4GBO!UI?2XQh93@~Hgvyvt7#V9@PyKyXI!dll z_$!=k>XVWRxjYrXyq`_(^}JN1qt?KbQ+-DeA9H;vQmHL?IUJ>lpLf~ubkgs=;@<#o z2$SWFb9ZJN;wIh19Y{UA6*m}&*pP?tNMCc*7F$ovvM#OG5X04pRz&3gud=@FX`zb-uErNWVY1f>D3d|*s6y(uQ=5zAn3>piU?MghV`3_MX z?z^?&X-7@0sg`kCDJ@^DUvSUdYnJ`-WiVUOJjz#vK5`3IhmsL(P}4?Gc@b$iR%d~= zMff|+bK^kIg}p`g)ddPCbLkB#P>Jd3YF7^r=Ri`LeYosd3|Y94``|5zE7)#E6e7$a z+;%SEcQ~lz^5ZLU&+Vh4r(NJOnDxWnqAcF)CuwYJB&yV(1E6^b~$rD$Di zy%<2c7+dt8LR6D@@t|wD_I9-C;U)mm=U-zhMgNaGt%Q6#)s!7|4p$Mt%bQ!|H6HB* z=7@{f`=s%wwcZuoC*s3c^YLFbY&zgxq%k`KNCBHT;|}t?H@xi_@s6Dlq!6EBls+8t z5s&l9gr3r4UU_3(*!XX>`I8WGc*y;wL&d?Sm8ve40!3R=b*Za|hsBN)7wKB)f}Ow= z+)Bt+52Nd1k^n!3r#H8cABVQ@lPMUW%FW8{eHq*Uw@DNhtw@2&K%$x!$vFIxU?w@j zu<^qLOhI8f0rdchIGy#A7drtyXS@q&YdkE7Z+p-cDA~PkJ>*Ga7OtVpE*=?2Ebi{^ zZvGu$C(crc->zK3Di5>$YE9cnl5m+Lhue$w<&rK&L5Azx@6Q&F{Jnp2p3d^|Ed(c< zZVe^=D1&2*XEkz{4m3gDFAPEAW@Y!zbUJK7W@8iE$=0~D;b&q#9x1ThP=su=+Wb5A zAfGKCRg!Or4J_dReFxxxpTsn}GqwdKS5$Nad+WFKDo~9qPALP1(bTbvpK|p+R<>vIF;m0 zTE3ZB1Fs5uvsvejJ#%)%#@d%kl}P%(v;4{oOW0>--0OHp89SOWcpR7CEYi1*yfrFd zx-4$Osql{Qy{|qHc&$T4@Y*w|uNI@kcB-)(aZxEAB<$cjc7GIz%d6FvLBUBj`h+l> zGOALFyWLxm`FLMk8~EWEBhsp>*Sf*HQJW9LAZ)>EQChD@4Vm?F_M`tMI{8oKAf+ngQ z0eZ5&^9${=6I}*2eR~hSIW9JPVFHB5ILk{*o^Ra*Um$fX%;e9*p&2kfnRbjztD6U9 zPlgC9_<io2@-wAImebwn<8}xmQzMcup)sS@$j(Glqt*k$3C+ z7C-^`DzSP@3V!q~Xm%{=ZpTL#y9u$HcgC5a$YfJEGEpPa+(eriGU50YzFb)nWjEtEIkhq0#G*6a8s8KCQ#XU2_ny|}(6z^y|H;JzWH>O>UC()1=b&lv z@&#qQ`Q&ddcy0-C5bPlDUXi)}A4W#@UK)xqy$7Cy&oW4O4q_v(EKl4Fmv8XAWT8u@ zac3CHDh|ZmP&qDF`1YfY#{eTzJ6r|K0iR#11PRMRc)D#6a%?GaT$;O8m>_Kw(^Cfy zW@dl!Z0knz2}g{B-7zQ5{_N^{)i#$lKs^`BalAwEgZFT;nqIP#fTKo2MO2pKKIj9x zMB}2wSC1;lId3iuwq5o!`HxdyQoja>g(`geZi}-OLnwV+!@A0b(E8lP_M?VdwtG2Rajn1X?K?fg?o5md$=Oa}u$;+vZ~?RZTtUSa z8rKtf*uZpIrw3@P=kY4`#x+dG%)bL_EM(utXwUEfJvFAQm^Kyv4~ov zfAE5#|I7mgk0Cu`vOusH^1T3briX5;1TUKrjmVE}|OL4G6k=Jaz zfWJ-bh4`=yAM|jT5rx~Y&gfZ1q~je{)Plp!v5VWw>`=`lZ<~#l?Zz@LRtHKl6~5I) z0!cZOw)q z9Jp;>IGuU@R$gs?Z;{)H7V=S5&wY3iB4zT{cylw#*7an>zwW@0RxVBEyVbv3)p9#B z_*x>NTlDgB&i47|FMvqrwNsAXtyP?Kw19>E>J1P55xBCe3CXU*p#8!F<^2lsL%~?xL+-t^|$R_l49Q&MF$S#ZxSh*3}j9nV{DY|8w@JW z>t}gf!Ym+cV7hQeu-kse06Rhy$-w@(ov2Mb;bhn5pKc4~UaijR596A6kKqc20%}*# z>2;xqJM86)TptHWNjz7{oaOJ!&i`uo6hg08L@Jzy1Z!jG(`Eia&t(%J>KS? z`rvLxT9;&hrwF`+;OcREP}P1l!bXZ2&UNeU=l|=uAOqC-_=Rhp&Jg_E`Hw(U^vdYk zRN+!joHrS~o%NpdJ`e!ZsA|CsJv-}CX7F5tP=<@Sp{fya4BeEkcE90%9^%@U7)f)M+i zTlCDDmLCz|{kM$NYEH!7CG%HC>dRHq-0$K^uh9nhk{u@xvAwpz$gc+HFUj7c=tSz_ sT<=Gra!AJ&{aL;LYvlMW$l1dVlVn)IL1G=-0_W%F@ literal 11775 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 EFR`NR9RL6T diff --git a/apps/weblibre/android/app/src/alpha/res/drawable-mdpi/ic_launcher_foreground.png b/apps/weblibre/android/app/src/alpha/res/drawable-mdpi/ic_launcher_foreground.png index 9dc5e77b58af963c742393f242678cde24612665..f840d42a8b40117b4a7710699839788e1a7ab5b9 100644 GIT binary patch literal 8546 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 Z6G-QZLOhtk%d6arS=52(h}Q5_(tp_~T3-MF literal 16813 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 Z6G-QZLOhtk%d6arS=52(h}Q5_(tp_~T3-MF literal 16813 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 literal 26146 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 diff --git a/apps/weblibre/android/app/src/alpha/res/drawable-xxhdpi/ic_launcher_monochrome.png b/apps/weblibre/android/app/src/alpha/res/drawable-xxhdpi/ic_launcher_monochrome.png index cf4bf79a145663584b93cdbe1760a0741e6a872d..3fa53b7f856e2614cdd31376a66a9395e2c39ec2 100644 GIT binary patch literal 27944 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 literal 26146 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 diff --git a/apps/weblibre/android/app/src/alpha/res/drawable-xxxhdpi/ic_launcher_foreground.png b/apps/weblibre/android/app/src/alpha/res/drawable-xxxhdpi/ic_launcher_foreground.png index b642ffee5e4326671ac78a10a35e2ebb37ae3872..0c268c4a9973e162a9e82e655e18659c5e0909bb 100644 GIT binary patch literal 38568 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 literal 36561 zcmc#)Wmj8Gv<@ysf)p77tL|iWewQ+}(;7E5%)kJH;Jt-g|$< z{g5wn=Bzcz?3w*oqSRF6urbLn0RRBDg1q!c005!pzYjVhd_<15Bme-2`>G%f(e%zf z?nKL`unu??pSU`C_MOjB@k(M{tfODn9gIChd`Sd|LqQ2)RKeSF&P)l&lGq7|!GB(Z z|4{ej3*pe8rtK8?mdzjC7#E9nzaCduS*x~twfl+Gx0mca<6Budd*)f^`RvxK-V@cC zAEWs5rz%Bo<(O4CMROI=l&j%;u%T~+4Xh?(RKt?K*+WPoGO~>%`G>8OeTn;Zjp!T| zWwbfbJ=kCttX9@K%pKqhA)`0FoMVS46AY4)?2ZzU<%(niLUT8Fl0U<)Nlm_JJdy2M#Fe2 zwL~FM4>ox-KMyx_m#MERaR?NO9b7pSfECy>GBrs4Gs4ng+f)2^HxyL*0G7o0Eq|)S zt5+02_{BDj2W$Bsk#DCcJKbN9L1sT45fgsBDk}M=qohm%tmN0119Lk58C{)igMtdN z4z|O0c?y!vfu?|D_E*U|P&8t?lFW4#vbl!_a!--@g>z=^FL9oVRMjDA^>r?s2s6O} zOv8u?hHG|MZJSFb=LUb`mvHXI`wt%p-F(w!GL9wr*|w)1h%J__a(mz=K&dwU&X|d9glk@cx zMtx|LR)6nhCPt>@;9@BQ;pJ-DGkMMoP4@E1c^+Y*$P?{$+3qRn66?IG!0~V*69iRJ z2K^m>G!dN%cnbjZaYD9LmBp`q5||icW!rk50AYil?gq=xTCUYpn{kI+vHGSmj?W49 zf?iDGS=k=5j2dMnHMTEtsRaGE_&P=3jj)7(!~(bPkZ^Ng13Z@NAJu_Ddrh|{0`VBTdASx7~+to4a%#F%{Gv2c6ahJRhyQA0K%}C-|!E(51J0 z?v0w)o~N=JtG_BAU!&cgQjkAgc0W)IU5=OQhx**FHh1mEkvCQ+(7D1_=`83yD<^ij zI`Kn93z=CRlSGVH=01&i8CQ1+cX3Hix0^!w>Dy{o!o|5{M|VLnK4<8IbZ&Pc^i5Z~aoiaSIe z|H@_^0jp11!TaB@`I-4W9o6SYXK{}+wzD2rsQ-qV6!WU^(|odtItnkLN#Nb$S;zi^ zfEkJBa$~g!-z6u9!?&%7FH~k-Bz=Qr5wTIt%ljg)9$0rD?`ce9Z-=2=u~^s4F%fxp zV*+*Q{2z0L_&v4{G|+I52RSRMn>F&t1Y+}EHmR=#NutHhw_SI1{+^0Bzw9;#F{EOO zW>B@`ArFA}j7}59eFlixDx~JZ_a%AtxW~n~uj~Dsed5(k`qk19_eOGYJWQ?!{luPA zU;VvE6F&n16hlmn*Y1dt=;(;QXic=8D|;~zCBd)SNJgVf{QAYparv^e{1Y zmizY~i$G&R#Gf@-6UyGHo{*dd?BzWuES=fAvQ!3ZN^*G_%X5mFB zsidVanad^$V)VjYy^7>`JdUS)j%3Ya4j}l+3CnUMw3^o|A#@F4kc(UkLFWR{=hg~) zu+iiG1brX7@jp68F?JVw!h^|p>k|b1-YoR zmfg4nZnvIpK`g$YHEWNRvo0xUha*Dk)>WRC;In}yXe%pQYw5dKmn&9D%+4?;} z+2mz77FlxXkj93ez-YStT7J;M&HX#==>4p*kzCZD&4W>fK4?`3f;3XCw{NOS)hD5Z zrWWvEN?g@p$0n+4G~Z%p#{N< z=hd~(u%kNhPJ~>QDCjQuJys*8VVk;=AGrj#6&SPN4@t}~(d5F@j+@onLy_+5)jXz5 zK{5Wjr@eQ7jOXT0Ubf~tHw@PA2NFwEiIDLmy}j32=1cQ?J(9hQL|pft4i(xP`#fy_ zT&>)r1-&c~+f`CIx_;!%io3ry&Os40^YpYVw1>W}$H?UFJZoIH22Y#buHkl`mt5Lw z7|T_AdjEhz9d1{iZn__v=PO>3`mr*uAPMVN^9I06gG0bB7nDhzeF(ATbwN+{A}*G3^^w>s`wDl8_svO5TD&7z)+|&@NAUI|Ij|G6C>D$ z0qNOj1R?C-uj!IZqZ}d>fCMmnPd`Q4jg4P?W4S0hizF+<<9q!PtpupfaD*!3$Q_rZ zL~QT#!1l`8c^{smvQEjo9T&?`siy2vd&Hm3%QkSCsr%OtR=+YDHOfFEiPaRH^1x9y z&leaoESiA^BHh5MbHe0Xf*j)e$lAn3ur&}VOcdWYoa7i_O+0zF&}Ckr)`G^NpTgIEAPLM=^vlS*2w!1e+ZH z#IV)X@DE(-MKeo(IB!MGG@?Z=cuy!3hjm+8A`fJNByRtai1%$f#7!)qlTIqn`5n^E zj^D2cO9HA?{HWiJQ(}vdXdp9=PcCM^FHc`ePm}If-&|u>ETsPDj~baGv6+$uA*dX?z)p z8S-_D=Z(FFq8*+PLhNkh3g<{K#^TP;d&>2%ZijUqOCL_Q<(yjT~&xup#e29~VHz%v5qS(j@ zpfoxABqMIglu)y=_Z8cgI{yTHcePbXjQjXEj-L_@s(dPa4?kVyINJpPVICDqOyR|2 zsegn|DvA3SQwb>Yia1)$yeR^9#1Im1b@1F~=!Ymd?7-42kfi>FxB^@);hflk$s`q0 z^du58)`uV8KW51DsuT8iB(54(I~xk!&|DsmAe+am=Dr)L)k#jr47L%@c}GbPB2Kys2grnyDBI!vm9T@{Ytymyt&H2R1XXksSnz{DP9dH}%5n#;Tq>Qbmi zD9wIW%{|kpBXlh|`N^0vF8x_cQ^d2HU;s_B0r@$=h#~e!1_YytPbn!G9k0H!5DXoZGB8swp(bKt-QJ~!1fbHLV zomxV&X#(UfPJe&Bo9~1O$+jW`*?P5#RQ$kLPSvq);Rm zQ|r8|Ezv;5R#a3e5kU}vy5AK)No_spf+~J1Z0hz(_GDX_xX{y0asnn^MoSY6^H$$rw>m^O zP7yIi3}4`>-1}XGpaaB&0w0I(hsd>X!5C`zeJL?!jt{#F6F2YkPj!O$B#HQ%A)H0mZy3CnJR zvjBWt`pM1H>F#7DH%6~pX!@9`pKuPHdWR+PUZ|B0t#;=H$|MnSVRiqgfGnX zNfnC6J%yc>ug|^xpz}E)qfZ?O(@_6cQR=54o32`cR;qjYUtxqx;mzuk7>w_!V3@fK`?3YPfg!f8opjJ8V- zdr4*rGeTqBDK=Sh@^Uh{)6F0(^9puH#(8XcZlaLy_2+!(9w*ztA*Kknv$Eu%5Sb(_f)J3SPq#o zs0h%XioOM2vM|CCMLxyZp*Rc!kz?=x z9)De-HBonsuKc=(w~-pWAbGK@smHxA3swLSF&*!IaOD=;)PXX1^sri^4rC|N8u%+G z`}>`t;+cwy8dmsXn1lYwb|;zAmSLQ@D^!t?Tn#PzJ4rV1J{zGqihG$paj)LiNW>?- zdcQDf)OF(hC&Oj6qmrY*ST25unv>szehcI@L3n~1i=B;-Wmkt9W8`Sx0K;hE-#Y^o z@awdXckBXwI9CcJz{uc3x|IcG|*rHQzkcWNrDzk;g~L;5GGaw$%`6osy7E*+#VU0RPD z2Corgfh>RB7rD@&b!_)x_Mkon7HC5_` zcPBl+sD83TICqG*-9x?~PPej<>NGkF3 zEvtESSNf?%y=-pP{04;7NzBdjWJ_+VG~#}g*A!;h{HZSn928W96>C8{%qHLf22a_n zrkVu&7)1*Xc9DS#Uo%^BlzulCZeE3Vd+Kk^0&Zla4ubY*yE zGIjIU6_r61Nd1XGvaCLKqrQv|uLgNHt?Oke9RE}xJ#{WlYejhK5{^Y~EGJR=v{Pf| z?6d>`1L{M8lQjVSsQTRFy*SE@T7oUTtMyL$UW*@;tqZS;J!x9$u>1;vVH|1*>x6Lf zv4o)dOU@yjgmY%WLoA z(ie+l!!}0`YX`P3gndIZ)uPbDs%%16kV6tR_19gdPh#d`7OB1z^X#0UvtVv+E~WTl z0a|>Y2D*I@k7BE~Jx%F#u|m-Sf(?5+;(k|`%9VJVn9!PTo22J$4aO|}^t58Qs3RKq zw4b4vu9eH3uD^CnCK!fX%bM>!?Cs*V&j6Qc`~Ik8l>Al^Xk=%L^&Ig(95XB$WyXZk zyu%qt7rmj}X_)32`?E_CnLRk4JR(gSrIlWpfKWHzoLt1F=hQhZh&btDsFJnnq#nnU zv2tiR`3%Iwi5&N|IhdHfwA>#LHFiYINl^|XY~59sDhXe`(667^fl_7x-oz5i5lIkh?R>RW0NTs#Js!s zzj*spK@0fy(gFSrkQU&ogQ%=662GaWd3&G_mNXlD>SF5|1QIt z7VGC$UFx}ccFC?o=`R+ydj(Efx<%2WiSYk6@dAmUnLzNDnn}h#dVDa2`stI z49qHGn3!_Pu_-w7w}rCD8z|@_G*?v21swybCs}mro7@*!4TSU~SV1xBqiXc!3p!2@ z5CL_^BP>$9U8kzqpn+tlFpFEZg<&|>&>oZ6tWAs$QiH~Bte0k42KR)$qo*ehrT@k- zhd@Ena;%!W`m$QP9M1adKXLd~*!Fqrv!zSgASv0Ol=qYs7E`mVHu$%~u2_ZAcy1vn zTXZ8KC3OB-vEMqJvV0Ah+NhwbDLz~8QpZuaQe#ogW5lefYoI0eB&}vC^f@t+1f!IG zW+p|;Ja*8E48yv`Ydbn^p1(DTtmfMiE=oXTPQ0)v>M@lm%i>Sk8t<7R3bPkpkmQ<2 zo$6~2naHTMC7r;^9lsjcFrjPjAM*{WUPP~Fw)TxMzk%;91qHmeBRX%(;1qJ>X{oe$ z&ZZpjc{mgjVQQOg`A<w zlTZ@j*l67NMId~3tPFHPr(WZ5e7NEv=G_*TiSStp zt1d2%xoVYLjk_51d8=UnlCiVn7&KSy_+u?sRO7`wmI-wP1pzR2nmg!kiXxBrRd2;1 z$+uH9Y>mf!u>dy6Um9tJ-<}^AutDP!b5YpbE@i`xb%#5OhooqX4zq@;r**v7Yc8RuyI91iUdMx z7t*MCFIbMVC=a2K0^E6(fAMV4JKd(yIY7iW^-O-^NKTbh@9IJ*Tp4CUg)gfsXJyNS z`@`fl)Ymg(;;^X#*CnJO8me-z{eVCnQGelxe{Wcb>9&0QWl(PCX+TCSIY~gAlqVwi zX-iK9NS`}e%unW49d4}!anBTSJGhu^7C@9-+U&PY8>Rd9J6VrngU<0U4~7CEOaGlM z5QtAm>qaV;rHzRxO{@uVH)yXfMXfxRfFTe=whWn<7WTf-eIB4AAOEW7gFI5i6#`Wl z9I@6&`f1kYL?tE9#lW7PxFfaK16Ig_1HQ8TX5@%_2hO1$3w`+_nXcluvgoAct-%G5($p+chYb+06BT9zwNBPI84w26ezk8;?f14}fsUJv3-T%Mt zV)RIJX+v^ycgO zP51366a>*J*l3b&Oijrli7BqJQCC>?I#EZ<@}vjVo!XlzPRgzpA9msVta+lL-SPOS zr=nOKZlOqMx9T@pe}MWB(~~Eo?tjzD6T!+(?wUomFnl?xJ>bCu0p|H1N*^%p#Vd`L zjVMUi=PD;POfLmrl&8=WvSnIp&~4RuMCfyLV@hqkqs+{oTjrrL~v28}GLT>q> zeOnrkYEx(tNqcTswfXVq+qgM85yCjDa+@EA$XxpPuFEI;{|Omt{_1{*pRk+aK!~3+ z^m?7>*%%5IJ#2Yg0KWe2X~~;RTp9S8lo5^7I!U@^r5?7n4IwS1J+YEnA^RL28<$Iu z?51+|)oVA5By5S@ID0=yk85|eO3-COR5<#KcXuLw-~&EKB|3I=s`%0;jPGXJF0U@? zQBb3*lk|_I2%eyjF~Twop?vPOqZ*t67lP^(H5V4G-^ID-|1y7-zB3%h{UV?EbL%^+ zRb6LnnE@)|kgv#BP3>sLJNG7xx`oBRwO?Z*m~6syG4kduxw#Gx^gS@F%dmOfe0TA* zfa+@x*n$GD-kIFH9JSVfT6%vd*2};T{1G2x7MarO^%hJpnI9{ zi%V&P>m!?=SG=t%5}4fqrNKv&tRn)hT@4JBpd`{_=<|oxd1%m$zFBNLY#q-q5~_MA z_ofi#9d?Tl&@72>$~?s2hW&UO4Olg1NG8xh%0_S{!Jt@gtiQ|N6BNUr97U|H>~ZyX zmvaqF8+Xlj(Tb^@72^jg2LF<5_KM`TBsmgsfs63I_U$>rYM4W5!YfkIqhnS1PoW8llYOjCd|tqg22Wcdo6Feu8-)t| ziW3zUNEuRSKemZm@s~%u6vW83CeoSm{mNp;2BcSLb8*c-w{&>Zkz3Fc3s$iHJ+65_ zNR~@e*^L>j;pFFar2DR!kUAEWguLT=g;H=y-c=a*f%Vk$4+H9Wq5*o^V5IMdd=g?+ ze(&!Ki*PAQjgFeMqiM3BGU1AmNkmLkfioZf_)k z-}=T)|9f{VBOqtm7@TCY!RX9mH&R$oLxJa! zTU;Jz$dMT!D*)nS>M|yITZ_(HZPEt&T#Xm>zsjWIoS<`kxk+c)ycV3@Ql3V&Dg7HY zvYW(`f7#h}E}8i2eLJEMdm~ZqL6^nQ+T?!6HCF7cN3D(N!UN#*VkO<^MvT3X?Wn8} zf4SaKVTdd&wMPGgl?IogQz{V*vk{w1MOC~2lZJa@?enKg)v}O%`|NH^9M1Mwx5Wf1 zV-bkPofJT1O?C(q;hu{_3NG%$ibAIxC8HNW6 z$aY$SDaXYG9C>K>l5dcPAd!*zq|_u;>zb~eAX)W`(Th7Qg8tUoWFfI}H8*|n$N_t+ zlLJ=$U*Nu^SJokGTSdy|_KF;$xx|sdrn?L$kzt9cmiHPk*{y~oWSo${h5qg*6hGeW_i z!YjN(N{y&mGZB#Yet}MX_SH5GwEYAn95YvInQ$7(v=-G<)rky=qA@v&G{mrZ){^zD zV!+VuR?y{O0CrAD4(8=A=_dxYv%TWVQ`0)4spjj&@(Wcw^hJzIu(7CTct!< zXjkxM)7|m+`^I=6AOGNe1GP~P_=1W#bnbmtv2J{!HJf1;OV@<5NX4%y7e7h-Z=<7k zeY3xFyiO=+hI<3`1t|!S@vgtreL2}@gh@)FNaE#IJK7fHGS%PA9Q}9S6ICLai1BYq zT!)}v*xAV0wXB{C?|4(xB6lBfr}JY$L9i)QO=)t}T81~$;cUtoiRa^}$mT}`Dq8ol zfSO#K!G6$X<_T^bk{$s9QJiDlX@m2b%C#UYq*vR@4I1fdm<(4L$3uR-S+totYaqID zO#o?!5WAgg{&6~IE}4BSclWmW9-%|1_`N{BV^eU_safzH|21I|_K&b}yQXEzqh`y;oKf;#Fe{ld|FaSM=JgMHhMFWG%l5t(av0Wdmd}{+5H>0M8Pw)Ax2UE38kUn=S~B25gp8Y$WYB=dd-T~wN5e$pnS{bfdRE`na)8% zqY}G$78bK!CWua#j?SS7T&s=?+`1U;aB7GN0dIP$C56#{lu@4X*$i}$HkIyEw8B>En^e|`RLj%`|+O z%x-gGUK(|7_8-^Ik3GcN2`{T7{-sK*P~TEmxbn#C7I@Oa3?qr~@KPNdP%fxP27_p!y{Zyo6g;b&i8MBsKAt%6m(`iMpc2F$A7dHazQ$PeH zIEw}(CNn7_T6*qRgMiS_{Cc>CCy(1Az9eOIs&fYi$7nCELO@4dN&FD#7n@Fh#*)e^ zINd--?UT(}=w) zXi4vNA*Q@KTn&`ys3@xS!y8RU90DNs*HBH5h$!6;MI4Ujf}Jyse9h+saZ2N|uW^^z3D!f5iw8EE+$9T=L3 zlu!(tLij$1dRPzy$F2dmmNw~Z8M4q1a>hvkE8EALAI%hi?pnC$`NNacRvPcqtK60O z@#Mct#wmFov(IHjRSu_FYV)U8JPb<7>ro&NSP__Y4$UJE)Ok`7<0>JRRNqM6l{hc@ zEqkOX1~Fp%8}!SbpfK=xTF~y3$jmXKnQ%S$+Et0hwdBA^%AbWLP?o%WlBOb%_!qMq z2DgeXLQ$zzxGKi&B^c3=ZC!77l3uWdeWVid7_8I#ty|qY;+D5-3@X#0l&L!dC_4}q zlY~MfzZVAo@3NkZX2rl3#wo{bDD-vtlVPZ(Cc{TS(Uh_Rd{~Y(=enR%H#` z)+z%%WFrA3)peyrVf`#KA$sbg!c-L&--o{9bz0NKCe{dWEB z;T8LEn=(E0Cw(TNVDLzmg&ey_(^Js(*>-s3aEqn1?z(eS^9-Gzhhra~bmNav{py$J zahcbdyPwoMEz2Uz)uN=gE!v?Fb!35@Yj!k_O(5}!u2fQ z(Dr`}N<9xgdbNJzlhVW$4ccrmjIjy;1X! zGQAd;1IP7}O{cgkRdgToy@eMJn0P&!y;5lO0+;zxmK9FcEk*o)HhQhh?c|$01m!M* z1Ou5hQk1I8KV&0TxA&8<<&=V}BX5Mo?{jlrIK3_mFBah0DxTqb9)-Ad9F|IWl81bi zgCc5?Rd5Jz)*9L>x!_A8o%(=+2mLp;vK8sMUYLz9cu?n69bSDkys;yJRVACdf5hq_ zF^p|_bRlOqT0J%5?w29ZS$3AYtT<-qEV_L^Ts^WZ`fK{l_>v^vReliEOOm0U^&r!EG%>$GSH3K-)d7=0xgi4+jg`<`WBCbo zoev{oVjXLqLwm)q_fiCxql=O8vVo79ciXKNM(fr0U*c1d-^NK};-9&x1vlwD$`*_l zPBk!nG6qtGt6kgue6K<%>Id)4PG~~yZZyuPCN|*a!ji@7CH%h5;_kbxcaMw?kvjt@)MuAdO!KRsgBUyy_a?B}sNZOr?(f_6Vq zh2|Sit5qK~avTZqKP>6JybvdB28m&S3TChNwRgL4%&}L(u@wbm`vj zK~)p^otEf$)*&V5*Zmpm7l$cJmO;_>9n%cyzHV=@%fqIuR7dw+?g7a>R1(H|vn&V>$rwy!Y2 ze&4tuUWf^e_i~TPYa0L8BA>>@>Ilyum4Q5cWcmF~$}h7R`{KokJI#V7X||6)91d^Q z(hix+sq{gMJxC4a(DB-K4M@i$5)VGPrN3 z#Dq!^T#p_dum4+Oc@vGSt>k(~pg^d+w;TXwQ&@<6Iq03}cK=NGv$j4;HNeB_aFd}n zZp6(uqWuiX`t=Fd@2qva`BasHBjDgM=Jg@DC^PVwSm#qrxxILxOJ#M;bMhmqQ}

GCt;)N_{bueU|}P5I&+?Of&+e)w}jCbL5~A5dwbm0)=35!hUnBc0(DSg~?jy;snO1C4Xb*M}LJ+4`2ufY&aOrD6j3D~4fI*p9?)z1-!E_NONIP6W;GZq3i-%f(~#`>>tuk1Pwi+`Qk#4?zbD8a7O9^uWm~ zZ$4Y3rn;Ph8ZC$+F?iYhEJshQX<)*^3ykk=bVSad1g?d+F8jfAC1eKGcCL%Aj4~XR zhD_xeo-9HOeYrMVM-*@hOi>Dkxs6HVcH;ly6a4A@sJ7YA+%!A6ppC+nh$EBy0$ua9 z@JU`r=s&;C$~kag&4|)f4$qpeOA~k;QqFrFty+H%3EJQ}(m8KtsrFTv&oWtkJ?9vI z+6ZQr{7w=wpubs&kD)^-EkuNaa!kaX*@DmbTRUdaj3cwB3MG>b=5}0s5Si;Wdj=k& z97<(}v4$Fq?G*Zrvr;X$ZxQXZps+bR5N!snFFt3sw=p5E`2K3NUvRT3&aF(kfkGp% zXT3(uMAx4OsnZbFA0|?@7YY{|>-(n!DHbh6d+ey83i)sCoH*MV6|wl|<}yVse=}zL zYSO|^I)mC|Tquri5>pF?#w%=4YL^$v91-KpgKpQ(?QaSBslfQJg*rE#M+DYcMjQdI zOT*Rv-px=lnWT-AYO1!t?jxf-!Q^p1pO@A(xuOfQ0z?_am;=Qt*W3jyW>_VsP`3Nm8)@`~*d9fm&80Ge~!CVC_8cNrJ# zr2V+5Ry_=*`IuVB?0|&o_hGozAFdrb=6e;vSB zdj5d)KIZib7P|!4*4@7RR;@FN`%sf1_JMB}x4)E!e!tosQ7wDw zvfZJomt8b@WVrY*?os?2dbAjfbQ1J955lT2CGB88d|eZ`E9f&$0v`&7EyPn@1w9<` zkhf$y;Kt=BbZje6Jgu!7IS0;uwv!AR|Nb2LT61PEuK&>MMVwi{A(pr7!2BYpoRfuD zjvgXE?cE%2jNzga$KdJNGQJCy61~1+f-D@Kd6|Fwj#dCyv_=vcQE`leL*x_PNRpH} zVi@KKA!BH=@Bg?%p7xi$C>J#FJ2JQa%5_uTFIE%LnPrvdhtTynzgX<_$Vr*J?yupf zn*Bfr+=Z?mEDKrq+%isJ&I+yZ0xK1o9Nues^m;@LR_-d(s5>z>JdV0mTx(#XtF3|{ zGbHO@ZYXU!pP1XsoAI>_wKl7{r|P?-ux0Q!M1wdRRIsgyc=DbGM@mtOFCLp1%OR(1 zL7xum&+_)?E66Fj=B^W)cRn@ z1RN7~HYp&Y@p?9flGV*v`e*VkbA#Zmua)``R(;w`y~y$7s1h>sHA5CboT_ylZhRqM z?w8$e;cyLxxvsUuQ$paoda9m~m zu`p2Y>r?FOy`;rU4F!`HQQgr)Yog2VhjKND zlT7YTd)wCqubf|7pW@fEt2f^*=eeW=ot69!_*>1uD9ajj)uU1JTc9UErab~KZIADK z?l@ECtb0a_idAGSg4+;Z$D~6hQTUd|-&*E6HqRtj2qi1oyz?_0=bw1#;n0y%KNV1I zYS!^EA9kI}dbybC$5GOlNwBnuDI&pTqo^7hScdi%V$^iL%hpS4vLmx&fh!!mvT%#| zIP11sU#QD%tlk#ioCGuj^%QFN^?%)m=i^EiL%^_q-IOvtf^)R68ojD<%6I~sITQYW z&@})BS`MYJt)oa5(&Kw}N2$haP;O?R?px`zx3sV({fJ0Ro>3Wy0|ZS>`qO!f5>4tX zBP=?!ele_sXL)X+&%o}}w7!OwklXFbR)%43|3V60!798En(zPg*$q|_f#zDYd76D` zhDu0BDg^)Ojh|X%4IxPJCgkRB5(T|aEHlSeAFwRpUR?291s_c#kKlxx8{j?_{o#a! zsbR_zKZ-D3kXPT?yf#i`2YgefS_|0_-2J4}-dceoNuu;1Vko636?!c|%@WKB~C5mdY#B@1u5Y85u*(b}OLDT)tOUFOcByNjwLg3Jx zoC&sMS}XF1iwM~8d{;f+==-CfQI_IFOgSm0NYYL$|&a;upJOTkL#(WzT6WNJddhoqZBvMno`UL<;fQGOTL}!e)g{v>r zh)&(bCwf2wSfZdzN69A0ZW1xgx-fP$oyIi6EKudw@Ig5d#wU1lTk6mafycV zcPooT_{rD(QOkG1N@A6%%j#6nNLbl%Nos_9v3fv^=+Xm)a_MXijE^(3Lb*NBN5sHr zlCUoWQM0166-4OtiF1`8iXmTN49jb^QL$g?~ez8yRHLX!S zRegG8zxQ%-|M|4jy>y>$g8nj5pY_N;9O?_6s&EVyw2DPkq#6D?4968+AudT)`-gcM z(JKM<*E!X$`1i2=W=i)wgZ|f(^Z_T6A;&3hF0NwAA$-!M;|l*u|E!CxugaJOTw}ju zgsO~X!hnj1!@2kTe~_W+VQ2`qxs_rja4%v+{~iqSohU^3sh1@{@aC6^L1g5T6MbFOQ#CcU^MGWQI-62#v8z=woi*fXR}BioOwcabec-93Ekx zuHmAE8XQOkwE+;E~EEjrW$qUd4T9+|B%oonNQDs^!x>~D$O zYy*O5sVy9g!MiPEyPfVs8}AO!FD~4e4M$H2`gOC&k-XX4FgeJDl6^W;`t@VwMiL1n z4;;pz%|NZ+CIVDx-ld&t2D)+{Av z2E{s`utox>xfft^9w(T%;Xaw4-{xgOBswvSx_|BdynZ6Pg4vUSZX33)Mwl;FslzJ& z5|4-SHagkwUR$IU1kHP#TZs#t9-k-DzRKah@bL2TKP=}NDW!wZ1Z5rWZ!-{VP17a3 z2`?nSV)?u`piowJmSKZF;5#iax{C1g5OYabEs4%7S;dLJHYby9cXsx(wsjdZisWaA z!p7(uDAKvyZiE)Zp$aKatWT{x<69}}lZq^XeHm!ZG#8uy)mRV*uG=I5uKuG1jPcHV zNt}{9d0l+XUSo%Dgmbzkp6zGD=97_evQ2ljxl^1ltqcpD`C(TI?;Oo+vYnn&^!EN6&2W%a?O6|EJ?VgX#X<(*gzlup0F) z8%*stiDR$~N3+kf^BPlXpWd**tC<{|s9&Pt?pSY}y?uaORjd7Y_CZ(KyO5~8!OXdW zp=5khnYSSSw6jyHJmU5B-z8PI5F;7orOz@!+_}k1&*pkp=f7lJfcItjLo*n*Bs~UC z=H^s6n)zx}SV8^J3nq@V<3sapxEFujIm8n6Pjy^xA@5DAvo>C{Cmo#dAQY}my8Nj1JAXt++~kg;k?{tv*${Q4cE z`WGYv;(paGzmm+i$bB`DxtKv`#Fa@t0gu$Me|;t`fCl6ao+0_$Ktx8F(vwam*UzolTJ#t{ zlDwIJVdy<1@g1nm!*Ag4%pbt~8{~_xP zMG?Egp8vcsxSQ-1zfzIS-LPnOfK{0$ zG*HyJx)uvS%yb`nv&6ialb3Nu_Z)lL#ayixyJx`$TOu)OA3cU7Do}Zp8X7?=$IN64 z{GPPN5Hv`(kFMXL$5oDrciG<}5)8oC#hG9Cb-iOFt|z6-lb>7KWqZ6-f)Hj100lm_d+b zCc2fDQHc2i@Efid#c& zaVL}Gra@fXw}^>CO1E$|Vw9(DaR+vypqj3lMQP!oysmbM6>IRpy9~Sj&DFK14Y^%c zKEonr8#)xkEzYG0?DwRi{zzw9{+nB$WpbN2;w^IR0Y(XMy!WVb)z;bmtFp8RW`Q%@ zQVyL$wV9LwHYP&HD^?xPY}W(cdP!9}&gD1C^45Bmbc&EU!+p315LY27th9mc#v$&?jqpx{{Rd@^S<$cgO8v54?POf8;S&aY^SJb zx0L|~#`+FCb$XlFu_LNS>!T9Ue|VUWV+QP2GL~pF17Yn5aaD&M75%2nCDxK-1BcpF z5Y0MR+h~cF`m2og)*y&1gJ;oh^0q+hqoVhYii*QZ#FIjM4HC72=+|LKMK3`N$Cq<_ zhM@|9)OYk=jRPIWS^A;3AjZ={UdT}-qnr-)?85;e!OhLYNCJHK)B8U2joGb7okHlx z^R9796889W8^vBDDZ;H0#7NX2-*bWhCZ?SdkdY**Z74k!5cLv72;rI8$M%iguACA$ z>9Thxgv|rtwiFZz7+T**&Je=c)e~cV*D9w3PB`sd;n>o~?*bV1!ME(_?`A*yfn84= zi+=wXtB*Y!-Ikfv+Ly6G^DAE1dG8&au_XVyZ$GxdV zQV0om{XXaQ`} z+K(37R1HN5rn6!;E%1UQNySZ19G6%9DN_E&FgVaXD_Mhi`cyoY;2pL?NbQ&0?A<^R z$3_R#V+f(HZYt}bNSq=;yUZe`FL^H?Qy*Jo}O>sU&ivx?RGdFMIV)m(~BHu zXm~EGnTW*OV6WNhak?}Bs4d zsi#lIZ@ju-PxH3d4gTzZ0^!(tvD5A^>l7DuV@xb=;2gbv)|q5CJT9z?u7Ws+VTYCD zydbtmSI+Nefrey~s**@zbr>roqS<)L{QT=&{oTbR=>8D*>nC-YIOst%!AD|(SXX{d zIg=_UF0N(WcFJZU^ix)nxB(?TOzBnCE zw!wTyHZV#G!7ayX76j-IbDSV~opcqKH<@2O9{S*0cBoO-Us6sGwRu$1nQSy>biM}w zoqA4c2fE9Oy2B-6o=URfmd9w8R-*n3u^Gx`&&y=j-DldnGGm2 zi{H4vIKw)2Ch_PqZD52bNidQSqVa}3a?!XTNtnk$5v@Ia=6LwukA+_P{K5{{_=x4& zJ&vO%ORQ?N10N&zm$g{TL@37VoGg8-Xfsyp5%o(&PeF{9s?UBwf6yqE2B9?ctfSsOd79pF-FCPi2f=J-tgKydKvbR2? zbq?{mjdt3*9024#pKk3~4_;4k4Y6GXF`DovGZCw)@J-q!nv3zqS&2*}lS`<1>nkPBjd!x}D#5K8m@ z)8qYzl+#N4Dqs9zFc^ru?Y==A$G-E^)wNKo$M}pU1YN@i1j3w&B}lw=C(b!e`t&zu z|NaBJlc4@ge!quZztQz_K6?*+{MdZBnV>9?lJW=o8$D`Io%WhFkh?Y6(9&= z-&N!4X*!)DK*(ToB$)2Ss?2NFTiDguD_dUFAqPL)5g%nU*Ea;6{)Eky$s!=28nLB zUE5GoXJ>S6DBahj1JOA}5amUa?tRCneyMkfb;IbEDxex7i8XcgaJ270n+hVwOG{hM z$yrCCk(l6iG-q^QA6g5=Y^KbQ0nuP_^2G$%_LiaRAM#f-AHul#oBZN zJG!sdr;sw#Q4ZVGrDd)0&8Q~+O>?NXB_eS_6l-YvMzwe}&O7bYX8S98b@KI8QHXcF zAlBof(%3J=%6B9tUm%=NI@=4l6(zVoV3z(dpn_kn|U9=@grLz)cH9M^vzTN=5TOYkAhSHAQ+pQOGPBH2AdC(#KbIJ(}V#)j5IqWAt&+k^mOtfPa^ zCIqfAse&>XMv9cfR%R85wR%K8C*)Fkw;sh_R3cNsTmY;?vx^0X~kCti6LvM;djTDgq&6^GE_OWRu$4hHj#Pr1yN|kBVCrYKuKH*m$_qx*LO1u-~hIWD<%pTg_m`odI;oiB=QaOa1Tc~ z3&WQ6G=flSU_q6TD3QRR#B-YpqQ_kfW)%)aMZU%^L}5T4%JYpCL@OhPVCEpCjF^Q)M6^Em>3oZtX6H) zFZptnBr}l=mo(9Zc~iSM_9%!FT=8>F&sfCs&>!ZzJk1XMu-RHAK@twFk4n8kVH>{Y zZMPBY1hKi0B;j-x*P|L6CA;ihGQ#ue^vomIP2Qy(ds{UyBGUrK`ZLSNCWoG<9J}p3 zPCJ#7qe(YgW~v~Tke!R0T??6HO2}9U6Wh|!WW8=@X&#kU2}uYrz|eS(YZVJ^pVy^r zs8zr2{`*D<;^Qa(T@}P`@7QLSHvtoiw}Q9Bsp*GQLG1MnedD8h>{jyGsrZrOv1cxb z3#}RoHpfW~3}&-+LNUItg#{7E!J+-0UEK>-CiC>%D1^8H&w2Hj*%lJS-ky@J(fNhd z%GWjqBLwl{(ldgPBlG?$E?XB(v7By8mi1w`w#4(Z6g(%0JW0s*bc4N62?}t_LH8{O z-7`zM`#!iy@~Nf(F}M=U)f@Fy7qU4i$4T0_;;q9qleSO4ZO{ZgtP*ljPVF9-#@%VE(@7~r3P05qg z)>!{25yWGSvq0PcgQ6z|qw|U&%0BIbhgwPy8Oq4;*qabd6a>-domF343*{bqCP7f_ zhHq^SiBt=%!K)-vg3r~8f;hLFIXjaYAGRo`ZfjzCM)zr8O%OORv4ooUrnzVZQNOeS zS_~3It-s1x-?c!tLTioRAPl2hX^s#{uI8sLNtd%2ZvM#eR#|jkM>;EJS~AnQBw>LF z*P5F6FW+DM%=-$_3Y_LtU~PR~fu>_Kw}c?#IIcQ4N|JPHs!WWGhGUQ*1|r3n4t9&z zYIY~mtrEbBX@Tt&6(ya(*vL~PS=^>La=dAAP&NQUxE5^1_$Ugogy67}t*}?Njtfg< zj;9z(zP}V99=^u(g9nmTyw5nyCL9M?)7OQ2dweWY_(_Y#lE$pGHerOQSR=NOAkr-} zVa|tmAc@@2z;o&=f(Y|5mJM<{tDZz!tUHCCF(gugr}ffNjgJk6Qnnsv!KChYU*!9b zUj;|A}2A>angtVYL`FLM+X%;&JrNt{ks?xpC0V3;9(RIY+Z|m7?Jph)-lg+5rZ3*R5oU_7o8m;#QwkkXhmxqj+ZjoRu@E$hlz}6Gquny zrf90W(-%&aEH&&IwX~Wb=9e0pd4Cz2>g_HXA?g|>TZ=)02r;l=ieQZ#jFmQwz zFAjQfoEYuB=J>_m7*={&T55k;0YdEbh3t;5L+T47J^M~gw~!sXJVI;%Bem+uWF@I( zMqge}A(&M-Wj%V9`Sh2cTMo3S>5?pn%`-CX@D*ds-gUJG}Zb-J3=(Z zM@7LvK}@Vb2vsk+iNWg)E4nNIw5T9P6KfEY16V4jWBu1PyGA9XThcXUV-iWx&a}WF z5dd|{3?ZDJttXF)VbIN+KLG&LHAD#EiPJ{-s3;gGh=Rn=`cDma?om!z#%8zp;;E*^ zw6d58taW^tHHeb16g=POEqHgawAF6$WUy9Q2uroq0-{PL3!84DJ5$JMad{{j; z%p_aEP(eJiaO6eT{JwhXar8|uKeypK^a975Z)vpwGfU6iwD)(^Q;)rOxdj9fVlWhK z=|Rl~WU`Wjfz36u8s9mj9!DZgHnFG%i3^KS;FjNrNqI1!t-)Suv zpsNAFjEAAiW+G}8g0aP9I&3u;Hrl#8BPSLg|9|bB2b?5Vedp^{=<1w%a@w7p%|Y6z zgk)tf2+7DGz&0SjIt+ZUvG0S!fe#Vq+tQ`AI~!Mq@PjgfFF%{G#?&0->P-s2+c)k*aGiY$#Ih95G^X_zS8pOmHrCfRGa zhbVP`C4ZGY^0v=eH-8o53t}`8>}=X1Z+o4s^{$RYCex_hLm3ERh7qlGO1I+k7A1NQ zH^Puvwno(XHMro-5!AOUt+ybm!``^;0jt@Wu7?|j{_nkNi6n#hZKus|HQ8BM&*fcS zgiOPZm-A%U`U#@SurAB-$&|68IV=T50-Xg>?X<_T*;rgxUw+*MF7l zxeoHrtbd`)uyd1jeWz(~j2#+hQ@M^;$MEU^N#Bj6X8oR*&3#1yKy_YXu8$zTb@ti2-|=}IS32Tt zfxf|s-|AL6t$XjFELUx|=C^0&xT%n`d@(Tk#yfW1hvQ0xn_L}rk;5zxGxg3lR728? zpbj#k0|0=lcWut^3W5*}MbrnJzL@?Nb7XBZ~(5_QHZ6_^ZexrUB`~~ z5yVs`5>3wJZR>pc=2xk2P@|G;=qgMbT^cKlo-%AA$wZu1pSevEE^fP-XJ~AWd!Su(IN?n;rJT zwGR$YWI4T;@<{m&hDj6ldN?ggDe3_TMaS}j=y!G0HtLM>I>Rw3hSs7lrc8Lvii6nv zisj1PAM945qoojsWpJETS1+2FE(oHd1(TbnrbyqP>#pPqf)GojMNJCLh5Q8b1!;}C zk;hYl-KI@f0L=eSby5D})PIA+6N@kT*KbC9J6q)Kb)Du4?g@ZPH}YDN`|_Nbn#Kh| z=<^iKWt{H6q)o%iZ?k>BpwwXg96a|aB zwWNK8%ZHokn%!k63MPw-wt6Z!wlp{GrqpgT<8v09yWp3S%oZZ2dmODcujajJ2q6@B zi^DIsMK%kqUaj8JP~^=HzuBqzBnA}u1&!S7rU(s+g;2yGw0c_9ZLh7XXaaE}#8}-6 zU60-vARd_CUf*F^e8A$OFeuQDfc8d25DHnF%fk~ZZ{!uSz-e!AGUW*LEPeRO$!QU0 z4|Fg7lO%{DmlZh8BaRW284x6wj$sf6XD?wJmCtQv7tUwdY`SJ80i2|*9FxFGMs{|R zoLI9qM3~GX&qHD5l5y=0zj}9QG|s644u)fwvH6#UjdlW_%W~SzE&-e*OcpMi!bAbX zKr+c^QyK{(q%2M@n}WRN!y?M!SkTCmF_YC?b47$WNdto9nWQ>pl-phy0kCX#W_ogQ zJp74CEEI7BJX9_MCfL-@^$#xhNCF(kDOzMxn#b0co+b+r(_C6}iSW1zt4*&q{q&*(3nL!D_36i$) znS`(?X&3;Qz|lhO=ETgDN#IyMyF!t|0d`!0QzERULZKs(D zlZ7TM2xaHc_z)XwR;yXS&(BvBTrBi>8!oW3T4G9g5WXz_Mx0U2J_G zdwx3@3TKj$We;CDIB5a^XOl6wXr562MHQ)c7K#-4tN2_Ule3qWK4nF~65T*eJoZ-c zmCb%i?K5-aWNJZr&uF4Z{=ClC={fzvsY?boYiNC0jh=>nMNWH52e}!WV)t%SCeg0D z+VwAwRuJ~8R3p~{eSH7b@h|kSKm;Y$P3o}9EC&JO1&$TeTI$1ABL~qj#jo<22KS-uk%MO1ObQ!6vW)pN>CO=$ho#7JkkaxA@Oz7VRECBp zG%QSeNKz|73>2pgIDO`#7Nt}|Jq0ls8Qt7-oxHuN>%a>~{$11JYUrS%Gm*q}cZ-tG zdPC>lmybQ9L!r!C?;*--%{GgMPR%}FVSO1wcxkBqxnx&^b^|PR+5iaQss1Zc5NB1< zvzxu^%qAO?)iCgy#lVS}mq(pnrODk*o2<2T>{$YCjT+pj(b}j;9sHSLvU5vh4rmm# z8(^u^2BgwV9doqSQxInsnrF99gf3`ToRKrrFHba4+hT-qGbDHS0|KRXs%ggTkt*^Bkgj*XQccP7tLKMh&>cEE8GiucEmiu2p;N z1!yh$KGhszq(SnIE(59A0#pDAqVOl8=lg;pFG{aX5~G~)Vlo-Cxcvi*;J1r6fRC2+uoztu{V3yYh68tUHePSGO+|_ zq3H?^teTc4AF0aEw1Q~(E(ghGMdP@WQ>0`LTk`-ktt~!MrL|+1>ytQ0rk;W*xnYbP z$_t{V4tzQ|X;sB-*Sc=nr*t1KjxeA2Qkl$?qci<^LG(Gx2FqJ5x(6z*8_U0nR)eqD z<|bU(kQv9pE&E-2w>kD~b2Ry=ul?)T3vXx@^rcz=02GPWd^#miY#%>!#SWRJi238k z@=+e#&Q^+~IbIX*CBk4{%+3Bn?5W96c?W=r*^7cG5V=G&Zbvgo(!4e|O6CfgLzKNo zG&0Xtuc&YwAB!)H+))yM!7qHGGoSgVC7`);VO5xEkgc`5ih3=c&2qDI^_*<2vmi3r zR3ttX@O0%mai;E;ZI>=n`EM{Tsi@gFm*O<9-cg@QQbsw zjXUivKqK>Pb=0r1%hMIQUI-#K8{yiUl~H`Nul7M3SA&@*;>j9Mr>J{~mxd;JjUzlR zPn`u(T5FDu%7Q3Gyy!Zhl^`0))^A zE3dGJ7)^!p+g@j@g?40EO+u%n*(*#CHHgFN^SLb+rI(jZGtp{-+yv(e?IGZ$v{=|( z4FrzywHwq1{GR-cN~V~(dK)LzUl2plf*=9_eD3yiu*NrqqV&v5UY-`;$^>yHzYQ?p zb+t~+)Lsw;0XA$vmKCCf(dDL%-T5|G;reo(nYls_(P}2zn&^q3f#M_*B+l0^6wTJn z>+?4%6s~`4wE7F;LI*x)`*?7mwndmtMw&q^USgh}<&x=&npaHKLzJUuPt4TbvF8dg z$yUSjQfU0zzA-m#D5SWo)+9R^E_y-k?6Vn45J?=XVQsc-Duh7{%^^X=uyA}bm5JFb zij6{>zwgAwmug#t#b9x6dfE#!<*yy9v=PM9bD3kFyIt2N0UpDIfDdBbk!bBF5bys>~98f;iB^oHI-0Nu+|v$gu> z6;ppf%#E7IY!)SexE6kCw86qUG#FOW$NMRaF%vgMiAbOpe*s)7JL=6LC8I*KQ z1<~hqspn{_4eKxy=0p+0=iBV|jka1CNk` z@~+!K+O0$~Z4_t98a_>af6;BQCw@3d|0 zHFvj}W+PR2$qCV8o1MqKnYXRN6eYy&+83<(`lB>m4hT`;OXRGrH8?Q}MNx-bI#NMU zTz<8vCeA7V2N=N3Cem{FCD!B!yZfemyAtuLr^YS}7X*FDHyOT*l-v(K835&s01C=p)(NJu~lc&FnGdOPX1TiuuiXuKgjojC{zlL9K zD3k1}z%lqh+t432k2LHRAd1*xP5w*Y$$y?&cQERMHMaB74*;;5*op&Cm~4d*o;iC7 z1NIu!Z*>?#5SeUpYVKltV3WM-b++2f?i5p-8P*2vArct;)`k=Nod&YEK&+lNn*D0e zxv!1&{W9EK@4BoU0l8sNZjg0f;i9Fp+k8=l#Op{CWywHL#%|) zv|n7HhH(!6(IxjQ)7En_dWIocu_1;2%E3;G+uZDdK<4hw#5MjRTY}HD4WCH5CbQ=H z)oxWtW4TqY@od>#h;v-uv;k_2B8a~^^Z0H1Kc2VVY71-_3)Uw4u+aj4l?BJ(=Q}UF zFHi^vkm11(E_%K*;GJfS@_Ql5lH~Ctvz8;XmhTRDuktW|zkBAUW+hjmo8-UTeg01_ z?G#{xHBK3l1ld&^@bBNVEx$cFKDFvf)dmu)wbRnlFlqyIPM4U34odK;&#|a8z z!0cGk@?b7Ll^4W-ugC0Iu>?p{z9zdzzF@19&~6$L0D`2f1X)Y_mv;qV{7M{A0Fd7El+}f4ZN&+2DDtGqN>FrO z8?A!C;j1dQz1^?gjiv;%(+_Y!QWoIUdKd(8g2DixONkySTaywxcqag9Kn#c_du(&@ ziSFjD^0v>}h7&8UlJuSdI014$AW3OCW(K*o32J%wu6GoY>!pxu@dJa_5x-;1GjAa(vTzfCq9zuA)XxnE+0#Y7%il9+lZ}f*^7+ zsU%wyZI?lz?7jvuT=Lmi*gca3QOGiEQtL!E&}8#aw1tV!F6A&VIdfrm-|h0Y)9Qnn z43kZ*pd^6f7FRQy42yzbA_1G!FvKQp^YC0o$kxnctjC;k5e1XOhlgFu9*$@*o|~`_ zCa7MAVko|ioO(3jE_c#mAWoW0HaC}!i5dso#|hHx4Df71-N?DLkcq9z*4<_8%Wr41 z+~nAhAc$6PtB_@~>6pf&W@vT>0Kl1eDCZ?rB)_C=1f+xelv06r57iS<=Br{C20Zw###Tr98lH}Hkgwuee`lh z2BPLquGF@rrOu*)0KVFC)$ZP*xri_?>p(6@muW=Fn^P*Ns4N4mS}q8TxT$OZ3I$Px zro-hG@HY>%uIr$~F+MoQJ^y*dpO18=i?3UorhS!6Jw6=?VF-CreBSxX41RhqEy{i;NA|90?8&F=U%n`k~FZ=p=<0U*a*en(?3i zhaV}QsF-&E|WLmIWl?Kz*Y6dz4AgT;}bq%w4x|4#Mp*kz+wWgAWq`-fgv z@A}*w*eV1uw}@{bL2M0}^7V-(4AHU?MrLG!%?qL&Db^r@D386^l?Gtix_O;?7tJ9q zNi-RZr{zb5hs2>lyj?G!}e{xg8 zSFTo)a7DBDmrgV-RrG(`F}vASn5=kW-0_Ri6?aN?KX?s zRZ#iy%hL>Fq!e29uE0V<(b2ph`rWHaCt$R|UmYz4Wr-VA5FhA^c<91B=F3yIzMVqrnwe4zCIuDL!)J<-1?$yijYaqfRU5 zHy7RcEiSgp#*L*kwBBDa6Ej6!CqwlRR~#lbP@Hh)bpNFq)u=WYP!J2oH*R|~K~RFQ zLYsJ_g>~p?DXgp+bIAP1^PQ|&mhjU;P7pPZ+Zod{sN^By^Jvhae9^$A;jnVQx2Pfp4Jsx_^Q(ea2!84e0kZ!^~ONASY}3* zP5=PlbG9w8PpllH%^`L)7X&e3JVA6(!X0f%dHbnx$Njxg0I*HYY?GOv(K77VUmtIJ zBm$!75l6BTpg2@&0)A*+{5u2Qa%b0o6pueNm&ajb7#jjwF;J%xb0)jiiSd zO@kBX(k?sUau9Ya5l-wcDB9N_HT`(dB@5y%4@2Y7m=Vb1DIxy=7)lW3=c`&vL>$M}f;ep?gBuyc31XJd zoE|)Q^+qM^O`E^3+!;%P8!4MgDlLBQYlY6Qp~PC|))u8-HkzW&MCn5z%kOqbg4pNC zUhQMvRxRmjirOY-mRV8)ZfZ{3RC4L2%D-Sfu_5BkEg4Ov$x{*Y$%y$IdxIX6=r)O; z+7$lIfDuFR{_K}CE;|`Z@yV1BoYOa%YB_KMhA z@`C7JO%KsvbBJmT=!KFWPX2tvDMD~2TCjTAj31sht)j2jM&*9|i&ceEzmCQ%Nn{>ZRX9yP=HSaH%Un`T*aKeaLP z?)IdafPGGOgQL(1`q{8^ympJfUAF9|oWVJS*ux`=;o+HiLF}<}y^ic)qI||=M4sFn zeRVJPO?}<@Z9x=gLP!uRAeIazQnN0*WLs-C*_wUpr)O7UtYqXKqS5$dpVE7p%#i1D z)_>C(@x9vF7acF?$y+Yg~w%{`k$d8kVwqK5&1CB~FHw&ab)`yxYqlL9B{u zdx%bl&FwDOz>keju`Ble(+ndDqP*5D3!?N^>D<3UK}0>o%5a;6+vgW{0fzmLt7hcw z?0is`K1a60%3V(DVXT+t!HZM&ubuKQKdbPisu|jrv z7!$6@Pn(thi>(U>G8B&e;riIu&-hE7y;=sM^fW_eMKHq6-~8kmJ9Nzoa#q{STUh+!S=AdX(L6NIlk)liUhGjc2}SU zA6Xy!M!!#sJi^}^@IIn(>KK#C;ASaycvuRDoFR>Gj0vdn`BFRWF$_C4KE3SW^}(Qm zsP+~~~qM;r12>q50tQh|UfN_HqLE|F}mz8F*xL(wAd6rZcTT0xY$ zhiDG5GV(pdOL6L|XV$Aze0*_d=&!fWfni&A;*N@S&={ zfi#v?zj1k2d$N0eZQ&pK{RmAwkj_gpQ8SAN+j7~*L2;!Of54&yLW*2*| zrr{)N0*+&56He1O%Zh2nC{u4$JzxP}(GoKnGFMIi@F57H99-A$?yUB7qxBHqeUrO2 zpxE&}`h3_Rf|%m*-OsF_-!6T}?tH1+Ils^{%PyRWIPlAL%fEW!T#1p@{~qu@I3IU5 zw6w$W0{;8Srf;5AOn+vI&k8c+_NRK~ZN6ABzI)F5!%NE3IFT;V;2#-rz7n*_+jEO9 zu)a_vc>KHD>{ddau>017m!EpYD1ED{>=4yPh!8>p`l!elR}eEyJemx894)zKL3cE5 zx;%5f^3w@pHEhgcF6csLnU}n30aOP?&x&9;S;pLYjwP0niP05#*F~~KBwO__G|Li; z6tHz9g4=3xk?6bV@8^=T1R0z3mHc}SJs>WbyBgBh?V;pCcY*`C1t{}?( zDzYF-dF#6lR3(TjaJ1AE3tebF47;QtaOk{Lp*M50>Q|Ez~k3CfRxrSP0rbu9-3e6|OE~(T> z)#I_n$r1o+VGE6@BPJZ?vs`6G5a-K0gRunHOR5NEQ5a04D%?6Gqw;=mf?*49)$b<8YB3+1Lp zqKQJNM~m4&@A=9NvmJJ;%c(e?XEN;NiJ+GCS`~&C#0AL%e6IGXxxot0HI(0;$5nKn zmzhHxWNhl)n_E&y5Z4kUfPb}pHorT_Sj(QGPEK#AImF6c_taq!gGM#DkhG&e5TQJWs7L=0R@4n`_7%=N=NwfTf&cxIl~59 z`b4al`!__@vCdwLm5)k06E1}0v03I@g8VS)F$RLz5=%do519|_+mYWMUB)CE=Hx+# zv{O(nr$j+yE)+OPneC;{t0e?UK>#STz4BNk=t_tGQsc8{HV@^{96=Y?)Gleh*j>8#|n&wALc2Ac6z{n9TOYRVQ;&MdPGNb2ATs0ff)NrB_>hFl}P7mfzmbMG)|Q<<#oD>1W^DM(!AtA_=WVwyS=02d8kNM`F z;D3F)8EIlev(3%^`1*+!i!%NdA@;4=tJ0K*EN8ZqR;{|wG)WfpLZ3#CVNk#&i73&? zO*URA*Xw8rxYc4Bj&mlPXYpYm_m99!7gSs1>X>Y9xJdM*d(CIl*;G{18bAnXyN5KH zndnSa`2d1*LwP~;SbR*hg6UWsx4E0RR9FzilCQN|Azze`&G1=f`GU;255o$xMX6Yn zO)MEJxF5s*o!IcHw!%u>0~@3J{LFh_?hIFNyN56WeU9u?H;?Dl5W}#ylb)xhoOwU- zWr#(b7^c{)5E#!U<{${T(ln%3b2~6 zKQrNx{Ih$UhEv4^G>LlN9PLHUt5hzb25myEF0r?l`Ghf?!1a`;*-jK6Hs>p~0IdAfE*Z zDR84GXx_->)J9NxoJtnNi{r@o8r!_f z5%ZCSoSZ#&?mPQ}IX4gdT#RPw9a9UjJOMMm&CQl<9Y33CKcA^|rYxnhNua2rAHD94 zyb|{ZwFTnf(D?E`D)PX&j`nhfHoM71iY>Cd+1Rze5<%3lrBp;TA>^#d*b0tSNC?!2 z`wx7f>)frXexi)WBwiaZgex?nTH-wU@J#!op(^rc4UT2*`-=gN7qc8(#)7pjRAP## z_F_J1V3TYaqYI+ChbTMnz4+FDls#*0^qmp{^|!;@&u7LSXdNVVF(N`}N=799a(L^h zRCTGWI37;V8ZJAX3{k<8)+jzIGDa7~WF|5jz3g{)$h$4xb+pOGW>d?brRg-YopTl$)Kiq3c1}Mf(@YJF{ zqtDsa)v~Q{*4SIoWSK>IL4e70(S#--Buh7rNkZ+{*(kGv=Q2>xJn#u+c8CHeaFzOf znrUKrui@Txoz3&{pChrvr9mYFDnXiXoZvYILdc5vbe0}tEf+JkgrG+TSN$pL4|ZVR zj(qQA{IU_VN-dxP1o7<1p}ZhUkG;FStRSu}Jyd`gc0S`gpLQ&DK$EBY4|e=re)rjn z|L6G73(J3Jh7~4)MKhZuZL_*tQsK)2SH+Cy%CP%8nZz8=rgbQYV^VCg(inVRH|2FM zCt;ty?E~#?O5X6_o;>%fi8thJ%HpI;zZ~| zhD}&#MF{+^j`HWMNkuG2C_0wq855}(f$!e*P7`Gv7(NtFEjdon!Aq(US;~|I0MOad zyk+zHw$?!Y!)P?FW7Q1CvJVuJY*w*2)xObzHsi z4oYR^-|Fja_4Qu6{hseX_Gva-x)+87u_Q}%?|$dTJ-6L_&4Ld*+gpwtt-$hOEwcQO z+3$1Xsv`^=C0k7c2x4xnc~m8c<$H)%pdRkt^v;{E`rzWv?H147H-7%nm;Sz_aTgNA zl9cEm0_>(6_TGHszQqUkZRoo1-aDUs<`srD=vuILEdQXX*O#RsGwLDM3UwSXpR*kR zyej{{H4gw_|JL`&+lfs0>DRwI9Uj`zd-Lu4?w4$pI`{AByZQ9RmlvO?OSqKAV{sgm zj<)7IZdLk*Q&XX*pL;zLi|^RF;kKI(m`v3Ab?sZXtpDwabEUp)EwlV8b3hd(TNL5U zY($qzt%}AFM0KQCf*^ZZcMeaUSmyXEZOGi}+h8$Co)6=}vk$-UWsaBDLynwz;_}S7 z58n1A3vIu3&qvMdPre{&)djsL-Yzbmgh zUx|Xata+^3Bs(-478_xqsqq9+YMovCkszw^I+gHB{ll-zYA7=?b8ckn)Rvy>>=uvH z>Wd^A!0y2aEbbvT%|F}Ezi?PqLz%M!LoA!6X_Mb~C1$u^-bGfOdbA-X5F17iXCh>^XUEU!5E5k!fA zV`vVsB%-+RIVa|QGLp%K%TsKYqiGXSiPOeP+1O)o*a#d46ah|OOeIp9n}}A(@*WWC z#8QS$#JqsU5X3YSyEO53U&mf~H{j`Z*#fatXsP23)j_nF@f{lrvA=?IWuo=0fJj1# zLcC?&^`{13$?KfU(Y(IxYAL)*HWe#nq>c2jB#Wn3LvvAiQSaUFy78f(KF!XrpY(LM zIUQ0^!ntrvyZZdsk9BNXr!=8I`}*MVfuwe2RmC!4>DTwP+iitrzg(bfBheUwD2+{q z4&?<=UTa=P5Dle>BvDkGELVFKnh0Iwg)B*!`a1Uh(L2BT*uih4GSRM9Q z;=I}aq}tLDo?CzZ*oz#;cXTv=;NCmr14hO)eBcGu zvNvrqkf;7Kiz-`Gc~npjvG(*2zjpVHf0o}3cshqBkJq(Em9n7C@On6X{_o7MtA+4Nn|7dV2rf?ErvnTY5MD z^*S!gnazsT=S(Pk>dXc0>Z6WIO&RHtU8du)ZA(EAA%qR#rqW1)I6XI%Vq!M45^S=~ zzj2v^^$dqtmS#V-U>C=iH2qyMzd8N5+upRXbAN71k+ja=C{AFXeC;1av9!N1(!es?AmSv>b@Od3WIF$^1 zb4#!J`;W}LcC3~&K_V2MedC|8>o8dp<%zHp7hZh(+;h={ans-$lcnAhPd;<-!lkhr zuiu^bNP`eQ`OHC$)Udma{)2Zn-ExhGA`8Ovhwp8^;Y8pupk6(E4&I5mpiwte>p!^7eDWLL|!o38qBU)Nq7D1Qp4 z`p=C#es1hYy&PIPnoVE$M9VTZ2>@W9x-XDQ3s1gWoAh1|tM@(Ee>NWi1H-U^kvD4c z%~v4HFEyRNFv2iwegg5@{X3?EvlEjG1DmggwkFeGe!O#ebK0vnng9OJy1w!+qYDDK z=|D@f|JnmP*RN}*NGioj1E6>!b^PSHS6(~H^TPXo?{;-ehqq4*WVzZ~R%k>c3F7qJ z;Gt7Lzj5AUx7pkCryuy?i${O-&TAf6aAb>jT~Et@p-r|E7hlr9o;bk1`Gt@N z=K1)&fu|2fS3jQ!0C@McpKkFMt?Qk*@M8Hh(IjH^B$MeUo<8`&_unb+Ivlo-eE2>8 z_ZPn!8lI@h_XB`^{qyVFo6Be8yk?i<&g(tDcv07WB0&)M-Ft`CYF58FoDSPfH|%L| zZAxdDo!k4=$G`Drx#*=vqLBm}h_{dT*5Q*X!B@-9J8c{OCRss7>DQ|V}D^VUs0 zy;5+>t?wP2IG#xBy}WhZ)poTSzTF@H#t(Td!uCG+30xL(~%yQq^vR0>Hdp5wr|Z{`8ONd z7ETRqYA#x|2odDgJS!n+a1Ak3tEjORW2l<7C8TP&ilWA#h8j{uQ$x8esj9}%(8e`i zbI>3)&(XZRKjN+R&N{!HbACB%ueJC3_P4)pytPV)*=MlP9y7<-toreNcRfIUzhQc5 zJy@Yu8h4Sn-*}xEEzKeoM(s|(f1l7nk8dle-5-cJiFnAp&~h%aKcK*CoIBslT>c#y zIfINffLQD{sH~2d05M0O=bKi}?!B40>aWTgPhEd96gU6mJ;s0S@2J{(Dy#Rtci%Qn zd86h5)6JvJ|A6k;v{OkuIlwIw|E>bcl9EcXNDv)zvNmR-1A)^ z4HlP_qTo#X9-aLeec0n=2eE4FuMYqrfOtKO44|#>9Yt;pE5n(EIo^bqh{2rF);`?* zgIRspBSiFZ`nBqYqav(WAVa`PbzNj|_BORjePAW;PHF6wYBg*75hpBh?{}q$d9Li_ zz0)n$_Jp3S94OFzcl6d~8MF^F_^0HuBmnmL!t@|fw%F!Q@Uvn@7prjRHa|y#+^#K| zIyO9@@#8@j!1>C|j>FH5m`b;v=CUqUt_9=26Aic8ZD;oAC9&_RaG2iao5?^rJ#1*U z`6RN>FRg5PU#=wZXXGWxB+N0*$(?rxhVj!@Ndb5&#pwywBW16Zp7u3%>Z(_Qxv-R4IGEuRb6dCv#M~{sPW0!o^3qC|Pf=$cF+?`{7)KHM8@Wh6vDV&1 z*`hyDq8+$DCCgSltdlJdlObub_AxWf$CKM^QNh%77VHszExdEPX&lYYA@u_UB+R** zcJYE9Q*-BB_oHSA`PBoqVz*QD60O0vDryQ9e+!VLx<}sZa*0W%skE7f&HMS9Yb0(i z%#Aw?b?Sx0IjvkBNi@5N$g^V3=66m`e5_Xx(QO@>eY8z!+`;U9&<-hX4~`hP^p&Wy zz6Fa7DNpl?3bdc5h>cwh#PuVXr+7H1*iUrv?4rYsRLd?nV<#W<=y($p`*V71eab$k z!nxD4Z+61u&3 z;x}P75OX+z&sh;XA_{UG_8L4@1op9HhSrAiFCwrNXNp^-e29@gUF1`Q}?ONmj&M%cc_!L z(nAKM)J#~6VTpt@T}zSWkA!-#jkYZ2uWmniO?J;*D>~Iz;Y;s`JJe15ez$ry>Oe`( zlCs4uVm=UCRql^AhQ-j92%eazfyn$0TF%C4Buwi{hHZpqklcCNdkZxJt2UQsEY#Mv zbT0(Y7eYwFyyIe`(L+l8AF~U;Vz&A9XAjziM9d*d+|fw*$$mq}6c@f96z8+;tI~O+ zRci8=G&1j|H<#(Ux+1}8d-qdsd%5RT^^PJ)1EGvLJ*JREhMei#(YfQwx2n%?VX}B& z$BzjcODJhzbonaFVGf5YTWaI{Lnz({QN9%pM`n`gvT@APsSxqUyWLUK2c7!Z@{rr+ zR&m#s(q+=Uy_*WYUPAD6OTrJ+GdotUZ1<-QFGo!Z^Y<%~7{q*H1OBpd&%pgb2?L3# z_t*Vf-0GJrGbP(}iw55=9P_1hJGQM<<`gzEc4SWjQ&iPik_yBa^@rpJ6V)!_33>z5 z-gr;@Tm6P&0G#4|?2w~(dn0!&gFfBSQd1W+<6Nc1tkKCVHHwImf`B91wS<2|= z-*`Azx`AF*es!JO%#2Cj8cMpV`ehi)qcF24Ps)VH1qB)rX*3=clwq5{TGPkN+MwiTg%Ja>?k zJYs)6?M|1pYnbP>=li*RN`enBuLbVcYh*1WDFONf+*F9$ELthL= zNv4L)#)VL8Y}clXF7Teoxd`8zJs}?SC{5F?TsC2kpuh1drGkliKs)u^qhALg|3KdG zW*r>#YRMo^rVXMZmCHs;?-kn3xUXQ7<4vj?7j^RSXLZkBW){id#?A31!Wc8vnoq7CQ#$iXBoP&>7H7BG=2! zOMHPgNysB8~9W{=!Qrv z_M{u7;Wu`vgmMvZ^wMw)RVK{S!lt!7^49FiQ3ZDN^HmMD1fzdASk96hY~}GB7ZQx5 zgr7@+1%Qccf(Q<_1Un;aijkB67t1vj$YFBkJeYMIDrJn25&&}kA3f4zb)KFw1Nh<# z3^{4xrUJ|TKWM+|d$7C4jb?mR;9Y;Jo?)wMl$+iQ*=g93q%uMfk=>fzd~3%N5O4f# zHE8%_*}v&64_#XB=Zbzh<8@H5E=lHQqwqE#=`|}slcC<^2001Vq?3mJ<6|f|fU00d z6?0+X$n5{3<;jtB?raEwIt)1v7S=Vy{WwIiS1tc}!&R?*?+O55Ipmhs z%hy;kpqf+RepCPnsm)J3$N)^!T{93Z6d73KC#>GKtzb|%8vhT8}L zYy~jeH@jk2D8O03#?DcG2*OFr8ofk83%%nZL0XhkmXI9x*%0L%C)ek& zWME4$G-em4=5i;@L0#Zygxh4~^)w0_CXHv z=E}QtW<7JytPn+c33L<^6euVtbSX)|FDNMJ>i<4S2#_cLJ} zF$R)*ZPXl%Ra9-vkZIl&B6_v9J?MlOV0+UKS-%b*oEv&rtN^e8kFM8445)kugx>~I zv)qnL$R`9JbefjA`(YJ?o_1lN!uSrx3kwT%^*^2$EJa;6nqkz^o4y{kWr221faeq5 zBMvFwhV9?3|3;o9Re_8?u5>Y9ueYMwi32wbw3Jetbd;03PIs7CbsxB(205r77e$I>WW z?w3q4i>-~ymDJP{?I3+zUEbLE5F3AjnQ+n4S_bb#3BMmEGHY{NE!FPbYGkojdV4&e zR=uIvZIE5U_#uP2O!2>J+n>=1r@w9AgH{+m%4CH*Jx+wXP?}!6{Mz#o?RJJ`7B-No zQS}!+C$t%pC9nX=F54jt=kFgAg(rUP@kpC9W$IOW?;#5Axn^UjTpf(bgmtT~2N})} z4-Z}p|76?-77uJ&j|vsSTELL5L`K~c8=)*VBvlGvf*Ja{_>$~%-?1#x7!4-V6w^V_ zUy+Bl=|;FHJ8sSin>JqV*k0vld=rw&(@>j0kN%Y8Y_|KxJ;>}aE7I?W72=)vJ%@sI!!% zt?NA0I^lBf>abyA<+<~|wXpwte9&3wF8SIvBFt+~!TBM<$0z7QJzSq?kq=4%`$I^{ z3riTZT^G8d8f`H$;B4!PLhq+)rDr#?=e$~NG&sP_m?n$WNBfzX`HdBg858Mh`8H2D zr^@SxtM$?eXFKXx*L+#9v|Eo&?;l8x&ZTLaF3KA_ z&#tiKPkRk&=R<&(mFMlv9=m%(qQX}WbwYqB1KGrH*o~9|S?9!T=DF$!P zcN+wmxKS?1IMxJaoH(mC8+)g(?V1+J#OkyRnp4xuTLOtzHydYNnGjQ{8y*?ysyCl0 zT+8e>fL^IKfW5wTe(hencyIp@Kck_K0vFz)hLrS`S z`(@*qjKcL6o{s8gyXTeI!R9d&3(5L{pNGWc(wTsak*x2ynbNsy?FxRwhB0S70&epP ze3K=We+R>aZ>_5&F-}REuss)r$8~`LHWSNe|21k79B75l*x0!0?abEeWth2X^_sRF z-m1~$Y+>~qCKfB^L4vNIgG|-H(6e8)=5)*{SJ-3rF_#tS#98^h;C7$(@K&6cpQPLP zFYHZnzR#C0xE^Qd!z7!4$rGH*H|@;8pGn@UMZ&xPkh(JO>h4%Lq#so%H5^*@||v ztLm`FJ2R=WNOW;xzkLz_Ol`p-kPL{t%ASRcqDNx)Vo6Gs+TT(%XHLs;yTqUZ)Ga5zC>sQ_J;4ngSydE&~SV`ob7)OPN30h>CaBKw{GYA z&E}-*#pJ3dN}7Hbro6iFu~y{vFluN1YH&x1C12p6{+c(7giN33l#B61jJkd66_U;S zB7Od%JLc%r%*BpvRk`2FS)afj>!z2Quzx*mR@{4;14q(ZXS@4Bx7msu7Jv#eS zu1K?Fb}NVWV;eOONbwVEM^R0!F_=AZU-14O^s@EmsgMqNs1!m!^4Yrv0a$s$;N+HRd%+Khpx`yq|2Lxjo|j63QpAA7rBP!T;Ib;bQpda+l-v0U+oNw( zMn&MNrvzSu4=lV(EXAf_@QuD_Y61IJH2B{n&tx9J$s~UlggK$j=A6<|Xu|NflO74b z`K)mzbk*j;PO~F3bKSdE0^lfF#~@mCQ{uT;4&VG4{`J$v)NG$7Es)V}cem{w27z9* znH3`UMc`L$nkIVcKQGG^i<;M5bi_D#il7cO`P5y2x_}C!-6et7W-B~9CEuU8S@cdx z@rJXJ7QIwBLa9u+7hG(ky-OrC&3z^sX{A(`O+TLUnrz!0D`3&+Cr}L$!~v934NFI- ze)2pzyChAXHXd83D;G+0EWXJht4=G{;$9bc$jSVN^;)}QL-)i?tgKbjHY+vun*$@B zis;qwnS_$y*`IjmHfNT{SwFmGxYx%&0SRT|53cTYf%VDH&9(YGv(4D|@t7 zEaLmeuGairMjST$Vrz`i>m=iQcqJ(@d_N?^{q32)i_sMEyRRu*yT6NGFwX*tXgGq% zvQ_#W#-n1*{dt(rieM0l#m``5umF9R_Md)11-U)8VHs-@*pvFf+;5XU-03uJj|&26 z6v#pnIg@c?VU|m=VvNCaLCb}Vzl)x91nSiF+$5e+ZC53tyy39nDL|*}Q*J_2AX!H} z`dkIIKyvZCZqw5x(=2jqw$C*%uXGsu9L5CQWVFDkqU+fe$d18~dfbr$BT=K6ai*5> zJy_4L%mQEv5#EtUML+BH8bI%aTf+{zT?qcdt%JzqA9%8~?bvh$YUvJP7n?xdw=|0- z02T-G_>=0p(XNOJ;s?$z(Z_LcXY=^uJ>>8p^L7@COb6Q1QiuPF>7B#OaQeOM{ubI6 zs<-M{RqBM@C%ZX7YKup>F1BuzYiTFJa0eB}O;Um2+OqWC%M6)hrR#`#PgW#V?cJqn zWsy3I;CG;Ai9=9R%Wkf}iEl8B=v!CvgNAEA`yE5E$GfQ ztCs{Md1H)`!PCKy6!gvs909QaaGK3_IZ@t2wc(|C409Z22yrBzft8R5%*&129q#l2 z({>q@p&%9sSI{7>EI{6Fro6J7G&u_VGTKr6Xm>Y`(qKg$obtoo{tD;fEU-}Rme=in zZ^(K!&#ie7HOl7MUqF8_v9gfZubA+##9^U$!#JC)6)X!+Vh)jP*$3K%(Fb#Jl!|)+d z`|5bT-GxCfwhyMJYX9Hvroi~=9dO(~?nJ%B3*KVp5y~X8LR$gApX2qoBGcVhB)~** z|LrGV$yhvEpA?;73&rm|hr8yt#67WXoMrwMDDw(%pEslTc@^r z2;d_KV)3FS3Gqo*6mm9>ynpiOkkP&5AnI!Bzcx0?|7VCKbry4sVP2^CN*;Md6Ub6! zq;C<*-XUBzEKCn+k{*Np*o|hsya&0#dR3xoZlNESM>G0umwva1Wy6(Wo!K`7;4g{6)4cbqgF=8>k#B2_ne zQ11#{koKRyEeLqa*`5C8kR8I-5#Or)lQoqVXAsR9^OA{$<;>Rvf+DGacTEYV{PUNyIit8^V2NAiO6F4yYRiVZaUqU zIc53e7Ld6)P_LeQHY3iCf$H|*{3lQ&7Wah-RKOUNUJeRU-L+AmMi~=Ucpa7kuoZ0# zr<)n4Rx<+GX6-NwKnm7Fgj(m#Bii_GIXKIlHyr+Hq&=vz%dp>Vrc8jF@4wTe@Ke*8 z(+Xci1dZX`#zmhGEWTYEjWa8bjVSvK&i3B1`bX@1idFQQJa0nl`LP^zAKGZtIia45 zLD>gT5xT5=NmKO~+n*V{x?DVMZW1jT9ZXr{Dg(Az?vjF?@Q>(qaI;yogCcntlA+u5 z>oem z`nqpQqapUy6B&gXgOfbhs}JVIoTUw?2WnN~3yerlg0Y-Xiqzqi5;8HxzPL7@RHBQq z+7yM7!C}i28QruuLCi|<6<#(re-i$5iJbJMs`V7n7UtA&LIWis{ug}GDM0S+=dNi6 z*Y`%}qO|L7BTtp>)ywiH$~ghCRj5Vb?c&gpvBoBd(nOCjHKm9iMd9q*rQYxmn%)<`tA z(tkg(3=02wd7A)_v1q+vaS5s?s(2{f&6M>K0~!Bl$bG3!`89Q9MtsH$@HaCn`?>K0 zGyHjLcvRD?vy9;d)j7p-t|6SN5b-}nJ0WVI4Pt0*o5Qrs3W39}#)Wufzt+q=`9oZmA5|gv<89-kAao_ow9N|~ zGGj_I%ViO0_>DN>Gk~@AzX1H4*!sg>C%79Y)j@INkCn7I4sKBe)1|MWEKT9>7Zvtk zszPM9j`gAdX=PllYL&1jI)}F)-@pkF(MkXk0RUVp1y;$fV6a1vLl2yZ^TzI*y)-vm zlHIx~Rnq#kDU8SYJlUHJqvq; zyA;;ftLP{uxkf2Hw)0Dd=h&TFW!2o?-VUKI#-!?ckn#3=8))Xo{yN@F)RjgNtu_Ab zQ^)y`>%EHaOC?fNUM{;jdAe>SCr1mkh)y;OSW4gIxL?_Lce-W)K<^a8kz%tc%_b=Z z%~$eMLhr;+%3>+gNy1~`uyG+Jf=4wrza{eQJVcoA9(PD_Ky(!hH;-k^0~RvTPuU14 zR&fW83ZIg00Hd7?kvf=vS2rPKTGEy+jvX6vJk~oQ`2WSlE-c{4z7${XeL%-^KgaNB z;3vQaxYwDCrHU+-xmId?9NQPKM7R`g>%%J5D=u|_9+C^P2Jovh1e{JaU5|pjLk28O z3BAy&x*jg$!|CYdb#IhDKJEV*$%3ucgP1dQXN>U^*~OXjdLB2(B8G#h*XwYCi;K&s z)AW_Ankwg(&S9JZTG=OY-1~f#2nCuR;^~IAb^@z-MaIz@E}cIO*M7HIDzb+!FEvP# z{DBkDeJyB2T=K+rD0fJiO^r2`U(`fyt$QcgXo3;N4xHkmY?*1iYiJWt<%*=*4ll}d zW0XkT+ZuddU-z8lQLOpHg@qlg7N#K3c+ZgKem&`OL8=ZO9&rx+XouNk%482Qhp;tq z>ui~@U^dmF_1@`P%Z?z<*b!#_p-3&80IJ|05EVa|tX}4T?eULKeW93G+T1>~_WPdL z+X>18MO^hIVNBxCKMrSa+xllZO~V=g6!$`6V9|XWV}etIr%;Hmvg(phZ9}5Y)aY<) za9Qo@0fqCkmH8#R;1ljYg%d{%Tp}-~wk?7oO0Bem@cUW(WI2k>Qb^;tTbBPdU`Xx> z2*(c1d$1j|VLBDSXjd#M{fGM2En{NQC`ypq1Reu3rv)pg8M_QD4)h z|7|#2+Raq+LKL!6wiGxM%ulGvXlh@4FkiwEb%SMJxpndM{IH()1q7&9_WdVn02IFxPVXJ+ z)+JjVTDAK&piAx%IsRT*X@mWH*oR{31L@m|e;+B8B}v;0e;TN*b=BfyeI{aWBsZqTY`*T)EhsFuj61g$#a&XYWe$`UF<4aLQp?O!PR zJDRTRkO>p85#jX2ho%>PoRm)k7gl%=p~~FGj2C(n4N+bsF_N20>7hTwuxTa9#kQ&O zFQdUN;CATh1U$+o{C-+D5qQgq-HI9mFMkwzkgfnz2-6dAh)F4HMh~4vWp1t+VxB@w znLyN*D0Qke5QA5Zi~2lYoGnRiw1oB#VUe1d=o?3!`~A?Uv~V{c$lsFFOZc`M^zx4x z2S)z@)*!yFJ0A%k`!9f(=`9xq{)nGjfl5HB9+vmmD_%KRFUy0Kit1I zNVD(EGA?BWl`Pr)-HsBm`DL$zsl@@IjPZ$haHJR?XtGq$D=yyNd;SXG$LOGM^g4Vx zntzj?n&)J5;SiG~-q|GNJ8?e){W>B{^1VY2!F>0l0$e2b9(ISDWGJg)tu|MS*J5r7 z;)dSAfjTgfGPU38Y!I87n3x>~z3VPs67cakN@7Rm-eE1osTgZk|D2|C41d8bh}AOw z3HO!S66j0lY_XK;JFaeI$%dB(Zif?N3{I z|8Y(txP`?9q@)$(3KLrB>d#)9*o|dozvG97STo0B-fPlzPEc5GWW?P4N)ha@KG58h zahkty+englz=XT)n6ZYPeEh*36p%SO`@+nOPdM7YZshSM zf6IZ36D$5EAcofj0v{}^;Y!6)5z7s`;+k`k1p2;uhsu-h?d>s$zJ((rFK*~}bv>l+ zky?Niw~1wH@_#zXcR;5%uX1U@(n`TL)hWCcX$(*ujYaFeZR)7aLDNwL)(67HLTelRX2g*)~u|o&VkRwxH#3_wu-ND zy)cQtr6sy(&@#UB2Cb*VN!j-OuuVGVp|Mw=_qgXz!WnK@sqILO+~WWGUO{bbLMV{dffFnp5a>oB^c6S_-m#jKCRAsRB# z;Y9!_TjA~pcEx)BDjp)elLty!67=g?9YIE2Q(YU#LBVukBQ5nfQfz#>7{@Mx>}wU; zgrF6UBuT|Lqld0W)t{@Tx5%L`eB>F}St7LK^61>(Z7|tv>AwOK2A$8p4C@Q&U9;HeJ%q$bj3Ek?!izHC>`;JIxCX zzWo-OnK3a92*4~8wC62C2?1dh+n}u$*#1dAj+U;FN9`=zI*q;w1&+GfdkapNQ-JkZ zVq@TQ&C2co+s(nm!x8w@sjcS0ub16!oyuf9W$RfQ`#o)R7(*kIc(L8}CJy&092s?S zYp1O=^g1cZ-CHe+R)$|o;Dr8JsSnTG98=PaJ8{1%Sy%JMWVI=QnMwOkXMP-IB3OE^ zF+h zg)|q^hUMcO-5dWubT>Ke$`1_3Y?a*p1s*jCTl!t2laLZ~CpPdXF?)xI(`{fvUqeWk z{`bC3a4j+jL{9EiR#kPOyi%$dW4y%RlX6U18r0<-unaqzhKx!iv6-}J&Bx2nk=LYJ zz-NaL_=)nPY^uq`B0iCYW%jtBh`3tAT06J~J3)rXh_Y5|jIvf^3^TdT)a!LI^FdE} zrBV?Xj_+#Aqm0QmcS*=56-&(u%(zZFgA#_Te#)$wrM0NYMeu-KGqV!#x!qmes7^5_9{NBu z27!RV>LR|~rsXB(+sOU61Z+x{7l|W_BS0boHi5sUY7xV67mr9pJKt@nv1KOsPdNAs zpYR7tF6I09N2%C#tl71z2t?Et6kkFT0|N;fDo}b<-%HMB`+kv2huyqcw7@{bX7DS- zxyo3j^*uR%UzuI~B?v(jgoMmm9PML%{>Xzk^l|;b8ZFy@|NgzA7@clot&8+w;}|8G znvO3g{ci9$S)3{@nrj@40^|IGycpl#`-vk4g^Yrp4%oo*HclqjWtN#Y3LRIWIcXkY z*Sc-O?7U31yyT+33;gj;dA?o9y4Fu-u|fb>0@Rx#xT>#g`~=tqg1^=+JO= zRK70{4DipPB30>iXpIoH^?}ME!Am3e-|H@uZ@J5Z?JW+kL2YSiX$rEpb&zJA*ZOyZ z&d&vo?P)?wfCl3@@e8k@Z5%k~IIUtBXkNlo8+sNl&fE!1L<5Yp*+wLE-!M;gMQ}vhUZCo7W3xiO4={er+8i1vy{w zown66zKuqs!AN9=OgU*V9uXiVW$SM>9i2J^(gLh+lpvfz_p}QO6~?4SgVX#-(^;b; z@d7G*zQu$J&8kt)_EGFe^T31fJYHXo%=@ogV{OKKg5^AV|-H1G-oT71}c4_jQEV?No(Rjw-Oa2!WQZt+ln4UNz8t z8U`{NUDXBMR!>fE_YKKVke*$eMcJ^?IdbL!0rEVaGw72bP~PlyA34%f)fu9HUr>P zA94(Dv@4=RYd2q>-pLx0A_!380;s~fZ5oz6wYj}MF$p_vHYq(XLqXHbev(8kk=iJt zX9m`)4W&APHi~~GxK}$fyu|d;nje*`lGujk4pUrTtKq>+$(YG2Or&R|WZpSQTB*)< z#s^#0Ypt;ouzWx8Jw6_8b8~B@QF|{hFGquhnU|glviRi7+a6=g_Y8ACZRza1Zg)kV zd$pEp(EB+@6aN{}ZF16$>_4PBNU*Q3!nvQ;BdO)KUpsnwx{x6M4-T83pMERRwMR$<7hq498v`LdEoAUmg@5&w~N!~2b#cT)N^;`C*00~w? zOax@bNy$8R^&`1WNuq$@CBzOJJGXJ~^oJ$^?4C=9yS%b z?j+lNKRoJ$oQ>J{yM)5Pz(PCsef7@kgr35}n5g4(`|Pqd9x_Tf*=Q?@rf?X4EWIrnmMazaw0Z4VTI zUuXQ1NOs|z4BjnWU7zxAREubVilte3C}@=TR)hzckO%^YI(=OR3`VrX2$Ta$6SB1E zFCk{3UIaiiB!2{PZ$F}AfAz$CUS){Gsh*u(dama?@|ylftLNWpmm>m}TJz1?*hpZB|eKmKTU z8PQ_3x02MCJFFAnI?TXQpDEDcCe(|{{1JVtF@mZPgTt);G_t*=|5yLJpyl9dT#Yx} zej&l1Po+P#v?jBO?@WP2$OZaaON9_}^=UQuX{mRvqpt=MU5=X6u{i5ZITIZS2giHB zVPn*Xds)6~h865cM6K`Hg{LNv*`4!nl}*XSN!~DRhIJ!`FJe^_Hg3GUL#n^tnoPAv zp{C;;rWVDY2&M`fmoCzQ|6C7QOqU2RrW2y>I!8xk^8}d>l#s6(WBf(2YS3V3EQwy$ zS6#(WOPQi{Hf9!ZqnjXkfYQ2-Cd-r5$)zRO2>FXHCTs(fvEfU~<%dy@3E5VdprGm& ztd1sPqtBTRLdeM|_4SDU(;S8NVF9N~@UkJ?qmnD#R9Tevu*lTmY=LF_3Gu6q7k&>P zJeOShgoaOB!GEnj4SbSB?@Q{rYBBip2|*q{Z2xP2rH4Y$&Q1vO-_oABg}*Gc+q7ywbW$<{J_^` zLZ)_SQ8ySH!Ak%q^hekMY5qh(lX4|*1YaMI4Z z5(N*7Tb=+(4LGZvfe06~ndKyCH0{1*e--WfzSZE>i>}h1pm!enaN>H}zTpcA`HnI? z)q9a`n-LM}1c_ z9mRYzsIEO?G4z)5<*&Md!P$H%DJjCRcC{rn=N?Tf9&_1^=+ld{-0p8^A3P@G#QyHq z60j8emfEyzpb395Xodj|jZP{nd{v^I6<$-J*wh5oKzZjw59eg@viM~V4P~S4Kw5b_ zKQ6JixG)DcDO6owcr8Ek)iul1;Wv2MuWm0$i!GF$?)Y(weK>t=E*i8I;(VVFqvBeF z5$RakvN`Amz++nJ?2r@-YGQguAf*=vDS|HW=->6RQ2CoNbLz4#c9UctN8f-wNo(U> zV)k2hkCQCDLy@`SllMo;}JF z6!yxpGSi#$q%(F;zi=IwHyh*eiE8$`-CyqtFkYWp_WzOeCsSV^_XJhaQP^t}WoKpR z-?AAcA5_ROTkGmAV20&brlAYo7kh*wIM{smim+RvsT|3L*{Ej>>cs}Z+@GN@7Y1?P zg&_IzW!3jgF#S%FnR}^sk(QN| z4Qi`G9ETokehKSxRaZd8VW#PDmPTR8WO%n68=fZSyrz6rKSOuHnPOeeY3d{ML8+NZf~ab^off*4@jR@J=MNlKKWDnsAXm(h^6wY!} z_OEKLN0{x^O=vlu*Ljn5;wXid@u;!z|K&67MAv`2CCjQ2Rb6|W5*@tSf3ZG1b;@z9 zByQ44ikMhqLw(L^sOizc^}Jp)T=o1=;a|sE|7}|E)BYr#nc zX{JHYQtbz#$y?BW?p3?&@XjOT zsHBC5vyn`Ftng^G<;EJ?NPkVmrvpEucLkBL?W!Y7h0%xn<8SZ3R&cFDTufrFgtFz2 zL|As@Umcmxe@O_J$pj&E-;sg~DHg2r@Om@h9CUKiySfko|HvI?%1Q^V3=M(2T4PXX zZipxj-q%jvt(X%KSZ_;% zBUL`iFp^%Y=i3k`%5SL;8^qC?3upOKlc}gtYG$Uw!)Ieezs}SC)uKsv{OTJUo8P;Q z!<5J@HPBCDGJnZMi3oBX>An-z92 zQk(bOX?*vhF!;&NoH`<3tdsF#bf>I@`JFW7~&6RwxP?^Uus@)-Ecxn&ui~_Xh*}cVMoiTPQ`6 zUUcrOODE`!HEO#9HU@bxieY~&Bae2+ZDx5MK7>8L;BeD%IN1G}_tR&_QFLB{AWX{0 zHR468YwpLnd0ccBDR6Gs5^YY-U!GUX+rq_KNiZHe+s?K_Tmn?+)6LhsbLTM>kR;6g zPca6ezjE`JjkN5qhStdQ@9ii>Cy1u)x#_caNkuzlS=SM2m!I`ku30Z$>6LvGO$)&z+(!s+Xox@p{mI3U%fC_j86S-zVuh;FHS$>db7rE$ zNJTlWSk!?N8@u{Th}P^ZInnSnWyqAA)F{PG$4thnCbGsuLW_ou1-gOlX8Jc$D}u9T zn?Bu(ro6n7y}Cp?6US}S!L|xs<&B^{8Rh7>qqNyC)CoMV#~P9i*b=yGKFJ2VW73MA zfa3S9V;iei2Sf|s@+LXcP}CduMll;z?H5TlIbS6a6k6WT*)-en33!i~L$S00T)C{L zF!yt98V{n*5A)tN22XMgNjZ|Qt>zxv8x74TosLmhKgy8z21U}CD_%Aj zua?h#WaK2HdvH1mpvl!o@cl2IGp3x9(AeW$^}!vV7EhX(ax{ z@)C6Rd-5I#aNDkv%`2`THDe`w3j`6feFZMvyT&|{P$QKLY4xk_{d9!AB&Z&p^)AXK z?!R}|y2`>_zRKzE%$b*neAvu*BX?1MB|G$95xmL{eoI(;>}Dms%t(twmfsr`l`n*rA*44r(hLa|n9Zi-JD+G@_70p+!LN=^FvcWW@uG4W{b7;o ze{K?}H>?wkR96eT5C{Frx4MCg4ITH}nl4G4g#t8vD(Q~C74(^cNu;8~{G{|@Df6h# zV5@ZHvMg`$evnRDu*XB9^)gPFQlh_RvW-4m?CA=6wf!E+^RZtOVl7&e9(`74Q3_cD z`dPxT4|tsWjvm7D3p|@k3ncNx3kx=xej_ZBN~W9%piWevTg}|S99P$ z>?A=KHEE^bGEG;JrH6q-&N|SlxKFV2QUJi6K9T2A54LVuXUs#T@!@9(GRHjf8wT$J z*ANc#eEpd1jhcpQvFk-rm?%G7eQmCVSV?WLB{*-p|YMp!6J;4P`{mK9{*)5(NEm5tjj4y+Pm1k0vuD?fUt>7Tf!ijS z9c-(ShbagEfWdoEgLQVg;)};Di0G6zHDaBv8x2cm@L0Cw3`Oo-{1Xyp?UcYyE z%K)|#F8n!Ckb)@gs;mHqYThgz{Gz_zjCE0XV6MgqhcN#Bs)$~sj zL_(g%^qsFEOOI2OQARgqJ{Bj+|(ev6@M{8wzyUy?wX zaRqUyPU4`bAO<3%LPh=sUj?0vlGhi3tO>s<@a19HUwgtu#b0>IoGZ%&tuJbD!hapx zw$C3T#rBgBA{-;Lh?9M3tzi%JV7Tk@HmH4yr(+We$(vFpulT*7M@Sq#G$dZvh2Qkp z$936o^0Q~8A1}E09`9i#R2rBwC2P&ECJUc>%?YWUFYhPzw|x7DAio7oEl>&zHDu%S zifF*zP_16>!<8lwVMxQ`CSyCpG~PwDHR*$f+-g4O{3Zw`ao37~W^s1Ot1|f}En#H09ftJ$@miDkiEy6IH!dy1+a?HA z+|Ty<0zM4qs_bt;^TWejF(2*=^rs$=%&dbK?^DOt$@M9&oC&FVv`hg`)U@xp_gn40 z-t}K3TwPrO#|vVz;7qTb^>Is9O?8awej=2OOnp1-d({q#lB#qijnQ9{%)HsVL&)^yoLHcVA?F$DiT3tFOmP#&M@KV`Pj%RM{lL z?9dZYOGP>)3O?H4NgR??@EO|GXNeHjRZ_x^Ns-yr2}Y5XB_#wkhVFl*gGPnzS&Jda zryJC$cjiJvC5m4M%0Lvxd0PjP3;~>zAb~aD9Mp`?+Tn>(8`fu;`|sP}MrKv7<%dmo z?*lWy?f9QnIC~)LYP?_WcaF}R9U42iTz)CeO9dc-3MFPH_&Wu){)8b)2PWB;Of;Mc zJJSk5$wgSPxAA`M@G~wA!&|$i3^*BX{79Sw-zpL8UUhX@^-n3fgO?>TAlVvn56tyv zzYJ%@>lMvkUdLgq+YZ3tEDz>pV;knyf$-;kE8&Oo;zh5Cvw-Fop25w-uny8TFIXAP zL`WIC({vU?C2I|VcwKb}E&!|pN%TZ5meb|J+z$rvJgMwLitW2i8uRkn>_;E4^Jrj* z%Hwd@Hr)TKVo{4Lj>K8=UoWAa=V8&nd-P|vfbo2)6peO7W?S}6vZAIRxSH{JeD=V1 zxg87x%3nrU;mZltaTwN-)(g%(6fW_}!$)3Cp3vsgB3$f=hs#C3Rw<(rvV?2Q`3IvA zNdT`?gT-l~)5xgxXl5!=>1=4AK8T3|ue%L(!GJZefTv!-hV^G<#t0;y`LGqztF%G+ zY1}_>Sc3hPAIlV0kHKL zlJU5%zy z)HN?QesyYU#D8cwH+X zSiiQ;gGNyd&X9K=+%DC4G`X^%@Kbar8h0_^Y4`o@B}(jCfQALA-DDGACG2&k1SOLV zD3DC%dC@uLySB*ha?_&f*%zC>{!NYfhAmhMfHQOZlUd6gDfV-GX;EKlXb<4NO(~+U zE7$_JUxQW0ro>ZQ;a55rC8nhXMmtRy9v%)J_Uwa~{dwzaU=?CoXEy#s3;aY~3PXjq zz4vq1&lL_$bZE~4C3)y`_msqW%8|FrztH=y{Tl%;<@30n%beFdAh^NK$&D$?%}$Vg~`oo8p7|KnX|N~f36$+(NY8> zXMLrvkxcZdJG&dR>zDW6uHeJiAlC3t{W9Rbx828~A@xn7o3g;;sK&p2dUUZGKUOE+ zNJ57pf`u~XeZ3fIal_fFymh>cI!MpdUyw^5`Q%=F+AKj?%#y9_kLaDjKhq+1+8@7$ z2x`uaYt4d8fH<@f7+Srvg+>`3XF@A(71$+TS`Lx;a#BQNzYh*^nhV5!#i4jKVL`c( zeZ0#kaM@%ns_81V?a87UCuSL$JdB90$1TGoCg!c@hfT&?M~oVxj!c+0!!Vc_a=79n zaKCWila!fag7>+2eYWnY#0%oJ3cm3lv60jgbR1x7)uM+-#Qk76%I1(gO;1V7La&%F5>dQM z<+7w+AEx5xsHTefoI>1x7*M5<;?P8`gW!PEbIg;5r^8os%eX{+?zKdmy4AqCN4t<5 z|F^7U(+*xuSyCY_NXY+uL;aXR{)*Sb;4cnAl175pM4C}g);VL(^9GmE!Fou>&G6UE zwj3TUQ43~V^FDi=p301Edz2*R?E$k%#Kufy!Ua96?k1rGxG_bEOPR#N?CW~}LA@($ zW1L8bfvo9ik*u@GO1(olALBt7;_s%8$n(fc)W4_p9rL`X!chkLJq8NC*K-(IrE`!( zKx>Ki_`h?b?i0RXiWxIf;6ul?{EG1deTaQch2vd55qH`qYlS1_R~=G5%Z*15?ilXLN!pfKmd1LFxT$w{dU)r#$v>8&QcNKw+>x9 z%-fQ8tVCcP(?M8W6H|IzpaCZikjE0d$wWf=5ez@k<%AQ#Owq&^B-9NfW%@(k6t$U* z!V=|=+M^W@4_985!|S)dhTO#*bcWe&e=nCap;DoVBiZI9@i=}P86qIKX>A=l7r$W9 zQ?Qjg**Oa%xG^}9kB@b%nrg@9AJG7{ovkc7-fJmCn_b&XzxK!SIrb9&{9X{!%h0A9 zsx2wE7@Y=PD^tU;oQ#fEtMGzSIjUG>5ti2k>+!ZG$)*uB7|Tq&i5on15S>6ut!|aF zI9ANI;Q!Kfv{Iw`z0CbYLZbQr_)>g9I!7=Dl(0B=!d%Lu@bd~;tnT+~F5E5I*AMp{ zL4SRo!c7(sfF{fmsP)eJJ-5xVP`Jb3B_#Bne!0;sa6H9MX9XD<0fM6g>IxbJ^H2Pw zM5Ao4`>KlI1l21Oss;5e5f`E!X{|!=wjcjhPF7h@g)#TC!dx8{Wks4n3I(S^L{zW> zJeGpJ`(>oQ;M4n3Un}~bi=#}Dj%tJHGbTsr?;o%e&^xbE!1EVg9`9)DOTzY7)P{cg zf^IOVsLYb`WSV~M=e3;ebyega%WDKN7qaNnuvR_DqjAlR{d`sm(>dm?3zF&Jh6-_* zx}aj@WHj?Axc5&J_g_ICvoMS+dD|7s^l*T-cu1oq#zeyGHL^e^KS z%vAB@GF{qIM9CgDXJZveX?bF!K1ET|jrC*mYL$Agt$-66&f;F5HZDHy( z{+OAC%iF_+Y5JY(@wk0?6aB;>kez{T8ZK5~2%3enI=PpF@GjyIKz(v-!a6WRFiu+{ zrBVYxS%}ou{l+w-jS~yxTA9M{HnQ(EK7!t+d)(WQ2b1Szc!o@xpV@6Iq$%vIuSihH z23VesP(m*8K;NJ)iA$LBkVTeMU@E|E(S3p@{XYO0LFc|C&7R^vRP)R;&qSq9Q}kSj zJa9@`Jm&(uSFtr}Cd30KQ{*F&+Jp24E6fqqQC^A2_hqFS@ab!P-IStG?9U#q!3T%C zMtvU@zl$K zLQ2Fh;-O^I?~b8to*6}XgHfg;sBX>2GVz~7ZtSiIUsd;LRp1y6u@;tMn*nQKA&?%p z1{U!O93G-3U`J|p)&T3oQ>XCY_e+$2H@6Mo z+1DCTbwcF#h3EX%>9j~r8gWOv-EN#Z6>1tt_%NF_XzOy#=zd{(oes&#K1&1rTefW3 z6|L`&nt)Xv-{oY$P7Yk-88hfjNVKG*wKFp1U*d^`1PNMAbaFss?#7wM!^qEGJUk{U z)F+zL0!mGVgPvYD{_xakJifIazrC{*e|w=0RWaJ0o|rm2-fdHkuQ;2(abE@Mn+KG< zE1e-c0+yFM%ctr?M@KgXoY9{Nv7x4y9bGfmk64gD%fA9g&F0OUqt-+&iedChzRycw zBLaq@_9`S`gBL}V z0b?nN**JD)Pei|$#A{{{$u7plg?T>P;qL!65k&1rv2H!!oSEl!*-R3!FWr@J@}W(A z!dac+8B2|qgysk$BATLIL8i;mf}Xxkn2jU;YwFNU#5yLE5y?qbpRv}q4%O@t*ps!W zZJc@C6wIasr1-9X;hATiiAn_y4^b5`3`QgFWx&Tg+u2?TwKEl)HM+Ae4^3?+V9r`N zyq%dE@H9mbF-3M}nv!4UO?`v9-Q5j(Nm&Bu>77}#u$)=x0mlMBR1%myL|wonpo)75 z*i4^+>9kC7L#RP}w^;8Q5wKL6fe|r|-5!P#QV~Eb0n9W3OAAs$Dq2N+Jw#o=+T~r9 ziPEy+ZIjWaxUObuAV*4+Lg$0wpAVK`v?0lsh4#+6QQt!lYT{o5(b3z7zP`wwNivJw^d$dha9WncgcJPKb z{t<_wMt5P(61-PV$>l_B;$MNIEe2*^P+H3{*eqIfbj_TcR{t3o=e%m?&Ye-UL5(%A zfGK9-obzX-fSIBkI`2<=kcu4hW=&qege$a_y745XsIH zK_p>6`QRe_?2(#?wa_4@7Z;wB1koDoKOcnFN=K{HAjMC>+S+5MmZnG%N-#)p%zz@l zTCFzVSj-3k+q`)rzv8U5uL8KyGbYMOCEK#l<%(JYx9SM@p+T>1$6nEbqNe_UQpneX zM$#b3mWkGmGa-MD_$}_dZ7ybK8S(SAC3t&ZJDz^I9&hb!hjRv(*&%`>nwqZqXa;V$ zJOdrwPOQFtUr4UEc?EL%7Jb=_m%y&6WMKa;bwvvh8LMS5n|$0gtj#LSry8ARjDuWhQp zL-#I#VW@SG=plB?%541Yh0#-K#Cl6oqHhI`W7VfIFc5p~Ohj9xw$AArjVK~P{VLE4 zUf!~0OVk%6RS_@&6Adoeh=7TrzB7dcm4i3@5z&a=0w!FDp&%x0!W|?k5qPt8dsLdS zst58)%b?fA*6%6u!Sb(lb7snKA25NcaQ;bRoPgF4^|4CLaM44?`bYX2;`OgD27Z=YI+JVReiO zm?-W$o!yB3$3h$qolf_%8n&n=V9&j_scPk=>xQ>QMGo=fl&AN)NwQTF6Tutav~~Mc zk>$&+n_1T$t&M%<;E_`}ck&5})1F*l@I|vEef%3(u+2OSn4{ z-w_ME{+|{la@D&7`ITb`SZZou1&;QP*a@NcMqJ)WR_(Oy)|{4>;y={5YSpS5HEdB$ zz=B1rh+2I0?Z?%zCJJbz!7i?`MzDlt(OTt9v z<7Rl^#L=psMf$r=RUf%OtF){!VZe1iV=T~T6Xy&#IsW-p3$9u|;w=@?tmiIB#O{NE zq4EAt{jnB(=#SZ?K}&~I$#a`-qy^TajC%B;2lWJu@qk}i^ah*G0wyX|#MtCZp*=_w zOW{TwOTd;cFvDv0J$+t#t2rX}#R}Z5Syz5=_b;OHT|GTLuA{r(`$J)Ii8H}y`g1hC zGupiJX0wlgiE>P0R(9Q2_XJe4u&2ak38~Q#*SWaf9n-MDuRS&3d1~efSXK3#r?CRf zU>Np8P4{Z(u@XA(Pgif7;w}_!Ll5H}i;_Hp&vyTSj5pq%o@-y6!@;>`{jp;ezlrvD zpEz^+k)ncmS4vE}Dcav1X?7p%#HqSIB>~>UtPBH|%(p48c`zmzVY69$-Wy9Lq(;MN z&`di4GbI=UqCpQIJ{N9z&Mw2vauY+v(FzM z8!wRK_s=w=xv34`xH%V+1U#{|9t|ziv#B5F`02p|??v2g7>t4LZn>_Z>A$lx)0Z)@ zj1b-uH;9Lx2Op`yFV>bQ+isaF0YF03yqTAu1C7S_+KF`+B9yh{7<9Kx5s{1Me=H6j zJUH`aU>K}^zKsJT0%l29)0~K6G%zJHDORbe-R~!05e`a&IEc)byqEKR}dZ>!r> zFhXz{c%=GR)sNLQqNU+Xy9C$L%#7?;8Dk}C+7fPd?QX-??Tt8gs#mF@S#zd0V4tGG zIRRs_?m~nosaYq%>6|{Zu+rlEV8b(0z@C5o{%2NRw)T(W{w}uDY_p)(8sSjWs0fjk z65A2cm8agrW#@htT8&=WPD{>(UYCFYXW$LR8xe8%{2Sv}QHV#ny1E`bUS0KFwe1iz zH8h-bNt#tT=^5J@*tB}m+wkjSqrRrmXs}>@q0d-f{{Wg>LMs5QCSn~dEm@1&#_8di z5XWNCS;hXt?G+Uj z81%ls>WPzekmDr0PbB$Imz=Pa6kEWt0Pse6dAVwNGpY)h0#l}+rJl`sS&Oj0;uUpm z3Nr>nY_5IzBRVvDuYJwSSu!I9jB|dhzOH_GQ&U~cB_$p>@cx1Pg2G=|t=5>B8Acla z{4Xdaq~fbgnNcsn>6%!3qF5v?F#1qjE2>oB@DKw6CRc)w+~DVCovqY4h!w(NiZMgAC$4X(zrU%eF7$HzQKq}A`(CTfdMh{r>njC?h3QNV0~0hONYw|W z2BHe4Od5lMp)0P|MS@>U!AeMJwc3}WV9=dOtu=^ALmkiV=qT9uzj(c|{L$`Z}?p<@xe;(;kCHljwk+Xfg@7cd|&q1mD} zp|!bM9dm|YOap^81p_^8klmAa@oKezyHAg)I1m^Iw5CMmM0Y#;LU_mIXl@BiA~$J_ zXzli@62m!kMhhgZ9s@n?qB=#y>TYL0T>U+&A19r`0!cS?&hXIJuad03`Gz9@OC0N|AW1rC6Krtx zc0iV+vwY)p48ZLUJO+}ah0&Id0Y@8L17inFMI6pyu%<$GIpON-4mf9$ZAQP{1Ml6@ zMtuPb(uO+Ol#b(0bt89}q)~hf?ty_Ro#Y8R32xWa{aaediXCoOD=!#CUaSFGPqp>2q>8lWyFI9n50~EvTJJRjLi94T^(;d8aCr1Oyil8zf8->yqSTG zIvo*WAw*1VWStutaLCSq2?1WLtsQ~i#kE`s zR}>G)Zk6wO7}PLu*#%dSYuoPE5r74`cSjr51?+`4?m4~kl69g6(MUy(7;3FDRp~Lt zNdO0idV}>2bR$7OazC8z)ls}?Z`Da3aX8{+4$JFKXKoG80&i(`YqF z^sm4X#s-$rT$#36H9-X|UeIbaNJ{ecRzLI1GbCUbRQ;D3!!4d|dkhKKR58R!0EdU; zwfj((cj551h)PVd>CoeFO^Zx`h9J6RosY|hw^=U9$s+P*Lc-p2;d0PMMJd`QXXPLz#!qz;3EQ-WiK3i|2RR^KJ;!E z5f2=y{+Ew{%}UF|Blj(U&7xI0Ex)_J1AC6TacUa%tdu!F)YwpWzuI<8md5(JwV7Gj zJEv1aJxO9Xe{m{GvQEKb=*9ad=7Dnw^^O0pz4ri*qdeEY@0r=AUG1u@db2HC?gd=w z4u(KTz>tsxNODL>;pEH7N0O6V$w`4=95^`va{dW9hlCOWNg#iW4F*gP*v2Lp+i{g; zOIERL^{(x8zV97r%j~Y?m9*NKclG|ROP4+GwpZGjx!>|U_d~B?G^yY~tALf4#Rs#A zqWDimIunH|1nlcS)$e}t7an)Wk%M?5>a+%Ur!in59ayr}S32c0KKE&k!_v`X z)KsiNZS(s|cWu%TgIl$_&yNSGbUSG8l1keUiVDueJ?GIanPVLl2;lis$3LRJhL7E5 zd)#6%D`GgDYWfEKqv91I-(mmvWwERYvk?dtPj_djhAw1zQWO&2{q9ZxCUhg4v5 z4nLEVV|h>%Mf#@wF`Q|4;Z(B&3+9-@ZTZDX~JID@P&$Q>G_! zR3l)5NvIDvBkRL86|1F#<96Z2YR|X#(fP9n2q!a7|-*umRz+S*AlzS@1?QWClj4sooEbMxJx?(NXogfG2|p zc(qRP7MIIDeTj8x=J@eL4=-JQ;kS5>t5&)%Q{{A<1|FXldhN*9qZIp~ka^xWSVAKkjj^05qG4<659?Y&f zENk}%#L-H*>aix8EL=c*;n3+r85B!7g2Q3|vpL)RbEW$+RTk9DLZ8EovfN0O@6TOZ zh?oA}OMN*tnk@{cd zrScrGy1F{W6PQD&M!-(A>>~Hb!yIr$sEx*iW)ePcHqk9$WQVA*@VKm{wOxnf4Rr56 zU}W#h(be90oy%dT&UGVMx;xL^R$Nj+Mj+}a7cfCeO9~(eJe*zw93DMP`p9(Uk_#-@ z`Zm4E%%IF(uB@y``YxiWt*t$zSchUzjetRNT^Ad`abW=~$fa)vMwSn0j{zg~%SsF7 zqul<1O2ztk7x2LCL>>BnNKzDbyqo}y@6#(B*x-XM~SV)B!bY{y0Z1Fr3wYgLw zGy(@fka7@JPof%NYB2E)p(+78*}SKIVfE$VV~eo>Mnevq?jc3{H4$jDj!J~bE8^iN zt@y;H%aNDcjsj~d+^(g_GFaep(;Y@{8yCr-ODw`5&(E2_&@ptgzLGAO6htb}ne7TWSAqkjU{Q_20C@=acisDgBr6M(GEXNrK4P6qJ5e&VqiwhzCs+>z2n!bFS+uk#ED7iz9bFD$(9g_H)m7JIYqlP9q1@n zsUe=UF?(R5AP8?L*Q0n;C1BfjZt`4r)qm{a5DDZk=*_U03Nh?Zf6l2XO8~V7h%p3$ zezf&AprRmR=VQ=i!(y_+1u6^kiJhI@sv13}gMbKP`E#q%{YaLoiV|qGk=xR+%>lP3 zvu0let%j2vIn>Oos#4i^A&TOe&wu{&stQO@m4Hcb{Ob_KE25i7J{S!GmTjUxfFqig zj$<+bBjj1i;2LgKtWT3A0Pcg{o=*C*;-^S^OZ^+gMa9$z*!-F**>V2>-9bZRL1s6k zT7^r+MrMl^%!zGMP4r<1)d`pe{#OGKWW@!EBS%yF;jv$&0!aQBP5B+k(A;(ymo59O ztj%gEM>}-_CI$sj{E^bVPy(LgNW!*Xjn{f2hyzD70|TlW@TU}$Q3KbIdh-EdEu_>G zJ|9ALC-ogaGaY|ovzsh>SA^Qi3g#)>sU#rf!ZT#`Fih{zIoO^3lBsft5^FV%o%@HP z{hBCZ5POduJG58nekegl`?+)Z*24cI@Z1lS?#uZwn~f+dDU`K!cG2mDnheC)SFouj zpihRyVnRhs73LRSctP61fW7kG{q@nc!E&+-VKi9aRIv+$T1Qe-h0MUB zA$8h}x@>qvIwBE8h~3@YogXOO52bN(rgyU8RJVY|87Z_1psY7F{2Tf-^aJWX+<_Cu3 z&)MqI6{u@Js6a0#PEf@CBObmp8(U}Np6n8J&z@tGX8ZfcpM8-%XHymryHlO}NXbC% z8)`JsYmXlKAEo=D1~@UWC)OlDZN$#N1_t9-T!<0w(s@Q)eQ_SH zT$hJ`p0MHWM_SU@%hVzbd{vm{mS71{b<2lPtALeNam|3}0=RJH#Om@@;5i;aQGLS| zG76-bEIo%c%S_>8a#4{S6#~h>^9Q`p2$7QFRmDrB??UZ}qPUNm!b{UkcXzkDxTO3T z$8jr^?n@#BK|p@4+_o{%{@0JbUWsciiQHW0R2b6Izv)6FKys#66Qq1fe!l#>C@d^w z0+uX)czUyY%`blX0YqFCYBL!uk|W1p%$|%Ipk2Ubm+N8B$$y8_XGTg`_yaz)_nk&n z(c0eX#X2d4 z#p6}qz&1HJWa&6KlGV3fXJ3R178+&8-F-fE^h7*v>dt(C%U9e2o{#*lML89!maibt;{{X@_mHUt@33Wm&XRXdjJ-rHQbh$ zU7WW5O$rfD*j;uNJI$q^?(VL>qLT8rI1V=`-Ir+0W)rfr<%6iUjvn~^@u?bv!+tcK zb7O90#x7A$`OKU1#AQoPS1q}tRSZ% zZEo*e=yS-VBgl=U%_l%hW?<9ZCn*R#^ciFKWin(N1yq#iWNnRSJ#oL&psiCbVCIt1 z?kFta(&vv3$s>L7c6D_;scgqoihw^r_7Hs-nY_YVg#x3VOB zJco$cgw8{5pBU2;am+5vO_%A5?dQi9#Gd^v@j^ZP+-(0r9pM(}+l- zheZ*_ypp)QOlDwXJ$H=WklJw2n+wOiw$lSSf4wl} z=`^iGcMLVT(yw2gnJcsq^`$Q4EMh{NV!0)z8LKaPKwtNM^?pc*Xj}UIHUoPqDc3dEO0?5e9q+hKb_>| z#4X1Ty#D&@CzR>U`BAli)t=bd5G`P2Sub0Yjp4!RA6#?10FB-Rzsn|VC?|Xl2Lyb2 z1k$k6V$kXk@Oj|(rcN+O?)AE5a&z85kW|1Of zhXstVZm|Z3j*tJE5Q0_Rw~XyDhUVr)(#I=FNy5eTykL!e*gRgt@_5fw$qJTyi^ky zDyuSqIfSYOOfL%L?ns8!;WY*#U}}MO|3eZg|M9P#!m1@kT)8#}7hhn)t^@Xj&n1>p z0nrbEi<~m~IqEB|B7(DNcpOr?A8JMfRFjKBmh4!vH_1Z=Ww*|{lj?NvV}6=kmrwH2r+l%DnkW&&Bw z*?-I_{gBDnSe}k#6TY?c1m=`354Yu;RVZM=;PV|F9jaaOPy-H^y+IVw!EsQ08dg20 zT(T~TroVsiys!J{xDCI2;w)M_+&JCpg2zjza7B})=izWEx1?D!w<_+t`0HQ)s>%fB z5ULh1jKt_X$$<+CSZP6AlAU5?S`9EV_zNajJ`7RYv|A=%CDyrV>RHkR1Py+nbSKn~ z%VrCR2tLAre?aNJtiEt@Tw5|#n9p^3q@PS8+o-I7ty{e;QL}1h>xodkfCU4F*9E~7 zcG0%xl!4>;k+58aQOmk@x@hS*CPi(9a^$4qmc-Oo1>Xfm20Leu9y&y?HF+BG3Eb2A zAQTs{`SYq|$7BgDnI*(b#E?Z?eU;0-U$}5iY*Qc@461LFI||hc*uUP(IiI-C-2x8P zkldSfm{qbAEgi}`n~>5DGYMb7gJDOnRA3a+@2^Ew@glT#)+*gK2~p&ZDBTBIICbby z`?3`)TREUAiKkBsxw&Q-402bi*0UW5j+eHQ!SJK^lte*P(@kEU1qQt?w)wpmUwl#3 zZZ@hHFbPhIgsBc4&aYalxPXyOS7C+;7^Gm1uCRa+=2fgxjerIGZdEULs18xA1;;5S zUHR%GG%Hz%z>WDMWAYz;UJfKofSm{jk|=zAlNUP77A6Jo=CfZfV5wmHdz(*h-0-vTadG|}7<4AMy~J%2l&4&ALz4cZ#7o3ow zLckK$q9M-oZ9U3Gcp7G?fDu#QSn@4p=tyUBd14$+r=10GOaM_76}>^l&cONym3N|0 z8jV8LnG51)V5-jl3PFW{iDNi&C?#N0`GXl2ff!;Sp(BU#0ww~DcDp^bMv)ng;79Ff ztpi0)W^2qISfT=!GYySKnSpWa5~3)IOu$mbsg|9sz|Y9p9Uih3DCo#B)1nWfu{rwS z@wp{0xe%(T(J;*LbH+3Y8k?gNeJ_52Gyzxa5%ml0!^4>9UOKT(MF z*@m?_lCkfqC1w;^wYdKet$6E$ffV&Ly%3WCt%jHU9n(dE2t=?wuoUy^yPJ1jf92g_ zW8Yj$DYP0reE#&?$q1BtAQBh)Xxe)wmaEv-+kikY0D&JFwdIk_?O5+FB|CqNowDm)^bKcKyfhJ_8Pt8KPElRMFYi! z@fY`GW?+Mk9@#N*rq9xw;qs*2Nj%WjQuunhKQzNg>wX8BePv}uW=u|}8-r7DpzrFF zpTC9q+O$XVr5aL}%b$i-L?T|%--RfO-KS2SqGJCGv(r>Er~gTXxKWLw-&v+Pc#&U|nZh9*@tiW+aU$qA8u9w_yG(*($(f zq7S3#?vu~Jj0OQ^MS8S#(OWM}(D3k2Voz|QEM8bG`z|<+YuL1D)3kqXs-Q~1L@t3N zhou~6yXw-`2ZN5{4X^|eh(x^X&WT?rLoqWjj8rQk2qX<>kE20507$7z(WW1l2 zG};C8X337Hn!w!EA3p$FUSpzKz{oy(I(oloL0k>?xX)8HZkmTns(J>@38Mfvg>*pZX2Nx#>Sts7a^h=Ao=(w|)3 z(bm+Q_7BZO0^)nnaMLbe*8H5fAfl<7_;&aE$Up{s)TOy}zG+JDsWh52?6Z>^V0_Hy zw6wIa8Q8QkvW&PcbZAYZCnEW-X)j_~kQe#N$tHJQ)8qw=lvW|Lt`PYOIC6*(S^ifl z-~&4W%{|loF|qkyNP&qj5wH3)PAr^b47cT)HOMv!ushS2PnAm2ai1xOFQGz>iEjw% z1Z*fM)aUS#lad4u2)q`7;Pj`i(;Pk@;?8@{VC`}f7SAkjk`^3yd#8Ixzt)Dh$dX9|#I35O_23dRGfk$UX ze{KyrtSev}o_gRZ??_AX6t`02epKT~M_yJpxAg^5IljXdK3`<{m+TmI{{{ z1|?XvK3tR6BmLAlv+<**#pv@-%uS=|oLlU*^MoUDF4LKeP2M|ZKQid^ z`QDhGPn$ld6R;g`-#7H>D}Hhuz=}{C2_7&Ra^Y|fPU~mS#2J_X5f9HhjI|beLy{SE zQKt9c7qd^|{?;12)?ci2cNBvRn8{w=bWl=U0ArRRx~c2-+i#z|A*Di4Ctw&^uiqg? z2^dDh>8D-5GGGsEqWoKVLqhQy_VIY?bS~cOG~(ot9^GyYJi(bK@QlfktqEdDt_SN1 zUAVE*hKmZ~qhbH8tQm*xIcWD9623>KBc%mQQw++Yg>gk_sfs=fL8XB40{_QAAo%0x zh^ex|d1&pboz~B#y^P94Aj^i|tE|Uo3gsrNEY!C;W8 z1m+M_-huA;iLUX9&2AF^Rv2otT2dX1CQ9-sM#V(Uq#4+S=0V(C81E4NpT`UE@Cj?e z*Dx59^@A_p1aX#Kv7}7 ztZm!2ZEOaXPDF?e9JsK669P6MrVYe)ywqE7zn_}r@nvX54-vmn#J`W~fY35Y(p85sV5$iUK z(F)P!g4^pz$@`O&J1eIc9(zB6fdF(G4j#L5h4S>KJotSs1iaIq>oVKmK#m~K^?J5B zXWBoP!63ZemW@W67IV!Jcbz4g4m@v*w?3LUB6y7sI#Vuu&LQdARuW#&z+fqa-(!or zwtdb3TtlgM7|Jo$#5OsPA3r|%ilPcauYk4eYKRsvf!APm>2lPy>`l!Fgf@+XB1ADL z)#nkFOzj`-PCEK9&k%2}`gS|r=BNyMa0Ylhc%lHmU)K?Cn`f{~*I{Z1j$BTo*S3;K z*E2UV3ybXw$4^iH68KRzpE`AF@}6=ELal%$n({8FTAMlni$P0y1maKbEXAA(J^F|I zQU%*L-XF$^#>7Ux#X9*&knByI8cx4Q6l3P{QmYw{tm%MuG;pd|=aH83$4hJ`I#8IW zl{^shESeE7M28>$IFj}vt0`f+w%ZddAyT<~2$}^fCSs_->}i+Ggru&?i8V{j;bS6ZITj5Dhw0nmAu}-Y6HMU+ zc+uMBiJGZQVg~I3CI-YET+BkTHMastv4D}a_34_0cShn(@e~!^yE%X2W0Qv<4w)=nr+HLZjL#&`S6x4Tj<;* zq+P&9ASJN5uz(fhroO%*?PXLdO5`{5;S3V6J8K4zr9z1v|*TEPCLdI)B1>D)$ zC^X2w`$ak4j1sW+p7`mCg<=6qrT=PSVLl1ikhO`TxFcmBpa9ehm8Uu5}^x+0d*P%mIBuMp}F?T%cSx!J`K z1T6xA)Mc>KZsJ=}B9A)ClqJM(M9o6T5?z6Q|U1r{%g41TFbnNs}tP!XORwcjGMuA8fDcxZJeOqvnNB70Gbb3CXW zO3y58Reoe&?Ul3H_)_&yI7yhySjk>kCO{(g?2Z5|*^%#_<|V{b3RrCZmne!2Tx`fO zWl%3*;KaBH8f#8PiUo|`B}B3W5gkGgX$etb2ksQGBF_VT)U4)%o-ABjW=FZvFSXw` zXIQFPrUuBq*E3ByxT$Iw*G2_;%*pnqO~6)0%}V$68Zpo8!{wv3x709WO(nJ%0i$^d zF_qOX5?Dx48+{mpdI56>g+>5m=QcdeNcBD##rg%kp2OjjPUK_@$T162Ud*tA{u$Ux ztK0^wc2JKa{RWg9M`mSr%p1fbb$KDzJ5|u-l3#8HO?n2uT3*D8yogBE4(g=n#*l!K z$Mn3IF%`AR`5k=+pjp5$djFYfDj3i6aRTOcyQxiJ4ne(uy|wc`=jX2a$;%w@*-#rv zW=;6_*8_j|o;;NF!Oy-P3i{pcO?CHtECo4t-j1)z@(CWs)m4 zi49@zb)WynN-7kaVuX%_(NBc0%^N`eXs}C%Q-h-e1{@hM;K9{h$kPRps|(_r3;OY! zy38n4`nT7*k&~^#ki#!6Clc>Znj!a}pnqx#wq#LtoCC*>9Xk$9v1^J$y?~7%z6Nk% z0V_!L42<$Uuvjv|StEK$Y)gwp%H29XC=X3vm9Gu~<2ezVFX+MA0{K#1qg^-VYeIZ^ z?vV5|74DoL33l1qYJq<=&+WvZ9+!^hoZVS7m@xvjbN|%-UTPy11J1<@s}nV|B}7$V z)V>L)pA0c2smaeNm#VI&suTgwWx$jrgSe*DF3lQ%kz(1;HIL*mk!3_NQh8=%Z%3g^ z^qcbs@TqdU6n$7@@ko14(fqC1g0pFe{Hro~rJxp!{;i&EiR648j}oxi**?rQd(mh+ zzo;_O%g7%5)JB~PYG%bY`Tc(X=_$Rb3djfn`^NV*?N4lWbpl)&IX37_D7IFkvo|&0 zPo~YlXy@xSkr_q1OM{c6`Ba@Q4eEz=;Tv}BK3O^;iOs9W2#z3q{Kki6Y3awx{K&Fp zk4KR92}8urSV=fN6EO8L)ft$@Y(j2sT(bHA|;k&|u%C)Hz zFg62I4Y|4iJ{}dxN4}Z+F6a&)d!ro8$v#+x(T8(Rb>uiX{T$xz$j1Fg3zA;Oz57rh zUT@EqwUHEo*RDF7)azG8i8xtijs9EB$@axPcS{Dja%2kJ6csE%{uhC22aXV#Bw!7I z__5H@f~t$K>(H|)d1p2QQx7C!PRB>?{-j>i_ZRmkd@jjzBKc0gJYJB-9*$5qs7I$; zBh^vsb_+P%Z@{)z^MrHL+FTmc#e~{Vq`Xb^USUbr*ncaG?in)F$5dm;5)`l`Q0>4G zB4Y&XRMYPI1=V3kDU=t@hgM^N&p-8xk_ni4SUJI)?CsSTjO%duKw}d8QDXbbH3GjWgdRhPBz6k=9w+KFl+FlQ#FwB(5f2k-ErVhol?CE?tr! z6Jm8mAg0FiEnX=YhM^v&x`k0#7&ilJ+`fG~o%Lm6$QS_|aiy0-5}KzfVAAZ1?SZL= z=o#4QVJ$9rwVD_@%bxSI?46(U7^O*)@jpmvW%PA&c>SC(5p8{)~fKeYlA0lG}tgdO#sf`~)4>%Nt+R6&% z<3#hGDZQ_T?j-(<;<|FXG@}aftJ;D%@gn@A+Xx^cU{{wp@LWrB%Xw#R9y{>o@rGP{ zcYn#4kE@BVqC*!a8Tf=IUUd0hbkz1bYyN|7V)i>Eu6CnaUQ1R;xi#k=#

_%0@x5`A4MYbT!F;jO{WyOr;~~`5~|H!>8EK1iIYNsT44>)V1=}>Zs%Kzn5#@nFUK!rDakB zD-r8no0D{q&aiLnwVeM`mybV1718WT!sjp%KR;50hfYMo{<{;rPnTPRr7zD7ANv#B zmw$UQA5Wf++`LHD=COFL#e(;`OyT3cG5%JxKuVCj_}e9tadl|_=;1$|!^`jWo$sZp zWGZ=J339+hQbuK}eNqbdCkuWs=+tl)PmZZ90wu@++q-Sswv1UP&L{yRh#VR?AUyS&U$t&Z z0+tFNG9}5<%g=tYjCekLqWorN($cx~!l0Yzgmr==ko@gx&=x>Ze&jqh=;4TD6IWu}?qze+xZ-}b6-RsjM zFoDHvG6Rby%Rui)bh3%e=4>N!EHWR>p)Y>%i$_w`FEx;H0tSe2RYpldb4Op}q~48Y z8>zgU_^&saJ+MZ%DX#6x5(g77Mq1EtBK~7(udJ=nZJf}13=YeUCTQLRo2->of*N2M z6TA^3;{=T35Laciw4i1Z0yZ-GBYR+~n16{Ap9mIP<2U;4uJa36z0-^ij{&9n$gK5y zi~6O!tA5HO)EMgLj(LM|rD%2!O{~~vcfZ_e-;@<~-io1r)No~svVRgW*(JnvYnI7B zNfa|CfjLCR3E0c;-ru(I%DYLzPs!LdWC?Nm$IjwoZ_Q$RVT@_Rz?0dRJls794*T2{ z(CU+q7_~X&)0V6(a^4$9l!DhtdLd{H5EK?Mkrdty1|ev5am)SD=(C8AW%7O1Xavlv zDwVZu-MW>{z^H)aIdI$sp~HgwSsPIh-#4w42$m_vtlTR?Uyhx195FWBwUwIVS{hcX()37k+TCn28u8Zu=#s+RM1sf!j1EX`FoXpx zFUtz|VAsUng6Cn%ErQR{k3b+e?&xT`5k(RD>^ua#F8ICfr2Asv$+C-l@4zuM2#?Ks z-q+RwctV(mZ;!5@YwpKWkW&GLTk!}&)JWFKcMKf&G7;ZIpjVw0KdmIW?#I+UicDatdD`Y z+?=SH2^bBO%;M@hTO%`vJaZ{Hjz=Ki8~1h5aH9!?!Jr6H3}OPa_h~|uglr;x$7qgOt6AG$m7xWSxKL-(b(+rYn`N%gx*IKg3|RJ3@CqX^P>?OWEpY%zWAkk zQL+?R<5ry9MMXtTlkTWW$w&cv>`$8;Hb#Zg>$C<`6fVG-&f^n$pV4hF^)SKCJoREP zUfDT_T#H8XgtM4=^baL}Gaw6>|JAw>E6hW%YCN*X$e!45){M+*2fV!Go#&>g)Cfaa z3Jrc~$#A?=`fuIkIOWP7=NV(;h>5nLjj;nTdrUMH4SBq|aZGp-xD` z^0VFLc)q7>((lpO>cTDGualPDygo6h{6{r2(Oj?u9*8?q*&nr#kpjl^Tmk`Ghqpia zz7X+fCa z%_!3yI6`EkfHicy-$Mpr0K%5tCHZrp*O}n)I>vu-6iROHstvN7_?OOE_-H5}Hx&2c zQcD*~G|B%h8H}+Y6|n1_*?4EL0I&8JPssCdrZ5pl`n+5VvQ4s7nf}Kfdu;NCuqq}a z&y*7di z2;m|7zQmAqY=bc~@4WxT^WiyP&ez{{&UMap&b|EZcIKTFSoLa#g%LgrmBiU}&Adqt zykaI$DO+^Ik000Q^X{9kVFzu&@{f}-8fwXx1A|wQTHgJWbGMdzXm|2F%EBCv3ppfM zLGAeNQVFT=!fUo(?pCSwF;z=$YR**8oN-Md7oBA5top%v!+D*sRHD8(`W$9~{H`7`HQom>t@MJT*cMR{XLkWQKOr2wIi7QtcaUcuz>++wepW|^U zzLhBK9@TGXaz4g02-Dd;J|kW?h;dHvyk)=S(9zf$2qMjvx@z9;SDw4d7PFzEBdOut8?^P4)cTO zbRhaQ!n8P7m+mHcOuR5OhP*5h!coOLvxSa`M%O0RePwt0Bq8Nx-5z5U?71hd@)shSSE3022&O4 zxt$SqV&9g|d-<5zeDHDC@}V)~b+Zv2iP+x-dCWD&ZxKv&jB)OqToXdP>MVBWdp^kU zb;^kcI%8$6uBQ$$9__W{kkwI}(k<-)8@j%+5jAh!Wjfy$5wMuj2)cV|p~?q`eQ#p# z^5T{&evF4oE4-b#a5dXnDe^Jl4?P&rt#~q2#iO1_SzQnV*E)| zn$7s5C@<>i8`b3tN^rZYa;vG1PDskK!IFh6Y{D|t=nDnrr3c2yk&p+`T&Tc6T4zgo{A{ydnd-ts+0plogVmiN_Ncu_FZ=Y}&If9PMrvv{Z>rsb)!wvePK zJzST?K*jCe1cUf)`C9!Ctchx!sCA{yQ?a3wZBnT{o>5zr`jEyyrf}`GUM^h;6o#Z4 z_oOJB`ejJyuzR&^?6wb8EaL#&^{g2%toA^Osbx)VSraGuNVH>W)U>3eLMma^Z83#_ zCmgoB#{VUUkPepD0#*XO-DL%y-^5KzsboN_Z7OOgC6 zHezR|2q_!C-V*TD;0?!8>xz+JPk>qJyFo~DYy8Zn#7$d~VO$oAfo}0cK4_u+XqJ}b zx~mywU6?u0&+&#wF9~D?gDMJi_GyG_myf!xFar}_GY;y;U&d5!-__jT+CZwI!o+Bq*BH4rCsUX^Bh%_(Y!B^Ss8h0gJ}Ry4RkN-^3ttxvlI~O zE1RG~?@K#i+XninyONr7Zh|^Q;D;bt@4&LylOSj!y$g}lRMu*>;#SeYGbMcot#QXN z(?Lty3f)J2(8>Laj=aylj)`f=0=AH)i_7n)Bl9l;6qJ|d(6XZHXKh%cxc;yQ3*3XG zg-Ltz@GwZ(;ftK38qmHAQ-nlOA@i5fR^LB4}?b?p0j;^~b zqtZqZ0OQ0%R>p|2rIgkbm?+X6NMt7xtm$#bEF_7F#6W{sn@@9wATtW-rw-_LfTc~{ zHBG?7(6z9UC6B%Y@MN>a;kGbcKXkVHT*`Q*GL?YX&(HzqGWejw{2j@jYOBkqrfu?D z5&Aacd+k{m;~25xi1R57bD1k~{?znon?t%{TdSIaNf=kq^|A*|P4U_j&2M??-#J?= zPd6sOppE!QR-998e;edHT`XVz$HRDcDWp%zkhj8sBhJg?f#<|3{u5A|IJZq17F!A` zpoZs0`6&wge?)zQCnEbGTikVL)&<`2AdDs=ffIfvJ5Z3cgPa_hB}lrPgpe$ML{qY%M-5AWu( z-7UyqclfYagNXVVPW`2o#&I+(s}Zn4sN`(O?!P`mjol!k8*n$7k!I3*wWyQUXYTkQ-mXHn3et%k8x4c6}W@m6JAwUf>bV9~dt_uVWt zzwZ-cbW_CuU%xf;_x9Z0Tm|IppHoUyhqOCM-#B)myMA6OD6w(*m8ct~H>Jb-`$As< z;v83IAl$9^gZ!kBMeX~Dbw}%{J4@q}!>d2~!-Rj}{XKXoc;aIrFp4p+RRpoq#{+7~ z!SuYGKfK>K^SPL;^JBheQ%2xoXPscJ!CFXH3`$qZP*0eA0I94w@8(?u@*Tejh5x5N_IV}JRp#!=Ct zS*O(Bdt2WR{){gFY@6q~O#=)lElJOx@UaNJ9-Iuuv;Qzk9sKe%J@o9pT90!=I?7Y+mja_8og zByZAI`U^f)zyqJ&G>>~HFaG&zxt-wdebRcNd@q>_MMjWcLk&<@W zE@+}1zcs@aMj$OPX82zxE)ROOH?`270aD>%)+k2Z*2zD*qXPxj(bCn){ml#qYZXoQ<#$Irk2f{zlnydPUE zFcU}{pZ7_UBD#LC`Tf!`E}>b^oXs5YX8rF0O?z>=X7At%eI z-%kNl7iwy?CS2MWfdwUMb<{|QC&sZg__>k0*9)6M1Rf!Y3eP+Y=*)+QOps6mVWP^N zbaxkj-hCX{Ye|pe%zHNJOPkU<^}CCktoiWr#ucobd{zRml%@cOY7b~0BUnnBvsFbf zh?AY_2NB_(UT)>z9+nW>QvRf?JZkg8KyDybb0Fh5h_E018BBM_=70FTfeRos6wqN{ za!Rf_VTS=BeyEkOK-Y(UHD1iuHc>gU)@-0XtWOf0( z{o9uwx*n@DYm-a=tv-%!XT=9O{Sw<8UMbLr?>rq*+sZh47T7P<_~O3DF5MSs4y7xD zr{oiWwvb~y>Yoj*Gi1(aUl6{(IulJBontMTN{KJG)NnQF(!{Cdq`J_2so znF;0+`e))rq@8uxkcQUtd$W|)=faD8DW``nwT=~#1u*Q^&5^Tp3v}~a!pZweU^A9w zZ!YsyawbS|S++o-;}58=uaIOgKvSN4uFr!L$yu>derN1iV2$?zCznf_R&n012uJUT zKjqf!``GOTn>m*ks#kkEdAcOnFx|s|V?rI3U$^zTfBXEou~iSi;)WnvUTWfqJM3ej zx45cKxW{+_uc|Zo1U|YRk0L#K4r?xbvX$`3{bhc)3#v}2plL(#nxl}l(_gO}o!&!k&r4t9tD=e{o-N5M6z#YDO_mDkVZPqYZxeOE8@unZzfB{3 zfy@*)TW%b}dfK z6RmFxAw61xMz*NZ6OJ1QOD=w8_x#$pN7LB{(0fxzUyIK*n4Ulyg#>v)k8eY4(*UTa z3GSUqMYcz|u8PM#NF*h3n)K#L7smwpZa_b)#jNZY6B9}HTv*8Ojyj!tSSr1)KZTa0 zG&d41G}8bxy+8^#UXR(mCVsW7zd$QAT$6h{GY8dDAYlk*0p`z&)-~OV-hYP1k6gMI z?{;to0x26o+;>y7L_@y}!^-FZ&fyzl72|V={eCo~Kk|16%!+RZzTHHiZqN_jGpAEC zSrsUrEo`Q*2{6#K*?dwn4g!{mpEFF;xnr)jGxu=5S|dQSvUfp-HhsA5qJ~=8OOWZw zpF0kj(LdOaKJMdeXa$#XzF^Vy^$(){MUk>|aEKGB6^nC5Yd*4YTzo|inlxmurd91y zcM=Q2?&{2QfUy1@{p$1BET1g~F_M(EHKnM+slgo6$}XkIAh_bOFGA~C)snQ$neyM_ zba`=-2gf1T-xScuZ!{RSw_k8M+^+7I(pHvo5@VU4J6jlXnITQE68N?iUZz^B%}|G1 zAZWi|8$LC>29>00gFGKW9YdN9lk)h+4tX7}d=0m1PUK5gT zk|IN&;x>79Zj6j-2;-e(yfnR|dg|Q#(^=6q+7525L2f8JOmtJU?fTr!#X_8DZ(}^( z7;I}ZA;0r;(aa>VCVaYL{?rRHoZac9cq5wKfu$o|%OC^7O}9R>=(4M6A^y{_zW7qQ vhcRIo`Phux3@>nz<)yaN|K|VBb)bn_O|J%B6CUY1M<2%e=6aPnuF?Mo`&aAs literal 36561 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 M07*qoM6N<$f;HRj9{>OV literal 4652 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 Z{|C4Q(D_J--&6nq002ovPDHLkV1jj(Sup?r literal 6522 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 literal 10593 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 b@Qa{WzGWey8XfMRbrK*UA}d@asPF$jD&2f? diff --git a/apps/weblibre/android/app/src/alpha/res/mipmap-xxxhdpi/launcher_icon.png b/apps/weblibre/android/app/src/alpha/res/mipmap-xxxhdpi/launcher_icon.png index 8c891d7e2d50b69e918327df907c613a05f5abf5..0a7a8ff721b1668cb0ded4f2fec1d6ff675c8fd3 100644 GIT binary patch literal 15619 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%0DYs74;>ommX^DOw;JGymtujs;P|HIo_i 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 diff --git a/apps/weblibre/android/app/src/main/AndroidManifest.xml b/apps/weblibre/android/app/src/main/AndroidManifest.xml index a3df48d1..7dd4e1fd 100644 --- a/apps/weblibre/android/app/src/main/AndroidManifest.xml +++ b/apps/weblibre/android/app/src/main/AndroidManifest.xml @@ -7,6 +7,7 @@ + @@ -117,7 +118,14 @@ - + + + + + + + + @@ -246,6 +254,15 @@ android:foregroundServiceType="dataSync" android:exported="false" /> + + + + diff --git a/apps/weblibre/android/app/src/main/kotlin/eu/weblibre/gecko/MyApplication.kt b/apps/weblibre/android/app/src/main/kotlin/eu/weblibre/gecko/MyApplication.kt index ccaf9aec..88c7632e 100644 --- a/apps/weblibre/android/app/src/main/kotlin/eu/weblibre/gecko/MyApplication.kt +++ b/apps/weblibre/android/app/src/main/kotlin/eu/weblibre/gecko/MyApplication.kt @@ -23,6 +23,7 @@ import android.app.Application import android.content.SharedPreferences import eu.weblibre.flutter_mozilla_components.ActiveProfile import eu.weblibre.flutter_mozilla_components.MegazordSetup +import eu.weblibre.flutter_mozilla_components.feature.SandboxCaptureFeature class MyApplication : Application() { override fun onCreate() { @@ -33,6 +34,12 @@ class MyApplication : Application() { // Resolve active profile EARLY so cold-start WorkManager workers // get profile-prefixed SharedPreferences ActiveProfile.resolveFromDisk(this) + + // Rehydrate the sandbox capture registry from the on-disk JSON mirror + // before Gecko has a chance to start restoring tabs. Each entry gets + // redirectUrl="about:blank"; Dart replaces those once CaptureServer is + // running with real loader/capture URLs. + SandboxCaptureFeature.preRestoreBootstrap(this) } override fun getSharedPreferences(name: String, mode: Int): SharedPreferences { diff --git a/apps/weblibre/assets/icon/icon_alpha.png b/apps/weblibre/assets/icon/icon_alpha.png index cb32fdd525ae7e9e12ad3a95a42e8ea83b0d9f49..093c4475d40e4f1c449e11e0d17ce81d11081a6a 100644 GIT binary patch literal 54795 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

Rl2q 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 literal 54060 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�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 - - - - + - - - + + + + + + + + + + + + + + + + + + + + @@ -18,7 +32,7 @@ - + @@ -26,7 +40,7 @@ - + @@ -37,7 +51,7 @@ - + ALPHA diff --git a/apps/weblibre/build.yaml b/apps/weblibre/build.yaml index 7d61628a..dcd81b0d 100644 --- a/apps/weblibre/build.yaml +++ b/apps/weblibre/build.yaml @@ -28,6 +28,11 @@ targets: lexo_rank_previous: "text (int, text null)" lexo_rank_reorder_after: "text (text null, text null)" lexo_rank_reorder_before: "text (text null, text null)" + generate_content_hash: "int ()" + url_indexable: "bool (text null)" + url_canonical: "text (text null)" + url_host: "text (text null)" + url_path: "text (text null)" modules: - json1 - fts5 diff --git a/apps/weblibre/drift_schemas/tabs/drift_schema_v10.json b/apps/weblibre/drift_schemas/tabs/drift_schema_v10.json new file mode 100644 index 00000000..65f09955 --- /dev/null +++ b/apps/weblibre/drift_schemas/tabs/drift_schema_v10.json @@ -0,0 +1,646 @@ +{ + "_meta": { + "description": "This file contains a serialized version of schema entities for drift.", + "version": "1.3.0" + }, + "options": { + "store_date_time_values_as_text": false + }, + "entities": [ + { + "id": 0, + "references": [], + "type": "table", + "data": { + "name": "container", + "was_declared_in_moor": true, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": "PRIMARY KEY NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + "primary-key" + ] + }, + { + "name": "name", + "getter_name": "name", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "color", + "getter_name": "color", + "moor_type": "int", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const ColorConverter()", + "dart_type_name": "Color" + } + }, + { + "name": "metadata", + "getter_name": "metadata", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const ContainerMetadataConverter()", + "dart_type_name": "ContainerMetadata" + } + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [] + } + }, + { + "id": 1, + "references": [ + 0 + ], + "type": "table", + "data": { + "name": "tab", + "was_declared_in_moor": true, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": "PRIMARY KEY NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + "primary-key" + ] + }, + { + "name": "source", + "getter_name": "source", + "moor_type": "int", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumIndexConverter(TabSource.values)", + "dart_type_name": "TabSource" + } + }, + { + "name": "parent_id", + "getter_name": "parentId", + "moor_type": "string", + "nullable": true, + "customConstraints": "REFERENCES tab(id)ON DELETE SET NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "container_id", + "getter_name": "containerId", + "moor_type": "string", + "nullable": true, + "customConstraints": "REFERENCES container(id)ON DELETE CASCADE", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + { + "foreign_key": { + "to": { + "table": "container", + "column": "id" + }, + "initially_deferred": false, + "on_update": null, + "on_delete": "cascade" + } + } + ] + }, + { + "name": "order_key", + "getter_name": "orderKey", + "moor_type": "string", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "url", + "getter_name": "url", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const UriConverterNullable()", + "dart_type_name": "Uri?" + } + }, + { + "name": "title", + "getter_name": "title", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "tab_mode", + "getter_name": "tabMode", + "moor_type": "int", + "nullable": false, + "customConstraints": "NOT NULL DEFAULT 0", + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumIndexConverter(TabModeDbValue.values)", + "dart_type_name": "TabModeDbValue" + } + }, + { + "name": "isolation_context_id", + "getter_name": "isolationContextId", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_pinned", + "getter_name": "isPinned", + "moor_type": "bool", + "nullable": false, + "customConstraints": "NOT NULL DEFAULT 0", + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_probably_readerable", + "getter_name": "isProbablyReaderable", + "moor_type": "bool", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "extracted_content_markdown", + "getter_name": "extractedContentMarkdown", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "extracted_content_plain", + "getter_name": "extractedContentPlain", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "full_content_markdown", + "getter_name": "fullContentMarkdown", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "full_content_plain", + "getter_name": "fullContentPlain", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "timestamp", + "getter_name": "timestamp", + "moor_type": "dateTime", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [ + "CHECK((tab_mode = 2 AND isolation_context_id IS NOT NULL)OR(tab_mode != 2 AND isolation_context_id IS NULL))" + ] + } + }, + { + "id": 2, + "references": [], + "type": "table", + "data": { + "name": "closed_tab_tombstone", + "was_declared_in_moor": true, + "columns": [ + { + "name": "tab_id", + "getter_name": "tabId", + "moor_type": "string", + "nullable": false, + "customConstraints": "PRIMARY KEY NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + "primary-key" + ] + }, + { + "name": "closed_at", + "getter_name": "closedAt", + "moor_type": "dateTime", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [] + } + }, + { + "id": 3, + "references": [ + 1 + ], + "type": "index", + "data": { + "on": 1, + "name": "idx_tab_parent_container", + "sql": "CREATE INDEX idx_tab_parent_container ON tab(parent_id, container_id);", + "unique": false, + "columns": [] + } + }, + { + "id": 4, + "references": [ + 1 + ], + "type": "table", + "data": { + "name": "capture_tab", + "was_declared_in_moor": true, + "columns": [ + { + "name": "tab_id", + "getter_name": "tabId", + "moor_type": "string", + "nullable": false, + "customConstraints": "NOT NULL PRIMARY KEY REFERENCES tab(id)ON DELETE CASCADE", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + "primary-key", + { + "foreign_key": { + "to": { + "table": "tab", + "column": "id" + }, + "initially_deferred": false, + "on_update": null, + "on_delete": "cascade" + } + } + ] + }, + { + "name": "capture_id", + "getter_name": "captureId", + "moor_type": "string", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "source_url", + "getter_name": "sourceUrl", + "moor_type": "string", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "status", + "getter_name": "status", + "moor_type": "string", + "nullable": false, + "customConstraints": "NOT NULL DEFAULT 'pending'", + "default_dart": "const CustomExpression('\\'pending\\'')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "dateTime", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [] + } + }, + { + "id": 5, + "references": [ + 4 + ], + "type": "index", + "data": { + "on": 4, + "name": "idx_capture_tab_capture_id", + "sql": "CREATE INDEX idx_capture_tab_capture_id ON capture_tab(capture_id);", + "unique": false, + "columns": [] + } + }, + { + "id": 6, + "references": [ + 1 + ], + "type": "table", + "data": { + "name": "tab_fts", + "was_declared_in_moor": true, + "columns": [ + { + "name": "title", + "getter_name": "title", + "moor_type": "string", + "nullable": false, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "url", + "getter_name": "url", + "moor_type": "string", + "nullable": false, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "extracted_content_plain", + "getter_name": "extractedContentPlain", + "moor_type": "string", + "nullable": false, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "full_content_plain", + "getter_name": "fullContentPlain", + "moor_type": "string", + "nullable": false, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": true, + "create_virtual_stmt": "CREATE VIRTUAL TABLE \"tab_fts\" USING fts5(title, url, extracted_content_plain, full_content_plain, content=tab, tokenize=\"trigram\")", + "without_rowid": false, + "constraints": [] + } + }, + { + "id": 7, + "references": [ + 1 + ], + "type": "trigger", + "data": { + "on": 1, + "references_in_body": [ + 1 + ], + "name": "tab_maintain_parent_chain_on_delete", + "sql": "CREATE TRIGGER tab_maintain_parent_chain_on_delete BEFORE DELETE ON tab BEGIN\n -- Update all children of the deleted row to point to its parent (grandparent)\n -- Only if the grandparent exists, otherwise set to NULL\n UPDATE tab\n SET parent_id = CASE\n WHEN OLD.parent_id IS NOT NULL AND\n EXISTS(SELECT 1 FROM tab WHERE id = OLD.parent_id)\n THEN OLD.parent_id\n ELSE NULL\n END\n WHERE parent_id = OLD.id;\nEND;" + } + }, + { + "id": 8, + "references": [ + 1, + 6 + ], + "type": "trigger", + "data": { + "on": 1, + "references_in_body": [ + 1, + 6 + ], + "name": "tab_after_insert", + "sql": "CREATE TRIGGER tab_after_insert AFTER INSERT ON tab BEGIN\n INSERT INTO \n tab_fts(rowid, title, url, extracted_content_plain, full_content_plain) \n VALUES (new.rowid, new.title, new.url, new.extracted_content_plain, new.full_content_plain);\nEND;" + } + }, + { + "id": 9, + "references": [ + 1, + 6 + ], + "type": "trigger", + "data": { + "on": 1, + "references_in_body": [ + 1, + 6 + ], + "name": "tab_after_delete", + "sql": "CREATE TRIGGER tab_after_delete AFTER DELETE ON tab BEGIN\n INSERT INTO \n tab_fts(tab_fts, rowid, title, url, extracted_content_plain, full_content_plain) \n VALUES('delete', old.rowid, old.title, old.url, old.extracted_content_plain, old.full_content_plain);\nEND;" + } + }, + { + "id": 10, + "references": [ + 1, + 6 + ], + "type": "trigger", + "data": { + "on": 1, + "references_in_body": [ + 1, + 6 + ], + "name": "tab_after_update", + "sql": "CREATE TRIGGER tab_after_update AFTER UPDATE ON tab BEGIN\n INSERT INTO \n tab_fts(tab_fts, rowid, title, url, extracted_content_plain, full_content_plain) \n VALUES('delete', old.rowid, old.title, old.url, old.extracted_content_plain, old.full_content_plain);\n INSERT INTO \n tab_fts(rowid, title, url, extracted_content_plain, full_content_plain) \n VALUES (new.rowid, new.title, new.url, new.extracted_content_plain, new.full_content_plain);\nEND;" + } + } + ], + "fixed_sql": [ + { + "name": "container", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"container\" (\"id\" TEXT PRIMARY KEY NOT NULL, \"name\" TEXT, \"color\" INTEGER NOT NULL, \"metadata\" TEXT);" + } + ] + }, + { + "name": "tab", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"tab\" (\"id\" TEXT PRIMARY KEY NOT NULL, \"source\" INTEGER NOT NULL, \"parent_id\" TEXT REFERENCES tab(id)ON DELETE SET NULL, \"container_id\" TEXT REFERENCES container(id)ON DELETE CASCADE, \"order_key\" TEXT NOT NULL, \"url\" TEXT, \"title\" TEXT, \"tab_mode\" INTEGER NOT NULL DEFAULT 0, \"isolation_context_id\" TEXT, \"is_pinned\" INTEGER NOT NULL DEFAULT 0, \"is_probably_readerable\" INTEGER, \"extracted_content_markdown\" TEXT, \"extracted_content_plain\" TEXT, \"full_content_markdown\" TEXT, \"full_content_plain\" TEXT, \"timestamp\" INTEGER NOT NULL, CHECK((tab_mode = 2 AND isolation_context_id IS NOT NULL)OR(tab_mode != 2 AND isolation_context_id IS NULL)));" + } + ] + }, + { + "name": "closed_tab_tombstone", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"closed_tab_tombstone\" (\"tab_id\" TEXT PRIMARY KEY NOT NULL, \"closed_at\" INTEGER NOT NULL);" + } + ] + }, + { + "name": "idx_tab_parent_container", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE INDEX idx_tab_parent_container ON tab (parent_id, container_id)" + } + ] + }, + { + "name": "capture_tab", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"capture_tab\" (\"tab_id\" TEXT NOT NULL PRIMARY KEY REFERENCES tab(id)ON DELETE CASCADE, \"capture_id\" TEXT NOT NULL, \"source_url\" TEXT NOT NULL, \"status\" TEXT NOT NULL DEFAULT 'pending', \"created_at\" INTEGER NOT NULL);" + } + ] + }, + { + "name": "idx_capture_tab_capture_id", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE INDEX idx_capture_tab_capture_id ON capture_tab (capture_id)" + } + ] + }, + { + "name": "tab_fts", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE VIRTUAL TABLE IF NOT EXISTS \"tab_fts\" USING fts5(title, url, extracted_content_plain, full_content_plain, content=tab, tokenize=\"trigram\");" + } + ] + }, + { + "name": "tab_maintain_parent_chain_on_delete", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TRIGGER tab_maintain_parent_chain_on_delete BEFORE DELETE ON tab BEGIN UPDATE tab SET parent_id = CASE WHEN OLD.parent_id IS NOT NULL AND EXISTS (SELECT 1 FROM tab WHERE id = OLD.parent_id) THEN OLD.parent_id ELSE NULL END WHERE parent_id = OLD.id;END" + } + ] + }, + { + "name": "tab_after_insert", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TRIGGER tab_after_insert AFTER INSERT ON tab BEGIN INSERT INTO tab_fts (\"rowid\", title, url, extracted_content_plain, full_content_plain) VALUES (new.\"rowid\", new.title, new.url, new.extracted_content_plain, new.full_content_plain);END" + } + ] + }, + { + "name": "tab_after_delete", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TRIGGER tab_after_delete AFTER DELETE ON tab BEGIN INSERT INTO tab_fts (tab_fts, \"rowid\", title, url, extracted_content_plain, full_content_plain) VALUES ('delete', old.\"rowid\", old.title, old.url, old.extracted_content_plain, old.full_content_plain);END" + } + ] + }, + { + "name": "tab_after_update", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TRIGGER tab_after_update AFTER UPDATE ON tab BEGIN INSERT INTO tab_fts (tab_fts, \"rowid\", title, url, extracted_content_plain, full_content_plain) VALUES ('delete', old.\"rowid\", old.title, old.url, old.extracted_content_plain, old.full_content_plain);INSERT INTO tab_fts (\"rowid\", title, url, extracted_content_plain, full_content_plain) VALUES (new.\"rowid\", new.title, new.url, new.extracted_content_plain, new.full_content_plain);END" + } + ] + } + ] +} \ No newline at end of file diff --git a/apps/weblibre/drift_schemas/tabs/drift_schema_v11.json b/apps/weblibre/drift_schemas/tabs/drift_schema_v11.json new file mode 100644 index 00000000..cd8721c5 --- /dev/null +++ b/apps/weblibre/drift_schemas/tabs/drift_schema_v11.json @@ -0,0 +1,646 @@ +{ + "_meta": { + "description": "This file contains a serialized version of schema entities for drift.", + "version": "1.3.0" + }, + "options": { + "store_date_time_values_as_text": false + }, + "entities": [ + { + "id": 0, + "references": [], + "type": "table", + "data": { + "name": "container", + "was_declared_in_moor": true, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": "PRIMARY KEY NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + "primary-key" + ] + }, + { + "name": "name", + "getter_name": "name", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "color", + "getter_name": "color", + "moor_type": "int", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const ColorConverter()", + "dart_type_name": "Color" + } + }, + { + "name": "metadata", + "getter_name": "metadata", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const ContainerMetadataConverter()", + "dart_type_name": "ContainerMetadata" + } + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [] + } + }, + { + "id": 1, + "references": [ + 0 + ], + "type": "table", + "data": { + "name": "tab", + "was_declared_in_moor": true, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": "PRIMARY KEY NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + "primary-key" + ] + }, + { + "name": "source", + "getter_name": "source", + "moor_type": "int", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumIndexConverter(TabSource.values)", + "dart_type_name": "TabSource" + } + }, + { + "name": "parent_id", + "getter_name": "parentId", + "moor_type": "string", + "nullable": true, + "customConstraints": "REFERENCES tab(id)ON DELETE SET NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "container_id", + "getter_name": "containerId", + "moor_type": "string", + "nullable": true, + "customConstraints": "REFERENCES container(id)ON DELETE CASCADE", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + { + "foreign_key": { + "to": { + "table": "container", + "column": "id" + }, + "initially_deferred": false, + "on_update": null, + "on_delete": "cascade" + } + } + ] + }, + { + "name": "order_key", + "getter_name": "orderKey", + "moor_type": "string", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "url", + "getter_name": "url", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const UriConverterNullable()", + "dart_type_name": "Uri?" + } + }, + { + "name": "title", + "getter_name": "title", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "tab_mode", + "getter_name": "tabMode", + "moor_type": "int", + "nullable": false, + "customConstraints": "NOT NULL DEFAULT 0", + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumIndexConverter(TabModeDbValue.values)", + "dart_type_name": "TabModeDbValue" + } + }, + { + "name": "isolation_context_id", + "getter_name": "isolationContextId", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_pinned", + "getter_name": "isPinned", + "moor_type": "bool", + "nullable": false, + "customConstraints": "NOT NULL DEFAULT 0", + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_probably_readerable", + "getter_name": "isProbablyReaderable", + "moor_type": "bool", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "extracted_content_markdown", + "getter_name": "extractedContentMarkdown", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "extracted_content_plain", + "getter_name": "extractedContentPlain", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "full_content_markdown", + "getter_name": "fullContentMarkdown", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "full_content_plain", + "getter_name": "fullContentPlain", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "timestamp", + "getter_name": "timestamp", + "moor_type": "dateTime", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [ + "CHECK((tab_mode = 2 AND isolation_context_id IS NOT NULL)OR(tab_mode != 2 AND isolation_context_id IS NULL))" + ] + } + }, + { + "id": 2, + "references": [], + "type": "table", + "data": { + "name": "closed_tab_tombstone", + "was_declared_in_moor": true, + "columns": [ + { + "name": "tab_id", + "getter_name": "tabId", + "moor_type": "string", + "nullable": false, + "customConstraints": "PRIMARY KEY NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + "primary-key" + ] + }, + { + "name": "closed_at", + "getter_name": "closedAt", + "moor_type": "dateTime", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [] + } + }, + { + "id": 3, + "references": [ + 1 + ], + "type": "index", + "data": { + "on": 1, + "name": "idx_tab_parent_container", + "sql": "CREATE INDEX idx_tab_parent_container ON tab(parent_id, container_id);", + "unique": false, + "columns": [] + } + }, + { + "id": 4, + "references": [ + 1 + ], + "type": "table", + "data": { + "name": "capture_tab", + "was_declared_in_moor": true, + "columns": [ + { + "name": "tab_id", + "getter_name": "tabId", + "moor_type": "string", + "nullable": false, + "customConstraints": "NOT NULL PRIMARY KEY REFERENCES tab(id)ON DELETE CASCADE", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + "primary-key", + { + "foreign_key": { + "to": { + "table": "tab", + "column": "id" + }, + "initially_deferred": false, + "on_update": null, + "on_delete": "cascade" + } + } + ] + }, + { + "name": "capture_id", + "getter_name": "captureId", + "moor_type": "string", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "source_url", + "getter_name": "sourceUrl", + "moor_type": "string", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "status", + "getter_name": "status", + "moor_type": "string", + "nullable": false, + "customConstraints": "NOT NULL DEFAULT 'pending'", + "default_dart": "const CustomExpression('\\'pending\\'')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "dateTime", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [] + } + }, + { + "id": 5, + "references": [ + 4 + ], + "type": "index", + "data": { + "on": 4, + "name": "idx_capture_tab_capture_id", + "sql": "CREATE INDEX idx_capture_tab_capture_id ON capture_tab(capture_id);", + "unique": false, + "columns": [] + } + }, + { + "id": 6, + "references": [ + 1 + ], + "type": "table", + "data": { + "name": "tab_fts", + "was_declared_in_moor": true, + "columns": [ + { + "name": "title", + "getter_name": "title", + "moor_type": "string", + "nullable": false, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "url", + "getter_name": "url", + "moor_type": "string", + "nullable": false, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "extracted_content_plain", + "getter_name": "extractedContentPlain", + "moor_type": "string", + "nullable": false, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "full_content_plain", + "getter_name": "fullContentPlain", + "moor_type": "string", + "nullable": false, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": true, + "create_virtual_stmt": "CREATE VIRTUAL TABLE \"tab_fts\" USING fts5(title, url, extracted_content_plain, full_content_plain, content=tab, tokenize=\"trigram\")", + "without_rowid": false, + "constraints": [] + } + }, + { + "id": 7, + "references": [ + 1 + ], + "type": "trigger", + "data": { + "on": 1, + "references_in_body": [ + 1 + ], + "name": "tab_maintain_parent_chain_on_delete", + "sql": "CREATE TRIGGER tab_maintain_parent_chain_on_delete BEFORE DELETE ON tab BEGIN\n -- Update all children of the deleted row to point to its parent (grandparent)\n -- Only if the grandparent exists, otherwise set to NULL\n UPDATE tab\n SET parent_id = CASE\n WHEN OLD.parent_id IS NOT NULL AND\n EXISTS(SELECT 1 FROM tab WHERE id = OLD.parent_id)\n THEN OLD.parent_id\n ELSE NULL\n END\n WHERE parent_id = OLD.id;\nEND;" + } + }, + { + "id": 8, + "references": [ + 1, + 6 + ], + "type": "trigger", + "data": { + "on": 1, + "references_in_body": [ + 1, + 6 + ], + "name": "tab_after_insert", + "sql": "CREATE TRIGGER tab_after_insert AFTER INSERT ON tab BEGIN\n INSERT INTO \n tab_fts(rowid, title, url, extracted_content_plain, full_content_plain) \n VALUES (new.rowid, new.title, new.url, new.extracted_content_plain, new.full_content_plain);\nEND;" + } + }, + { + "id": 9, + "references": [ + 1, + 6 + ], + "type": "trigger", + "data": { + "on": 1, + "references_in_body": [ + 1, + 6 + ], + "name": "tab_after_delete", + "sql": "CREATE TRIGGER tab_after_delete AFTER DELETE ON tab BEGIN\n INSERT INTO \n tab_fts(tab_fts, rowid, title, url, extracted_content_plain, full_content_plain) \n VALUES('delete', old.rowid, old.title, old.url, old.extracted_content_plain, old.full_content_plain);\nEND;" + } + }, + { + "id": 10, + "references": [ + 1, + 6 + ], + "type": "trigger", + "data": { + "on": 1, + "references_in_body": [ + 1, + 6 + ], + "name": "tab_after_update", + "sql": "CREATE TRIGGER tab_after_update AFTER UPDATE OF\n title, url, extracted_content_plain, full_content_plain\nON tab BEGIN\n INSERT INTO\n tab_fts(tab_fts, rowid, title, url, extracted_content_plain, full_content_plain)\n VALUES('delete', old.rowid, old.title, old.url, old.extracted_content_plain, old.full_content_plain);\n INSERT INTO\n tab_fts(rowid, title, url, extracted_content_plain, full_content_plain)\n VALUES (new.rowid, new.title, new.url, new.extracted_content_plain, new.full_content_plain);\nEND;" + } + } + ], + "fixed_sql": [ + { + "name": "container", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"container\" (\"id\" TEXT PRIMARY KEY NOT NULL, \"name\" TEXT, \"color\" INTEGER NOT NULL, \"metadata\" TEXT);" + } + ] + }, + { + "name": "tab", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"tab\" (\"id\" TEXT PRIMARY KEY NOT NULL, \"source\" INTEGER NOT NULL, \"parent_id\" TEXT REFERENCES tab(id)ON DELETE SET NULL, \"container_id\" TEXT REFERENCES container(id)ON DELETE CASCADE, \"order_key\" TEXT NOT NULL, \"url\" TEXT, \"title\" TEXT, \"tab_mode\" INTEGER NOT NULL DEFAULT 0, \"isolation_context_id\" TEXT, \"is_pinned\" INTEGER NOT NULL DEFAULT 0, \"is_probably_readerable\" INTEGER, \"extracted_content_markdown\" TEXT, \"extracted_content_plain\" TEXT, \"full_content_markdown\" TEXT, \"full_content_plain\" TEXT, \"timestamp\" INTEGER NOT NULL, CHECK((tab_mode = 2 AND isolation_context_id IS NOT NULL)OR(tab_mode != 2 AND isolation_context_id IS NULL)));" + } + ] + }, + { + "name": "closed_tab_tombstone", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"closed_tab_tombstone\" (\"tab_id\" TEXT PRIMARY KEY NOT NULL, \"closed_at\" INTEGER NOT NULL);" + } + ] + }, + { + "name": "idx_tab_parent_container", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE INDEX idx_tab_parent_container ON tab (parent_id, container_id)" + } + ] + }, + { + "name": "capture_tab", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"capture_tab\" (\"tab_id\" TEXT NOT NULL PRIMARY KEY REFERENCES tab(id)ON DELETE CASCADE, \"capture_id\" TEXT NOT NULL, \"source_url\" TEXT NOT NULL, \"status\" TEXT NOT NULL DEFAULT 'pending', \"created_at\" INTEGER NOT NULL);" + } + ] + }, + { + "name": "idx_capture_tab_capture_id", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE INDEX idx_capture_tab_capture_id ON capture_tab (capture_id)" + } + ] + }, + { + "name": "tab_fts", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE VIRTUAL TABLE IF NOT EXISTS \"tab_fts\" USING fts5(title, url, extracted_content_plain, full_content_plain, content=tab, tokenize=\"trigram\");" + } + ] + }, + { + "name": "tab_maintain_parent_chain_on_delete", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TRIGGER tab_maintain_parent_chain_on_delete BEFORE DELETE ON tab BEGIN UPDATE tab SET parent_id = CASE WHEN OLD.parent_id IS NOT NULL AND EXISTS (SELECT 1 FROM tab WHERE id = OLD.parent_id) THEN OLD.parent_id ELSE NULL END WHERE parent_id = OLD.id;END" + } + ] + }, + { + "name": "tab_after_insert", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TRIGGER tab_after_insert AFTER INSERT ON tab BEGIN INSERT INTO tab_fts (\"rowid\", title, url, extracted_content_plain, full_content_plain) VALUES (new.\"rowid\", new.title, new.url, new.extracted_content_plain, new.full_content_plain);END" + } + ] + }, + { + "name": "tab_after_delete", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TRIGGER tab_after_delete AFTER DELETE ON tab BEGIN INSERT INTO tab_fts (tab_fts, \"rowid\", title, url, extracted_content_plain, full_content_plain) VALUES ('delete', old.\"rowid\", old.title, old.url, old.extracted_content_plain, old.full_content_plain);END" + } + ] + }, + { + "name": "tab_after_update", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TRIGGER tab_after_update AFTER UPDATE OF title, url, extracted_content_plain, full_content_plain ON tab BEGIN INSERT INTO tab_fts (tab_fts, \"rowid\", title, url, extracted_content_plain, full_content_plain) VALUES ('delete', old.\"rowid\", old.title, old.url, old.extracted_content_plain, old.full_content_plain);INSERT INTO tab_fts (\"rowid\", title, url, extracted_content_plain, full_content_plain) VALUES (new.\"rowid\", new.title, new.url, new.extracted_content_plain, new.full_content_plain);END" + } + ] + } + ] +} \ No newline at end of file diff --git a/apps/weblibre/drift_schemas/tabs/drift_schema_v12.json b/apps/weblibre/drift_schemas/tabs/drift_schema_v12.json new file mode 100644 index 00000000..95044b9c --- /dev/null +++ b/apps/weblibre/drift_schemas/tabs/drift_schema_v12.json @@ -0,0 +1,1119 @@ +{ + "_meta": { + "description": "This file contains a serialized version of schema entities for drift.", + "version": "1.3.0" + }, + "options": { + "store_date_time_values_as_text": false + }, + "entities": [ + { + "id": 0, + "references": [], + "type": "table", + "data": { + "name": "container", + "was_declared_in_moor": true, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": "PRIMARY KEY NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + "primary-key" + ] + }, + { + "name": "name", + "getter_name": "name", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "color", + "getter_name": "color", + "moor_type": "int", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const ColorConverter()", + "dart_type_name": "Color" + } + }, + { + "name": "metadata", + "getter_name": "metadata", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const ContainerMetadataConverter()", + "dart_type_name": "ContainerMetadata" + } + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [] + } + }, + { + "id": 1, + "references": [ + 0 + ], + "type": "table", + "data": { + "name": "tab", + "was_declared_in_moor": true, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": "PRIMARY KEY NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + "primary-key" + ] + }, + { + "name": "source", + "getter_name": "source", + "moor_type": "int", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumIndexConverter(TabSource.values)", + "dart_type_name": "TabSource" + } + }, + { + "name": "parent_id", + "getter_name": "parentId", + "moor_type": "string", + "nullable": true, + "customConstraints": "REFERENCES tab(id)ON DELETE SET NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "container_id", + "getter_name": "containerId", + "moor_type": "string", + "nullable": true, + "customConstraints": "REFERENCES container(id)ON DELETE CASCADE", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + { + "foreign_key": { + "to": { + "table": "container", + "column": "id" + }, + "initially_deferred": false, + "on_update": null, + "on_delete": "cascade" + } + } + ] + }, + { + "name": "order_key", + "getter_name": "orderKey", + "moor_type": "string", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "url", + "getter_name": "url", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const UriConverterNullable()", + "dart_type_name": "Uri?" + } + }, + { + "name": "title", + "getter_name": "title", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "tab_mode", + "getter_name": "tabMode", + "moor_type": "int", + "nullable": false, + "customConstraints": "NOT NULL DEFAULT 0", + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumIndexConverter(TabModeDbValue.values)", + "dart_type_name": "TabModeDbValue" + } + }, + { + "name": "isolation_context_id", + "getter_name": "isolationContextId", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_pinned", + "getter_name": "isPinned", + "moor_type": "bool", + "nullable": false, + "customConstraints": "NOT NULL DEFAULT 0", + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_probably_readerable", + "getter_name": "isProbablyReaderable", + "moor_type": "bool", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "extracted_content_markdown", + "getter_name": "extractedContentMarkdown", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "extracted_content_plain", + "getter_name": "extractedContentPlain", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "full_content_markdown", + "getter_name": "fullContentMarkdown", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "full_content_plain", + "getter_name": "fullContentPlain", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "timestamp", + "getter_name": "timestamp", + "moor_type": "dateTime", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "content_hash", + "getter_name": "contentHash", + "moor_type": "int", + "nullable": true, + "customConstraints": "GENERATED ALWAYS AS (generate_content_hash(title, extracted_content_plain, full_content_plain)) VIRTUAL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + { + "generated_as": { + "dart_expression": { + "elements": [ + "const ", + { + "lexeme": "CustomExpression", + "import_uri": "package:drift/drift.dart" + }, + "('generate_content_hash(title, extracted_content_plain, full_content_plain)')" + ] + }, + "stored": false + } + } + ] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [ + "CHECK((tab_mode = 2 AND isolation_context_id IS NOT NULL)OR(tab_mode != 2 AND isolation_context_id IS NULL))" + ] + } + }, + { + "id": 2, + "references": [], + "type": "table", + "data": { + "name": "closed_tab_tombstone", + "was_declared_in_moor": true, + "columns": [ + { + "name": "tab_id", + "getter_name": "tabId", + "moor_type": "string", + "nullable": false, + "customConstraints": "PRIMARY KEY NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + "primary-key" + ] + }, + { + "name": "closed_at", + "getter_name": "closedAt", + "moor_type": "dateTime", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [] + } + }, + { + "id": 3, + "references": [ + 1 + ], + "type": "index", + "data": { + "on": 1, + "name": "idx_tab_parent_container", + "sql": "CREATE INDEX idx_tab_parent_container ON tab(parent_id, container_id);", + "unique": false, + "columns": [] + } + }, + { + "id": 4, + "references": [ + 1 + ], + "type": "table", + "data": { + "name": "capture_tab", + "was_declared_in_moor": true, + "columns": [ + { + "name": "tab_id", + "getter_name": "tabId", + "moor_type": "string", + "nullable": false, + "customConstraints": "NOT NULL PRIMARY KEY REFERENCES tab(id)ON DELETE CASCADE", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + "primary-key", + { + "foreign_key": { + "to": { + "table": "tab", + "column": "id" + }, + "initially_deferred": false, + "on_update": null, + "on_delete": "cascade" + } + } + ] + }, + { + "name": "capture_id", + "getter_name": "captureId", + "moor_type": "string", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "source_url", + "getter_name": "sourceUrl", + "moor_type": "string", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "status", + "getter_name": "status", + "moor_type": "string", + "nullable": false, + "customConstraints": "NOT NULL DEFAULT 'pending'", + "default_dart": "const CustomExpression('\\'pending\\'')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "dateTime", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [] + } + }, + { + "id": 5, + "references": [ + 4 + ], + "type": "index", + "data": { + "on": 4, + "name": "idx_capture_tab_capture_id", + "sql": "CREATE INDEX idx_capture_tab_capture_id ON capture_tab(capture_id);", + "unique": false, + "columns": [] + } + }, + { + "id": 6, + "references": [ + 1 + ], + "type": "table", + "data": { + "name": "tab_fts", + "was_declared_in_moor": true, + "columns": [ + { + "name": "title", + "getter_name": "title", + "moor_type": "string", + "nullable": false, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "url", + "getter_name": "url", + "moor_type": "string", + "nullable": false, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "extracted_content_plain", + "getter_name": "extractedContentPlain", + "moor_type": "string", + "nullable": false, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "full_content_plain", + "getter_name": "fullContentPlain", + "moor_type": "string", + "nullable": false, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": true, + "create_virtual_stmt": "CREATE VIRTUAL TABLE \"tab_fts\" USING fts5(title, url, extracted_content_plain, full_content_plain, content=tab, tokenize=\"trigram\")", + "without_rowid": false, + "constraints": [] + } + }, + { + "id": 7, + "references": [ + 1 + ], + "type": "trigger", + "data": { + "on": 1, + "references_in_body": [ + 1 + ], + "name": "tab_maintain_parent_chain_on_delete", + "sql": "CREATE TRIGGER tab_maintain_parent_chain_on_delete BEFORE DELETE ON tab BEGIN\n -- Update all children of the deleted row to point to its parent (grandparent)\n -- Only if the grandparent exists, otherwise set to NULL\n UPDATE tab\n SET parent_id = CASE\n WHEN OLD.parent_id IS NOT NULL AND\n EXISTS(SELECT 1 FROM tab WHERE id = OLD.parent_id)\n THEN OLD.parent_id\n ELSE NULL\n END\n WHERE parent_id = OLD.id;\nEND;" + } + }, + { + "id": 8, + "references": [ + 1, + 6 + ], + "type": "trigger", + "data": { + "on": 1, + "references_in_body": [ + 1, + 6 + ], + "name": "tab_after_insert", + "sql": "CREATE TRIGGER tab_after_insert AFTER INSERT ON tab BEGIN\n INSERT INTO \n tab_fts(rowid, title, url, extracted_content_plain, full_content_plain) \n VALUES (new.rowid, new.title, new.url, new.extracted_content_plain, new.full_content_plain);\nEND;" + } + }, + { + "id": 9, + "references": [ + 1, + 6 + ], + "type": "trigger", + "data": { + "on": 1, + "references_in_body": [ + 1, + 6 + ], + "name": "tab_after_delete", + "sql": "CREATE TRIGGER tab_after_delete AFTER DELETE ON tab BEGIN\n INSERT INTO \n tab_fts(tab_fts, rowid, title, url, extracted_content_plain, full_content_plain) \n VALUES('delete', old.rowid, old.title, old.url, old.extracted_content_plain, old.full_content_plain);\nEND;" + } + }, + { + "id": 10, + "references": [ + 1, + 6 + ], + "type": "trigger", + "data": { + "on": 1, + "references_in_body": [ + 1, + 6 + ], + "name": "tab_after_update", + "sql": "CREATE TRIGGER tab_after_update AFTER UPDATE OF\n title, url, extracted_content_plain, full_content_plain\nON tab\nWHEN OLD.content_hash IS NOT NEW.content_hash\n OR OLD.url IS NOT NEW.url\nBEGIN\n INSERT INTO\n tab_fts(tab_fts, rowid, title, url, extracted_content_plain, full_content_plain)\n VALUES('delete', old.rowid, old.title, old.url, old.extracted_content_plain, old.full_content_plain);\n INSERT INTO\n tab_fts(rowid, title, url, extracted_content_plain, full_content_plain)\n VALUES (new.rowid, new.title, new.url, new.extracted_content_plain, new.full_content_plain);\nEND;" + } + }, + { + "id": 11, + "references": [], + "type": "table", + "data": { + "name": "local_index_setting", + "was_declared_in_moor": true, + "columns": [ + { + "name": "key", + "getter_name": "key", + "moor_type": "string", + "nullable": false, + "customConstraints": "PRIMARY KEY NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + "primary-key" + ] + }, + { + "name": "value", + "getter_name": "value", + "moor_type": "int", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "strict": true + } + }, + { + "id": 12, + "references": [], + "type": "table", + "data": { + "name": "history", + "was_declared_in_moor": true, + "columns": [ + { + "name": "url_canonical", + "getter_name": "urlCanonical", + "moor_type": "string", + "nullable": false, + "customConstraints": "PRIMARY KEY NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + "primary-key" + ] + }, + { + "name": "url_host", + "getter_name": "urlHost", + "moor_type": "string", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "url_path", + "getter_name": "urlPath", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "title", + "getter_name": "title", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_probably_readerable", + "getter_name": "isProbablyReaderable", + "moor_type": "bool", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "extracted_content_markdown", + "getter_name": "extractedContentMarkdown", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "extracted_content_plain", + "getter_name": "extractedContentPlain", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "full_content_markdown", + "getter_name": "fullContentMarkdown", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "full_content_plain", + "getter_name": "fullContentPlain", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "content_hash", + "getter_name": "contentHash", + "moor_type": "int", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "observed_at", + "getter_name": "observedAt", + "moor_type": "dateTime", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "observed_count", + "getter_name": "observedCount", + "moor_type": "int", + "nullable": false, + "customConstraints": "NOT NULL DEFAULT 1", + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [] + } + }, + { + "id": 13, + "references": [ + 12 + ], + "type": "index", + "data": { + "on": 12, + "name": "idx_history_host", + "sql": "CREATE INDEX idx_history_host ON history(url_host);", + "unique": false, + "columns": [] + } + }, + { + "id": 14, + "references": [ + 12 + ], + "type": "index", + "data": { + "on": 12, + "name": "idx_history_observed", + "sql": "CREATE INDEX idx_history_observed ON history(observed_at DESC);", + "unique": false, + "columns": [] + } + }, + { + "id": 15, + "references": [ + 12 + ], + "type": "table", + "data": { + "name": "history_fts", + "was_declared_in_moor": true, + "columns": [ + { + "name": "title", + "getter_name": "title", + "moor_type": "string", + "nullable": false, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "url_host", + "getter_name": "urlHost", + "moor_type": "string", + "nullable": false, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "url_path", + "getter_name": "urlPath", + "moor_type": "string", + "nullable": false, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "extracted_content_plain", + "getter_name": "extractedContentPlain", + "moor_type": "string", + "nullable": false, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "full_content_plain", + "getter_name": "fullContentPlain", + "moor_type": "string", + "nullable": false, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": true, + "create_virtual_stmt": "CREATE VIRTUAL TABLE \"history_fts\" USING fts5(title, url_host, url_path, extracted_content_plain, full_content_plain, content=history, tokenize=\"trigram\")", + "without_rowid": false, + "constraints": [] + } + }, + { + "id": 16, + "references": [ + 12, + 15 + ], + "type": "trigger", + "data": { + "on": 12, + "references_in_body": [ + 12, + 15 + ], + "name": "history_after_insert", + "sql": "CREATE TRIGGER history_after_insert AFTER INSERT ON history BEGIN\n INSERT INTO\n history_fts(rowid, title, url_host, url_path, extracted_content_plain, full_content_plain)\n VALUES (new.rowid, new.title, new.url_host, new.url_path, new.extracted_content_plain, new.full_content_plain);\nEND;" + } + }, + { + "id": 17, + "references": [ + 12, + 15 + ], + "type": "trigger", + "data": { + "on": 12, + "references_in_body": [ + 12, + 15 + ], + "name": "history_after_delete", + "sql": "CREATE TRIGGER history_after_delete AFTER DELETE ON history BEGIN\n INSERT INTO\n history_fts(history_fts, rowid, title, url_host, url_path, extracted_content_plain, full_content_plain)\n VALUES('delete', old.rowid, old.title, old.url_host, old.url_path, old.extracted_content_plain, old.full_content_plain);\nEND;" + } + }, + { + "id": 18, + "references": [ + 12, + 15 + ], + "type": "trigger", + "data": { + "on": 12, + "references_in_body": [ + 12, + 15 + ], + "name": "history_after_update", + "sql": "CREATE TRIGGER history_after_update AFTER UPDATE OF\n title, url_host, url_path, extracted_content_plain, full_content_plain\nON history BEGIN\n INSERT INTO\n history_fts(history_fts, rowid, title, url_host, url_path, extracted_content_plain, full_content_plain)\n VALUES('delete', old.rowid, old.title, old.url_host, old.url_path, old.extracted_content_plain, old.full_content_plain);\n INSERT INTO\n history_fts(rowid, title, url_host, url_path, extracted_content_plain, full_content_plain)\n VALUES (new.rowid, new.title, new.url_host, new.url_path, new.extracted_content_plain, new.full_content_plain);\nEND;" + } + }, + { + "id": 19, + "references": [ + 1, + 11, + 12 + ], + "type": "trigger", + "data": { + "on": 1, + "references_in_body": [ + 1, + 11, + 12 + ], + "name": "tab_to_history_on_insert", + "sql": "CREATE TRIGGER tab_to_history_on_insert AFTER INSERT ON tab\nWHEN NEW.url IS NOT NULL\n AND url_indexable(CAST(NEW.url AS TEXT)) = 1\n AND (SELECT value FROM local_index_setting WHERE \"key\" = 'enabled') = 1\n AND (NEW.tab_mode != 1\n OR (SELECT value FROM local_index_setting WHERE \"key\" = 'index_private') = 1)\nBEGIN\n INSERT INTO history (\n url_canonical, url_host, url_path, title, is_probably_readerable,\n extracted_content_markdown, extracted_content_plain,\n full_content_markdown, full_content_plain,\n content_hash, observed_at, observed_count\n ) VALUES (\n url_canonical(CAST(NEW.url AS TEXT)),\n url_host(CAST(NEW.url AS TEXT)),\n url_path(CAST(NEW.url AS TEXT)),\n NEW.title,\n NEW.is_probably_readerable,\n NEW.extracted_content_markdown,\n NEW.extracted_content_plain,\n NEW.full_content_markdown,\n NEW.full_content_plain,\n NEW.content_hash,\n strftime('%s','now') * 1000,\n 1\n )\n ON CONFLICT(url_canonical) DO UPDATE SET\n title = COALESCE(excluded.title, history.title),\n is_probably_readerable = excluded.is_probably_readerable,\n extracted_content_markdown = excluded.extracted_content_markdown,\n extracted_content_plain = excluded.extracted_content_plain,\n full_content_markdown = excluded.full_content_markdown,\n full_content_plain = excluded.full_content_plain,\n content_hash = excluded.content_hash,\n observed_at = excluded.observed_at,\n observed_count = history.observed_count + 1\n WHERE history.content_hash IS NOT excluded.content_hash;\nEND;" + } + }, + { + "id": 20, + "references": [ + 1, + 11, + 12 + ], + "type": "trigger", + "data": { + "on": 1, + "references_in_body": [ + 1, + 11, + 12 + ], + "name": "tab_to_history_on_update", + "sql": "CREATE TRIGGER tab_to_history_on_update AFTER UPDATE OF\n title, url, extracted_content_plain, extracted_content_markdown,\n full_content_plain, full_content_markdown, is_probably_readerable\nON tab\nWHEN NEW.url IS NOT NULL\n AND url_indexable(CAST(NEW.url AS TEXT)) = 1\n AND (OLD.content_hash IS NOT NEW.content_hash\n OR OLD.url IS NOT NEW.url)\n AND (SELECT value FROM local_index_setting WHERE \"key\" = 'enabled') = 1\n AND (NEW.tab_mode != 1\n OR (SELECT value FROM local_index_setting WHERE \"key\" = 'index_private') = 1)\nBEGIN\n INSERT INTO history (\n url_canonical, url_host, url_path, title, is_probably_readerable,\n extracted_content_markdown, extracted_content_plain,\n full_content_markdown, full_content_plain,\n content_hash, observed_at, observed_count\n ) VALUES (\n url_canonical(CAST(NEW.url AS TEXT)),\n url_host(CAST(NEW.url AS TEXT)),\n url_path(CAST(NEW.url AS TEXT)),\n NEW.title,\n NEW.is_probably_readerable,\n NEW.extracted_content_markdown,\n NEW.extracted_content_plain,\n NEW.full_content_markdown,\n NEW.full_content_plain,\n NEW.content_hash,\n strftime('%s','now') * 1000,\n 1\n )\n ON CONFLICT(url_canonical) DO UPDATE SET\n title = COALESCE(excluded.title, history.title),\n is_probably_readerable = excluded.is_probably_readerable,\n extracted_content_markdown = excluded.extracted_content_markdown,\n extracted_content_plain = excluded.extracted_content_plain,\n full_content_markdown = excluded.full_content_markdown,\n full_content_plain = excluded.full_content_plain,\n content_hash = excluded.content_hash,\n observed_at = excluded.observed_at,\n observed_count = history.observed_count + 1\n WHERE history.content_hash IS NOT excluded.content_hash;\nEND;" + } + } + ], + "fixed_sql": [ + { + "name": "container", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"container\" (\"id\" TEXT PRIMARY KEY NOT NULL, \"name\" TEXT, \"color\" INTEGER NOT NULL, \"metadata\" TEXT);" + } + ] + }, + { + "name": "tab", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"tab\" (\"id\" TEXT PRIMARY KEY NOT NULL, \"source\" INTEGER NOT NULL, \"parent_id\" TEXT REFERENCES tab(id)ON DELETE SET NULL, \"container_id\" TEXT REFERENCES container(id)ON DELETE CASCADE, \"order_key\" TEXT NOT NULL, \"url\" TEXT, \"title\" TEXT, \"tab_mode\" INTEGER NOT NULL DEFAULT 0, \"isolation_context_id\" TEXT, \"is_pinned\" INTEGER NOT NULL DEFAULT 0, \"is_probably_readerable\" INTEGER, \"extracted_content_markdown\" TEXT, \"extracted_content_plain\" TEXT, \"full_content_markdown\" TEXT, \"full_content_plain\" TEXT, \"timestamp\" INTEGER NOT NULL, \"content_hash\" INTEGER GENERATED ALWAYS AS (generate_content_hash(title, extracted_content_plain, full_content_plain)) VIRTUAL, CHECK((tab_mode = 2 AND isolation_context_id IS NOT NULL)OR(tab_mode != 2 AND isolation_context_id IS NULL)));" + } + ] + }, + { + "name": "closed_tab_tombstone", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"closed_tab_tombstone\" (\"tab_id\" TEXT PRIMARY KEY NOT NULL, \"closed_at\" INTEGER NOT NULL);" + } + ] + }, + { + "name": "idx_tab_parent_container", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE INDEX idx_tab_parent_container ON tab (parent_id, container_id)" + } + ] + }, + { + "name": "capture_tab", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"capture_tab\" (\"tab_id\" TEXT NOT NULL PRIMARY KEY REFERENCES tab(id)ON DELETE CASCADE, \"capture_id\" TEXT NOT NULL, \"source_url\" TEXT NOT NULL, \"status\" TEXT NOT NULL DEFAULT 'pending', \"created_at\" INTEGER NOT NULL);" + } + ] + }, + { + "name": "idx_capture_tab_capture_id", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE INDEX idx_capture_tab_capture_id ON capture_tab (capture_id)" + } + ] + }, + { + "name": "tab_fts", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE VIRTUAL TABLE IF NOT EXISTS \"tab_fts\" USING fts5(title, url, extracted_content_plain, full_content_plain, content=tab, tokenize=\"trigram\");" + } + ] + }, + { + "name": "tab_maintain_parent_chain_on_delete", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TRIGGER tab_maintain_parent_chain_on_delete BEFORE DELETE ON tab BEGIN UPDATE tab SET parent_id = CASE WHEN OLD.parent_id IS NOT NULL AND EXISTS (SELECT 1 FROM tab WHERE id = OLD.parent_id) THEN OLD.parent_id ELSE NULL END WHERE parent_id = OLD.id;END" + } + ] + }, + { + "name": "tab_after_insert", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TRIGGER tab_after_insert AFTER INSERT ON tab BEGIN INSERT INTO tab_fts (\"rowid\", title, url, extracted_content_plain, full_content_plain) VALUES (new.\"rowid\", new.title, new.url, new.extracted_content_plain, new.full_content_plain);END" + } + ] + }, + { + "name": "tab_after_delete", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TRIGGER tab_after_delete AFTER DELETE ON tab BEGIN INSERT INTO tab_fts (tab_fts, \"rowid\", title, url, extracted_content_plain, full_content_plain) VALUES ('delete', old.\"rowid\", old.title, old.url, old.extracted_content_plain, old.full_content_plain);END" + } + ] + }, + { + "name": "tab_after_update", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TRIGGER tab_after_update AFTER UPDATE OF title, url, extracted_content_plain, full_content_plain ON tab WHEN OLD.content_hash IS NOT NEW.content_hash OR OLD.url IS NOT NEW.url BEGIN INSERT INTO tab_fts (tab_fts, \"rowid\", title, url, extracted_content_plain, full_content_plain) VALUES ('delete', old.\"rowid\", old.title, old.url, old.extracted_content_plain, old.full_content_plain);INSERT INTO tab_fts (\"rowid\", title, url, extracted_content_plain, full_content_plain) VALUES (new.\"rowid\", new.title, new.url, new.extracted_content_plain, new.full_content_plain);END" + } + ] + }, + { + "name": "local_index_setting", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"local_index_setting\" (\"key\" TEXT PRIMARY KEY NOT NULL, \"value\" INTEGER NOT NULL) STRICT;" + } + ] + }, + { + "name": "history", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"history\" (\"url_canonical\" TEXT PRIMARY KEY NOT NULL, \"url_host\" TEXT NOT NULL, \"url_path\" TEXT, \"title\" TEXT, \"is_probably_readerable\" INTEGER, \"extracted_content_markdown\" TEXT, \"extracted_content_plain\" TEXT, \"full_content_markdown\" TEXT, \"full_content_plain\" TEXT, \"content_hash\" INTEGER, \"observed_at\" INTEGER NOT NULL, \"observed_count\" INTEGER NOT NULL DEFAULT 1);" + } + ] + }, + { + "name": "idx_history_host", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE INDEX idx_history_host ON history (url_host)" + } + ] + }, + { + "name": "idx_history_observed", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE INDEX idx_history_observed ON history (observed_at DESC)" + } + ] + }, + { + "name": "history_fts", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE VIRTUAL TABLE IF NOT EXISTS \"history_fts\" USING fts5(title, url_host, url_path, extracted_content_plain, full_content_plain, content=history, tokenize=\"trigram\");" + } + ] + }, + { + "name": "history_after_insert", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TRIGGER history_after_insert AFTER INSERT ON history BEGIN INSERT INTO history_fts (\"rowid\", title, url_host, url_path, extracted_content_plain, full_content_plain) VALUES (new.\"rowid\", new.title, new.url_host, new.url_path, new.extracted_content_plain, new.full_content_plain);END" + } + ] + }, + { + "name": "history_after_delete", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TRIGGER history_after_delete AFTER DELETE ON history BEGIN INSERT INTO history_fts (history_fts, \"rowid\", title, url_host, url_path, extracted_content_plain, full_content_plain) VALUES ('delete', old.\"rowid\", old.title, old.url_host, old.url_path, old.extracted_content_plain, old.full_content_plain);END" + } + ] + }, + { + "name": "history_after_update", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TRIGGER history_after_update AFTER UPDATE OF title, url_host, url_path, extracted_content_plain, full_content_plain ON history BEGIN INSERT INTO history_fts (history_fts, \"rowid\", title, url_host, url_path, extracted_content_plain, full_content_plain) VALUES ('delete', old.\"rowid\", old.title, old.url_host, old.url_path, old.extracted_content_plain, old.full_content_plain);INSERT INTO history_fts (\"rowid\", title, url_host, url_path, extracted_content_plain, full_content_plain) VALUES (new.\"rowid\", new.title, new.url_host, new.url_path, new.extracted_content_plain, new.full_content_plain);END" + } + ] + }, + { + "name": "tab_to_history_on_insert", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TRIGGER tab_to_history_on_insert AFTER INSERT ON tab WHEN NEW.url IS NOT NULL AND url_indexable(CAST(NEW.url AS TEXT)) = 1 AND (SELECT value FROM local_index_setting WHERE \"key\" = 'enabled') = 1 AND(NEW.tab_mode != 1 OR (SELECT value FROM local_index_setting WHERE \"key\" = 'index_private') = 1)BEGIN INSERT INTO history (url_canonical, url_host, url_path, title, is_probably_readerable, extracted_content_markdown, extracted_content_plain, full_content_markdown, full_content_plain, content_hash, observed_at, observed_count) VALUES (url_canonical(CAST(NEW.url AS TEXT)), url_host(CAST(NEW.url AS TEXT)), url_path(CAST(NEW.url AS TEXT)), NEW.title, NEW.is_probably_readerable, NEW.extracted_content_markdown, NEW.extracted_content_plain, NEW.full_content_markdown, NEW.full_content_plain, NEW.content_hash, strftime('%s', 'now') * 1000, 1) ON CONFLICT (url_canonical) DO UPDATE SET title = COALESCE(excluded.title, history.title), is_probably_readerable = excluded.is_probably_readerable, extracted_content_markdown = excluded.extracted_content_markdown, extracted_content_plain = excluded.extracted_content_plain, full_content_markdown = excluded.full_content_markdown, full_content_plain = excluded.full_content_plain, content_hash = excluded.content_hash, observed_at = excluded.observed_at, observed_count = history.observed_count + 1 WHERE history.content_hash IS NOT excluded.content_hash;END" + } + ] + }, + { + "name": "tab_to_history_on_update", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TRIGGER tab_to_history_on_update AFTER UPDATE OF title, url, extracted_content_plain, extracted_content_markdown, full_content_plain, full_content_markdown, is_probably_readerable ON tab WHEN NEW.url IS NOT NULL AND url_indexable(CAST(NEW.url AS TEXT)) = 1 AND(OLD.content_hash IS NOT NEW.content_hash OR OLD.url IS NOT NEW.url)AND (SELECT value FROM local_index_setting WHERE \"key\" = 'enabled') = 1 AND(NEW.tab_mode != 1 OR (SELECT value FROM local_index_setting WHERE \"key\" = 'index_private') = 1)BEGIN INSERT INTO history (url_canonical, url_host, url_path, title, is_probably_readerable, extracted_content_markdown, extracted_content_plain, full_content_markdown, full_content_plain, content_hash, observed_at, observed_count) VALUES (url_canonical(CAST(NEW.url AS TEXT)), url_host(CAST(NEW.url AS TEXT)), url_path(CAST(NEW.url AS TEXT)), NEW.title, NEW.is_probably_readerable, NEW.extracted_content_markdown, NEW.extracted_content_plain, NEW.full_content_markdown, NEW.full_content_plain, NEW.content_hash, strftime('%s', 'now') * 1000, 1) ON CONFLICT (url_canonical) DO UPDATE SET title = COALESCE(excluded.title, history.title), is_probably_readerable = excluded.is_probably_readerable, extracted_content_markdown = excluded.extracted_content_markdown, extracted_content_plain = excluded.extracted_content_plain, full_content_markdown = excluded.full_content_markdown, full_content_plain = excluded.full_content_plain, content_hash = excluded.content_hash, observed_at = excluded.observed_at, observed_count = history.observed_count + 1 WHERE history.content_hash IS NOT excluded.content_hash;END" + } + ] + } + ] +} \ No newline at end of file diff --git a/apps/weblibre/drift_schemas/tabs/drift_schema_v13.json b/apps/weblibre/drift_schemas/tabs/drift_schema_v13.json new file mode 100644 index 00000000..962d69bc --- /dev/null +++ b/apps/weblibre/drift_schemas/tabs/drift_schema_v13.json @@ -0,0 +1,1139 @@ +{ + "_meta": { + "description": "This file contains a serialized version of schema entities for drift.", + "version": "1.3.0" + }, + "options": { + "store_date_time_values_as_text": false + }, + "entities": [ + { + "id": 0, + "references": [], + "type": "table", + "data": { + "name": "container", + "was_declared_in_moor": true, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": "PRIMARY KEY NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + "primary-key" + ] + }, + { + "name": "name", + "getter_name": "name", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "color", + "getter_name": "color", + "moor_type": "int", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const ColorConverter()", + "dart_type_name": "Color" + } + }, + { + "name": "order_key", + "getter_name": "orderKey", + "moor_type": "string", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_pinned", + "getter_name": "isPinned", + "moor_type": "bool", + "nullable": false, + "customConstraints": "NOT NULL DEFAULT 0", + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "metadata", + "getter_name": "metadata", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const ContainerMetadataConverter()", + "dart_type_name": "ContainerMetadata" + } + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [] + } + }, + { + "id": 1, + "references": [ + 0 + ], + "type": "table", + "data": { + "name": "tab", + "was_declared_in_moor": true, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": "PRIMARY KEY NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + "primary-key" + ] + }, + { + "name": "source", + "getter_name": "source", + "moor_type": "int", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumIndexConverter(TabSource.values)", + "dart_type_name": "TabSource" + } + }, + { + "name": "parent_id", + "getter_name": "parentId", + "moor_type": "string", + "nullable": true, + "customConstraints": "REFERENCES tab(id)ON DELETE SET NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "container_id", + "getter_name": "containerId", + "moor_type": "string", + "nullable": true, + "customConstraints": "REFERENCES container(id)ON DELETE CASCADE", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + { + "foreign_key": { + "to": { + "table": "container", + "column": "id" + }, + "initially_deferred": false, + "on_update": null, + "on_delete": "cascade" + } + } + ] + }, + { + "name": "order_key", + "getter_name": "orderKey", + "moor_type": "string", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "url", + "getter_name": "url", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const UriConverterNullable()", + "dart_type_name": "Uri?" + } + }, + { + "name": "title", + "getter_name": "title", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "tab_mode", + "getter_name": "tabMode", + "moor_type": "int", + "nullable": false, + "customConstraints": "NOT NULL DEFAULT 0", + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumIndexConverter(TabModeDbValue.values)", + "dart_type_name": "TabModeDbValue" + } + }, + { + "name": "isolation_context_id", + "getter_name": "isolationContextId", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_pinned", + "getter_name": "isPinned", + "moor_type": "bool", + "nullable": false, + "customConstraints": "NOT NULL DEFAULT 0", + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_probably_readerable", + "getter_name": "isProbablyReaderable", + "moor_type": "bool", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "extracted_content_markdown", + "getter_name": "extractedContentMarkdown", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "extracted_content_plain", + "getter_name": "extractedContentPlain", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "full_content_markdown", + "getter_name": "fullContentMarkdown", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "full_content_plain", + "getter_name": "fullContentPlain", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "timestamp", + "getter_name": "timestamp", + "moor_type": "dateTime", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "content_hash", + "getter_name": "contentHash", + "moor_type": "int", + "nullable": true, + "customConstraints": "GENERATED ALWAYS AS (generate_content_hash(title, extracted_content_plain, full_content_plain)) VIRTUAL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + { + "generated_as": { + "dart_expression": { + "elements": [ + "const ", + { + "lexeme": "CustomExpression", + "import_uri": "package:drift/drift.dart" + }, + "('generate_content_hash(title, extracted_content_plain, full_content_plain)')" + ] + }, + "stored": false + } + } + ] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [ + "CHECK((tab_mode = 2 AND isolation_context_id IS NOT NULL)OR(tab_mode != 2 AND isolation_context_id IS NULL))" + ] + } + }, + { + "id": 2, + "references": [], + "type": "table", + "data": { + "name": "closed_tab_tombstone", + "was_declared_in_moor": true, + "columns": [ + { + "name": "tab_id", + "getter_name": "tabId", + "moor_type": "string", + "nullable": false, + "customConstraints": "PRIMARY KEY NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + "primary-key" + ] + }, + { + "name": "closed_at", + "getter_name": "closedAt", + "moor_type": "dateTime", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [] + } + }, + { + "id": 3, + "references": [ + 1 + ], + "type": "index", + "data": { + "on": 1, + "name": "idx_tab_parent_container", + "sql": "CREATE INDEX idx_tab_parent_container ON tab(parent_id, container_id);", + "unique": false, + "columns": [] + } + }, + { + "id": 4, + "references": [ + 1 + ], + "type": "table", + "data": { + "name": "capture_tab", + "was_declared_in_moor": true, + "columns": [ + { + "name": "tab_id", + "getter_name": "tabId", + "moor_type": "string", + "nullable": false, + "customConstraints": "NOT NULL PRIMARY KEY REFERENCES tab(id)ON DELETE CASCADE", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + "primary-key", + { + "foreign_key": { + "to": { + "table": "tab", + "column": "id" + }, + "initially_deferred": false, + "on_update": null, + "on_delete": "cascade" + } + } + ] + }, + { + "name": "capture_id", + "getter_name": "captureId", + "moor_type": "string", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "source_url", + "getter_name": "sourceUrl", + "moor_type": "string", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "status", + "getter_name": "status", + "moor_type": "string", + "nullable": false, + "customConstraints": "NOT NULL DEFAULT 'pending'", + "default_dart": "const CustomExpression('\\'pending\\'')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "dateTime", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [] + } + }, + { + "id": 5, + "references": [ + 4 + ], + "type": "index", + "data": { + "on": 4, + "name": "idx_capture_tab_capture_id", + "sql": "CREATE INDEX idx_capture_tab_capture_id ON capture_tab(capture_id);", + "unique": false, + "columns": [] + } + }, + { + "id": 6, + "references": [ + 1 + ], + "type": "table", + "data": { + "name": "tab_fts", + "was_declared_in_moor": true, + "columns": [ + { + "name": "title", + "getter_name": "title", + "moor_type": "string", + "nullable": false, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "url", + "getter_name": "url", + "moor_type": "string", + "nullable": false, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "extracted_content_plain", + "getter_name": "extractedContentPlain", + "moor_type": "string", + "nullable": false, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "full_content_plain", + "getter_name": "fullContentPlain", + "moor_type": "string", + "nullable": false, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": true, + "create_virtual_stmt": "CREATE VIRTUAL TABLE \"tab_fts\" USING fts5(title, url, extracted_content_plain, full_content_plain, content=tab, tokenize=\"trigram\")", + "without_rowid": false, + "constraints": [] + } + }, + { + "id": 7, + "references": [ + 1 + ], + "type": "trigger", + "data": { + "on": 1, + "references_in_body": [ + 1 + ], + "name": "tab_maintain_parent_chain_on_delete", + "sql": "CREATE TRIGGER tab_maintain_parent_chain_on_delete BEFORE DELETE ON tab BEGIN\n -- Update all children of the deleted row to point to its parent (grandparent)\n -- Only if the grandparent exists, otherwise set to NULL\n UPDATE tab\n SET parent_id = CASE\n WHEN OLD.parent_id IS NOT NULL AND\n EXISTS(SELECT 1 FROM tab WHERE id = OLD.parent_id)\n THEN OLD.parent_id\n ELSE NULL\n END\n WHERE parent_id = OLD.id;\nEND;" + } + }, + { + "id": 8, + "references": [ + 1, + 6 + ], + "type": "trigger", + "data": { + "on": 1, + "references_in_body": [ + 1, + 6 + ], + "name": "tab_after_insert", + "sql": "CREATE TRIGGER tab_after_insert AFTER INSERT ON tab BEGIN\n INSERT INTO \n tab_fts(rowid, title, url, extracted_content_plain, full_content_plain) \n VALUES (new.rowid, new.title, new.url, new.extracted_content_plain, new.full_content_plain);\nEND;" + } + }, + { + "id": 9, + "references": [ + 1, + 6 + ], + "type": "trigger", + "data": { + "on": 1, + "references_in_body": [ + 1, + 6 + ], + "name": "tab_after_delete", + "sql": "CREATE TRIGGER tab_after_delete AFTER DELETE ON tab BEGIN\n INSERT INTO \n tab_fts(tab_fts, rowid, title, url, extracted_content_plain, full_content_plain) \n VALUES('delete', old.rowid, old.title, old.url, old.extracted_content_plain, old.full_content_plain);\nEND;" + } + }, + { + "id": 10, + "references": [ + 1, + 6 + ], + "type": "trigger", + "data": { + "on": 1, + "references_in_body": [ + 1, + 6 + ], + "name": "tab_after_update", + "sql": "CREATE TRIGGER tab_after_update AFTER UPDATE OF\n title, url, extracted_content_plain, full_content_plain\nON tab\nWHEN OLD.content_hash IS NOT NEW.content_hash\n OR OLD.url IS NOT NEW.url\nBEGIN\n INSERT INTO\n tab_fts(tab_fts, rowid, title, url, extracted_content_plain, full_content_plain)\n VALUES('delete', old.rowid, old.title, old.url, old.extracted_content_plain, old.full_content_plain);\n INSERT INTO\n tab_fts(rowid, title, url, extracted_content_plain, full_content_plain)\n VALUES (new.rowid, new.title, new.url, new.extracted_content_plain, new.full_content_plain);\nEND;" + } + }, + { + "id": 11, + "references": [], + "type": "table", + "data": { + "name": "local_index_setting", + "was_declared_in_moor": true, + "columns": [ + { + "name": "key", + "getter_name": "key", + "moor_type": "string", + "nullable": false, + "customConstraints": "PRIMARY KEY NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + "primary-key" + ] + }, + { + "name": "value", + "getter_name": "value", + "moor_type": "int", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "strict": true + } + }, + { + "id": 12, + "references": [], + "type": "table", + "data": { + "name": "history", + "was_declared_in_moor": true, + "columns": [ + { + "name": "url_canonical", + "getter_name": "urlCanonical", + "moor_type": "string", + "nullable": false, + "customConstraints": "PRIMARY KEY NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + "primary-key" + ] + }, + { + "name": "url_host", + "getter_name": "urlHost", + "moor_type": "string", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "url_path", + "getter_name": "urlPath", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "title", + "getter_name": "title", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_probably_readerable", + "getter_name": "isProbablyReaderable", + "moor_type": "bool", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "extracted_content_markdown", + "getter_name": "extractedContentMarkdown", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "extracted_content_plain", + "getter_name": "extractedContentPlain", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "full_content_markdown", + "getter_name": "fullContentMarkdown", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "full_content_plain", + "getter_name": "fullContentPlain", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "content_hash", + "getter_name": "contentHash", + "moor_type": "int", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "observed_at", + "getter_name": "observedAt", + "moor_type": "dateTime", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "observed_count", + "getter_name": "observedCount", + "moor_type": "int", + "nullable": false, + "customConstraints": "NOT NULL DEFAULT 1", + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [] + } + }, + { + "id": 13, + "references": [ + 12 + ], + "type": "index", + "data": { + "on": 12, + "name": "idx_history_host", + "sql": "CREATE INDEX idx_history_host ON history(url_host);", + "unique": false, + "columns": [] + } + }, + { + "id": 14, + "references": [ + 12 + ], + "type": "index", + "data": { + "on": 12, + "name": "idx_history_observed", + "sql": "CREATE INDEX idx_history_observed ON history(observed_at DESC);", + "unique": false, + "columns": [] + } + }, + { + "id": 15, + "references": [ + 12 + ], + "type": "table", + "data": { + "name": "history_fts", + "was_declared_in_moor": true, + "columns": [ + { + "name": "title", + "getter_name": "title", + "moor_type": "string", + "nullable": false, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "url_host", + "getter_name": "urlHost", + "moor_type": "string", + "nullable": false, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "url_path", + "getter_name": "urlPath", + "moor_type": "string", + "nullable": false, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "extracted_content_plain", + "getter_name": "extractedContentPlain", + "moor_type": "string", + "nullable": false, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "full_content_plain", + "getter_name": "fullContentPlain", + "moor_type": "string", + "nullable": false, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": true, + "create_virtual_stmt": "CREATE VIRTUAL TABLE \"history_fts\" USING fts5(title, url_host, url_path, extracted_content_plain, full_content_plain, content=history, tokenize=\"trigram\")", + "without_rowid": false, + "constraints": [] + } + }, + { + "id": 16, + "references": [ + 12, + 15 + ], + "type": "trigger", + "data": { + "on": 12, + "references_in_body": [ + 12, + 15 + ], + "name": "history_after_insert", + "sql": "CREATE TRIGGER history_after_insert AFTER INSERT ON history BEGIN\n INSERT INTO\n history_fts(rowid, title, url_host, url_path, extracted_content_plain, full_content_plain)\n VALUES (new.rowid, new.title, new.url_host, new.url_path, new.extracted_content_plain, new.full_content_plain);\nEND;" + } + }, + { + "id": 17, + "references": [ + 12, + 15 + ], + "type": "trigger", + "data": { + "on": 12, + "references_in_body": [ + 12, + 15 + ], + "name": "history_after_delete", + "sql": "CREATE TRIGGER history_after_delete AFTER DELETE ON history BEGIN\n INSERT INTO\n history_fts(history_fts, rowid, title, url_host, url_path, extracted_content_plain, full_content_plain)\n VALUES('delete', old.rowid, old.title, old.url_host, old.url_path, old.extracted_content_plain, old.full_content_plain);\nEND;" + } + }, + { + "id": 18, + "references": [ + 12, + 15 + ], + "type": "trigger", + "data": { + "on": 12, + "references_in_body": [ + 12, + 15 + ], + "name": "history_after_update", + "sql": "CREATE TRIGGER history_after_update AFTER UPDATE OF\n title, url_host, url_path, extracted_content_plain, full_content_plain\nON history BEGIN\n INSERT INTO\n history_fts(history_fts, rowid, title, url_host, url_path, extracted_content_plain, full_content_plain)\n VALUES('delete', old.rowid, old.title, old.url_host, old.url_path, old.extracted_content_plain, old.full_content_plain);\n INSERT INTO\n history_fts(rowid, title, url_host, url_path, extracted_content_plain, full_content_plain)\n VALUES (new.rowid, new.title, new.url_host, new.url_path, new.extracted_content_plain, new.full_content_plain);\nEND;" + } + }, + { + "id": 19, + "references": [ + 1, + 11, + 12 + ], + "type": "trigger", + "data": { + "on": 1, + "references_in_body": [ + 1, + 11, + 12 + ], + "name": "tab_to_history_on_insert", + "sql": "CREATE TRIGGER tab_to_history_on_insert AFTER INSERT ON tab\nWHEN NEW.url IS NOT NULL\n AND url_indexable(CAST(NEW.url AS TEXT)) = 1\n AND (SELECT value FROM local_index_setting WHERE \"key\" = 'enabled') = 1\n AND (NEW.tab_mode != 1\n OR (SELECT value FROM local_index_setting WHERE \"key\" = 'index_private') = 1)\nBEGIN\n INSERT INTO history (\n url_canonical, url_host, url_path, title, is_probably_readerable,\n extracted_content_markdown, extracted_content_plain,\n full_content_markdown, full_content_plain,\n content_hash, observed_at, observed_count\n ) VALUES (\n url_canonical(CAST(NEW.url AS TEXT)),\n url_host(CAST(NEW.url AS TEXT)),\n url_path(CAST(NEW.url AS TEXT)),\n NEW.title,\n NEW.is_probably_readerable,\n NEW.extracted_content_markdown,\n NEW.extracted_content_plain,\n NEW.full_content_markdown,\n NEW.full_content_plain,\n NEW.content_hash,\n strftime('%s','now') * 1000,\n 1\n )\n ON CONFLICT(url_canonical) DO UPDATE SET\n title = COALESCE(excluded.title, history.title),\n is_probably_readerable = excluded.is_probably_readerable,\n extracted_content_markdown = excluded.extracted_content_markdown,\n extracted_content_plain = excluded.extracted_content_plain,\n full_content_markdown = excluded.full_content_markdown,\n full_content_plain = excluded.full_content_plain,\n content_hash = excluded.content_hash,\n observed_at = excluded.observed_at,\n observed_count = history.observed_count + 1\n WHERE history.content_hash IS NOT excluded.content_hash;\nEND;" + } + }, + { + "id": 20, + "references": [ + 1, + 11, + 12 + ], + "type": "trigger", + "data": { + "on": 1, + "references_in_body": [ + 1, + 11, + 12 + ], + "name": "tab_to_history_on_update", + "sql": "CREATE TRIGGER tab_to_history_on_update AFTER UPDATE OF\n title, url, extracted_content_plain, extracted_content_markdown,\n full_content_plain, full_content_markdown, is_probably_readerable\nON tab\nWHEN NEW.url IS NOT NULL\n AND url_indexable(CAST(NEW.url AS TEXT)) = 1\n AND (OLD.content_hash IS NOT NEW.content_hash\n OR OLD.url IS NOT NEW.url)\n AND (SELECT value FROM local_index_setting WHERE \"key\" = 'enabled') = 1\n AND (NEW.tab_mode != 1\n OR (SELECT value FROM local_index_setting WHERE \"key\" = 'index_private') = 1)\nBEGIN\n INSERT INTO history (\n url_canonical, url_host, url_path, title, is_probably_readerable,\n extracted_content_markdown, extracted_content_plain,\n full_content_markdown, full_content_plain,\n content_hash, observed_at, observed_count\n ) VALUES (\n url_canonical(CAST(NEW.url AS TEXT)),\n url_host(CAST(NEW.url AS TEXT)),\n url_path(CAST(NEW.url AS TEXT)),\n NEW.title,\n NEW.is_probably_readerable,\n NEW.extracted_content_markdown,\n NEW.extracted_content_plain,\n NEW.full_content_markdown,\n NEW.full_content_plain,\n NEW.content_hash,\n strftime('%s','now') * 1000,\n 1\n )\n ON CONFLICT(url_canonical) DO UPDATE SET\n title = COALESCE(excluded.title, history.title),\n is_probably_readerable = excluded.is_probably_readerable,\n extracted_content_markdown = excluded.extracted_content_markdown,\n extracted_content_plain = excluded.extracted_content_plain,\n full_content_markdown = excluded.full_content_markdown,\n full_content_plain = excluded.full_content_plain,\n content_hash = excluded.content_hash,\n observed_at = excluded.observed_at,\n observed_count = history.observed_count + 1\n WHERE history.content_hash IS NOT excluded.content_hash;\nEND;" + } + } + ], + "fixed_sql": [ + { + "name": "container", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"container\" (\"id\" TEXT PRIMARY KEY NOT NULL, \"name\" TEXT, \"color\" INTEGER NOT NULL, \"order_key\" TEXT NOT NULL, \"is_pinned\" INTEGER NOT NULL DEFAULT 0, \"metadata\" TEXT);" + } + ] + }, + { + "name": "tab", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"tab\" (\"id\" TEXT PRIMARY KEY NOT NULL, \"source\" INTEGER NOT NULL, \"parent_id\" TEXT REFERENCES tab(id)ON DELETE SET NULL, \"container_id\" TEXT REFERENCES container(id)ON DELETE CASCADE, \"order_key\" TEXT NOT NULL, \"url\" TEXT, \"title\" TEXT, \"tab_mode\" INTEGER NOT NULL DEFAULT 0, \"isolation_context_id\" TEXT, \"is_pinned\" INTEGER NOT NULL DEFAULT 0, \"is_probably_readerable\" INTEGER, \"extracted_content_markdown\" TEXT, \"extracted_content_plain\" TEXT, \"full_content_markdown\" TEXT, \"full_content_plain\" TEXT, \"timestamp\" INTEGER NOT NULL, \"content_hash\" INTEGER GENERATED ALWAYS AS (generate_content_hash(title, extracted_content_plain, full_content_plain)) VIRTUAL, CHECK((tab_mode = 2 AND isolation_context_id IS NOT NULL)OR(tab_mode != 2 AND isolation_context_id IS NULL)));" + } + ] + }, + { + "name": "closed_tab_tombstone", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"closed_tab_tombstone\" (\"tab_id\" TEXT PRIMARY KEY NOT NULL, \"closed_at\" INTEGER NOT NULL);" + } + ] + }, + { + "name": "idx_tab_parent_container", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE INDEX idx_tab_parent_container ON tab (parent_id, container_id)" + } + ] + }, + { + "name": "capture_tab", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"capture_tab\" (\"tab_id\" TEXT NOT NULL PRIMARY KEY REFERENCES tab(id)ON DELETE CASCADE, \"capture_id\" TEXT NOT NULL, \"source_url\" TEXT NOT NULL, \"status\" TEXT NOT NULL DEFAULT 'pending', \"created_at\" INTEGER NOT NULL);" + } + ] + }, + { + "name": "idx_capture_tab_capture_id", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE INDEX idx_capture_tab_capture_id ON capture_tab (capture_id)" + } + ] + }, + { + "name": "tab_fts", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE VIRTUAL TABLE IF NOT EXISTS \"tab_fts\" USING fts5(title, url, extracted_content_plain, full_content_plain, content=tab, tokenize=\"trigram\");" + } + ] + }, + { + "name": "tab_maintain_parent_chain_on_delete", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TRIGGER tab_maintain_parent_chain_on_delete BEFORE DELETE ON tab BEGIN UPDATE tab SET parent_id = CASE WHEN OLD.parent_id IS NOT NULL AND EXISTS (SELECT 1 FROM tab WHERE id = OLD.parent_id) THEN OLD.parent_id ELSE NULL END WHERE parent_id = OLD.id;END" + } + ] + }, + { + "name": "tab_after_insert", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TRIGGER tab_after_insert AFTER INSERT ON tab BEGIN INSERT INTO tab_fts (\"rowid\", title, url, extracted_content_plain, full_content_plain) VALUES (new.\"rowid\", new.title, new.url, new.extracted_content_plain, new.full_content_plain);END" + } + ] + }, + { + "name": "tab_after_delete", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TRIGGER tab_after_delete AFTER DELETE ON tab BEGIN INSERT INTO tab_fts (tab_fts, \"rowid\", title, url, extracted_content_plain, full_content_plain) VALUES ('delete', old.\"rowid\", old.title, old.url, old.extracted_content_plain, old.full_content_plain);END" + } + ] + }, + { + "name": "tab_after_update", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TRIGGER tab_after_update AFTER UPDATE OF title, url, extracted_content_plain, full_content_plain ON tab WHEN OLD.content_hash IS NOT NEW.content_hash OR OLD.url IS NOT NEW.url BEGIN INSERT INTO tab_fts (tab_fts, \"rowid\", title, url, extracted_content_plain, full_content_plain) VALUES ('delete', old.\"rowid\", old.title, old.url, old.extracted_content_plain, old.full_content_plain);INSERT INTO tab_fts (\"rowid\", title, url, extracted_content_plain, full_content_plain) VALUES (new.\"rowid\", new.title, new.url, new.extracted_content_plain, new.full_content_plain);END" + } + ] + }, + { + "name": "local_index_setting", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"local_index_setting\" (\"key\" TEXT PRIMARY KEY NOT NULL, \"value\" INTEGER NOT NULL) STRICT;" + } + ] + }, + { + "name": "history", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"history\" (\"url_canonical\" TEXT PRIMARY KEY NOT NULL, \"url_host\" TEXT NOT NULL, \"url_path\" TEXT, \"title\" TEXT, \"is_probably_readerable\" INTEGER, \"extracted_content_markdown\" TEXT, \"extracted_content_plain\" TEXT, \"full_content_markdown\" TEXT, \"full_content_plain\" TEXT, \"content_hash\" INTEGER, \"observed_at\" INTEGER NOT NULL, \"observed_count\" INTEGER NOT NULL DEFAULT 1);" + } + ] + }, + { + "name": "idx_history_host", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE INDEX idx_history_host ON history (url_host)" + } + ] + }, + { + "name": "idx_history_observed", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE INDEX idx_history_observed ON history (observed_at DESC)" + } + ] + }, + { + "name": "history_fts", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE VIRTUAL TABLE IF NOT EXISTS \"history_fts\" USING fts5(title, url_host, url_path, extracted_content_plain, full_content_plain, content=history, tokenize=\"trigram\");" + } + ] + }, + { + "name": "history_after_insert", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TRIGGER history_after_insert AFTER INSERT ON history BEGIN INSERT INTO history_fts (\"rowid\", title, url_host, url_path, extracted_content_plain, full_content_plain) VALUES (new.\"rowid\", new.title, new.url_host, new.url_path, new.extracted_content_plain, new.full_content_plain);END" + } + ] + }, + { + "name": "history_after_delete", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TRIGGER history_after_delete AFTER DELETE ON history BEGIN INSERT INTO history_fts (history_fts, \"rowid\", title, url_host, url_path, extracted_content_plain, full_content_plain) VALUES ('delete', old.\"rowid\", old.title, old.url_host, old.url_path, old.extracted_content_plain, old.full_content_plain);END" + } + ] + }, + { + "name": "history_after_update", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TRIGGER history_after_update AFTER UPDATE OF title, url_host, url_path, extracted_content_plain, full_content_plain ON history BEGIN INSERT INTO history_fts (history_fts, \"rowid\", title, url_host, url_path, extracted_content_plain, full_content_plain) VALUES ('delete', old.\"rowid\", old.title, old.url_host, old.url_path, old.extracted_content_plain, old.full_content_plain);INSERT INTO history_fts (\"rowid\", title, url_host, url_path, extracted_content_plain, full_content_plain) VALUES (new.\"rowid\", new.title, new.url_host, new.url_path, new.extracted_content_plain, new.full_content_plain);END" + } + ] + }, + { + "name": "tab_to_history_on_insert", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TRIGGER tab_to_history_on_insert AFTER INSERT ON tab WHEN NEW.url IS NOT NULL AND url_indexable(CAST(NEW.url AS TEXT)) = 1 AND (SELECT value FROM local_index_setting WHERE \"key\" = 'enabled') = 1 AND(NEW.tab_mode != 1 OR (SELECT value FROM local_index_setting WHERE \"key\" = 'index_private') = 1)BEGIN INSERT INTO history (url_canonical, url_host, url_path, title, is_probably_readerable, extracted_content_markdown, extracted_content_plain, full_content_markdown, full_content_plain, content_hash, observed_at, observed_count) VALUES (url_canonical(CAST(NEW.url AS TEXT)), url_host(CAST(NEW.url AS TEXT)), url_path(CAST(NEW.url AS TEXT)), NEW.title, NEW.is_probably_readerable, NEW.extracted_content_markdown, NEW.extracted_content_plain, NEW.full_content_markdown, NEW.full_content_plain, NEW.content_hash, strftime('%s', 'now') * 1000, 1) ON CONFLICT (url_canonical) DO UPDATE SET title = COALESCE(excluded.title, history.title), is_probably_readerable = excluded.is_probably_readerable, extracted_content_markdown = excluded.extracted_content_markdown, extracted_content_plain = excluded.extracted_content_plain, full_content_markdown = excluded.full_content_markdown, full_content_plain = excluded.full_content_plain, content_hash = excluded.content_hash, observed_at = excluded.observed_at, observed_count = history.observed_count + 1 WHERE history.content_hash IS NOT excluded.content_hash;END" + } + ] + }, + { + "name": "tab_to_history_on_update", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TRIGGER tab_to_history_on_update AFTER UPDATE OF title, url, extracted_content_plain, extracted_content_markdown, full_content_plain, full_content_markdown, is_probably_readerable ON tab WHEN NEW.url IS NOT NULL AND url_indexable(CAST(NEW.url AS TEXT)) = 1 AND(OLD.content_hash IS NOT NEW.content_hash OR OLD.url IS NOT NEW.url)AND (SELECT value FROM local_index_setting WHERE \"key\" = 'enabled') = 1 AND(NEW.tab_mode != 1 OR (SELECT value FROM local_index_setting WHERE \"key\" = 'index_private') = 1)BEGIN INSERT INTO history (url_canonical, url_host, url_path, title, is_probably_readerable, extracted_content_markdown, extracted_content_plain, full_content_markdown, full_content_plain, content_hash, observed_at, observed_count) VALUES (url_canonical(CAST(NEW.url AS TEXT)), url_host(CAST(NEW.url AS TEXT)), url_path(CAST(NEW.url AS TEXT)), NEW.title, NEW.is_probably_readerable, NEW.extracted_content_markdown, NEW.extracted_content_plain, NEW.full_content_markdown, NEW.full_content_plain, NEW.content_hash, strftime('%s', 'now') * 1000, 1) ON CONFLICT (url_canonical) DO UPDATE SET title = COALESCE(excluded.title, history.title), is_probably_readerable = excluded.is_probably_readerable, extracted_content_markdown = excluded.extracted_content_markdown, extracted_content_plain = excluded.extracted_content_plain, full_content_markdown = excluded.full_content_markdown, full_content_plain = excluded.full_content_plain, content_hash = excluded.content_hash, observed_at = excluded.observed_at, observed_count = history.observed_count + 1 WHERE history.content_hash IS NOT excluded.content_hash;END" + } + ] + } + ] +} \ No newline at end of file diff --git a/apps/weblibre/drift_schemas/user/drift_schema_v4.json b/apps/weblibre/drift_schemas/user/drift_schema_v4.json new file mode 100644 index 00000000..745bc44e --- /dev/null +++ b/apps/weblibre/drift_schemas/user/drift_schema_v4.json @@ -0,0 +1,408 @@ +{ + "_meta": { + "description": "This file contains a serialized version of schema entities for drift.", + "version": "1.3.0" + }, + "options": { + "store_date_time_values_as_text": false + }, + "entities": [ + { + "id": 0, + "references": [], + "type": "table", + "data": { + "name": "setting", + "was_declared_in_moor": true, + "columns": [ + { + "name": "key", + "getter_name": "key", + "moor_type": "string", + "nullable": false, + "customConstraints": "PRIMARY KEY NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + "primary-key" + ] + }, + { + "name": "partition_key", + "getter_name": "partitionKey", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "value", + "getter_name": "value", + "moor_type": "any", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "strict": true + } + }, + { + "id": 1, + "references": [], + "type": "table", + "data": { + "name": "icon_cache", + "was_declared_in_moor": true, + "columns": [ + { + "name": "origin", + "getter_name": "origin", + "moor_type": "string", + "nullable": false, + "customConstraints": "PRIMARY KEY NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + "primary-key" + ] + }, + { + "name": "icon_data", + "getter_name": "iconData", + "moor_type": "blob", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "fetch_date", + "getter_name": "fetchDate", + "moor_type": "dateTime", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [] + } + }, + { + "id": 2, + "references": [], + "type": "table", + "data": { + "name": "onboarding", + "was_declared_in_moor": true, + "columns": [ + { + "name": "revision", + "getter_name": "revision", + "moor_type": "int", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "completion_date", + "getter_name": "completionDate", + "moor_type": "dateTime", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [] + } + }, + { + "id": 3, + "references": [], + "type": "table", + "data": { + "name": "riverpod", + "was_declared_in_moor": true, + "columns": [ + { + "name": "key", + "getter_name": "key", + "moor_type": "string", + "nullable": false, + "customConstraints": "PRIMARY KEY NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + "primary-key" + ] + }, + { + "name": "json", + "getter_name": "json", + "moor_type": "string", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "expireAt", + "getter_name": "expireAt", + "moor_type": "dateTime", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "destroyKey", + "getter_name": "destroyKey", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": true, + "constraints": [] + } + }, + { + "id": 4, + "references": [], + "type": "table", + "data": { + "name": "toolbar_button_configs", + "was_declared_in_moor": true, + "columns": [ + { + "name": "button_id", + "getter_name": "buttonId", + "moor_type": "string", + "nullable": false, + "customConstraints": "NOT NULL PRIMARY KEY", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + "primary-key" + ] + }, + { + "name": "order_key", + "getter_name": "orderKey", + "moor_type": "string", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_visible", + "getter_name": "isVisible", + "moor_type": "bool", + "nullable": false, + "customConstraints": "NOT NULL DEFAULT TRUE", + "default_dart": "const CustomExpression('TRUE')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "fallback_id", + "getter_name": "fallbackId", + "moor_type": "string", + "nullable": true, + "customConstraints": "REFERENCES toolbar_button_configs(button_id)ON DELETE SET NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [] + } + }, + { + "id": 5, + "references": [ + 4 + ], + "type": "index", + "data": { + "on": 4, + "name": "idx_toolbar_order_key", + "sql": "CREATE INDEX idx_toolbar_order_key ON toolbar_button_configs(order_key);", + "unique": false, + "columns": [] + } + }, + { + "id": 6, + "references": [], + "type": "table", + "data": { + "name": "search_tokens", + "was_declared_in_moor": true, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "int", + "nullable": false, + "customConstraints": "PRIMARY KEY AUTOINCREMENT", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + "auto-increment" + ] + }, + { + "name": "token", + "getter_name": "token", + "moor_type": "blob", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "inserted_at", + "getter_name": "insertedAt", + "moor_type": "dateTime", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "issuer_key_version", + "getter_name": "issuerKeyVersion", + "moor_type": "string", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [] + } + }, + { + "id": 7, + "references": [ + 6 + ], + "type": "index", + "data": { + "on": 6, + "name": "idx_search_tokens_inserted_at", + "sql": "CREATE INDEX idx_search_tokens_inserted_at ON search_tokens(inserted_at);", + "unique": false, + "columns": [] + } + } + ], + "fixed_sql": [ + { + "name": "setting", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"setting\" (\"key\" TEXT PRIMARY KEY NOT NULL, \"partition_key\" TEXT, \"value\" ANY) STRICT;" + } + ] + }, + { + "name": "icon_cache", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"icon_cache\" (\"origin\" TEXT PRIMARY KEY NOT NULL, \"icon_data\" BLOB NOT NULL, \"fetch_date\" INTEGER NOT NULL);" + } + ] + }, + { + "name": "onboarding", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"onboarding\" (\"revision\" INTEGER NOT NULL, \"completion_date\" INTEGER NOT NULL);" + } + ] + }, + { + "name": "riverpod", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"riverpod\" (\"key\" TEXT PRIMARY KEY NOT NULL, \"json\" TEXT NOT NULL, \"expireAt\" INTEGER, \"destroyKey\" TEXT) WITHOUT ROWID;" + } + ] + }, + { + "name": "toolbar_button_configs", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"toolbar_button_configs\" (\"button_id\" TEXT NOT NULL PRIMARY KEY, \"order_key\" TEXT NOT NULL, \"is_visible\" INTEGER NOT NULL DEFAULT TRUE, \"fallback_id\" TEXT REFERENCES toolbar_button_configs(button_id)ON DELETE SET NULL);" + } + ] + }, + { + "name": "idx_toolbar_order_key", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE INDEX idx_toolbar_order_key ON toolbar_button_configs (order_key)" + } + ] + }, + { + "name": "search_tokens", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"search_tokens\" (\"id\" INTEGER PRIMARY KEY AUTOINCREMENT, \"token\" BLOB NOT NULL, \"inserted_at\" INTEGER NOT NULL, \"issuer_key_version\" TEXT NOT NULL);" + } + ] + }, + { + "name": "idx_search_tokens_inserted_at", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE INDEX idx_search_tokens_inserted_at ON search_tokens (inserted_at)" + } + ] + } + ] +} \ No newline at end of file diff --git a/apps/weblibre/drift_schemas/user/drift_schema_v5.json b/apps/weblibre/drift_schemas/user/drift_schema_v5.json new file mode 100644 index 00000000..ab2c5001 --- /dev/null +++ b/apps/weblibre/drift_schemas/user/drift_schema_v5.json @@ -0,0 +1,441 @@ +{ + "_meta": { + "description": "This file contains a serialized version of schema entities for drift.", + "version": "1.3.0" + }, + "options": { + "store_date_time_values_as_text": false + }, + "entities": [ + { + "id": 0, + "references": [], + "type": "table", + "data": { + "name": "setting", + "was_declared_in_moor": true, + "columns": [ + { + "name": "key", + "getter_name": "key", + "moor_type": "string", + "nullable": false, + "customConstraints": "PRIMARY KEY NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + "primary-key" + ] + }, + { + "name": "partition_key", + "getter_name": "partitionKey", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "value", + "getter_name": "value", + "moor_type": "any", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "strict": true + } + }, + { + "id": 1, + "references": [], + "type": "table", + "data": { + "name": "icon_cache", + "was_declared_in_moor": true, + "columns": [ + { + "name": "origin", + "getter_name": "origin", + "moor_type": "string", + "nullable": false, + "customConstraints": "PRIMARY KEY NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + "primary-key" + ] + }, + { + "name": "icon_data", + "getter_name": "iconData", + "moor_type": "blob", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "fetch_date", + "getter_name": "fetchDate", + "moor_type": "dateTime", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [] + } + }, + { + "id": 2, + "references": [], + "type": "table", + "data": { + "name": "onboarding", + "was_declared_in_moor": true, + "columns": [ + { + "name": "revision", + "getter_name": "revision", + "moor_type": "int", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "completion_date", + "getter_name": "completionDate", + "moor_type": "dateTime", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [] + } + }, + { + "id": 3, + "references": [], + "type": "table", + "data": { + "name": "riverpod", + "was_declared_in_moor": true, + "columns": [ + { + "name": "key", + "getter_name": "key", + "moor_type": "string", + "nullable": false, + "customConstraints": "PRIMARY KEY NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + "primary-key" + ] + }, + { + "name": "json", + "getter_name": "json", + "moor_type": "string", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "expireAt", + "getter_name": "expireAt", + "moor_type": "dateTime", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "destroyKey", + "getter_name": "destroyKey", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": true, + "constraints": [] + } + }, + { + "id": 4, + "references": [], + "type": "table", + "data": { + "name": "toolbar_button_configs", + "was_declared_in_moor": true, + "columns": [ + { + "name": "button_id", + "getter_name": "buttonId", + "moor_type": "string", + "nullable": false, + "customConstraints": "NOT NULL PRIMARY KEY", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + "primary-key" + ] + }, + { + "name": "order_key", + "getter_name": "orderKey", + "moor_type": "string", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_visible", + "getter_name": "isVisible", + "moor_type": "bool", + "nullable": false, + "customConstraints": "NOT NULL DEFAULT TRUE", + "default_dart": "const CustomExpression('TRUE')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "fallback_id", + "getter_name": "fallbackId", + "moor_type": "string", + "nullable": true, + "customConstraints": "REFERENCES toolbar_button_configs(button_id)ON DELETE SET NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [] + } + }, + { + "id": 5, + "references": [ + 4 + ], + "type": "index", + "data": { + "on": 4, + "name": "idx_toolbar_order_key", + "sql": "CREATE INDEX idx_toolbar_order_key ON toolbar_button_configs(order_key);", + "unique": false, + "columns": [] + } + }, + { + "id": 6, + "references": [], + "type": "table", + "data": { + "name": "search_tokens", + "was_declared_in_moor": true, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "int", + "nullable": false, + "customConstraints": "PRIMARY KEY AUTOINCREMENT", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + "auto-increment" + ] + }, + { + "name": "token", + "getter_name": "token", + "moor_type": "blob", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "inserted_at", + "getter_name": "insertedAt", + "moor_type": "dateTime", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "issuer_key_version", + "getter_name": "issuerKeyVersion", + "moor_type": "string", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "reserved_at", + "getter_name": "reservedAt", + "moor_type": "dateTime", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [] + } + }, + { + "id": 7, + "references": [ + 6 + ], + "type": "index", + "data": { + "on": 6, + "name": "idx_search_tokens_inserted_at", + "sql": "CREATE INDEX idx_search_tokens_inserted_at ON search_tokens(inserted_at);", + "unique": false, + "columns": [] + } + }, + { + "id": 8, + "references": [ + 6 + ], + "type": "index", + "data": { + "on": 6, + "name": "idx_search_tokens_reserved_at", + "sql": "CREATE INDEX idx_search_tokens_reserved_at ON search_tokens(reserved_at);", + "unique": false, + "columns": [] + } + } + ], + "fixed_sql": [ + { + "name": "setting", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"setting\" (\"key\" TEXT PRIMARY KEY NOT NULL, \"partition_key\" TEXT, \"value\" ANY) STRICT;" + } + ] + }, + { + "name": "icon_cache", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"icon_cache\" (\"origin\" TEXT PRIMARY KEY NOT NULL, \"icon_data\" BLOB NOT NULL, \"fetch_date\" INTEGER NOT NULL);" + } + ] + }, + { + "name": "onboarding", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"onboarding\" (\"revision\" INTEGER NOT NULL, \"completion_date\" INTEGER NOT NULL);" + } + ] + }, + { + "name": "riverpod", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"riverpod\" (\"key\" TEXT PRIMARY KEY NOT NULL, \"json\" TEXT NOT NULL, \"expireAt\" INTEGER, \"destroyKey\" TEXT) WITHOUT ROWID;" + } + ] + }, + { + "name": "toolbar_button_configs", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"toolbar_button_configs\" (\"button_id\" TEXT NOT NULL PRIMARY KEY, \"order_key\" TEXT NOT NULL, \"is_visible\" INTEGER NOT NULL DEFAULT TRUE, \"fallback_id\" TEXT REFERENCES toolbar_button_configs(button_id)ON DELETE SET NULL);" + } + ] + }, + { + "name": "idx_toolbar_order_key", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE INDEX idx_toolbar_order_key ON toolbar_button_configs (order_key)" + } + ] + }, + { + "name": "search_tokens", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"search_tokens\" (\"id\" INTEGER PRIMARY KEY AUTOINCREMENT, \"token\" BLOB NOT NULL, \"inserted_at\" INTEGER NOT NULL, \"issuer_key_version\" TEXT NOT NULL, \"reserved_at\" INTEGER);" + } + ] + }, + { + "name": "idx_search_tokens_inserted_at", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE INDEX idx_search_tokens_inserted_at ON search_tokens (inserted_at)" + } + ] + }, + { + "name": "idx_search_tokens_reserved_at", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE INDEX idx_search_tokens_reserved_at ON search_tokens (reserved_at)" + } + ] + } + ] +} \ No newline at end of file diff --git a/apps/weblibre/lib/core/filesystem.dart b/apps/weblibre/lib/core/filesystem.dart index 73908b28..e25e82fe 100644 --- a/apps/weblibre/lib/core/filesystem.dart +++ b/apps/weblibre/lib/core/filesystem.dart @@ -34,6 +34,7 @@ final filesystem = _Filesystem(); class _Filesystem { late final Directory dataDir; + late final Directory tempDir; late final Directory profilesDir; late final UuidValue selectedProfile; @@ -312,7 +313,8 @@ class _Filesystem { Future _setupSqliteCache() async { // Make sqlite3 pick a more suitable location for temporary files - the // one from the system may be inaccessible due to sandboxing. - final cachebase = (await path_provider.getTemporaryDirectory()).path; + tempDir = await path_provider.getTemporaryDirectory(); + final cachebase = tempDir.path; // We can't access /tmp on Android, which sqlite3 would try by default. // Explicitly tell it about the correct temporary directory. sqlite3.tempDirectory = cachebase; diff --git a/apps/weblibre/lib/core/providers/device_info.dart b/apps/weblibre/lib/core/providers/device_info.dart index d8707ca6..7f887eea 100644 --- a/apps/weblibre/lib/core/providers/device_info.dart +++ b/apps/weblibre/lib/core/providers/device_info.dart @@ -33,12 +33,24 @@ class AndroidDeviceInfo extends _$AndroidDeviceInfo { final deviceInfo = DeviceInfoPlugin(); final androidInfo = await deviceInfo.androidInfo; - return AndroidDeviceInfoData(sdkInt: androidInfo.version.sdkInt); + return AndroidDeviceInfoData( + sdkInt: androidInfo.version.sdkInt, + manufacturer: androidInfo.manufacturer, + model: androidInfo.model, + ); } } class AndroidDeviceInfoData { final int sdkInt; + final String manufacturer; + final String model; - const AndroidDeviceInfoData({required this.sdkInt}); + const AndroidDeviceInfoData({ + required this.sdkInt, + required this.manufacturer, + required this.model, + }); + + String get deviceName => '$manufacturer $model'; } diff --git a/apps/weblibre/lib/core/providers/device_info.g.dart b/apps/weblibre/lib/core/providers/device_info.g.dart index 74532d06..7df92ecc 100644 --- a/apps/weblibre/lib/core/providers/device_info.g.dart +++ b/apps/weblibre/lib/core/providers/device_info.g.dart @@ -33,7 +33,7 @@ final class AndroidDeviceInfoProvider AndroidDeviceInfo create() => AndroidDeviceInfo(); } -String _$androidDeviceInfoHash() => r'05c6cc63a6ee34f137aef538d65e8ab94b9cca89'; +String _$androidDeviceInfoHash() => r'54a5ceafce6ed9260b18baaec18d131f3c7d3833'; abstract class _$AndroidDeviceInfo extends $AsyncNotifier { diff --git a/apps/weblibre/lib/core/routing/routes.dart b/apps/weblibre/lib/core/routing/routes.dart index 693c7b4a..5a0d0df0 100644 --- a/apps/weblibre/lib/core/routing/routes.dart +++ b/apps/weblibre/lib/core/routing/routes.dart @@ -28,6 +28,7 @@ import 'package:weblibre/core/routing/widgets/bottom_sheet_page.dart'; import 'package:weblibre/core/routing/widgets/dialog_page.dart'; import 'package:weblibre/domain/entities/profile.dart'; import 'package:weblibre/features/about/presentation/screens/about.dart'; +import 'package:weblibre/features/account/presentation/screens/account_settings.dart'; import 'package:weblibre/features/addons/presentation/screens/addon_details.dart'; import 'package:weblibre/features/addons/presentation/screens/addon_internal_settings.dart'; import 'package:weblibre/features/addons/presentation/screens/addon_listing_details.dart'; diff --git a/apps/weblibre/lib/core/routing/routes.g.dart b/apps/weblibre/lib/core/routing/routes.g.dart index a841ee94..32079146 100644 --- a/apps/weblibre/lib/core/routing/routes.g.dart +++ b/apps/weblibre/lib/core/routing/routes.g.dart @@ -1635,6 +1635,11 @@ RouteBase get $settingsRoute => GoRouteData.$route( name: 'ErrorLogsRoute', factory: $ErrorLogsRoute._fromState, ), + GoRouteData.$route( + path: 'account', + name: 'AccountSettingsRoute', + factory: $AccountSettingsRoute._fromState, + ), GoRouteData.$route( path: 'sync', name: 'SyncSettingsRoute', @@ -2103,6 +2108,27 @@ mixin $ErrorLogsRoute on GoRouteData { void replace(BuildContext context) => context.replace(location); } +mixin $AccountSettingsRoute on GoRouteData { + static AccountSettingsRoute _fromState(GoRouterState state) => + AccountSettingsRoute(); + + @override + String get location => GoRouteData.$location('/settings/account'); + + @override + void go(BuildContext context) => context.go(location); + + @override + Future push(BuildContext context) => context.push(location); + + @override + void pushReplacement(BuildContext context) => + context.pushReplacement(location); + + @override + void replace(BuildContext context) => context.replace(location); +} + mixin $SyncSettingsRoute on GoRouteData { static SyncSettingsRoute _fromState(GoRouterState state) => SyncSettingsRoute(); diff --git a/apps/weblibre/lib/core/routing/routes.settings.dart b/apps/weblibre/lib/core/routing/routes.settings.dart index e0bcb583..05f7359a 100644 --- a/apps/weblibre/lib/core/routing/routes.settings.dart +++ b/apps/weblibre/lib/core/routing/routes.settings.dart @@ -96,6 +96,10 @@ part of 'routes.dart'; path: 'custom_tracking_protection', ), TypedGoRoute(name: 'ErrorLogsRoute', path: 'error_logs'), + TypedGoRoute( + name: 'AccountSettingsRoute', + path: 'account', + ), TypedGoRoute(name: 'SyncSettingsRoute', path: 'sync'), TypedGoRoute( name: 'UrlCleanerSettingsRoute', @@ -272,6 +276,13 @@ class CustomTrackingProtectionRoute extends GoRouteData } } +class AccountSettingsRoute extends GoRouteData with $AccountSettingsRoute { + @override + Widget build(BuildContext context, GoRouterState state) { + return const AccountSettingsScreen(); + } +} + class SyncSettingsRoute extends GoRouteData with $SyncSettingsRoute { @override Widget build(BuildContext context, GoRouterState state) { diff --git a/apps/weblibre/lib/data/database/converters/icon_data.dart b/apps/weblibre/lib/data/database/converters/icon_data.dart index 556aca08..0e23772f 100644 --- a/apps/weblibre/lib/data/database/converters/icon_data.dart +++ b/apps/weblibre/lib/data/database/converters/icon_data.dart @@ -31,8 +31,8 @@ class IconDataJsonConverter IconData fromJson(Map json) { return IconData( json['codePoint'] as int, - fontFamily: json['fontFamily'] as String, - fontPackage: json['fontPackage'] as String, + fontFamily: json['fontFamily'] as String?, + fontPackage: json['fontPackage'] as String?, ); } diff --git a/apps/weblibre/lib/data/database/functions/url_functions.dart b/apps/weblibre/lib/data/database/functions/url_functions.dart new file mode 100644 index 00000000..ed3e1891 --- /dev/null +++ b/apps/weblibre/lib/data/database/functions/url_functions.dart @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:sqlite3/common.dart'; +import 'package:weblibre/utils/url_canonical.dart'; +import 'package:xxh3/xxh3.dart'; + +/// Bytes 0x1F (Unit Separator) between hashed parts. Prevents collisions +/// where `(a, b)` and `(ab, '')` would otherwise hash to the same value. +const _hashSeparator = 0x1F; + +/// xxh3-64 over the trigger-relevant content fields. +/// +/// Inputs may be any nullable mix of TEXT columns. The hash is stable for +/// the same content + ordering, and `IS NOT` comparison on the resulting +/// INTEGER is enough to gate downstream FTS / fan-out triggers. +int? _contentHash(List args) { + if (args.every((a) => a == null)) return null; + final builder = BytesBuilder(copy: false); + for (final part in args) { + if (part is String) { + builder.add(utf8.encode(part)); + } + builder.addByte(_hashSeparator); + } + // xxh3 returns an unsigned 64-bit value, but Dart ints on 64-bit platforms + // are signed 64-bit. The bit pattern round-trips fine through SQLite as + // INTEGER; we just lose the unsigned interpretation, which doesn't matter + // for `IS NOT` comparisons. + return xxh3(builder.toBytes()); +} + +String? _urlCanonical(List args) { + final url = args.first as String?; + if (url == null) return null; + return canonicalizeUrl(url)?.canonical; +} + +String? _urlHost(List args) { + final url = args.first as String?; + if (url == null) return null; + return canonicalizeUrl(url)?.host; +} + +String? _urlPath(List args) { + final url = args.first as String?; + if (url == null) return null; + return canonicalizeUrl(url)?.path; +} + +/// Boolean-as-INTEGER (0/1) for use in trigger `WHEN` clauses. +int _urlIndexable(List args) { + final url = args.first as String?; + if (url == null) return 0; + return isUrlIndexable(url) ? 1 : 0; +} + +void registerUrlFunctions(CommonDatabase database) { + database.createFunction( + functionName: 'url_canonical', + argumentCount: const AllowedArgumentCount(1), + deterministic: true, + directOnly: false, + function: _urlCanonical, + ); + database.createFunction( + functionName: 'url_host', + argumentCount: const AllowedArgumentCount(1), + deterministic: true, + directOnly: false, + function: _urlHost, + ); + database.createFunction( + functionName: 'url_path', + argumentCount: const AllowedArgumentCount(1), + deterministic: true, + directOnly: false, + function: _urlPath, + ); + database.createFunction( + functionName: 'url_indexable', + argumentCount: const AllowedArgumentCount(1), + deterministic: true, + directOnly: false, + function: _urlIndexable, + ); + // 3-arg form covers the (title, extracted_plain, full_plain) trigger guard. + // Marked deterministic so generated columns can use it. + database.createFunction( + functionName: 'generate_content_hash', + argumentCount: const AllowedArgumentCount(3), + deterministic: true, + directOnly: false, + function: _contentHash, + ); +} diff --git a/apps/weblibre/lib/domain/entities/equatable_image.dart b/apps/weblibre/lib/domain/entities/equatable_image.dart index 14b6fd39..c025d675 100644 --- a/apps/weblibre/lib/domain/entities/equatable_image.dart +++ b/apps/weblibre/lib/domain/entities/equatable_image.dart @@ -19,31 +19,47 @@ */ import 'dart:ui'; +/// A wrapper around [Image] that compares by a precomputed byte-hash. +/// +/// Instances are byte-hash deduped via the decoder's LRU, so the same +/// `EquatableImage` may be shared across many holders (multiple tabs of +/// the same site, the icon cache, widget closures). To make that safe, +/// the underlying `ui.Image` is disposed via a [Finalizer] when this +/// wrapper becomes unreachable — never on cache eviction or state +/// transitions, where another holder might still be using it. class EquatableImage { + static final Finalizer _finalizer = Finalizer( + (image) => image.dispose(), + ); + Image? _value; final int _imageHash; bool _isDisposed = false; EquatableImage(Image value, {required int hash}) : _value = value, - _imageHash = hash; + _imageHash = hash { + _finalizer.attach(this, value, detach: this); + } - /// The underlying ui.Image. Returns null if disposed. + /// The underlying ui.Image. Returns null if explicitly disposed. Image? get value => _isDisposed ? null : _value; - /// Whether this image has been disposed. + /// Whether this image has been explicitly disposed. bool get isDisposed => _isDisposed; - /// Disposes the underlying ui.Image to free GPU memory. - /// This is safe to call multiple times. + /// Explicitly release the underlying GPU resource now. + /// + /// Optional — if not called, the resource is freed when this wrapper is + /// garbage collected. Only call this when you are certain no other holder + /// still references the image; otherwise prefer to drop your reference + /// and let GC handle it. void dispose() { if (_isDisposed) return; _isDisposed = true; - // Delay disposal to allow widgets to finish rendering - Future.delayed(const Duration(seconds: 3), () { - _value?.dispose(); - _value = null; - }); + _finalizer.detach(this); + _value?.dispose(); + _value = null; } @override diff --git a/apps/weblibre/lib/domain/services/app_initialization.dart b/apps/weblibre/lib/domain/services/app_initialization.dart index 422da451..18c611cf 100644 --- a/apps/weblibre/lib/domain/services/app_initialization.dart +++ b/apps/weblibre/lib/domain/services/app_initialization.dart @@ -23,6 +23,7 @@ import 'package:exceptions/exceptions.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:weblibre/core/providers/format.dart'; import 'package:weblibre/features/about/domain/providers.dart'; +import 'package:weblibre/features/account/domain/services/account_callback_handler.dart'; import 'package:weblibre/features/bangs/data/models/bang_group.dart'; import 'package:weblibre/features/bangs/domain/repositories/sync.dart'; @@ -78,6 +79,9 @@ class AppInitializationService extends _$AppInitializationService { result.onFailure(errors.add); } + // Activate account callback deep link handler + ref.read(accountCallbackHandlerProvider); + return (initialized: true, stage: null, errors: errors); }); diff --git a/apps/weblibre/lib/domain/services/app_initialization.g.dart b/apps/weblibre/lib/domain/services/app_initialization.g.dart index 0c417f2a..2c2535c2 100644 --- a/apps/weblibre/lib/domain/services/app_initialization.g.dart +++ b/apps/weblibre/lib/domain/services/app_initialization.g.dart @@ -54,7 +54,7 @@ final class AppInitializationServiceProvider } String _$appInitializationServiceHash() => - r'bf4c472d5b0b758d703b8ca40763cf2a59712a4c'; + r'79339776571e4bf409213cba4b4696ad2cca179e'; abstract class _$AppInitializationService extends diff --git a/apps/weblibre/lib/domain/services/favicon_resolver.dart b/apps/weblibre/lib/domain/services/favicon_resolver.dart new file mode 100644 index 00000000..8b435366 --- /dev/null +++ b/apps/weblibre/lib/domain/services/favicon_resolver.dart @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:http/io_client.dart'; +import 'package:socks5_proxy/socks_client.dart'; + +enum FaviconResolveStatus { hit, missing, error } + +class FaviconResolveResult { + final FaviconResolveStatus status; + final Uint8List? bytes; + + const FaviconResolveResult._({required this.status, this.bytes}); + + const FaviconResolveResult.hit(Uint8List bytes) + : this._(status: FaviconResolveStatus.hit, bytes: bytes); + + const FaviconResolveResult.missing() + : this._(status: FaviconResolveStatus.missing); + + const FaviconResolveResult.error() + : this._(status: FaviconResolveStatus.error); +} + +abstract class FaviconResolver { + Future resolve(Uri url, {int? proxyPort}); +} + +final class DdgFaviconResolver implements FaviconResolver { + static const _timeout = Duration(seconds: 15); + + @override + Future resolve(Uri url, {int? proxyPort}) async { + final host = url.host.trim().toLowerCase(); + if (host.isEmpty) { + return const FaviconResolveResult.error(); + } + + final httpClient = HttpClient(); + if (proxyPort != null) { + SocksTCPClient.assignToHttpClient(httpClient, [ + ProxySettings(InternetAddress.loopbackIPv4, proxyPort), + ]); + } + + final client = IOClient(httpClient); + try { + final response = await client + .get(Uri.https('icons.duckduckgo.com', '/ip2/$host.ico')) + .timeout(_timeout); + + if (response.statusCode == 200 && response.bodyBytes.isNotEmpty) { + return FaviconResolveResult.hit(response.bodyBytes); + } + + if (response.statusCode == 404) { + return const FaviconResolveResult.missing(); + } + + return const FaviconResolveResult.error(); + } on SocketException { + return const FaviconResolveResult.error(); + } on HttpException { + return const FaviconResolveResult.error(); + } on HandshakeException { + return const FaviconResolveResult.error(); + } on TlsException { + return const FaviconResolveResult.error(); + } catch (_) { + return const FaviconResolveResult.error(); + } finally { + client.close(); + } + } +} diff --git a/apps/weblibre/lib/domain/services/generic_website.dart b/apps/weblibre/lib/domain/services/generic_website.dart index a914098a..571fece7 100644 --- a/apps/weblibre/lib/domain/services/generic_website.dart +++ b/apps/weblibre/lib/domain/services/generic_website.dart @@ -23,15 +23,14 @@ import 'dart:ui'; import 'package:exceptions/exceptions.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; -import 'package:html/dom.dart'; import 'package:html/parser.dart' as html_parser; import 'package:http/io_client.dart'; -import 'package:nullability/nullability.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:socks5_proxy/socks_client.dart'; import 'package:weblibre/core/http_error_handler.dart'; import 'package:weblibre/data/models/web_page_info.dart'; +import 'package:weblibre/domain/services/favicon_resolver.dart'; import 'package:weblibre/extensions/http_encoding.dart'; import 'package:weblibre/extensions/uri.dart'; import 'package:weblibre/features/geckoview/domain/entities/browser_icon.dart'; @@ -41,184 +40,49 @@ import 'package:weblibre/utils/lru_cache.dart'; part 'generic_website.g.dart'; -const _typeMap = { - "manifest": IconType.manifestIcon, - "icon": IconType.favicon, - "shortcut icon": IconType.favicon, - "fluid-icon": IconType.fluidIcon, - "apple-touch-icon": IconType.appleTouchIcon, - "image_src": IconType.imageSrc, - "apple-touch-icon image_src": IconType.appleTouchIcon, - "apple-touch-icon-precomposed": IconType.appleTouchIcon, - "og:image": IconType.openGraph, - "og:image:url": IconType.openGraph, - "og:image:secure_url": IconType.openGraph, - "twitter:image": IconType.twitter, - "msapplication-TileImage": IconType.microsoftTile, -}; +@Riverpod(keepAlive: true) +FaviconResolver faviconResolver(Ref ref) => DdgFaviconResolver(); -final class _InFlightFetch { - final Future> future; - - const _InFlightFetch(this.future); -} - -Iterable sizesToList(String? sizes) sync* { - if (sizes != null) { - final splitted = sizes - .split(' ') - .where((size) => size.contains('x')) - .toList(); - - for (final size in splitted) { - final dimensions = size.split('x'); - if (dimensions.length == 2) { - final height = int.tryParse(dimensions[0]); - final width = int.tryParse(dimensions[1]); - - if (width != null && height != null) { - yield ResourceSize(height: height, width: width); - } - } - } - } -} +@Riverpod(keepAlive: true) +GeckoIconService geckoIconService(Ref ref) => GeckoIconService(); @Riverpod(keepAlive: true) class GenericWebsiteService extends _$GenericWebsiteService { - final GeckoIconService _iconsService; - late CacheRepository _cacheRepository; + late FaviconResolver _faviconResolver; + late GeckoIconService _iconsService; late final LRUCache _browserIconCache; - final _inFlightFetches = {}; + final _inFlightIconFetches = >{}; - GenericWebsiteService() - : _iconsService = GeckoIconService(), - _browserIconCache = LRUCache(50, onEvict: (icon) => icon.image.dispose()); + GenericWebsiteService() : _browserIconCache = LRUCache(50); @override void build() { _cacheRepository = ref.watch(cacheRepositoryProvider.notifier); - } - - static Map _serializeResource(Resource resource) { - return { - 'url': resource.url, - 'type': resource.type, - 'sizes': resource.sizes.nonNulls.map((s) => [s.height, s.width]).toList(), - 'mimeType': resource.mimeType, - 'maskable': resource.maskable, - }; - } - - static Resource _deserializeResource(Map resource) { - return Resource( - url: resource['url'] as String, - type: resource['type'] as IconType, - mimeType: resource['mimeType'] as String?, - sizes: (resource['sizes'] as List>) - .map((s) => ResourceSize(height: s[0], width: s[1])) - .toList(), - maskable: resource['maskable'] as bool, - ); - } - - static Uri _resolveRelativeUri(Uri baseUri, Uri uri) { - if (!uri.isAbsolute) { - return baseUri.resolveUri(uri); - } - return uri; + _faviconResolver = ref.watch(faviconResolverProvider); + _iconsService = ref.watch(geckoIconServiceProvider); } static bool _isHttpUrl(Uri url) => url.isHttpOrHttps; - static List _extractIcons(Uri baseUrl, Document document) { - final List icons = []; - - void collectLinkIcons(String rel) { - final links = document.querySelectorAll('link[rel="$rel"]'); - for (final link in links) { - final href = link.attributes['href']; - final type = _typeMap[rel]; - final mimeType = link.attributes['type']; - if (href != null && type != null) { - if (Uri.tryParse(href) case final Uri url) { - icons.add( - Resource( - url: _resolveRelativeUri(baseUrl, url).toString(), - type: type, - sizes: sizesToList(link.attributes['sizes']).toList(), - mimeType: mimeType.isNotEmpty ? mimeType : null, - maskable: false, - ), - ); - } - } - } + static bool _isResolverEligible(Uri url) { + if (!_isHttpUrl(url) || url.host.isEmpty || url.isLocalhost) { + return false; } - void collectMetaPropertyIcons(String property) { - final metas = document.querySelectorAll('meta[property="$property"]'); - for (final meta in metas) { - final content = meta.attributes['content']; - final type = _typeMap[property]; - if (content != null && type != null) { - if (Uri.tryParse(content) case final Uri url) { - icons.add( - Resource( - type: type, - url: _resolveRelativeUri(baseUrl, url).toString(), - sizes: [], - maskable: false, - ), - ); - } - } - } + final host = url.host.toLowerCase(); + if (host.endsWith('.onion')) { + return false; } - void collectMetaNameIcons(String name) { - final metas = document.querySelectorAll('meta[name="$name"]'); - for (final meta in metas) { - final content = meta.attributes['content']; - final type = _typeMap[name]; - if (content != null && type != null) { - if (Uri.tryParse(content) case final Uri url) { - icons.add( - Resource( - type: type, - url: _resolveRelativeUri(baseUrl, url).toString(), - sizes: [], - maskable: false, - ), - ); - } - } - } - } - - collectLinkIcons("icon"); - collectLinkIcons("shortcut icon"); - collectLinkIcons("fluid-icon"); - collectLinkIcons("apple-touch-icon"); - collectLinkIcons("image_src"); - collectLinkIcons("apple-touch-icon image_src"); - collectLinkIcons("apple-touch-icon-precomposed"); - - collectMetaPropertyIcons("og:image"); - collectMetaPropertyIcons("og:image:url"); - collectMetaPropertyIcons("og:image:secure_url"); - - collectMetaNameIcons("twitter:image"); - collectMetaNameIcons("msapplication-TileImage"); - - return icons; + return InternetAddress.tryParse(host) == null; } Future> fetchPageInfo({ required Uri url, required bool isImageRequest, required int? proxyPort, + bool forceRefresh = false, }) { return Result.fromAsync(() async { final result = await compute((args) async { @@ -251,7 +115,6 @@ class GenericWebsiteService extends _$GenericWebsiteService { final document = html_parser.parse(response.bodyUnicodeFallback); final title = document.querySelector('title')?.text; - final resources = _extractIcons(baseUri, document); final feeds = await FeedFinder( url: baseUri, document: document, @@ -259,7 +122,6 @@ class GenericWebsiteService extends _$GenericWebsiteService { return { 'title': title, - 'resources': resources.map(_serializeResource).toList(), 'feeds': feeds.map((uri) => uri.toString()).toList(), }; } finally { @@ -268,28 +130,21 @@ class GenericWebsiteService extends _$GenericWebsiteService { }, [url.toString(), isImageRequest, proxyPort]); if (result['imageBytes'] case final Uint8List imageBytes) { - return WebPageInfo( - url: url, - favicon: await BrowserIcon.fromBytes( - imageBytes, - dominantColor: null, - source: IconSource.download, - ), + final favicon = await BrowserIcon.fromBytes( + imageBytes, + dominantColor: null, + source: IconSource.download, ); + if (favicon != null) { + await _cacheRepository.cacheIcon(url, imageBytes); + _browserIconCache.set(url.origin, favicon); + } + return WebPageInfo(url: url, favicon: favicon); } - final resources = (result['resources']! as List>) - .map(_deserializeResource) - .toList(); - - final favicon = - await getCachedIcon(url) ?? - await loadIcon(url: url, resources: resources); - return WebPageInfo( url: url, title: (result['title'] as String?)?.trim(), - favicon: favicon, feeds: Set.from( (result['feeds']! as List).map((url) => Uri.tryParse(url)), ), @@ -300,7 +155,9 @@ class GenericWebsiteService extends _$GenericWebsiteService { Future getCachedIcon(Uri url) async { if (_isHttpUrl(url)) { final cachedBrowserIcon = _browserIconCache.get(url.origin); - if (cachedBrowserIcon?.image.value != null) { + if (cachedBrowserIcon?.source == IconSource.generator) { + _browserIconCache.remove(url.origin); + } else if (cachedBrowserIcon?.image.value != null) { return cachedBrowserIcon; } else if (cachedBrowserIcon != null) { _browserIconCache.remove(url.origin); @@ -308,100 +165,192 @@ class GenericWebsiteService extends _$GenericWebsiteService { final cachedIcon = await _cacheRepository.getCachedIcon(url.origin); if (cachedIcon != null) { - return _browserIconCache.set( - url.origin, - await BrowserIcon.fromBytes( - cachedIcon, - dominantColor: null, - source: IconSource.disk, - ), + final decoded = await BrowserIcon.fromBytes( + cachedIcon, + dominantColor: null, + source: IconSource.disk, ); - } - } - - return null; - } - - Future loadIcon({ - required Uri url, - required List resources, - bool isPrivate = false, - bool waitOnNetworkLoad = true, - }) async { - final result = await _iconsService.loadIcon( - url: url, - resources: resources, - isPrivate: isPrivate, - waitOnNetworkLoad: waitOnNetworkLoad, - ); - - if (result.source != IconSource.generator && - result.source != IconSource.memory) { - await _cacheRepository.cacheIcon(url, result.image); - } - - return _browserIconCache.set( - url.origin, - await BrowserIcon.fromBytes( - result.image, - dominantColor: result.color.mapNotNull((color) => Color(color)), - source: result.source, - ), - ); - } - - Future> _deduplicatedFetchPageInfo(Uri url) { - final existing = _inFlightFetches[url]; - if (existing != null) { - return existing.future; - } - - late final Future> inFlightFetch; - inFlightFetch = fetchPageInfo(url: url, isImageRequest: true, proxyPort: null) - .timeout( - const Duration(seconds: 20), - onTimeout: () => Result.failure( - const ErrorMessage(source: 'icon', message: 'Icon fetch timeout'), - ), - ) - .whenComplete(() { - // Only clear this entry if it is still the active in-flight request. - if (identical(_inFlightFetches[url]?.future, inFlightFetch)) { - _inFlightFetches.remove(url); - } - }); - - _inFlightFetches[url] = _InFlightFetch(inFlightFetch); - return inFlightFetch; - } - - Future getUrlIcon(List urlList) async { - for (final url in urlList) { - if (!_isHttpUrl(url)) { - continue; - } - - final cachedIcon = await getCachedIcon(url); - - if (cachedIcon != null) { - return cachedIcon; - } - - if (ref.mounted) { - final result = await _deduplicatedFetchPageInfo(url); - - if (result.isSuccess) { - if (result.value.favicon case final BrowserIcon favicon) { - if (!_browserIconCache.contains(url.origin)) { - _browserIconCache.set(url.origin, favicon); - } - - return favicon; - } + if (decoded != null) { + return _browserIconCache.set(url.origin, decoded); } } } return null; } + + Future _resolveIconWithDdg( + Uri url, { + required bool cacheMissing, + }) { + final origin = url.origin; + final existing = _inFlightIconFetches[origin]; + if (existing != null) { + return existing; + } + + late final Future inFlight; + inFlight = _fetchAndCacheDdgIcon(url, cacheMissing: cacheMissing) + .whenComplete(() { + if (identical(_inFlightIconFetches[origin], inFlight)) { + _inFlightIconFetches.remove(origin); + } + }); + + _inFlightIconFetches[origin] = inFlight; + return inFlight; + } + + Future primeCachedIcon(Uri url, Uint8List bytes) async { + await _cacheRepository.cacheIconIfAbsent(url, bytes); + } + + static const _iconStaleTtl = Duration(days: 30); + static const _missingIconStaleTtl = Duration(days: 7); + + Future refreshIconIfStale(Uri url) async { + if (!_isHttpUrl(url)) return; + if (_inFlightIconFetches.containsKey(url.origin)) return; + + final rawIcon = await _cacheRepository.getCachedIconRaw(url.origin); + if (rawIcon == null) return; + + final fetchedAt = await _cacheRepository.getCachedIconFetchDate(url.origin); + if (fetchedAt == null) return; + + final ttl = _cacheRepository.isMissingIconBytes(rawIcon) + ? _missingIconStaleTtl + : _iconStaleTtl; + if (DateTime.now().difference(fetchedAt) < ttl) return; + if (!_isResolverEligible(url)) return; + + if (!ref.mounted) return; + await _resolveIconWithDdg( + url, + cacheMissing: _cacheRepository.isMissingIconBytes(rawIcon), + ); + } + + Future getUrlIcon( + List urlList, { + bool cacheOnly = false, + }) async { + final eligibleUrls = urlList.where(_isHttpUrl).toList(); + if (eligibleUrls.isEmpty) { + return null; + } + + for (final url in eligibleUrls) { + final cachedIcon = await getCachedIcon(url); + if (cachedIcon != null) { + return cachedIcon; + } + } + + if (cacheOnly) { + return _loadIconWithoutNetwork(eligibleUrls.first); + } + + for (final url in eligibleUrls) { + if (!_isResolverEligible(url)) { + continue; + } + + if (await _hasFreshMissingIcon(url)) { + continue; + } + + if (ref.mounted) { + final resolved = await _resolveIconWithDdg(url, cacheMissing: true); + if (resolved != null) { + return resolved; + } + } + } + + BrowserIcon? generatedFallback; + for (final url in eligibleUrls) { + final icon = await _loadIconWithoutNetwork(url); + if (icon == null) { + continue; + } + if (icon.source != IconSource.generator) { + return icon; + } + generatedFallback ??= icon; + } + + return generatedFallback; + } + + Future _fetchAndCacheDdgIcon( + Uri url, { + required bool cacheMissing, + }) async { + final result = await _faviconResolver.resolve(url); + switch (result.status) { + case FaviconResolveStatus.hit: + final bytes = result.bytes!; + final decoded = await BrowserIcon.fromBytes( + bytes, + dominantColor: null, + source: IconSource.download, + ); + if (decoded == null) { + return null; + } + + await _cacheRepository.cacheIcon(url, bytes); + return _browserIconCache.set(url.origin, decoded); + case FaviconResolveStatus.missing: + if (cacheMissing) { + await _cacheRepository.cacheMissingIcon(url); + } + return null; + case FaviconResolveStatus.error: + return null; + } + } + + Future _hasFreshMissingIcon(Uri url) async { + final rawIcon = await _cacheRepository.getCachedIconRaw(url.origin); + if (!_cacheRepository.isMissingIconBytes(rawIcon)) { + return false; + } + + final fetchedAt = await _cacheRepository.getCachedIconFetchDate(url.origin); + if (fetchedAt == null) { + return false; + } + + return DateTime.now().difference(fetchedAt) < _missingIconStaleTtl; + } + + Future _loadIconWithoutNetwork(Uri url) async { + final result = await _iconsService.loadIcon( + url: url, + waitOnNetworkLoad: false, + ); + + final decoded = await BrowserIcon.fromBytes( + result.image, + dominantColor: result.color == null ? null : Color(result.color!), + source: result.source, + ); + if (decoded == null) { + return null; + } + + if (result.source == IconSource.generator) { + _browserIconCache.remove(url.origin); + return decoded; + } + + if (result.source != IconSource.generator && + result.source != IconSource.memory) { + await _cacheRepository.cacheIcon(url, result.image); + } + + return _browserIconCache.set(url.origin, decoded); + } } diff --git a/apps/weblibre/lib/domain/services/generic_website.g.dart b/apps/weblibre/lib/domain/services/generic_website.g.dart index eebdc04e..e7e35286 100644 --- a/apps/weblibre/lib/domain/services/generic_website.g.dart +++ b/apps/weblibre/lib/domain/services/generic_website.g.dart @@ -9,6 +9,94 @@ part of 'generic_website.dart'; // GENERATED CODE - DO NOT MODIFY BY HAND // ignore_for_file: type=lint, type=warning +@ProviderFor(faviconResolver) +final faviconResolverProvider = FaviconResolverProvider._(); + +final class FaviconResolverProvider + extends + $FunctionalProvider + with $Provider { + FaviconResolverProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'faviconResolverProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$faviconResolverHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + FaviconResolver create(Ref ref) { + return faviconResolver(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(FaviconResolver value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$faviconResolverHash() => r'3935f8cb31a4cee625de343597e4a486e169f4f9'; + +@ProviderFor(geckoIconService) +final geckoIconServiceProvider = GeckoIconServiceProvider._(); + +final class GeckoIconServiceProvider + extends + $FunctionalProvider< + GeckoIconService, + GeckoIconService, + GeckoIconService + > + with $Provider { + GeckoIconServiceProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'geckoIconServiceProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$geckoIconServiceHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + GeckoIconService create(Ref ref) { + return geckoIconService(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(GeckoIconService value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$geckoIconServiceHash() => r'e7d2862992c6dfaa031499de06c2d6af04e30fba'; + @ProviderFor(GenericWebsiteService) final genericWebsiteServiceProvider = GenericWebsiteServiceProvider._(); @@ -42,7 +130,7 @@ final class GenericWebsiteServiceProvider } String _$genericWebsiteServiceHash() => - r'a2bd892f0ca07467eaa906648e57f232a7e50155'; + r'83c62a15492606a7ffe4a7990fc1f9bdbfee87cc'; abstract class _$GenericWebsiteService extends $Notifier { void build(); diff --git a/apps/weblibre/lib/features/account/data/account_secure_store.dart b/apps/weblibre/lib/features/account/data/account_secure_store.dart new file mode 100644 index 00000000..5dcbf683 --- /dev/null +++ b/apps/weblibre/lib/features/account/data/account_secure_store.dart @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'dart:convert'; + +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/features/account/data/models/account_persisted_data.dart'; + +part 'account_secure_store.g.dart'; + +/// Encapsulates persistence of [AccountPersistedData] in +/// `flutter_secure_storage`. Kept as a thin wrapper so the auth repository +/// stays focused on state-machine logic and can be exercised with a fake +/// store in tests. +/// +/// Storage holds the Supabase refresh token, the end-to-end sync key, and +/// the in-flight PKCE verifier — all sensitive. We rely on the default +/// Android backend, which (as of flutter_secure_storage v10) uses a custom +/// AES-GCM cipher with a Keystore-wrapped key per-app; the older +/// `encryptedSharedPreferences` option was deprecated when Google +/// deprecated the Jetpack Security library, with automatic migration on +/// first read. +class AccountSecureStore { + static const _storageKey = 'account_auth_data'; + + final FlutterSecureStorage _storage; + + AccountSecureStore({FlutterSecureStorage? storage}) + : _storage = storage ?? const FlutterSecureStorage(); + + Future read() async { + final json = await _storage.read(key: _storageKey); + if (json == null) return AccountPersistedData(); + return AccountPersistedData.fromJson( + jsonDecode(json) as Map, + ); + } + + Future write(AccountPersistedData data) { + return _storage.write(key: _storageKey, value: jsonEncode(data.toJson())); + } + + Future clear() { + return _storage.delete(key: _storageKey); + } +} + +@Riverpod(keepAlive: true) +AccountSecureStore accountSecureStore(Ref ref) => AccountSecureStore(); diff --git a/apps/weblibre/lib/features/account/data/account_secure_store.g.dart b/apps/weblibre/lib/features/account/data/account_secure_store.g.dart new file mode 100644 index 00000000..613a91ab --- /dev/null +++ b/apps/weblibre/lib/features/account/data/account_secure_store.g.dart @@ -0,0 +1,58 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'account_secure_store.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(accountSecureStore) +final accountSecureStoreProvider = AccountSecureStoreProvider._(); + +final class AccountSecureStoreProvider + extends + $FunctionalProvider< + AccountSecureStore, + AccountSecureStore, + AccountSecureStore + > + with $Provider { + AccountSecureStoreProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'accountSecureStoreProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$accountSecureStoreHash(); + + @$internal + @override + $ProviderElement $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + AccountSecureStore create(Ref ref) { + return accountSecureStore(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(AccountSecureStore value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$accountSecureStoreHash() => + r'12a2383a575fb8a07a6fc1e8ac4a477c66db7009'; diff --git a/apps/weblibre/lib/features/account/data/models/account_auth_state.dart b/apps/weblibre/lib/features/account/data/models/account_auth_state.dart new file mode 100644 index 00000000..0767b422 --- /dev/null +++ b/apps/weblibre/lib/features/account/data/models/account_auth_state.dart @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'package:copy_with_extension/copy_with_extension.dart'; +import 'package:fast_equatable/fast_equatable.dart'; +import 'package:supabase/supabase.dart'; + +part 'account_auth_state.g.dart'; + +enum AccountAuthStatus { signedOut, signingIn, signedIn, error } + +@CopyWith() +class AccountAuthState with FastEquatable { + final AccountAuthStatus status; + final String? email; + final String? displayName; + final String? userId; + final String? lastError; + final String? syncKey; + // The SupabaseClient is intentionally excluded from equality. Two auth + // states for the same user but with distinct client instances are still + // semantically equal — including identityHashCode here would defeat + // Riverpod's caching by treating every reissued state as different. + // ignore: missing_field_in_equatable_props + final SupabaseClient? client; + + AccountAuthState({ + this.status = AccountAuthStatus.signedOut, + this.email, + this.displayName, + this.userId, + this.lastError, + this.syncKey, + this.client, + }); + + bool get isSignedIn => status == AccountAuthStatus.signedIn; + bool get isSignedOut => status == AccountAuthStatus.signedOut; + bool get isSigningIn => status == AccountAuthStatus.signingIn; + bool get hasError => status == AccountAuthStatus.error; + bool get hasSyncKey => syncKey != null; + + @override + List get hashParameters => [ + status, + email, + displayName, + userId, + lastError, + syncKey, + ]; +} diff --git a/apps/weblibre/lib/features/account/data/models/account_auth_state.g.dart b/apps/weblibre/lib/features/account/data/models/account_auth_state.g.dart new file mode 100644 index 00000000..d0b12f96 --- /dev/null +++ b/apps/weblibre/lib/features/account/data/models/account_auth_state.g.dart @@ -0,0 +1,126 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'account_auth_state.dart'; + +// ************************************************************************** +// CopyWithGenerator +// ************************************************************************** + +abstract class _$AccountAuthStateCWProxy { + AccountAuthState status(AccountAuthStatus status); + + AccountAuthState email(String? email); + + AccountAuthState displayName(String? displayName); + + AccountAuthState userId(String? userId); + + AccountAuthState lastError(String? lastError); + + AccountAuthState syncKey(String? syncKey); + + AccountAuthState client(SupabaseClient? client); + + /// Creates a new instance with the provided field values. + /// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `AccountAuthState(...).copyWith.fieldName(value)`. + /// + /// Example: + /// ```dart + /// AccountAuthState(...).copyWith(id: 12, name: "My name") + /// ``` + AccountAuthState call({ + AccountAuthStatus status, + String? email, + String? displayName, + String? userId, + String? lastError, + String? syncKey, + SupabaseClient? client, + }); +} + +/// Callable proxy for `copyWith` functionality. +/// Use as `instanceOfAccountAuthState.copyWith(...)` or call `instanceOfAccountAuthState.copyWith.fieldName(value)` for a single field. +class _$AccountAuthStateCWProxyImpl implements _$AccountAuthStateCWProxy { + const _$AccountAuthStateCWProxyImpl(this._value); + + final AccountAuthState _value; + + @override + AccountAuthState status(AccountAuthStatus status) => call(status: status); + + @override + AccountAuthState email(String? email) => call(email: email); + + @override + AccountAuthState displayName(String? displayName) => + call(displayName: displayName); + + @override + AccountAuthState userId(String? userId) => call(userId: userId); + + @override + AccountAuthState lastError(String? lastError) => call(lastError: lastError); + + @override + AccountAuthState syncKey(String? syncKey) => call(syncKey: syncKey); + + @override + AccountAuthState client(SupabaseClient? client) => call(client: client); + + @override + /// Creates a new instance with the provided field values. + /// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `AccountAuthState(...).copyWith.fieldName(value)`. + /// + /// Example: + /// ```dart + /// AccountAuthState(...).copyWith(id: 12, name: "My name") + /// ``` + AccountAuthState call({ + Object? status = const $CopyWithPlaceholder(), + Object? email = const $CopyWithPlaceholder(), + Object? displayName = const $CopyWithPlaceholder(), + Object? userId = const $CopyWithPlaceholder(), + Object? lastError = const $CopyWithPlaceholder(), + Object? syncKey = const $CopyWithPlaceholder(), + Object? client = const $CopyWithPlaceholder(), + }) { + return AccountAuthState( + status: status == const $CopyWithPlaceholder() || status == null + ? _value.status + // ignore: cast_nullable_to_non_nullable + : status as AccountAuthStatus, + email: email == const $CopyWithPlaceholder() + ? _value.email + // ignore: cast_nullable_to_non_nullable + : email as String?, + displayName: displayName == const $CopyWithPlaceholder() + ? _value.displayName + // ignore: cast_nullable_to_non_nullable + : displayName as String?, + userId: userId == const $CopyWithPlaceholder() + ? _value.userId + // ignore: cast_nullable_to_non_nullable + : userId as String?, + lastError: lastError == const $CopyWithPlaceholder() + ? _value.lastError + // ignore: cast_nullable_to_non_nullable + : lastError as String?, + syncKey: syncKey == const $CopyWithPlaceholder() + ? _value.syncKey + // ignore: cast_nullable_to_non_nullable + : syncKey as String?, + client: client == const $CopyWithPlaceholder() + ? _value.client + // ignore: cast_nullable_to_non_nullable + : client as SupabaseClient?, + ); + } +} + +extension $AccountAuthStateCopyWith on AccountAuthState { + /// Returns a callable class used to build a new instance with modified fields. + /// Example: `instanceOfAccountAuthState.copyWith(...)` or `instanceOfAccountAuthState.copyWith.fieldName(...)`. + // ignore: library_private_types_in_public_api + _$AccountAuthStateCWProxy get copyWith => _$AccountAuthStateCWProxyImpl(this); +} diff --git a/apps/weblibre/lib/features/account/data/models/account_persisted_data.dart b/apps/weblibre/lib/features/account/data/models/account_persisted_data.dart new file mode 100644 index 00000000..d7942055 --- /dev/null +++ b/apps/weblibre/lib/features/account/data/models/account_persisted_data.dart @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'package:copy_with_extension/copy_with_extension.dart'; +import 'package:fast_equatable/fast_equatable.dart'; +import 'package:json_annotation/json_annotation.dart'; +import 'package:weblibre/features/account/data/models/persisted_session.dart'; + +part 'account_persisted_data.g.dart'; + +@CopyWith() +@JsonSerializable() +class AccountPersistedData with FastEquatable { + final PersistedSession? session; + final String? userId; + final String? email; + final String? displayName; + final String? pendingCodeVerifier; + final String? syncKey; + + AccountPersistedData({ + this.session, + this.userId, + this.email, + this.displayName, + this.pendingCodeVerifier, + this.syncKey, + }); + + factory AccountPersistedData.fromJson(Map json) => + _$AccountPersistedDataFromJson(json); + + Map toJson() => _$AccountPersistedDataToJson(this); + + @override + List get hashParameters => [ + session, + userId, + email, + displayName, + pendingCodeVerifier, + syncKey, + ]; +} diff --git a/apps/weblibre/lib/features/account/data/models/account_persisted_data.g.dart b/apps/weblibre/lib/features/account/data/models/account_persisted_data.g.dart new file mode 100644 index 00000000..48a54d23 --- /dev/null +++ b/apps/weblibre/lib/features/account/data/models/account_persisted_data.g.dart @@ -0,0 +1,147 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'account_persisted_data.dart'; + +// ************************************************************************** +// CopyWithGenerator +// ************************************************************************** + +abstract class _$AccountPersistedDataCWProxy { + AccountPersistedData session(PersistedSession? session); + + AccountPersistedData userId(String? userId); + + AccountPersistedData email(String? email); + + AccountPersistedData displayName(String? displayName); + + AccountPersistedData pendingCodeVerifier(String? pendingCodeVerifier); + + AccountPersistedData syncKey(String? syncKey); + + /// Creates a new instance with the provided field values. + /// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `AccountPersistedData(...).copyWith.fieldName(value)`. + /// + /// Example: + /// ```dart + /// AccountPersistedData(...).copyWith(id: 12, name: "My name") + /// ``` + AccountPersistedData call({ + PersistedSession? session, + String? userId, + String? email, + String? displayName, + String? pendingCodeVerifier, + String? syncKey, + }); +} + +/// Callable proxy for `copyWith` functionality. +/// Use as `instanceOfAccountPersistedData.copyWith(...)` or call `instanceOfAccountPersistedData.copyWith.fieldName(value)` for a single field. +class _$AccountPersistedDataCWProxyImpl + implements _$AccountPersistedDataCWProxy { + const _$AccountPersistedDataCWProxyImpl(this._value); + + final AccountPersistedData _value; + + @override + AccountPersistedData session(PersistedSession? session) => + call(session: session); + + @override + AccountPersistedData userId(String? userId) => call(userId: userId); + + @override + AccountPersistedData email(String? email) => call(email: email); + + @override + AccountPersistedData displayName(String? displayName) => + call(displayName: displayName); + + @override + AccountPersistedData pendingCodeVerifier(String? pendingCodeVerifier) => + call(pendingCodeVerifier: pendingCodeVerifier); + + @override + AccountPersistedData syncKey(String? syncKey) => call(syncKey: syncKey); + + @override + /// Creates a new instance with the provided field values. + /// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `AccountPersistedData(...).copyWith.fieldName(value)`. + /// + /// Example: + /// ```dart + /// AccountPersistedData(...).copyWith(id: 12, name: "My name") + /// ``` + AccountPersistedData call({ + Object? session = const $CopyWithPlaceholder(), + Object? userId = const $CopyWithPlaceholder(), + Object? email = const $CopyWithPlaceholder(), + Object? displayName = const $CopyWithPlaceholder(), + Object? pendingCodeVerifier = const $CopyWithPlaceholder(), + Object? syncKey = const $CopyWithPlaceholder(), + }) { + return AccountPersistedData( + session: session == const $CopyWithPlaceholder() + ? _value.session + // ignore: cast_nullable_to_non_nullable + : session as PersistedSession?, + userId: userId == const $CopyWithPlaceholder() + ? _value.userId + // ignore: cast_nullable_to_non_nullable + : userId as String?, + email: email == const $CopyWithPlaceholder() + ? _value.email + // ignore: cast_nullable_to_non_nullable + : email as String?, + displayName: displayName == const $CopyWithPlaceholder() + ? _value.displayName + // ignore: cast_nullable_to_non_nullable + : displayName as String?, + pendingCodeVerifier: pendingCodeVerifier == const $CopyWithPlaceholder() + ? _value.pendingCodeVerifier + // ignore: cast_nullable_to_non_nullable + : pendingCodeVerifier as String?, + syncKey: syncKey == const $CopyWithPlaceholder() + ? _value.syncKey + // ignore: cast_nullable_to_non_nullable + : syncKey as String?, + ); + } +} + +extension $AccountPersistedDataCopyWith on AccountPersistedData { + /// Returns a callable class used to build a new instance with modified fields. + /// Example: `instanceOfAccountPersistedData.copyWith(...)` or `instanceOfAccountPersistedData.copyWith.fieldName(...)`. + // ignore: library_private_types_in_public_api + _$AccountPersistedDataCWProxy get copyWith => + _$AccountPersistedDataCWProxyImpl(this); +} + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +AccountPersistedData _$AccountPersistedDataFromJson( + Map json, +) => AccountPersistedData( + session: json['session'] == null + ? null + : PersistedSession.fromJson(json['session'] as Map), + userId: json['userId'] as String?, + email: json['email'] as String?, + displayName: json['displayName'] as String?, + pendingCodeVerifier: json['pendingCodeVerifier'] as String?, + syncKey: json['syncKey'] as String?, +); + +Map _$AccountPersistedDataToJson( + AccountPersistedData instance, +) => { + 'session': instance.session?.toJson(), + 'userId': instance.userId, + 'email': instance.email, + 'displayName': instance.displayName, + 'pendingCodeVerifier': instance.pendingCodeVerifier, + 'syncKey': instance.syncKey, +}; diff --git a/apps/weblibre/lib/features/account/data/models/persisted_session.dart b/apps/weblibre/lib/features/account/data/models/persisted_session.dart new file mode 100644 index 00000000..2001cb65 --- /dev/null +++ b/apps/weblibre/lib/features/account/data/models/persisted_session.dart @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'package:fast_equatable/fast_equatable.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part 'persisted_session.g.dart'; + +@JsonSerializable() +class PersistedSession with FastEquatable { + @JsonKey(name: 'access_token') + final String accessToken; + + @JsonKey(name: 'refresh_token') + final String refreshToken; + + @JsonKey(name: 'token_type') + final String tokenType; + + @JsonKey(name: 'expires_in') + final int expiresIn; + + PersistedSession({ + required this.accessToken, + required this.refreshToken, + required this.tokenType, + required this.expiresIn, + }); + + factory PersistedSession.fromJson(Map json) => + _$PersistedSessionFromJson(json); + + Map toJson() => _$PersistedSessionToJson(this); + + @override + List get hashParameters => [ + accessToken, + refreshToken, + tokenType, + expiresIn, + ]; +} diff --git a/apps/weblibre/lib/features/account/data/models/persisted_session.g.dart b/apps/weblibre/lib/features/account/data/models/persisted_session.g.dart new file mode 100644 index 00000000..cd345fb1 --- /dev/null +++ b/apps/weblibre/lib/features/account/data/models/persisted_session.g.dart @@ -0,0 +1,23 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'persisted_session.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +PersistedSession _$PersistedSessionFromJson(Map json) => + PersistedSession( + accessToken: json['access_token'] as String, + refreshToken: json['refresh_token'] as String, + tokenType: json['token_type'] as String, + expiresIn: (json['expires_in'] as num).toInt(), + ); + +Map _$PersistedSessionToJson(PersistedSession instance) => + { + 'access_token': instance.accessToken, + 'refresh_token': instance.refreshToken, + 'token_type': instance.tokenType, + 'expires_in': instance.expiresIn, + }; diff --git a/apps/weblibre/lib/features/account/data/models/settings_sync_envelope.dart b/apps/weblibre/lib/features/account/data/models/settings_sync_envelope.dart new file mode 100644 index 00000000..bf73370f --- /dev/null +++ b/apps/weblibre/lib/features/account/data/models/settings_sync_envelope.dart @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'package:json_annotation/json_annotation.dart'; +import 'package:weblibre/features/user/data/models/engine_settings.dart'; +import 'package:weblibre/features/user/data/models/general_settings.dart'; +import 'package:weblibre/features/user/data/models/tor_settings.dart'; + +part 'settings_sync_envelope.g.dart'; + +@JsonSerializable() +class SettingsSyncPayload { + final GeneralSettings? general; + final EngineSettings? engine; + final TorSettings? tor; + + SettingsSyncPayload({this.general, this.engine, this.tor}); + + factory SettingsSyncPayload.fromJson(Map json) => + _$SettingsSyncPayloadFromJson(json); + + Map toJson() => _$SettingsSyncPayloadToJson(this); +} + +@JsonSerializable() +class SettingsSyncEnvelope { + @JsonKey(name: 'schema_version') + final int schemaVersion; + + @JsonKey(name: 'exported_at') + final String exportedAt; + + final SettingsSyncPayload payload; + + SettingsSyncEnvelope({ + required this.schemaVersion, + required this.exportedAt, + required this.payload, + }); + + factory SettingsSyncEnvelope.fromJson(Map json) => + _$SettingsSyncEnvelopeFromJson(json); + + Map toJson() => _$SettingsSyncEnvelopeToJson(this); +} diff --git a/apps/weblibre/lib/features/account/data/models/settings_sync_envelope.g.dart b/apps/weblibre/lib/features/account/data/models/settings_sync_envelope.g.dart new file mode 100644 index 00000000..a951999a --- /dev/null +++ b/apps/weblibre/lib/features/account/data/models/settings_sync_envelope.g.dart @@ -0,0 +1,46 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'settings_sync_envelope.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +SettingsSyncPayload _$SettingsSyncPayloadFromJson(Map json) => + SettingsSyncPayload( + general: json['general'] == null + ? null + : GeneralSettings.fromJson(json['general'] as Map), + engine: json['engine'] == null + ? null + : EngineSettings.fromJson(json['engine'] as Map), + tor: json['tor'] == null + ? null + : TorSettings.fromJson(json['tor'] as Map), + ); + +Map _$SettingsSyncPayloadToJson( + SettingsSyncPayload instance, +) => { + 'general': instance.general?.toJson(), + 'engine': instance.engine?.toJson(), + 'tor': instance.tor?.toJson(), +}; + +SettingsSyncEnvelope _$SettingsSyncEnvelopeFromJson( + Map json, +) => SettingsSyncEnvelope( + schemaVersion: (json['schema_version'] as num).toInt(), + exportedAt: json['exported_at'] as String, + payload: SettingsSyncPayload.fromJson( + json['payload'] as Map, + ), +); + +Map _$SettingsSyncEnvelopeToJson( + SettingsSyncEnvelope instance, +) => { + 'schema_version': instance.schemaVersion, + 'exported_at': instance.exportedAt, + 'payload': instance.payload.toJson(), +}; diff --git a/apps/weblibre/lib/features/account/data/models/subscription_status.dart b/apps/weblibre/lib/features/account/data/models/subscription_status.dart new file mode 100644 index 00000000..20e0dee8 --- /dev/null +++ b/apps/weblibre/lib/features/account/data/models/subscription_status.dart @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'package:fast_equatable/fast_equatable.dart'; + +class SubscriptionStatus with FastEquatable { + final bool hasActiveSubscription; + final DateTime? entitledUntil; + final String status; + final String? subscriptionStatus; + final bool cancelAtPeriodEnd; + final DateTime? currentPeriodEnd; + final String? planLabel; + + SubscriptionStatus({ + required this.hasActiveSubscription, + this.entitledUntil, + required this.status, + this.subscriptionStatus, + required this.cancelAtPeriodEnd, + this.currentPeriodEnd, + this.planLabel, + }); + + factory SubscriptionStatus.fromJson(Map json) { + return SubscriptionStatus( + hasActiveSubscription: json['has_active_subscription'] as bool? ?? false, + entitledUntil: json['entitled_until'] != null + ? DateTime.parse(json['entitled_until'] as String) + : null, + status: json['status'] as String? ?? 'inactive', + subscriptionStatus: json['subscription_status'] as String?, + cancelAtPeriodEnd: json['cancel_at_period_end'] as bool? ?? false, + currentPeriodEnd: json['current_period_end'] != null + ? DateTime.parse(json['current_period_end'] as String) + : null, + planLabel: json['plan_label'] as String?, + ); + } + + bool get isActive => hasActiveSubscription; + bool get isPaused => subscriptionStatus == 'paused'; + bool get isPastDue => subscriptionStatus == 'past_due'; + + /// Subscription is on its way out: either explicitly scheduled to end at + /// period end, already in `canceled` (grace period), or + /// `scheduled_cancel`. The user can still resume from the portal until + /// the period ends. + bool get isWindingDown => + cancelAtPeriodEnd || + subscriptionStatus == 'canceled' || + subscriptionStatus == 'scheduled_cancel'; + + /// Whether the backend reports any underlying subscription state at all. + /// When false the user has never had a sub (or it was wiped) and the UI + /// should offer "Subscribe" rather than a state-specific message. + bool get hasSubscriptionRecord => subscriptionStatus != null; + + // Not `const` because `FastEquatable` carries a cached-hash field that + // disallows const construction. Value-equality from the mixin means + // `SubscriptionStatus.inactive == SubscriptionStatus(...same fields...)` + // still holds, which is what Riverpod's `select` dedupe cares about. + static final inactive = SubscriptionStatus( + hasActiveSubscription: false, + status: 'inactive', + cancelAtPeriodEnd: false, + ); + + @override + List get hashParameters => [ + hasActiveSubscription, + entitledUntil, + status, + subscriptionStatus, + cancelAtPeriodEnd, + currentPeriodEnd, + planLabel, + ]; +} diff --git a/apps/weblibre/lib/features/account/data/repositories/account_sync_repository.dart b/apps/weblibre/lib/features/account/data/repositories/account_sync_repository.dart new file mode 100644 index 00000000..e9692c17 --- /dev/null +++ b/apps/weblibre/lib/features/account/data/repositories/account_sync_repository.dart @@ -0,0 +1,181 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:supabase/supabase.dart'; +import 'package:weblibre/features/account/domain/repositories/account_auth.dart'; + +part 'account_sync_repository.g.dart'; + +enum SyncDocumentKind { + weblibreSettings('weblibre_settings', 'Settings'), + geckoUserJs('gecko_user_js', 'Gecko Prefs'), + + /// Small encrypted canary written on first-device sync setup so a second + /// device can verify the candidate sync key before persisting it. Not + /// surfaced in any settings UI — it has no corresponding + /// [SyncDocumentService] wired in [AccountSettingsScreen]. + syncValidationProbe('sync_validation_probe', 'Sync Validation Probe'); + + final String value; + final String displayName; + + const SyncDocumentKind(this.value, this.displayName); +} + +class SyncDocumentMetadata { + final String id; + final String? label; + final DateTime createdAt; + final DateTime updatedAt; + final String? sourceDeviceId; + final String? sourceAppVersion; + final int schemaVersion; + + SyncDocumentMetadata({ + required this.id, + this.label, + required this.createdAt, + required this.updatedAt, + required this.schemaVersion, + this.sourceDeviceId, + this.sourceAppVersion, + }); + + factory SyncDocumentMetadata.fromRow(Map row) { + return SyncDocumentMetadata( + id: row['id'] as String, + label: row['label'] as String?, + createdAt: DateTime.parse(row['created_at'] as String), + updatedAt: DateTime.parse(row['updated_at'] as String), + schemaVersion: row['schema_version'] as int, + sourceDeviceId: row['source_device_id'] as String?, + sourceAppVersion: row['source_app_version'] as String?, + ); + } +} + +class SyncDocumentResult { + final String contentBlob; + final SyncDocumentMetadata metadata; + + SyncDocumentResult({required this.contentBlob, required this.metadata}); +} + +/// All write methods on this repository require the user to be signed in. +/// Callers MUST gate on `ref.watch(accountSyncRepositoryProvider) != null` +/// (or the equivalent `AccountAuthState.isSignedIn` check) before invoking +/// `storeDocument`, `listDocuments`, `fetchDocument`, `deleteDocument`, or +/// `updateLabel`. Calling them on a signed-out instance throws +/// [StateError]; the repository deliberately doesn't fail silently because +/// silent no-ops would hide UI bugs (a "Store" button that does nothing). +@Riverpod(keepAlive: true) +class AccountSyncRepository extends _$AccountSyncRepository { + @override + SupabaseClient? build() { + final authState = ref.watch(accountAuthRepositoryProvider).value; + if (authState == null || !authState.isSignedIn) return null; + return authState.client; + } + + SupabaseClient get _client { + final client = state; + if (client == null) { + throw StateError( + 'AccountSyncRepository called while signed-out. ' + 'Gate the call site on accountAuthRepositoryProvider.isSignedIn ' + 'or accountSyncRepositoryProvider != null first.', + ); + } + return client; + } + + /// Stores a new document. Always inserts a new row. + /// Returns the generated document UUID. + Future storeDocument({ + required SyncDocumentKind kind, + required int schemaVersion, + required String contentBlob, + String? label, + String? sourceDeviceId, + String? sourceAppVersion, + }) async { + final row = await _client + .from('account_sync_documents') + .insert({ + 'user_id': _client.auth.currentUser!.id, + 'document_kind': kind.value, + 'label': label, + 'schema_version': schemaVersion, + 'content_blob': contentBlob, + 'source_device_id': sourceDeviceId, + 'source_app_version': sourceAppVersion, + }) + .select('id') + .single(); + + return row['id'] as String; + } + + /// Lists all documents of a given kind, ordered by most recent first. + /// Returns metadata only (no content). + Future> listDocuments({ + required SyncDocumentKind kind, + }) async { + final rows = await _client + .from('account_sync_documents') + .select( + 'id, label, created_at, updated_at, schema_version, ' + 'source_device_id, source_app_version', + ) + .eq('document_kind', kind.value) + .order('updated_at', ascending: false); + + return rows.map(SyncDocumentMetadata.fromRow).toList(); + } + + /// Fetches a single document by ID (with encrypted content blob). + Future fetchDocument({required String id}) async { + final row = await _client + .from('account_sync_documents') + .select() + .eq('id', id) + .maybeSingle(); + + if (row == null) return null; + + return SyncDocumentResult( + contentBlob: row['content_blob'] as String, + metadata: SyncDocumentMetadata.fromRow(row), + ); + } + + /// Deletes a document by ID. + Future deleteDocument({required String id}) async { + await _client.from('account_sync_documents').delete().eq('id', id); + } + + /// Updates the label of a document. + Future updateLabel({required String id, required String? label}) async { + await _client + .from('account_sync_documents') + .update({'label': label}) + .eq('id', id); + } +} diff --git a/apps/weblibre/lib/features/account/data/repositories/account_sync_repository.g.dart b/apps/weblibre/lib/features/account/data/repositories/account_sync_repository.g.dart new file mode 100644 index 00000000..a1cb1322 --- /dev/null +++ b/apps/weblibre/lib/features/account/data/repositories/account_sync_repository.g.dart @@ -0,0 +1,92 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'account_sync_repository.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning +/// All write methods on this repository require the user to be signed in. +/// Callers MUST gate on `ref.watch(accountSyncRepositoryProvider) != null` +/// (or the equivalent `AccountAuthState.isSignedIn` check) before invoking +/// `storeDocument`, `listDocuments`, `fetchDocument`, `deleteDocument`, or +/// `updateLabel`. Calling them on a signed-out instance throws +/// [StateError]; the repository deliberately doesn't fail silently because +/// silent no-ops would hide UI bugs (a "Store" button that does nothing). + +@ProviderFor(AccountSyncRepository) +final accountSyncRepositoryProvider = AccountSyncRepositoryProvider._(); + +/// All write methods on this repository require the user to be signed in. +/// Callers MUST gate on `ref.watch(accountSyncRepositoryProvider) != null` +/// (or the equivalent `AccountAuthState.isSignedIn` check) before invoking +/// `storeDocument`, `listDocuments`, `fetchDocument`, `deleteDocument`, or +/// `updateLabel`. Calling them on a signed-out instance throws +/// [StateError]; the repository deliberately doesn't fail silently because +/// silent no-ops would hide UI bugs (a "Store" button that does nothing). +final class AccountSyncRepositoryProvider + extends $NotifierProvider { + /// All write methods on this repository require the user to be signed in. + /// Callers MUST gate on `ref.watch(accountSyncRepositoryProvider) != null` + /// (or the equivalent `AccountAuthState.isSignedIn` check) before invoking + /// `storeDocument`, `listDocuments`, `fetchDocument`, `deleteDocument`, or + /// `updateLabel`. Calling them on a signed-out instance throws + /// [StateError]; the repository deliberately doesn't fail silently because + /// silent no-ops would hide UI bugs (a "Store" button that does nothing). + AccountSyncRepositoryProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'accountSyncRepositoryProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$accountSyncRepositoryHash(); + + @$internal + @override + AccountSyncRepository create() => AccountSyncRepository(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(SupabaseClient? value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$accountSyncRepositoryHash() => + r'ce558b699974edce2ca06b3990cf09c245d09126'; + +/// All write methods on this repository require the user to be signed in. +/// Callers MUST gate on `ref.watch(accountSyncRepositoryProvider) != null` +/// (or the equivalent `AccountAuthState.isSignedIn` check) before invoking +/// `storeDocument`, `listDocuments`, `fetchDocument`, `deleteDocument`, or +/// `updateLabel`. Calling them on a signed-out instance throws +/// [StateError]; the repository deliberately doesn't fail silently because +/// silent no-ops would hide UI bugs (a "Store" button that does nothing). + +abstract class _$AccountSyncRepository extends $Notifier { + SupabaseClient? build(); + @$mustCallSuper + @override + void runBuild() { + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + SupabaseClient?, + Object?, + Object? + >; + element.handleCreate(ref, build); + } +} diff --git a/apps/weblibre/lib/features/account/data/supabase_config.dart b/apps/weblibre/lib/features/account/data/supabase_config.dart new file mode 100644 index 00000000..af8bf728 --- /dev/null +++ b/apps/weblibre/lib/features/account/data/supabase_config.dart @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +// Supabase project configuration for the WebLibre native app. +// +// All three values are overridable at build time via `--dart-define` so +// developer / staging / production builds can point at distinct Supabase +// projects without code changes. The defaults match the current dev +// project; the anon key is safe to ship in client binaries by design +// (it's a public JWT bound only to anon Postgres RLS). +abstract final class SupabaseConfig { + static const supabaseUrl = String.fromEnvironment( + 'SUPABASE_URL', + defaultValue: 'https://wqpnmlqacxijdmbvcxun.supabase.co', + ); + + static const supabaseAnonKey = String.fromEnvironment( + 'SUPABASE_ANON_KEY', + defaultValue: + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6IndxcG5tbHFhY3hpamRtYnZjeHVuIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzU0NjQ1MjUsImV4cCI6MjA5MTA0MDUyNX0.5iifJWjp6bYZhfko-oGPPMuwMLAiFdfUXHQqk8W3wxA', + ); + + static const accountWebUrl = String.fromEnvironment( + 'ACCOUNT_BACKEND_ORIGIN', + defaultValue: 'https://account.weblibre.eu', + ); +} diff --git a/apps/weblibre/lib/features/account/domain/repositories/account_auth.dart b/apps/weblibre/lib/features/account/domain/repositories/account_auth.dart new file mode 100644 index 00000000..e8417671 --- /dev/null +++ b/apps/weblibre/lib/features/account/domain/repositories/account_auth.dart @@ -0,0 +1,408 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'dart:async'; + +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:supabase/supabase.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:weblibre/core/logger.dart'; +import 'package:weblibre/core/providers/device_info.dart'; +import 'package:weblibre/features/about/domain/providers.dart'; +import 'package:weblibre/features/account/data/account_secure_store.dart'; +import 'package:weblibre/features/account/data/models/account_auth_state.dart'; +import 'package:weblibre/features/account/data/models/account_persisted_data.dart'; +import 'package:weblibre/features/account/data/models/persisted_session.dart'; +import 'package:weblibre/features/account/data/supabase_config.dart'; +import 'package:weblibre/features/account/domain/services/handoff_redeem_client.dart'; +import 'package:weblibre/features/account/domain/utils/pkce.dart'; + +// Re-export so call sites that already imported AccountAuthFlowException from +// this repository keep compiling after the redeem client split. +export 'package:weblibre/features/account/domain/services/handoff_redeem_client.dart' + show AccountAuthFlowException; + +part 'account_auth.g.dart'; + +/// Convert any thrown error into a message safe to show in the UI. +/// Untrusted exception strings (e.g. `e.toString()` for arbitrary HTTP / +/// platform errors) can include response bodies, headers, or auth tokens — +/// log them in full but never put them in user-visible state. +String _sanitizeAuthError(Object error, String fallback) { + if (error is AccountAuthFlowException) { + return error.userMessage; + } + if (error is AuthRetryableFetchException) { + return 'Network error. Please check your connection and try again.'; + } + if (error is AuthException) { + return error.message; + } + return fallback; +} + +@Riverpod(keepAlive: true) +class AccountAuthRepository extends _$AccountAuthRepository { + StreamSubscription? _authSubscription; + Timer? _signingInTimeout; + Timer? _restoreRetryTimer; + + AccountSecureStore get _store => ref.read(accountSecureStoreProvider); + HandoffRedeemClient get _redeemClient => + ref.read(handoffRedeemClientProvider); + + AccountAuthState get _currentOrEmpty => state.value ?? AccountAuthState(); + + @override + Future build() async { + ref.onDispose(() async { + _signingInTimeout?.cancel(); + _restoreRetryTimer?.cancel(); + await _authSubscription?.cancel(); + final client = state.value?.client; + await client?.dispose(); + }); + + _restoreRetryTimer?.cancel(); + _restoreRetryTimer = null; + + final data = await _store.read(); + + if (data.session == null) { + return AccountAuthState(); + } + + try { + final client = _createClient(); + final response = await client.auth.setSession(data.session!.refreshToken); + + if (response.session != null) { + _listenToAuthState(client); + final user = response.session!.user; + + await _persistSession(response.session!, data); + + return AccountAuthState( + status: AccountAuthStatus.signedIn, + email: user.email, + displayName: + user.userMetadata?['display_name'] as String? ?? + user.userMetadata?['full_name'] as String? ?? + user.email, + userId: user.id, + syncKey: data.syncKey, + client: client, + ); + } else { + await client.dispose(); + return AccountAuthState(); + } + } on AuthRetryableFetchException catch (e) { + // Transient network error — preserve session and retry shortly. + return _transientRestoreFailure(data, e); + } on AuthException { + // Definitive auth failure (expired/revoked token) — clear credentials. + await _store.clear(); + return AccountAuthState(); + } catch (e) { + // Non-auth error (e.g. SocketException) — also transient, preserve. + return _transientRestoreFailure(data, e); + } + } + + AccountAuthState _transientRestoreFailure( + AccountPersistedData data, + Object error, + ) { + _scheduleRestoreRetry(); + return AccountAuthState( + status: AccountAuthStatus.error, + email: data.email, + displayName: data.displayName ?? data.email, + userId: data.userId, + syncKey: data.syncKey, + lastError: _sanitizeAuthError( + error, + 'Could not restore your account session. Retrying shortly.', + ), + ); + } + + void _scheduleRestoreRetry() { + _restoreRetryTimer?.cancel(); + _restoreRetryTimer = Timer(const Duration(seconds: 30), () { + if (ref.mounted) { + ref.invalidateSelf(); + } + }); + } + + // -- Auth state listener --------------------------------------------------- + + SupabaseClient _createClient() { + return SupabaseClient( + SupabaseConfig.supabaseUrl, + SupabaseConfig.supabaseAnonKey, + ); + } + + void _listenToAuthState(SupabaseClient client) { + unawaited(_authSubscription?.cancel()); + _authSubscription = client.auth.onAuthStateChange.listen((data) { + if (data.event == AuthChangeEvent.signedOut || + // ignore: deprecated_member_use + data.event == AuthChangeEvent.userDeleted) { + unawaited(_handleSignedOut()); + } else if (data.event == AuthChangeEvent.tokenRefreshed && + data.session != null) { + unawaited(_persistSessionRefresh(data.session!)); + } + }); + } + + Future _handleSignedOut() async { + await _store.clear(); + // Stashed Privacy Pass tokens survive sign-out: they are anonymous + // blobs already redeemed against the user's credit balance, and the + // backend cannot link them back to the issuing account. Clearing them + // here would destroy prepaid value with no refund path. + await _authSubscription?.cancel(); + final client = state.value?.client; + await client?.dispose(); + state = AsyncData(AccountAuthState()); + } + + // -- Sign-in flow ---------------------------------------------------------- + + Future startSignIn() async { + _signingInTimeout?.cancel(); + _signingInTimeout = null; + state = AsyncData( + _currentOrEmpty.copyWith(status: AccountAuthStatus.signingIn), + ); + + try { + final codes = PkceCodes.generate(); + + final data = await _store.read(); + await _store.write(data.copyWith(pendingCodeVerifier: codes.verifier)); + + final queryParams = { + 'mode': 'handoff', + 'code_challenge': codes.challenge, + }; + + final packageInfoData = ref.read(packageInfoProvider).value; + if (packageInfoData != null) { + queryParams['app_version'] = + '${packageInfoData.version}+${packageInfoData.buildNumber}'; + } + + final deviceInfoData = ref.read(androidDeviceInfoProvider).value; + if (deviceInfoData != null) { + queryParams['device_name'] = deviceInfoData.deviceName; + } + + final baseUri = Uri.parse(SupabaseConfig.accountWebUrl); + final uri = baseUri.replace(queryParameters: queryParams); + + final launched = await launchUrl(uri, mode: LaunchMode.inAppBrowserView); + if (!launched) { + state = AsyncData( + _currentOrEmpty.copyWith( + status: AccountAuthStatus.error, + lastError: 'Could not open sign-in page', + ), + ); + return; + } + + // Set the timer last so any earlier error path doesn't have to think + // about cancelling a timer it never started. Guard the body so that a + // timer fired after handleHandoffCode has reset _signingInTimeout to + // null does nothing. + late final Timer timer; + timer = Timer(const Duration(minutes: 5), () { + if (!identical(_signingInTimeout, timer)) return; + _signingInTimeout = null; + if (state.value?.status == AccountAuthStatus.signingIn) { + state = AsyncData( + AccountAuthState( + status: AccountAuthStatus.error, + lastError: 'Sign-in timed out. Please try again.', + ), + ); + } + }); + _signingInTimeout = timer; + } catch (e, s) { + logger.e('startSignIn failed', error: e, stackTrace: s); + state = AsyncData( + _currentOrEmpty.copyWith( + status: AccountAuthStatus.error, + lastError: _sanitizeAuthError( + e, + 'Could not open the sign-in page. Please try again.', + ), + ), + ); + } + } + + Future cancelSignIn() async { + _signingInTimeout?.cancel(); + _signingInTimeout = null; + + // Clear the pending code verifier so a late browser callback is rejected. + final data = await _store.read(); + // ignore: avoid_redundant_argument_values + await _store.write(data.copyWith(pendingCodeVerifier: null)); + + state = AsyncData(AccountAuthState()); + } + + Future handleHandoffCode(String code) async { + _signingInTimeout?.cancel(); + _signingInTimeout = null; + state = AsyncData( + _currentOrEmpty.copyWith(status: AccountAuthStatus.signingIn), + ); + + try { + final data = await _store.read(); + final codeVerifier = data.pendingCodeVerifier; + + if (codeVerifier == null) { + throw AccountAuthFlowException( + 'No pending sign-in found. Please start sign-in again.', + ); + } + + final result = await _redeemClient.redeem( + handoffCode: code, + codeVerifier: codeVerifier, + ); + + final refreshToken = result.session['refresh_token'] as String; + + final previousClient = _currentOrEmpty.client; + final newClient = _createClient(); + + try { + await newClient.auth.setSession(refreshToken); + } catch (e) { + await newClient.dispose(); + rethrow; + } + + await previousClient?.dispose(); + _listenToAuthState(newClient); + + // Persist session and clear the verifier in one write. Reuse the + // existing persisted record via copyWith so any unrelated fields + // (notably syncKey) survive a re-sign-in without being clobbered. + final persistedSession = PersistedSession.fromJson(result.session); + final user = result.session['user'] as Map?; + await _store.write( + data.copyWith( + session: persistedSession, + userId: user?['id'] as String?, + email: user?['email'] as String?, + displayName: + (user?['user_metadata'] as Map?)?['display_name'] + as String?, + // ignore: avoid_redundant_argument_values + pendingCodeVerifier: null, + ), + ); + + state = AsyncData( + AccountAuthState( + status: AccountAuthStatus.signedIn, + email: result.account['email'] as String?, + displayName: result.account['display_name'] as String?, + userId: result.account['user_id'] as String?, + syncKey: _currentOrEmpty.syncKey, + client: newClient, + ), + ); + } catch (e, s) { + logger.e('handleHandoffCode failed', error: e, stackTrace: s); + state = AsyncData( + _currentOrEmpty.copyWith( + status: AccountAuthStatus.error, + lastError: _sanitizeAuthError(e, 'Sign-in failed. Please try again.'), + ), + ); + } + } + + Future signOut() async { + try { + await state.value?.client?.auth.signOut(); + } catch (_) { + // Sign out may fail if the session is already invalid + } + await _handleSignedOut(); + } + + // -- Sync key management --------------------------------------------------- + + Future setSyncKey(String key) async { + final data = await _store.read(); + await _store.write(data.copyWith(syncKey: key)); + state = AsyncData(_currentOrEmpty.copyWith(syncKey: key)); + } + + Future clearSyncKey() async { + final data = await _store.read(); + // ignore: avoid_redundant_argument_values + await _store.write(data.copyWith(syncKey: null)); + // ignore: avoid_redundant_argument_values + state = AsyncData(_currentOrEmpty.copyWith(syncKey: null)); + } + + // -- Session persistence helpers ------------------------------------------ + + Future _persistSession( + Session session, + AccountPersistedData current, + ) async { + await _store.write( + current.copyWith( + session: PersistedSession( + accessToken: session.accessToken, + refreshToken: session.refreshToken!, + tokenType: session.tokenType, + expiresIn: session.expiresIn ?? 3600, + ), + userId: session.user.id, + email: session.user.email, + displayName: + session.user.userMetadata?['display_name'] as String? ?? + session.user.userMetadata?['full_name'] as String?, + ), + ); + } + + Future _persistSessionRefresh(Session session) async { + final data = await _store.read(); + await _persistSession(session, data); + } +} diff --git a/apps/weblibre/lib/features/account/domain/repositories/account_auth.g.dart b/apps/weblibre/lib/features/account/domain/repositories/account_auth.g.dart new file mode 100644 index 00000000..d4325e8b --- /dev/null +++ b/apps/weblibre/lib/features/account/domain/repositories/account_auth.g.dart @@ -0,0 +1,57 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'account_auth.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(AccountAuthRepository) +final accountAuthRepositoryProvider = AccountAuthRepositoryProvider._(); + +final class AccountAuthRepositoryProvider + extends $AsyncNotifierProvider { + AccountAuthRepositoryProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'accountAuthRepositoryProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$accountAuthRepositoryHash(); + + @$internal + @override + AccountAuthRepository create() => AccountAuthRepository(); +} + +String _$accountAuthRepositoryHash() => + r'604bd3954347d0ed6cd4f894bff8388642787104'; + +abstract class _$AccountAuthRepository + extends $AsyncNotifier { + FutureOr build(); + @$mustCallSuper + @override + void runBuild() { + final ref = + this.ref as $Ref, AccountAuthState>; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, AccountAuthState>, + AsyncValue, + Object?, + Object? + >; + element.handleCreate(ref, build); + } +} diff --git a/apps/weblibre/lib/features/account/domain/repositories/subscription_repository.dart b/apps/weblibre/lib/features/account/domain/repositories/subscription_repository.dart new file mode 100644 index 00000000..4d59f3ca --- /dev/null +++ b/apps/weblibre/lib/features/account/domain/repositories/subscription_repository.dart @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/features/account/data/models/subscription_status.dart'; +import 'package:weblibre/features/account/domain/repositories/account_auth.dart'; + +part 'subscription_repository.g.dart'; + +@Riverpod(keepAlive: true) +class SubscriptionRepository extends _$SubscriptionRepository { + @override + Future build() async { + final authState = ref.watch(accountAuthRepositoryProvider).value; + if (authState == null || !authState.isSignedIn) { + // Signed-out is not an error; it's a known "no subscription" state. + return SubscriptionStatus.inactive; + } + + // Don't swallow RPC failures as `SubscriptionStatus.inactive` — that + // renders identically to "user has no subscription" and would prompt + // them to subscribe when the actual fix is to retry. Let errors + // propagate so the AsyncValue carries them. + final client = authState.client!; + final response = await client.rpc('get_my_subscription_status').single(); + return SubscriptionStatus.fromJson(response); + } + + Future refresh() async { + ref.invalidateSelf(); + // Fire-and-forget; consumers re-render from the new AsyncValue. + try { + await future; + } catch (_) { + // intentionally ignored — see comment above + } + } +} diff --git a/apps/weblibre/lib/features/account/domain/repositories/subscription_repository.g.dart b/apps/weblibre/lib/features/account/domain/repositories/subscription_repository.g.dart new file mode 100644 index 00000000..726d9afa --- /dev/null +++ b/apps/weblibre/lib/features/account/domain/repositories/subscription_repository.g.dart @@ -0,0 +1,57 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'subscription_repository.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(SubscriptionRepository) +final subscriptionRepositoryProvider = SubscriptionRepositoryProvider._(); + +final class SubscriptionRepositoryProvider + extends $AsyncNotifierProvider { + SubscriptionRepositoryProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'subscriptionRepositoryProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$subscriptionRepositoryHash(); + + @$internal + @override + SubscriptionRepository create() => SubscriptionRepository(); +} + +String _$subscriptionRepositoryHash() => + r'c618435ad9e22316809a0a7f573dcea28301ba46'; + +abstract class _$SubscriptionRepository + extends $AsyncNotifier { + FutureOr build(); + @$mustCallSuper + @override + void runBuild() { + final ref = + this.ref as $Ref, SubscriptionStatus>; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, SubscriptionStatus>, + AsyncValue, + Object?, + Object? + >; + element.handleCreate(ref, build); + } +} diff --git a/apps/weblibre/lib/features/account/domain/services/account_callback_handler.dart b/apps/weblibre/lib/features/account/domain/services/account_callback_handler.dart new file mode 100644 index 00000000..581ff3c8 --- /dev/null +++ b/apps/weblibre/lib/features/account/domain/services/account_callback_handler.dart @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/core/logger.dart'; +import 'package:weblibre/features/account/domain/repositories/account_auth.dart'; +import 'package:weblibre/features/share_intent/domain/services/sharing_intent.dart'; + +part 'account_callback_handler.g.dart'; + +/// Parsed `weblibre://account/callback?code=...` deep link. +class AccountCallback { + final String handoffCode; + + const AccountCallback({required this.handoffCode}); +} + +/// Parses [data] as a WebLibre account callback URI. Returns `null` if it +/// isn't one (any other intent) or if the `code` query parameter is +/// missing/empty. Callers can check `!= null` instead of running two +/// passes (used-to-be `isAccountCallbackUri` then `extractHandoffCode`). +AccountCallback? tryParseAccountCallback(String data) { + final uri = Uri.tryParse(data); + if (uri == null) return null; + if (uri.scheme != 'weblibre' || + uri.host != 'account' || + uri.path != '/callback') { + return null; + } + final code = uri.queryParameters['code']; + if (code == null || code.isEmpty) return null; + return AccountCallback(handoffCode: code); +} + +/// Listens for account callback deep links and forwards handoff codes +/// to the account auth repository. +/// +/// This provider must be watched during app initialization to activate +/// the callback listener. +@Riverpod(keepAlive: true) +void accountCallbackHandler(Ref ref) { + final stream = ref.watch(accountCallbackStreamProvider); + + final subscription = stream.listen((code) async { + logger.i('Received account handoff callback'); + await ref + .read(accountAuthRepositoryProvider.notifier) + .handleHandoffCode(code); + }); + + ref.onDispose(subscription.cancel); +} diff --git a/apps/weblibre/lib/features/account/domain/services/account_callback_handler.g.dart b/apps/weblibre/lib/features/account/domain/services/account_callback_handler.g.dart new file mode 100644 index 00000000..e1e6fad4 --- /dev/null +++ b/apps/weblibre/lib/features/account/domain/services/account_callback_handler.g.dart @@ -0,0 +1,68 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'account_callback_handler.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning +/// Listens for account callback deep links and forwards handoff codes +/// to the account auth repository. +/// +/// This provider must be watched during app initialization to activate +/// the callback listener. + +@ProviderFor(accountCallbackHandler) +final accountCallbackHandlerProvider = AccountCallbackHandlerProvider._(); + +/// Listens for account callback deep links and forwards handoff codes +/// to the account auth repository. +/// +/// This provider must be watched during app initialization to activate +/// the callback listener. + +final class AccountCallbackHandlerProvider + extends $FunctionalProvider + with $Provider { + /// Listens for account callback deep links and forwards handoff codes + /// to the account auth repository. + /// + /// This provider must be watched during app initialization to activate + /// the callback listener. + AccountCallbackHandlerProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'accountCallbackHandlerProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$accountCallbackHandlerHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + void create(Ref ref) { + return accountCallbackHandler(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(void value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$accountCallbackHandlerHash() => + r'8d8e627efed8c030a2a9fe179cc71bdc33dd79c0'; diff --git a/apps/weblibre/lib/features/account/domain/services/handoff_redeem_client.dart b/apps/weblibre/lib/features/account/domain/services/handoff_redeem_client.dart new file mode 100644 index 00000000..bf66043f --- /dev/null +++ b/apps/weblibre/lib/features/account/domain/services/handoff_redeem_client.dart @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/features/account/data/supabase_config.dart'; + +part 'handoff_redeem_client.g.dart'; + +/// Thrown by [HandoffRedeemClient.redeem] when we want to surface a +/// specific, already-safe message to the user (e.g. a server-supplied error +/// string). Distinguishes "messages we trust to show verbatim" from +/// arbitrary `Exception.toString()` output, which may carry HTTP bodies, +/// tokens, or stack frames. +class AccountAuthFlowException implements Exception { + final String userMessage; + AccountAuthFlowException(this.userMessage); + + @override + String toString() => userMessage; +} + +/// Successful response from the `handoff-redeem` Supabase function. Holds +/// the raw `session` and `account` payloads so the caller decides how to +/// persist them. +class HandoffRedeemResult { + final Map session; + final Map account; + + const HandoffRedeemResult({required this.session, required this.account}); +} + +/// Stateless client for the account web app's `handoff-redeem` endpoint. +/// Owns the HTTP transport and the response parsing so the auth repository +/// only orchestrates state transitions around it. +class HandoffRedeemClient { + final http.Client _client; + + HandoffRedeemClient({http.Client? client}) : _client = client ?? http.Client(); + + void close() => _client.close(); + + /// Exchange a one-time `handoff_code` plus the matching PKCE + /// `code_verifier` for a Supabase session. Throws + /// [AccountAuthFlowException] on a non-200 status with the server's error + /// message when available, or a generic fallback otherwise. + Future redeem({ + required String handoffCode, + required String codeVerifier, + }) async { + const redeemUrl = + '${SupabaseConfig.supabaseUrl}/functions/v1/handoff-redeem'; + final response = await _client.post( + Uri.parse(redeemUrl), + headers: { + 'Content-Type': 'application/json', + 'apikey': SupabaseConfig.supabaseAnonKey, + }, + body: jsonEncode({ + 'handoff_code': handoffCode, + 'code_verifier': codeVerifier, + }), + ); + + if (response.statusCode != 200) { + // Try to extract the server's error field. The full response body + // is logged by the caller — we only surface the trusted message + // field to the UI to avoid leaking response detail. + String? serverMessage; + try { + final error = jsonDecode(response.body) as Map; + serverMessage = error['error'] as String?; + } catch (_) { + // Body wasn't JSON — fall back to a generic message so we don't + // echo HTML/HTTP detail to the user. + } + throw AccountAuthFlowException( + serverMessage ?? 'Sign-in failed. Please try again.', + ); + } + + final responseData = jsonDecode(response.body) as Map; + return HandoffRedeemResult( + session: responseData['session'] as Map, + account: responseData['account'] as Map, + ); + } +} + +@Riverpod(keepAlive: true) +HandoffRedeemClient handoffRedeemClient(Ref ref) { + final client = HandoffRedeemClient(); + ref.onDispose(client.close); + return client; +} diff --git a/apps/weblibre/lib/features/account/domain/services/handoff_redeem_client.g.dart b/apps/weblibre/lib/features/account/domain/services/handoff_redeem_client.g.dart new file mode 100644 index 00000000..3e75e060 --- /dev/null +++ b/apps/weblibre/lib/features/account/domain/services/handoff_redeem_client.g.dart @@ -0,0 +1,58 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'handoff_redeem_client.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(handoffRedeemClient) +final handoffRedeemClientProvider = HandoffRedeemClientProvider._(); + +final class HandoffRedeemClientProvider + extends + $FunctionalProvider< + HandoffRedeemClient, + HandoffRedeemClient, + HandoffRedeemClient + > + with $Provider { + HandoffRedeemClientProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'handoffRedeemClientProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$handoffRedeemClientHash(); + + @$internal + @override + $ProviderElement $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + HandoffRedeemClient create(Ref ref) { + return handoffRedeemClient(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(HandoffRedeemClient value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$handoffRedeemClientHash() => + r'6746d14547090c9ff4fcdc1c554fc7aa3d1e6795'; diff --git a/apps/weblibre/lib/features/account/domain/services/prefs_js_reader.dart b/apps/weblibre/lib/features/account/domain/services/prefs_js_reader.dart new file mode 100644 index 00000000..1463a78d --- /dev/null +++ b/apps/weblibre/lib/features/account/domain/services/prefs_js_reader.dart @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:weblibre/core/logger.dart'; +import 'package:weblibre/features/account/domain/utils/user_js_parser.dart'; +import 'package:weblibre/utils/filesystem.dart'; + +/// Reads persisted Gecko user prefs directly from the active profile's +/// `prefs.js` file. +/// +/// Gecko writes `user_pref(...)` entries only for prefs with a user-set value, +/// so reading this file gives us the syncable modified-prefs view without +/// needing a native bridge. +class PrefsJsReader { + final Directory selectedProfileDir; + + PrefsJsReader({required this.selectedProfileDir}); + + /// Returns a map of user-set prefs parsed from `prefs.js`, or an empty map + /// if no `prefs.js` file exists for the active profile. + Future> readUserPrefs() async { + final file = _resolvePrefsJsFile(); + if (file == null || !await file.exists()) { + return const {}; + } + + final text = await file.readAsString(); + return parseUserJs(text).prefs; + } + + File? _resolvePrefsJsFile() { + final profileIds = getMozillaProfileIds(selectedProfileDir); + if (profileIds.isEmpty) { + return null; + } + + final candidates = profileIds + .map( + (id) => File( + p.join(selectedProfileDir.path, 'files', 'mozilla', id, 'prefs.js'), + ), + ) + .where((f) => f.existsSync()) + .toList(); + + if (candidates.isEmpty) { + return null; + } + + if (candidates.length == 1) { + return candidates.first; + } + + logger.w( + 'Multiple Gecko profiles with prefs.js found (${candidates.length}); ' + 'selecting newest by modification time', + ); + + candidates.sort( + (a, b) => b.statSync().modified.compareTo(a.statSync().modified), + ); + return candidates.first; + } +} diff --git a/apps/weblibre/lib/features/account/domain/services/prefs_sync_service.dart b/apps/weblibre/lib/features/account/domain/services/prefs_sync_service.dart new file mode 100644 index 00000000..2bba5795 --- /dev/null +++ b/apps/weblibre/lib/features/account/domain/services/prefs_sync_service.dart @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'dart:convert'; + +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/core/filesystem.dart'; +import 'package:weblibre/features/account/data/repositories/account_sync_repository.dart'; +import 'package:weblibre/features/account/domain/services/prefs_js_reader.dart'; +import 'package:weblibre/features/account/domain/services/sync_document_service.dart'; +import 'package:weblibre/features/account/domain/utils/user_js_parser.dart'; +import 'package:weblibre/features/account/domain/utils/user_js_serializer.dart'; + +part 'prefs_sync_service.g.dart'; + +const _schemaVersion = 1; + +@Riverpod(keepAlive: true) +class PrefsSyncService extends _$PrefsSyncService + implements SyncDocumentService { + final GeckoPrefService _prefService = GeckoPrefService(); + final PrefsJsReader _prefsReader = PrefsJsReader( + selectedProfileDir: filesystem.selectedProfileDir, + ); + + @override + void build() {} + + @override + SyncDocumentKind get kind => SyncDocumentKind.geckoUserJs; + + @override + int get schemaVersion => _schemaVersion; + + @override + Future> serializeCurrent() async { + final userPrefs = await _prefsReader.readUserPrefs(); + + final text = serializeUserJs( + userPrefs: userPrefs, + schemaVersion: _schemaVersion, + ); + + return utf8.encode(text); + } + + @override + Future applyRestored(List plaintext) async { + final text = utf8.decode(plaintext); + final parsed = parseUserJs(text); + + if (parsed.schemaVersion != null && + parsed.schemaVersion! > _schemaVersion) { + throw Exception( + 'Unsupported prefs schema version: ${parsed.schemaVersion} ' + '(this app supports up to $_schemaVersion)', + ); + } + + final remotePrefs = parsed.prefs; + + // Find local user-set prefs whose exported form would have been preserved, + // so we can reset those absent from the remote snapshot. + final localPrefs = await _prefsReader.readUserPrefs(); + final syncableLocalKeys = localPrefs.entries + .where((e) => isSyncablePrefValue(e.value)) + .map((e) => e.key) + .toSet(); + + final prefsToReset = syncableLocalKeys + .difference(remotePrefs.keys.toSet()) + .toList(); + + if (prefsToReset.isNotEmpty) { + await _prefService.resetPrefs(prefsToReset); + } + + if (remotePrefs.isNotEmpty) { + await _prefService.applyPrefs(remotePrefs); + } + } +} diff --git a/apps/weblibre/lib/features/account/domain/services/prefs_sync_service.g.dart b/apps/weblibre/lib/features/account/domain/services/prefs_sync_service.g.dart new file mode 100644 index 00000000..5042672d --- /dev/null +++ b/apps/weblibre/lib/features/account/domain/services/prefs_sync_service.g.dart @@ -0,0 +1,62 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'prefs_sync_service.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(PrefsSyncService) +final prefsSyncServiceProvider = PrefsSyncServiceProvider._(); + +final class PrefsSyncServiceProvider + extends $NotifierProvider { + PrefsSyncServiceProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'prefsSyncServiceProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$prefsSyncServiceHash(); + + @$internal + @override + PrefsSyncService create() => PrefsSyncService(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(void value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$prefsSyncServiceHash() => r'9e95a066d751c12dc2a46bbe041c49e0389a7874'; + +abstract class _$PrefsSyncService extends $Notifier { + void build(); + @$mustCallSuper + @override + void runBuild() { + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + void, + Object?, + Object? + >; + element.handleCreate(ref, build); + } +} diff --git a/apps/weblibre/lib/features/account/domain/services/settings_sync_service.dart b/apps/weblibre/lib/features/account/domain/services/settings_sync_service.dart new file mode 100644 index 00000000..5b8510af --- /dev/null +++ b/apps/weblibre/lib/features/account/domain/services/settings_sync_service.dart @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'dart:convert'; + +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/features/account/data/models/settings_sync_envelope.dart'; +import 'package:weblibre/features/account/data/repositories/account_sync_repository.dart'; +import 'package:weblibre/features/account/domain/services/sync_document_service.dart'; +import 'package:weblibre/features/user/domain/repositories/engine_settings.dart'; +import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; +import 'package:weblibre/features/user/domain/repositories/tor_settings.dart'; + +part 'settings_sync_service.g.dart'; + +const _schemaVersion = 1; + +@Riverpod(keepAlive: true) +class SettingsSyncService extends _$SettingsSyncService + implements SyncDocumentService { + @override + void build() {} + + @override + SyncDocumentKind get kind => SyncDocumentKind.weblibreSettings; + + @override + int get schemaVersion => _schemaVersion; + + @override + Future> serializeCurrent() async { + final (general, engine, tor) = await ( + ref.read(generalSettingsRepositoryProvider.notifier).fetchSettings(), + ref.read(engineSettingsRepositoryProvider.notifier).fetchSettings(), + ref.read(torSettingsRepositoryProvider.notifier).fetchSettings(), + ).wait; + + final envelope = SettingsSyncEnvelope( + schemaVersion: _schemaVersion, + exportedAt: DateTime.now().toUtc().toIso8601String(), + payload: SettingsSyncPayload(general: general, engine: engine, tor: tor), + ); + + return utf8.encode(jsonEncode(envelope.toJson())); + } + + @override + Future applyRestored(List plaintext) async { + final json = jsonDecode(utf8.decode(plaintext)) as Map; + final envelope = SettingsSyncEnvelope.fromJson(json); + + if (envelope.schemaVersion > _schemaVersion) { + throw Exception( + 'Unsupported settings schema version: ${envelope.schemaVersion} ' + '(this app supports up to $_schemaVersion)', + ); + } + + final payload = envelope.payload; + + if (payload.general != null) { + await ref + .read(generalSettingsRepositoryProvider.notifier) + .updateSettings((_) => payload.general!); + } + if (payload.engine != null) { + await ref + .read(engineSettingsRepositoryProvider.notifier) + .updateSettings((_) => payload.engine!); + } + if (payload.tor != null) { + await ref + .read(torSettingsRepositoryProvider.notifier) + .updateSettings((_) => payload.tor!); + } + } +} diff --git a/apps/weblibre/lib/features/account/domain/services/settings_sync_service.g.dart b/apps/weblibre/lib/features/account/domain/services/settings_sync_service.g.dart new file mode 100644 index 00000000..94919421 --- /dev/null +++ b/apps/weblibre/lib/features/account/domain/services/settings_sync_service.g.dart @@ -0,0 +1,63 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'settings_sync_service.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(SettingsSyncService) +final settingsSyncServiceProvider = SettingsSyncServiceProvider._(); + +final class SettingsSyncServiceProvider + extends $NotifierProvider { + SettingsSyncServiceProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'settingsSyncServiceProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$settingsSyncServiceHash(); + + @$internal + @override + SettingsSyncService create() => SettingsSyncService(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(void value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$settingsSyncServiceHash() => + r'3f7488d705394417b3d8ca6319c978d65f1ef35d'; + +abstract class _$SettingsSyncService extends $Notifier { + void build(); + @$mustCallSuper + @override + void runBuild() { + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + void, + Object?, + Object? + >; + element.handleCreate(ref, build); + } +} diff --git a/apps/weblibre/lib/features/account/domain/services/sync_document_service.dart b/apps/weblibre/lib/features/account/domain/services/sync_document_service.dart new file mode 100644 index 00000000..8855c6cf --- /dev/null +++ b/apps/weblibre/lib/features/account/domain/services/sync_document_service.dart @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'package:weblibre/features/account/data/repositories/account_sync_repository.dart'; + +/// Contract for document kinds that can be synced. +/// +/// Implementations handle serialization and deserialization of their domain +/// data. Encryption, repository interaction, and UI are handled externally +/// by the reusable [SyncDocumentListSection] widget. +abstract class SyncDocumentService { + /// The document kind identifier for Supabase storage. + SyncDocumentKind get kind; + + /// Current schema version for this document kind. + int get schemaVersion; + + /// Serializes the current app state to plaintext bytes for encryption. + Future> serializeCurrent(); + + /// Deserializes decrypted plaintext bytes and applies them to app state. + Future applyRestored(List plaintext); +} diff --git a/apps/weblibre/lib/features/account/domain/utils/pkce.dart b/apps/weblibre/lib/features/account/domain/utils/pkce.dart new file mode 100644 index 00000000..156194ac --- /dev/null +++ b/apps/weblibre/lib/features/account/domain/utils/pkce.dart @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'dart:convert'; +import 'dart:math'; + +import 'package:crypto/crypto.dart'; + +/// A PKCE code-verifier / code-challenge pair tied to a single sign-in +/// attempt. The verifier stays on the device; the challenge is sent to the +/// account web app and later redeemed alongside the verifier to prove the +/// app instance that started the flow is the one finishing it. +/// +/// **Encoding deviates from RFC 7636.** The reference spec mandates +/// base64url for both verifier and `S256` challenge; this implementation +/// uses lowercase hex on both sides: +/// - verifier: 32 random bytes encoded as 64 hex characters (within the +/// 43–128 range allowed by RFC 7636 §4.1). +/// - challenge: hex digest of `SHA-256(UTF-8(verifier))`. +/// +/// Both sides of the flow are owned (this client and the `handoff-redeem` +/// Supabase function), so the wire format is internally consistent. The +/// deviation matters only if the redeem endpoint is ever replaced with a +/// standards-compliant OAuth server — in which case both `_generateCodeVerifier` +/// and `_challengeFor` must switch to base64url to interoperate. +class PkceCodes { + final String verifier; + final String challenge; + + const PkceCodes({required this.verifier, required this.challenge}); + + factory PkceCodes.generate() { + final verifier = _generateCodeVerifier(); + return PkceCodes(verifier: verifier, challenge: _challengeFor(verifier)); + } + + static String _generateCodeVerifier() { + final random = Random.secure(); + final bytes = List.generate(32, (_) => random.nextInt(256)); + return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + } + + static String _challengeFor(String verifier) { + return sha256.convert(utf8.encode(verifier)).toString(); + } +} diff --git a/apps/weblibre/lib/features/account/domain/utils/user_js_parser.dart b/apps/weblibre/lib/features/account/domain/utils/user_js_parser.dart new file mode 100644 index 00000000..52dc1778 --- /dev/null +++ b/apps/weblibre/lib/features/account/domain/utils/user_js_parser.dart @@ -0,0 +1,461 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +/// Parses Firefox-compatible `user.js` text into a map of pref name to value. +/// +/// Accepts the Firefox `user.js` subset used by WebLibre: +/// - blank lines and whitespace +/// - `//`, `#`, and `/* ... */` comments +/// - `user_pref("name", value);` +/// - string, boolean, and integer values +/// +/// Duplicate pref names use last-write-wins semantics. +class UserJsParseResult { + final Map prefs; + final int? schemaVersion; + final String? exportedAt; + + UserJsParseResult({required this.prefs, this.schemaVersion, this.exportedAt}); +} + +UserJsParseResult parseUserJs(String text) { + final parser = _UserJsParser(text); + return parser.parse(); +} + +class _UserJsParser { + final String _text; + int _index = 0; + final Map _prefs = {}; + int? _schemaVersion; + String? _exportedAt; + + _UserJsParser(this._text); + + UserJsParseResult parse() { + while (true) { + _skipTrivia(); + if (_isEof) { + break; + } + + final identifier = _parseIdentifier(); + if (identifier != 'user_pref') { + _skipToStatementEnd(); + continue; + } + + if (!_consumeChar('(')) { + _skipToStatementEnd(); + continue; + } + + final name = _parseStringLiteral(); + if (name == null) { + _skipToStatementEnd(); + continue; + } + + if (!_consumeChar(',')) { + _skipToStatementEnd(); + continue; + } + + final value = _parseValue(); + if (value == null) { + _skipToStatementEnd(); + continue; + } + + if (!_consumeChar(')')) { + _skipToStatementEnd(); + continue; + } + + _skipTrivia(); + if (!_consumeRawChar(';')) { + _skipToStatementEnd(); + continue; + } + + _prefs[name] = value; + } + + return UserJsParseResult( + prefs: _prefs, + schemaVersion: _schemaVersion, + exportedAt: _exportedAt, + ); + } + + bool get _isEof => _index >= _text.length; + + String? _peek([int offset = 0]) { + final position = _index + offset; + if (position >= _text.length) { + return null; + } + return _text[position]; + } + + String? _advance() { + if (_isEof) { + return null; + } + return _text[_index++]; + } + + bool _consumeRawChar(String char) { + if (_peek() != char) { + return false; + } + _index++; + return true; + } + + bool _consumeChar(String char) { + _skipTrivia(); + return _consumeRawChar(char); + } + + void _skipTrivia() { + while (!_isEof) { + final char = _peek(); + if (char == null) { + return; + } + + if (_isWhitespace(char)) { + _advance(); + continue; + } + + if (char == '#') { + _skipLineComment(isSlashComment: false); + continue; + } + + if (char == '/' && _peek(1) == '/') { + _skipLineComment(isSlashComment: true); + continue; + } + + if (char == '/' && _peek(1) == '*') { + _skipBlockComment(); + continue; + } + + return; + } + } + + void _skipLineComment({required bool isSlashComment}) { + final prefixLength = isSlashComment ? 2 : 1; + final start = _index + prefixLength; + _index += prefixLength; + + while (!_isEof) { + final char = _peek(); + if (char == '\n' || char == '\r') { + break; + } + _index++; + } + + if (isSlashComment) { + _parseMetadataComment(_text.substring(start, _index).trim()); + } + + if (_peek() == '\r') { + _index++; + if (_peek() == '\n') { + _index++; + } + } else if (_peek() == '\n') { + _index++; + } + } + + void _skipBlockComment() { + _index += 2; + while (!_isEof) { + if (_peek() == '*' && _peek(1) == '/') { + _index += 2; + return; + } + _index++; + } + } + + void _parseMetadataComment(String comment) { + if (comment.startsWith('schema_version=')) { + _schemaVersion = int.tryParse( + comment.substring('schema_version='.length), + ); + return; + } + if (comment.startsWith('exported_at=')) { + _exportedAt = comment.substring('exported_at='.length); + } + } + + String? _parseIdentifier() { + _skipTrivia(); + final start = _index; + while (!_isEof) { + final char = _peek(); + if (char == null || !_isIdentifierChar(char)) { + break; + } + _index++; + } + if (_index == start) { + return null; + } + return _text.substring(start, _index); + } + + Object? _parseValue() { + _skipTrivia(); + final char = _peek(); + if (char == null) { + return null; + } + + if (char == '"' || char == "'") { + return _parseStringLiteral(); + } + + if (char == 't' || char == 'f') { + final identifier = _parseIdentifier(); + if (identifier == 'true') { + return true; + } + if (identifier == 'false') { + return false; + } + return null; + } + + return _parseIntLiteral(); + } + + int? _parseIntLiteral() { + _skipTrivia(); + final start = _index; + + var sign = 1; + if (_peek() == '+') { + _index++; + } else if (_peek() == '-') { + sign = -1; + _index++; + } + + // Firefox tokenizes +/- separately, then skips whitespace/comments before + // reading the integer literal. + _skipTrivia(); + + final digitStart = _index; + while (!_isEof) { + final char = _peek(); + if (char == null || !_isDigit(char)) { + break; + } + _index++; + } + + if (_index == digitStart) { + _index = start; + return null; + } + + final trailing = _peek(); + if (trailing != null && _isIdentifierChar(trailing)) { + _index = start; + return null; + } + + final digits = _text.substring(digitStart, _index); + final value = int.tryParse(digits); + if (value == null) { + _index = start; + return null; + } + + final signedValue = sign * value; + if (signedValue < -2147483648 || signedValue > 2147483647) { + _index = start; + return null; + } + + return signedValue; + } + + String? _parseStringLiteral() { + _skipTrivia(); + final quote = _peek(); + if (quote != '"' && quote != "'") { + return null; + } + + _index++; + final buffer = StringBuffer(); + + while (!_isEof) { + final char = _advance(); + if (char == null) { + return null; + } + + if (char == quote) { + return buffer.toString(); + } + + if (char != '\\') { + buffer.write(char); + continue; + } + + final escaped = _parseEscape(); + if (escaped == null) { + return null; + } + buffer.write(escaped); + } + + return null; + } + + String? _parseEscape() { + final char = _advance(); + switch (char) { + case '"': + return '"'; + case "'": + return "'"; + case '\\': + return '\\'; + case 'n': + return '\n'; + case 'r': + return '\r'; + case 'x': + final value = _parseHexValue(length: 2); + if (value == null || value == 0) { + return null; + } + return String.fromCharCode(value); + case 'u': + final value = _parseUnicodeEscape(); + if (value == null || value == 0) { + return null; + } + return String.fromCharCode(value); + default: + return null; + } + } + + int? _parseUnicodeEscape() { + final value = _parseHexValue(length: 4); + if (value == null) { + return null; + } + + if (_isHighSurrogate(value)) { + if (_advance() != '\\' || _advance() != 'u') { + return null; + } + final lowValue = _parseHexValue(length: 4); + if (lowValue == null || !_isLowSurrogate(lowValue)) { + return null; + } + return 0x10000 + ((value - 0xD800) << 10) + (lowValue - 0xDC00); + } + + if (_isLowSurrogate(value)) { + return null; + } + + return value; + } + + int? _parseHexValue({required int length}) { + var value = 0; + for (var i = 0; i < length; i++) { + final char = _advance(); + final digit = char == null ? null : _hexDigitValue(char); + if (digit == null) { + return null; + } + value = (value << 4) + digit; + } + return value; + } + + int? _hexDigitValue(String char) { + final code = char.codeUnitAt(0); + if (code >= 0x30 && code <= 0x39) { + return code - 0x30; + } + if (code >= 0x41 && code <= 0x46) { + return code - 0x41 + 10; + } + if (code >= 0x61 && code <= 0x66) { + return code - 0x61 + 10; + } + return null; + } + + void _skipToStatementEnd() { + while (!_isEof) { + final char = _advance(); + if (char == ';') { + return; + } + } + } + + bool _isWhitespace(String char) { + return char == ' ' || + char == '\t' || + char == '\n' || + char == '\r' || + char == '\v' || + char == '\f'; + } + + bool _isIdentifierChar(String char) { + final code = char.codeUnitAt(0); + return (code >= 0x41 && code <= 0x5A) || + (code >= 0x61 && code <= 0x7A) || + (code >= 0x30 && code <= 0x39) || + code == 0x5F; + } + + bool _isDigit(String char) { + final code = char.codeUnitAt(0); + return code >= 0x30 && code <= 0x39; + } + + bool _isHighSurrogate(int value) => value >= 0xD800 && value <= 0xDBFF; + + bool _isLowSurrogate(int value) => value >= 0xDC00 && value <= 0xDFFF; +} diff --git a/apps/weblibre/lib/features/account/domain/utils/user_js_serializer.dart b/apps/weblibre/lib/features/account/domain/utils/user_js_serializer.dart new file mode 100644 index 00000000..0fe7c5b8 --- /dev/null +++ b/apps/weblibre/lib/features/account/domain/utils/user_js_serializer.dart @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +/// Unsyncable URL-style pref value prefixes (inspired by Firefox Sync). +const unsyncablePrefPrefixes = ['moz-extension:', 'blob:', 'data:', 'file:']; + +/// Returns true if a persisted Gecko pref value is syncable (supported scalar +/// type and not an unsyncable URL-style string value). +bool isSyncablePrefValue(Object value) { + if (value is bool || value is int) return true; + if (value is String) { + return !unsyncablePrefPrefixes.any(value.startsWith); + } + // Unsupported type (e.g. double, list) + return false; +} + +/// Serializes persisted Gecko user prefs into canonical `user.js` text. +/// +/// [userPrefs] is the map of user-set prefs parsed from the active Gecko +/// profile's `prefs.js` file. +/// +/// String-valued prefs starting with unsyncable URL prefixes are excluded. +String serializeUserJs({ + required Map userPrefs, + required int schemaVersion, + String? exportedAt, +}) { + final buffer = StringBuffer(); + buffer.writeln('// WebLibre Gecko prefs snapshot'); + buffer.writeln('// schema_version=$schemaVersion'); + buffer.writeln( + '// exported_at=${exportedAt ?? DateTime.now().toUtc().toIso8601String()}', + ); + + final syncable = + userPrefs.entries.where((e) => isSyncablePrefValue(e.value)).toList() + ..sort((a, b) => a.key.compareTo(b.key)); + + for (final entry in syncable) { + final literal = _toLiteral(entry.value); + if (literal == null) continue; + final escapedName = _escapeString(entry.key); + buffer.writeln('user_pref("$escapedName", $literal);'); + } + + return buffer.toString(); +} + +String? _toLiteral(Object? value) { + if (value is bool) return value.toString(); + if (value is int) return value.toString(); + if (value is String) return '"${_escapeString(value)}"'; + return null; +} + +String _escapeString(String value) { + return value + .replaceAll('\\', r'\\') + .replaceAll('"', r'\"') + .replaceAll('\n', r'\n') + .replaceAll('\r', r'\r'); +} diff --git a/apps/weblibre/lib/features/account/presentation/screens/account_settings.dart b/apps/weblibre/lib/features/account/presentation/screens/account_settings.dart new file mode 100644 index 00000000..c6f3aa25 --- /dev/null +++ b/apps/weblibre/lib/features/account/presentation/screens/account_settings.dart @@ -0,0 +1,223 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:weblibre/core/providers/device_info.dart'; +import 'package:weblibre/features/about/domain/providers.dart'; +import 'package:weblibre/features/account/data/models/account_auth_state.dart'; +import 'package:weblibre/features/account/data/models/subscription_status.dart'; +import 'package:weblibre/features/account/data/repositories/account_sync_repository.dart'; +import 'package:weblibre/features/account/domain/repositories/account_auth.dart'; +import 'package:weblibre/features/account/domain/repositories/subscription_repository.dart'; +import 'package:weblibre/features/account/domain/services/prefs_sync_service.dart'; +import 'package:weblibre/features/account/domain/services/settings_sync_service.dart'; +import 'package:weblibre/features/account/presentation/widgets/account_auth_status_card.dart'; +import 'package:weblibre/features/account/presentation/widgets/subscription_card.dart'; +import 'package:weblibre/features/account/presentation/widgets/sync_document_list_section.dart'; +import 'package:weblibre/features/account/presentation/widgets/sync_setup_card.dart'; +import 'package:weblibre/features/search_credits/presentation/widgets/search_credits_section.dart'; +import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart'; + +class AccountSettingsScreen extends HookConsumerWidget { + const AccountSettingsScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final authAsync = ref.watch(accountAuthRepositoryProvider); + final subscriptionAsync = ref.watch(subscriptionRepositoryProvider); + final search = useSettingsSearch(); + + Widget buildBody(Widget sliver) { + return SettingsCustomScrollScaffold( + title: 'WebLibre Account', + searchController: search.controller, + searchHintText: 'Search account settings', + slivers: [sliver], + ); + } + + return authAsync.when( + loading: () => buildBody( + const SliverFillRemaining( + hasScrollBody: false, + child: Center(child: CircularProgressIndicator()), + ), + ), + error: (_, _) => buildBody( + const SliverFillRemaining( + hasScrollBody: false, + child: Center(child: Text('Failed to load account')), + ), + ), + data: (authState) { + final sections = _buildSections( + ref: ref, + authState: authState, + subscriptionAsync: subscriptionAsync, + ); + + final filteredSections = filterSettingsSections( + sections: sections, + query: search.rawQuery, + ); + + return buildBody( + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 24, 16, 20), + sliver: SliverToBoxAdapter( + child: SettingsSectionList( + sections: filteredSections, + query: search.rawQuery, + ), + ), + ), + ); + }, + ); + } + + List _buildSections({ + required WidgetRef ref, + required AccountAuthState authState, + required AsyncValue subscriptionAsync, + }) { + final showSyncSnapshots = + authState.isSignedIn && subscriptionAsync.value?.isActive == true; + final syncClient = showSyncSnapshots + ? ref.read(accountSyncRepositoryProvider) + : null; + final syncRepo = syncClient != null + ? ref.read(accountSyncRepositoryProvider.notifier) + : null; + final sourceDeviceId = ref.read(androidDeviceInfoProvider).value?.deviceName; + final sourceAppVersion = _appVersion(ref); + + return [ + SettingsSectionDefinition( + title: 'Account', + entries: [ + SettingsEntryDefinition( + title: switch (authState.status) { + AccountAuthStatus.signedOut => 'Sign in to WebLibre Account', + AccountAuthStatus.signingIn => 'Signing in', + AccountAuthStatus.signedIn => 'Signed in account', + AccountAuthStatus.error => 'Sign-in failed', + }, + subtitle: switch (authState.status) { + AccountAuthStatus.signedOut => 'Sync your settings across devices', + AccountAuthStatus.signingIn => 'Complete sign-in in your browser', + AccountAuthStatus.signedIn => + authState.displayName ?? authState.email ?? 'Signed in', + AccountAuthStatus.error => authState.lastError, + }, + keywords: [ + 'sign in', + 'account', + 'authentication', + if (authState.hasSyncKey) ...['sync key', 'reset sync key'], + ], + child: AccountAuthStatusCard(authState: authState), + ), + ], + ), + if (authState.isSignedIn) + SettingsSectionDefinition( + title: 'Subscription', + entries: [ + SettingsEntryDefinition( + title: 'Supporter subscription', + subtitle: 'Status, billing, and subscription management', + keywords: const ['billing', 'supporter'], + child: SubscriptionCard(subscriptionAsync: subscriptionAsync), + ), + ], + ), + if (authState.isSignedIn) + const SettingsSectionDefinition( + title: 'Search Credits', + entries: [ + SettingsEntryDefinition( + title: 'Search credits', + subtitle: 'Credits balance, token issuance, and purchases', + keywords: ['tokens', 'search pack'], + child: SearchCreditsSection(embedded: true), + ), + ], + ), + if (showSyncSnapshots && syncRepo != null) + if (authState.hasSyncKey) ...[ + SettingsSectionDefinition( + title: 'Settings Snapshots', + entries: [ + SettingsEntryDefinition( + title: 'Settings snapshots', + subtitle: 'Store and restore synced application settings', + keywords: const ['backups', 'settings sync'], + child: SyncDocumentListSection( + service: ref.read(settingsSyncServiceProvider.notifier), + syncRepo: syncRepo, + syncKey: authState.syncKey!, + sourceDeviceId: sourceDeviceId, + sourceAppVersion: sourceAppVersion, + embedded: true, + ), + ), + ], + ), + SettingsSectionDefinition( + title: 'Preferences Snapshots', + entries: [ + SettingsEntryDefinition( + title: 'Preferences snapshots', + subtitle: 'Store and restore synced preference documents', + keywords: const ['backups', 'prefs sync'], + child: SyncDocumentListSection( + service: ref.read(prefsSyncServiceProvider.notifier), + syncRepo: syncRepo, + syncKey: authState.syncKey!, + sourceDeviceId: sourceDeviceId, + sourceAppVersion: sourceAppVersion, + embedded: true, + ), + ), + ], + ), + ] else + SettingsSectionDefinition( + title: 'Encrypted Sync', + entries: [ + SettingsEntryDefinition( + title: 'Set up encrypted sync', + subtitle: + 'Enable end-to-end encrypted sync using your account password', + keywords: const ['sync key', 'backups', 'snapshots'], + child: SyncSetupCard(email: authState.email), + ), + ], + ), + ]; + } +} + +String? _appVersion(WidgetRef ref) { + final info = ref.read(packageInfoProvider).value; + if (info == null) return null; + return '${info.version}+${info.buildNumber}'; +} diff --git a/apps/weblibre/lib/features/account/presentation/widgets/account_auth_status_card.dart b/apps/weblibre/lib/features/account/presentation/widgets/account_auth_status_card.dart new file mode 100644 index 00000000..e25a7104 --- /dev/null +++ b/apps/weblibre/lib/features/account/presentation/widgets/account_auth_status_card.dart @@ -0,0 +1,255 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:weblibre/features/account/data/models/account_auth_state.dart'; +import 'package:weblibre/features/account/domain/repositories/account_auth.dart'; + +/// Body of the "Account" settings entry. Renders the auth state machine: +/// signed-out CTA, signing-in spinner with cancel, signed-in identity with +/// sign-out + sync-key reset, or an error tile with retry. +class AccountAuthStatusCard extends ConsumerWidget { + const AccountAuthStatusCard({super.key, required this.authState}); + + final AccountAuthState authState; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return switch (authState.status) { + AccountAuthStatus.signedOut => const _SignedOutTile(), + AccountAuthStatus.signingIn => const _SigningInTile(), + AccountAuthStatus.signedIn => _SignedInTile(authState: authState), + AccountAuthStatus.error => _ErrorTile(authState: authState), + }; + } +} + +class _SignedOutTile extends ConsumerWidget { + const _SignedOutTile(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return ListTile( + leading: const Icon(Icons.login), + title: const Text('Sign in to WebLibre Account'), + subtitle: const Text('Sync your settings across devices'), + contentPadding: const EdgeInsets.symmetric( + vertical: 8.0, + horizontal: 16.0, + ), + trailing: const Icon(Icons.chevron_right), + onTap: () async { + await ref.read(accountAuthRepositoryProvider.notifier).startSignIn(); + }, + ); + } +} + +class _SigningInTile extends ConsumerWidget { + const _SigningInTile(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + children: [ + const CircularProgressIndicator(), + const SizedBox(height: 16), + const Text('Signing in...'), + const SizedBox(height: 8), + Text( + 'Complete sign-in in your browser', + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 16), + TextButton( + onPressed: () async { + await ref + .read(accountAuthRepositoryProvider.notifier) + .cancelSignIn(); + }, + child: const Text('Cancel'), + ), + ], + ), + ); + } +} + +class _SignedInTile extends ConsumerWidget { + const _SignedInTile({required this.authState}); + + final AccountAuthState authState; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Column( + children: [ + ListTile( + leading: const Icon(Icons.account_circle), + title: Text(authState.displayName ?? authState.email ?? 'Signed in'), + subtitle: + authState.email != null && + authState.email != authState.displayName + ? Text(authState.email!) + : null, + trailing: IconButton( + icon: const Icon(Icons.logout), + tooltip: 'Sign Out', + onPressed: () async { + final confirmed = await _showSignOutConfirmation(context); + if (confirmed == true) { + await ref + .read(accountAuthRepositoryProvider.notifier) + .signOut(); + } + }, + ), + contentPadding: const EdgeInsets.symmetric( + vertical: 8.0, + horizontal: 16.0, + ), + ), + if (authState.hasSyncKey) ...[ + const Divider(height: 1), + const _ResetSyncKeyTile(), + ], + ], + ); + } + + static Future _showSignOutConfirmation(BuildContext context) { + return showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text('Sign out?'), + content: const Text( + 'Are you sure you want to sign out of your WebLibre Account?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Sign Out'), + ), + ], + ); + }, + ); + } +} + +class _ErrorTile extends ConsumerWidget { + const _ErrorTile({required this.authState}); + + final AccountAuthState authState; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final colorScheme = Theme.of(context).colorScheme; + return Padding( + padding: const EdgeInsets.all(16.0), + child: DecoratedBox( + decoration: BoxDecoration( + color: colorScheme.errorContainer, + borderRadius: BorderRadius.circular(16), + ), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Sign-in failed', + style: TextStyle( + color: colorScheme.onErrorContainer, + fontWeight: FontWeight.bold, + ), + ), + if (authState.lastError != null) ...[ + const SizedBox(height: 8), + Text( + authState.lastError!, + style: TextStyle(color: colorScheme.onErrorContainer), + ), + ], + const SizedBox(height: 16), + FilledButton.tonal( + onPressed: () async { + await ref + .read(accountAuthRepositoryProvider.notifier) + .startSignIn(); + }, + child: const Text('Try Again'), + ), + ], + ), + ), + ), + ); + } +} + +class _ResetSyncKeyTile extends ConsumerWidget { + const _ResetSyncKeyTile(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return ListTile( + leading: const Icon(Icons.key_off_outlined), + title: const Text('Reset Sync Key'), + subtitle: const Text( + 'Re-enter your password if you mistyped it or changed it', + ), + onTap: () async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Reset Sync Key'), + content: const Text( + 'You will need to re-enter your account password. ' + 'If your password changed, existing snapshots ' + 'encrypted with the old password will no longer ' + 'be decryptable.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Reset'), + ), + ], + ), + ); + if (confirmed == true) { + await ref.read(accountAuthRepositoryProvider.notifier).clearSyncKey(); + } + }, + ); + } +} diff --git a/apps/weblibre/lib/features/account/presentation/widgets/subscription_card.dart b/apps/weblibre/lib/features/account/presentation/widgets/subscription_card.dart new file mode 100644 index 00000000..5bdeaed5 --- /dev/null +++ b/apps/weblibre/lib/features/account/presentation/widgets/subscription_card.dart @@ -0,0 +1,308 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:intl/intl.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:weblibre/features/account/data/models/subscription_status.dart'; +import 'package:weblibre/features/account/data/supabase_config.dart'; +import 'package:weblibre/features/account/domain/repositories/subscription_repository.dart'; + +/// Visual presentation of one subscription state. All branches of the +/// subscription UI render through the same ListTile + badge + note + manage +/// button structure — the differences boil down to these fields, which the +/// state machine selects in `_resolvePresentation`. +class _SubscriptionPresentation { + final IconData leadingIcon; + final Color? leadingIconColor; + final String planTitle; + final String badgeLabel; + final Color badgeColor; + final Color badgeTextColor; + final String? note; + final Color? noteColor; + final String manageLabel; + final String? subtitle; + final DateTime? expiryHint; + + const _SubscriptionPresentation({ + required this.leadingIcon, + required this.planTitle, + required this.badgeLabel, + required this.badgeColor, + required this.badgeTextColor, + required this.manageLabel, + this.leadingIconColor, + this.note, + this.noteColor, + this.subtitle, + this.expiryHint, + }); +} + +class SubscriptionCard extends HookConsumerWidget { + const SubscriptionCard({super.key, required this.subscriptionAsync}); + + final AsyncValue subscriptionAsync; + + @override + Widget build(BuildContext context, WidgetRef ref) { + // Refresh on resume regardless of which branch is currently rendered. + // Keeping the hook at the top level means a stale error tile (e.g. the + // user opened the screen offline, then reconnected and returned to the + // app) still gets a fresh fetch — previously the hook only ran in the + // `data` branch and never fired from the error state. + useOnAppLifecycleStateChange((previous, current) async { + if (current == AppLifecycleState.resumed) { + await ref.read(subscriptionRepositoryProvider.notifier).refresh(); + } + }); + + return subscriptionAsync.when( + loading: () => const Padding( + padding: EdgeInsets.symmetric(vertical: 24.0), + child: Center(child: CircularProgressIndicator()), + ), + // Render a dedicated error tile so the user can tell "fetch failed" + // apart from "no subscription" — they have different fixes (retry + // vs. subscribe). + error: (_, _) => _SubscriptionErrorTile( + onRetry: () => + ref.read(subscriptionRepositoryProvider.notifier).refresh(), + ), + data: (status) { + final presentation = _resolvePresentation(context, status); + return _SubscriptionStateBody(presentation: presentation); + }, + ); + } +} + +class _SubscriptionErrorTile extends StatelessWidget { + const _SubscriptionErrorTile({required this.onRetry}); + + final Future Function() onRetry; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return ListTile( + leading: Icon(Icons.error_outline, color: scheme.error), + title: const Text('Could not load subscription'), + subtitle: const Text('Check your connection and try again.'), + trailing: IconButton( + icon: const Icon(Icons.refresh), + tooltip: 'Retry', + onPressed: onRetry, + ), + ); + } +} + +_SubscriptionPresentation _resolvePresentation( + BuildContext context, + SubscriptionStatus status, +) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final planTitle = status.planLabel ?? 'Supporter'; + + if (status.isActive) { + final isWindingDown = status.isWindingDown; + final endDate = status.currentPeriodEnd ?? status.entitledUntil; + return _SubscriptionPresentation( + leadingIcon: Icons.verified, + leadingIconColor: scheme.primary, + planTitle: planTitle, + badgeLabel: isWindingDown ? 'Will not renew' : 'Active', + badgeColor: isWindingDown + ? scheme.surfaceContainerHighest + : scheme.primaryContainer, + badgeTextColor: isWindingDown + ? scheme.onSurface + : scheme.onPrimaryContainer, + subtitle: status.entitledUntil != null + ? 'Until ${_formatDate(status.entitledUntil!)}' + : null, + expiryHint: isWindingDown ? endDate : null, + manageLabel: 'Manage Subscription', + ); + } + if (status.isPaused) { + return _SubscriptionPresentation( + leadingIcon: Icons.pause_circle_outline, + leadingIconColor: scheme.onSurfaceVariant, + planTitle: planTitle, + badgeLabel: 'Paused', + badgeColor: scheme.tertiaryContainer, + badgeTextColor: scheme.onTertiaryContainer, + note: + 'Your subscription is paused. Resume it from the customer ' + 'portal to restore access.', + noteColor: scheme.onSurfaceVariant, + manageLabel: 'Manage Subscription', + ); + } + if (status.isPastDue) { + return _SubscriptionPresentation( + leadingIcon: Icons.error_outline, + leadingIconColor: scheme.onSurfaceVariant, + planTitle: planTitle, + badgeLabel: 'Past due', + badgeColor: scheme.errorContainer, + badgeTextColor: scheme.onErrorContainer, + note: + 'Payment failed. Update your payment method to keep your ' + 'subscription active.', + noteColor: scheme.error, + manageLabel: 'Update Payment Method', + ); + } + if (status.isWindingDown) { + // No active entitlement remains (isActive was false above) — the + // grace period has ended or there never was one. Offer renewal. + return _SubscriptionPresentation( + leadingIcon: Icons.history_toggle_off, + leadingIconColor: scheme.onSurfaceVariant, + planTitle: planTitle, + badgeLabel: 'Will not renew', + badgeColor: scheme.surfaceContainerHighest, + badgeTextColor: scheme.onSurface, + note: + 'Your subscription has ended. Renew from the customer ' + 'portal to continue.', + noteColor: scheme.onSurfaceVariant, + manageLabel: 'Renew Subscription', + ); + } + return _inactivePresentation(context); +} + +_SubscriptionPresentation _inactivePresentation(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return _SubscriptionPresentation( + leadingIcon: Icons.card_membership, + leadingIconColor: scheme.onSurfaceVariant, + planTitle: 'Supporter Subscription', + subtitle: 'Subscribe to unlock sync features', + badgeLabel: 'Inactive', + badgeColor: scheme.surfaceContainerHighest, + badgeTextColor: scheme.onSurface, + manageLabel: 'Subscribe', + ); +} + +String _formatDate(DateTime date) => DateFormat.yMMMd().format(date); + +class _SubscriptionStateBody extends ConsumerWidget { + const _SubscriptionStateBody({required this.presentation}); + + final _SubscriptionPresentation presentation; + + Future _openPortal() async { + await launchUrl( + Uri.parse(SupabaseConfig.accountWebUrl), + mode: LaunchMode.inAppBrowserView, + ); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + + return Column( + children: [ + ListTile( + leading: Icon( + presentation.leadingIcon, + color: presentation.leadingIconColor, + ), + title: Row( + children: [ + Text(presentation.planTitle), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: presentation.badgeColor, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + presentation.badgeLabel, + style: theme.textTheme.labelSmall?.copyWith( + color: presentation.badgeTextColor, + ), + ), + ), + ], + ), + subtitle: presentation.subtitle != null + ? Text(presentation.subtitle!) + : null, + trailing: IconButton( + icon: const Icon(Icons.refresh), + tooltip: 'Refresh status', + onPressed: () async { + await ref.read(subscriptionRepositoryProvider.notifier).refresh(); + }, + ), + ), + if (presentation.expiryHint != null) + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: Row( + children: [ + Icon(Icons.warning_amber, size: 16, color: scheme.error), + const SizedBox(width: 4), + Expanded( + child: Text( + 'Your subscription will end on ' + '${_formatDate(presentation.expiryHint!)}', + style: theme.textTheme.bodySmall?.copyWith( + color: scheme.error, + ), + ), + ), + ], + ), + ), + if (presentation.note != null) + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: Text( + presentation.note!, + style: theme.textTheme.bodySmall?.copyWith( + color: presentation.noteColor, + ), + ), + ), + const Divider(height: 1), + ListTile( + leading: const Icon(Icons.open_in_new), + title: Text(presentation.manageLabel), + contentPadding: const EdgeInsets.symmetric(horizontal: 16.0), + onTap: _openPortal, + ), + ], + ); + } +} diff --git a/apps/weblibre/lib/features/account/presentation/widgets/sync_document_dialogs.dart b/apps/weblibre/lib/features/account/presentation/widgets/sync_document_dialogs.dart new file mode 100644 index 00000000..90ff2b2a --- /dev/null +++ b/apps/weblibre/lib/features/account/presentation/widgets/sync_document_dialogs.dart @@ -0,0 +1,207 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'package:flutter/material.dart'; +import 'package:weblibre/features/account/data/repositories/account_sync_repository.dart'; + +// -- Metadata display helpers ------------------------------------------------ + +class MetadataRow extends StatelessWidget { + const MetadataRow({super.key, required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 100, + child: Text( + label, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + Expanded( + child: Text(value, style: Theme.of(context).textTheme.bodySmall), + ), + ], + ), + ); + } +} + +String formatDateTime(DateTime dt) { + final local = dt.toLocal(); + return '${local.year}-${_pad(local.month)}-${_pad(local.day)} ' + '${_pad(local.hour)}:${_pad(local.minute)}'; +} + +String _pad(int n) => n.toString().padLeft(2, '0'); + +// -- Dialogs ----------------------------------------------------------------- + +Future showStoreLabelDialog(BuildContext context) { + final controller = TextEditingController(); + + return showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Store Snapshot'), + content: TextField( + controller: controller, + decoration: const InputDecoration( + labelText: 'Label (optional)', + hintText: 'e.g. "Before update", "Home setup"', + border: OutlineInputBorder(), + ), + autofocus: true, + textCapitalization: TextCapitalization.sentences, + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + final label = controller.text.trim(); + Navigator.of(context).pop(label.isEmpty ? '' : label); + }, + child: const Text('Store'), + ), + ], + ), + ); +} + +Future showEditLabelDialog( + BuildContext context, { + String? currentLabel, +}) { + final controller = TextEditingController(text: currentLabel); + + return showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Edit Label'), + content: TextField( + controller: controller, + decoration: const InputDecoration( + labelText: 'Label', + border: OutlineInputBorder(), + ), + autofocus: true, + textCapitalization: TextCapitalization.sentences, + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + final label = controller.text.trim(); + Navigator.of(context).pop(label.isEmpty ? '' : label); + }, + child: const Text('Save'), + ), + ], + ), + ); +} + +Future showRestoreConfirmation( + BuildContext context, { + required SyncDocumentMetadata metadata, +}) { + return showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Restore Snapshot'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('This will overwrite your current local settings.'), + const SizedBox(height: 16), + if (metadata.label != null && metadata.label!.isNotEmpty) + MetadataRow(label: 'Label', value: metadata.label!), + MetadataRow( + label: 'Stored', + value: formatDateTime(metadata.updatedAt), + ), + if (metadata.sourceAppVersion != null) + MetadataRow( + label: 'App version', + value: metadata.sourceAppVersion!, + ), + if (metadata.sourceDeviceId != null) + MetadataRow(label: 'Device', value: metadata.sourceDeviceId!), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Restore'), + ), + ], + ), + ); +} + +Future showDeleteConfirmation( + BuildContext context, { + required SyncDocumentMetadata metadata, +}) { + final label = metadata.label?.isNotEmpty == true + ? '"${metadata.label}"' + : 'this snapshot'; + + return showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Delete Snapshot'), + content: Text('Are you sure you want to delete $label?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + FilledButton( + style: FilledButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.error, + ), + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Delete'), + ), + ], + ), + ); +} diff --git a/apps/weblibre/lib/features/account/presentation/widgets/sync_document_list_section.dart b/apps/weblibre/lib/features/account/presentation/widgets/sync_document_list_section.dart new file mode 100644 index 00000000..41b62d3c --- /dev/null +++ b/apps/weblibre/lib/features/account/presentation/widgets/sync_document_list_section.dart @@ -0,0 +1,382 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:secure_archive/secure_archive.dart'; +import 'package:weblibre/features/account/data/repositories/account_sync_repository.dart'; +import 'package:weblibre/features/account/domain/services/sync_document_service.dart'; +import 'package:weblibre/features/account/presentation/widgets/sync_document_dialogs.dart'; +import 'package:weblibre/features/settings/presentation/widgets/settings_content_card.dart'; + +class SyncDocumentListSection extends HookWidget { + const SyncDocumentListSection({ + super.key, + required this.service, + required this.syncRepo, + required this.syncKey, + this.sourceDeviceId, + this.sourceAppVersion, + this.embedded = false, + }); + + final SyncDocumentService service; + final AccountSyncRepository syncRepo; + final String syncKey; + final String? sourceDeviceId; + final String? sourceAppVersion; + final bool embedded; + + @override + Widget build(BuildContext context) { + final documents = useState?>(null); + final loading = useState(true); + final busy = useState(false); + + final secureData = useMemoized( + () => SecureData(argon2Params: Argon2Params.memoryConstrained()), + ); + + Future refresh() async { + try { + final docs = await syncRepo.listDocuments(kind: service.kind); + documents.value = docs; + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to load snapshots: $e')), + ); + } + } finally { + if (context.mounted) { + loading.value = false; + } + } + } + + useEffect(() { + unawaited(refresh()); + return null; + }, const []); + + Future storeCurrent() async { + final labelResult = await showStoreLabelDialog(context); + if (labelResult == null) return; + + busy.value = true; + try { + final plaintext = await service.serializeCurrent(); + final encrypted = await secureData.encrypt( + plaintext, + syncKey, + compress: true, + ); + final blob = base64Encode(encrypted); + final label = labelResult.isEmpty ? null : labelResult; + + await syncRepo.storeDocument( + kind: service.kind, + schemaVersion: service.schemaVersion, + contentBlob: blob, + label: label, + sourceDeviceId: sourceDeviceId, + sourceAppVersion: sourceAppVersion, + ); + + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('${service.kind.displayName} stored')), + ); + } + await refresh(); + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Failed to store: $e'))); + } + } finally { + if (context.mounted) { + busy.value = false; + } + } + } + + Future restore(SyncDocumentMetadata metadata) async { + final confirmed = await showRestoreConfirmation( + context, + metadata: metadata, + ); + if (confirmed != true) return; + + busy.value = true; + try { + final result = await syncRepo.fetchDocument(id: metadata.id); + if (result == null) { + if (context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Snapshot not found'))); + } + return; + } + + final encrypted = base64Decode(result.contentBlob); + final plaintext = await secureData.decrypt(encrypted, syncKey); + await service.applyRestored(plaintext); + + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('${service.kind.displayName} restored')), + ); + } + } catch (e) { + if (context.mounted) { + if (_isDecryptionFailure(e)) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Decryption failed — wrong password or data corrupted. ' + 'Try resetting your sync key.', + ), + ), + ); + } else { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Failed to restore: $e'))); + } + } + } finally { + if (context.mounted) { + busy.value = false; + } + } + } + + Future editLabel(SyncDocumentMetadata metadata) async { + final newLabel = await showEditLabelDialog( + context, + currentLabel: metadata.label, + ); + if (newLabel == null) return; + + try { + await syncRepo.updateLabel( + id: metadata.id, + label: newLabel.isEmpty ? null : newLabel, + ); + await refresh(); + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Failed to update label: $e'))); + } + } + } + + Future delete(SyncDocumentMetadata metadata) async { + final confirmed = await showDeleteConfirmation( + context, + metadata: metadata, + ); + if (confirmed != true) return; + + try { + await syncRepo.deleteDocument(id: metadata.id); + if (context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Snapshot deleted'))); + } + await refresh(); + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Failed to delete: $e'))); + } + } + } + + final content = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), + child: Text( + '${service.kind.displayName} Snapshots', + style: Theme.of(context).textTheme.titleSmall, + ), + ), + ListTile( + leading: busy.value + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.cloud_upload_outlined), + title: const Text('Store Current'), + subtitle: Text( + 'Encrypt and upload current ${service.kind.displayName.toLowerCase()}', + ), + enabled: !busy.value, + onTap: storeCurrent, + ), + if (loading.value) + const Padding( + padding: EdgeInsets.symmetric(vertical: 16.0), + child: Center(child: CircularProgressIndicator()), + ) + else if (documents.value == null || documents.value!.isEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 16), + child: Text( + 'No snapshots stored yet', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ) + else ...[ + const Divider(height: 1), + for (final doc in documents.value!) + _DocumentTile( + metadata: doc, + busy: busy.value, + onRestore: () => restore(doc), + onEditLabel: () => editLabel(doc), + onDelete: () => delete(doc), + ), + ], + ], + ); + + return SettingsContentCard(embedded: embedded, child: content); + } +} + +class _DocumentTile extends StatelessWidget { + const _DocumentTile({ + required this.metadata, + required this.busy, + required this.onRestore, + required this.onEditLabel, + required this.onDelete, + }); + + final SyncDocumentMetadata metadata; + final bool busy; + final VoidCallback onRestore; + final VoidCallback onEditLabel; + final VoidCallback onDelete; + + @override + Widget build(BuildContext context) { + final title = metadata.label?.isNotEmpty == true + ? metadata.label! + : 'Untitled'; + final subtitle = StringBuffer(formatDateTime(metadata.updatedAt)); + if (metadata.sourceDeviceId != null) { + subtitle.write(' · ${metadata.sourceDeviceId}'); + } + + return ListTile( + leading: const Icon(Icons.description_outlined), + title: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis), + subtitle: Text( + subtitle.toString(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + trailing: MenuAnchor( + builder: (context, controller, _) => IconButton( + icon: const Icon(Icons.more_vert), + onPressed: busy + ? null + : () => + controller.isOpen ? controller.close() : controller.open(), + ), + menuChildren: [ + MenuItemButton( + leadingIcon: const Icon(Icons.cloud_download_outlined), + onPressed: onRestore, + child: const Text('Restore'), + ), + MenuItemButton( + leadingIcon: const Icon(Icons.edit_outlined), + onPressed: onEditLabel, + child: const Text('Edit Label'), + ), + MenuItemButton( + leadingIcon: Icon( + Icons.delete_outlined, + color: Theme.of(context).colorScheme.error, + ), + onPressed: onDelete, + child: Text( + 'Delete', + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ), + ], + ), + ); + } +} + +/// Canonical secure_archive error message fragments that all map to +/// "the candidate key cannot open this blob" (wrong password, MAC failure, +/// corrupted ciphertext). Sourced from `secure_archive/lib/src/data/`: +/// +/// - "wrong password or corrupted data" — from `secure_data.dart` (thrown +/// on `SecretBoxAuthenticationError` from chacha20-poly1305 MAC check). +/// - "Could not validate backup integrity" — from the archive layer. +/// - "Corrupt output" — from the streaming gzip decoder. +/// +/// If `secure_archive` upstream switches to typed exceptions, prefer +/// catching the type and delete these strings. +const _secureArchiveDecryptionFailureFragments = { + 'wrong password or corrupted data', + 'Could not validate backup integrity', + 'Corrupt output', +}; + +/// True when [error] indicates the candidate sync key could not open the +/// snapshot — either it's wrong, the envelope is corrupted, or the format +/// version isn't recognised. Used to present a key/integrity problem +/// instead of a generic failure. +/// +/// `FormatException` covers header-level failures (unsupported version, +/// bad framing), which `secure_archive` raises before any crypto runs. +/// Other `Exception` instances are sniffed by message contents — see +/// [_secureArchiveDecryptionFailureFragments] for the contract. +bool _isDecryptionFailure(Object error) { + if (error is FormatException) return true; + if (error is Exception) { + final message = error.toString(); + return _secureArchiveDecryptionFailureFragments.any(message.contains); + } + return false; +} diff --git a/apps/weblibre/lib/features/account/presentation/widgets/sync_setup_card.dart b/apps/weblibre/lib/features/account/presentation/widgets/sync_setup_card.dart new file mode 100644 index 00000000..1ed08a00 --- /dev/null +++ b/apps/weblibre/lib/features/account/presentation/widgets/sync_setup_card.dart @@ -0,0 +1,267 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'dart:convert'; + +import 'package:crypto/crypto.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:secure_archive/secure_archive.dart'; +import 'package:weblibre/features/account/data/repositories/account_sync_repository.dart'; +import 'package:weblibre/features/account/domain/repositories/account_auth.dart'; + +/// Lets a signed-in user derive an end-to-end encryption key from their +/// account password. +/// +/// Two safety nets keep a mistyped password from silently locking the +/// user out of future restores: +/// +/// 1. **Confirm-password field.** Same password must be typed twice; the +/// enable button stays disabled until the two fields match. This is +/// the only defence on the *first device* because there's nothing +/// remote to validate against yet. +/// 2. **Validation probe.** After the first device successfully enables +/// sync, a small encrypted canary is uploaded under +/// [SyncDocumentKind.syncValidationProbe]. On every *subsequent* +/// device, the probe is decrypted with the candidate key before the +/// key is persisted — wrong passwords are caught immediately rather +/// than silently breaking the next restore. +class SyncSetupCard extends HookConsumerWidget { + const SyncSetupCard({super.key, required this.email}); + + final String? email; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final passwordController = useTextEditingController(); + final confirmController = useTextEditingController(); + useListenable(passwordController); + useListenable(confirmController); + + final busy = useState(false); + final error = useState(null); + + final password = passwordController.text; + final confirm = confirmController.text; + final passwordsMatch = password.isNotEmpty && password == confirm; + + Future setupSyncKey() async { + if (password.isEmpty) { + error.value = 'Please enter your password'; + return; + } + if (password != confirm) { + error.value = 'Passwords do not match'; + return; + } + + busy.value = true; + error.value = null; + + try { + final syncKey = _deriveSyncKey(email: email, password: password); + + // Validate the candidate key against any existing encrypted envelope + // before persisting. Without this, a wrong password is silently + // accepted, leaving the in-memory syncKey unable to decrypt future + // restores — and a "Set up sync" UX that looks successful is + // actively misleading. + final syncRepo = ref.read(accountSyncRepositoryProvider.notifier); + final probe = await _findValidationProbe(syncRepo); + if (probe != null) { + final ok = await _canDecrypt(probe.contentBlob, syncKey); + if (!ok) { + error.value = + 'Password did not match your existing encrypted backups.'; + return; + } + } else { + // First device on this account — nothing exists to validate + // against, so we leave a probe behind for the *next* device's + // setup to verify against. Failing the whole setup if the + // probe upload fails (rather than silently degrading to "no + // probe written") keeps the contract simple: if sync is + // enabled here, a probe exists on the server. The user can + // retry — probe upload is a single small insert and the most + // likely failure cause is transient network. + await _uploadValidationProbe(syncRepo, syncKey); + } + + await ref + .read(accountAuthRepositoryProvider.notifier) + .setSyncKey(syncKey); + } catch (e) { + error.value = 'Failed to set up sync: $e'; + } finally { + busy.value = false; + } + } + + return Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.lock_outlined), + const SizedBox(width: 8), + Text( + 'Set Up Encrypted Sync', + style: Theme.of(context).textTheme.titleSmall, + ), + ], + ), + const SizedBox(height: 8), + Text( + 'Enter your account password to enable end-to-end encrypted ' + 'sync. Your data is encrypted on-device before upload — ' + 'the server never sees your settings.', + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 16), + TextField( + controller: passwordController, + decoration: InputDecoration( + labelText: 'Account Password', + border: const OutlineInputBorder(), + errorText: error.value, + ), + obscureText: true, + enabled: !busy.value, + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + TextField( + controller: confirmController, + decoration: const InputDecoration( + labelText: 'Confirm Password', + border: OutlineInputBorder(), + ), + obscureText: true, + enabled: !busy.value, + onSubmitted: (_) => setupSyncKey(), + ), + const SizedBox(height: 16), + Align( + alignment: Alignment.centerRight, + child: FilledButton( + onPressed: (busy.value || !passwordsMatch) + ? null + : setupSyncKey, + child: busy.value + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Enable Sync'), + ), + ), + ], + ), + ); + } +} + +/// Derives the sync key the rest of the app stores. The output is a +/// 64-char hex string fed to [SecureData] as a passphrase; the real +/// argon2id stretch happens inside [SecureData] every time it +/// encrypts/decrypts. +/// +/// This HMAC step is not the cryptographic primitive — it just +/// canonicalises `(email, password)` into a fixed-length deterministic +/// string. Two devices reaching the same [syncKey] is necessary for +/// cross-device decrypt to work. +String _deriveSyncKey({required String? email, required String password}) { + final normalizedEmail = email?.toLowerCase() ?? ''; + final hmac = Hmac(sha256, utf8.encode('weblibre-sync')); + final digest = hmac.convert(utf8.encode('$normalizedEmail:$password')); + return digest.toString(); +} + +/// Pick any existing remote envelope to verify the candidate sync key +/// against. +/// +/// Checks the dedicated [SyncDocumentKind.syncValidationProbe] first so +/// every setup pays the same small-payload decrypt cost regardless of how +/// large the user's settings snapshots are. Falls back to scanning the +/// other kinds in declaration order if no probe is found — that covers +/// users who set up sync before probes were a thing. +Future _findValidationProbe( + AccountSyncRepository syncRepo, +) async { + final ordered = [ + SyncDocumentKind.syncValidationProbe, + ...SyncDocumentKind.values.where( + (k) => k != SyncDocumentKind.syncValidationProbe, + ), + ]; + for (final kind in ordered) { + final docs = await syncRepo.listDocuments(kind: kind); + if (docs.isEmpty) continue; + final result = await syncRepo.fetchDocument(id: docs.first.id); + if (result != null) return result; + } + return null; +} + +/// Encrypt a small canary payload under [syncKey] and upload it as a +/// [SyncDocumentKind.syncValidationProbe] document. Future devices fetch +/// this row in [_findValidationProbe] and refuse to persist a mismatching +/// candidate key. +/// +/// The probe payload is intentionally tiny and version-tagged so a future +/// migration can recognise it without breaking older clients (they will +/// simply still decrypt-and-succeed, since the marker is opaque to them). +Future _uploadValidationProbe( + AccountSyncRepository syncRepo, + String syncKey, +) async { + final payload = utf8.encode('weblibre:sync-probe:v1'); + final secureData = SecureData( + argon2Params: Argon2Params.memoryConstrained(), + ); + final ciphertext = await secureData.encrypt(payload, syncKey); + await syncRepo.storeDocument( + kind: SyncDocumentKind.syncValidationProbe, + schemaVersion: 1, + contentBlob: base64Encode(ciphertext), + label: 'sync validation probe', + ); +} + +Future _canDecrypt(String contentBlob, String syncKey) async { + try { + final encrypted = base64Decode(contentBlob); + final secureData = SecureData( + argon2Params: Argon2Params.memoryConstrained(), + ); + await secureData.decrypt(encrypted, syncKey); + return true; + } catch (_) { + // Wrong password, corrupted envelope, or unsupported version — in + // all cases the candidate key is unusable for restoring this + // backup, so decline to persist it. The user can reset the sync + // key from the settings UI if their server-side data really is + // corrupted. + return false; + } +} diff --git a/apps/weblibre/lib/features/addons/presentation/screens/addon_listing_details.dart b/apps/weblibre/lib/features/addons/presentation/screens/addon_listing_details.dart index 8ab69d8a..f9ba159a 100644 --- a/apps/weblibre/lib/features/addons/presentation/screens/addon_listing_details.dart +++ b/apps/weblibre/lib/features/addons/presentation/screens/addon_listing_details.dart @@ -242,47 +242,53 @@ class _ScreenshotsSection extends StatelessWidget { Widget build(BuildContext context) { return SizedBox( height: 200, - child: ListView.separated( - scrollDirection: Axis.horizontal, - itemCount: previews.length, - separatorBuilder: (_, _) => const SizedBox(width: 12), - itemBuilder: (context, index) { - final p = previews[index]; - return GestureDetector( - onTap: () => showDialog( - context: context, - barrierColor: Colors.black87, - builder: (context) => Dialog( - backgroundColor: Colors.transparent, - insetPadding: EdgeInsets.zero, - child: GestureDetector( - onTap: () => Navigator.of(context).pop(), - child: InteractiveViewer( - child: Hero( - tag: p.imageUrl, - child: Image.network(p.imageUrl, fit: BoxFit.contain), + child: FadingScroll( + fadingSize: 15, + builder: (context, controller) { + return ListView.separated( + controller: controller, + scrollDirection: Axis.horizontal, + itemCount: previews.length, + separatorBuilder: (_, _) => const SizedBox(width: 12), + itemBuilder: (context, index) { + final p = previews[index]; + return GestureDetector( + onTap: () => showDialog( + context: context, + barrierColor: Colors.black87, + builder: (context) => Dialog( + backgroundColor: Colors.transparent, + insetPadding: EdgeInsets.zero, + child: GestureDetector( + onTap: () => Navigator.of(context).pop(), + child: InteractiveViewer( + child: Hero( + tag: p.imageUrl, + child: Image.network(p.imageUrl, fit: BoxFit.contain), + ), + ), ), ), ), - ), - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Hero( - tag: p.imageUrl, - child: Image.network( - p.thumbnailUrl ?? p.imageUrl, - fit: BoxFit.cover, - errorBuilder: (_, _, _) => Container( - width: 300, - color: Theme.of( - context, - ).colorScheme.surfaceContainerHighest, - child: const Icon(Icons.broken_image_outlined), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Hero( + tag: p.imageUrl, + child: Image.network( + p.thumbnailUrl ?? p.imageUrl, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => Container( + width: 300, + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + child: const Icon(Icons.broken_image_outlined), + ), + ), ), ), - ), - ), + ); + }, ); }, ), diff --git a/apps/weblibre/lib/features/addons/presentation/screens/addon_manager.dart b/apps/weblibre/lib/features/addons/presentation/screens/addon_manager.dart index 258c11fe..f7727103 100644 --- a/apps/weblibre/lib/features/addons/presentation/screens/addon_manager.dart +++ b/apps/weblibre/lib/features/addons/presentation/screens/addon_manager.dart @@ -86,8 +86,6 @@ class AddonManagerScreen extends ConsumerWidget { } } -enum _AddonManagerMenuAction { checkForUpdates, installFromFile } - class _AddonManagerOverflowMenu extends ConsumerWidget { final bool canCheckForUpdates; @@ -99,44 +97,39 @@ class _AddonManagerOverflowMenu extends ConsumerWidget { bulkAddonUpdateProvider.select((value) => value.isLoading), ); - return PopupMenuButton<_AddonManagerMenuAction>( - icon: updatesBusy - ? const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.more_vert), - onSelected: (action) async { - switch (action) { - case _AddonManagerMenuAction.checkForUpdates: - await ref.read(bulkAddonUpdateProvider.notifier).triggerAll(); - if (!context.mounted) return; - showInfoMessage( - context, - 'Background update checks started for installed extensions', - ); - case _AddonManagerMenuAction.installFromFile: - await showInstallLocalAddonDialog(context); - } - }, - itemBuilder: (context) => [ - PopupMenuItem( - value: _AddonManagerMenuAction.checkForUpdates, - enabled: canCheckForUpdates && !updatesBusy, - child: const ListTile( - leading: Icon(Icons.system_update_alt), - title: Text('Check for updates'), - contentPadding: EdgeInsets.zero, - ), + return MenuAnchor( + builder: (context, controller, _) => IconButton( + icon: updatesBusy + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.more_vert), + onPressed: () => + controller.isOpen ? controller.close() : controller.open(), + ), + menuChildren: [ + MenuItemButton( + leadingIcon: const Icon(Icons.system_update_alt), + onPressed: canCheckForUpdates && !updatesBusy + ? () async { + await ref.read(bulkAddonUpdateProvider.notifier).triggerAll(); + if (!context.mounted) return; + showInfoMessage( + context, + 'Background update checks started for installed extensions', + ); + } + : null, + child: const Text('Check for updates'), ), - const PopupMenuItem( - value: _AddonManagerMenuAction.installFromFile, - child: ListTile( - leading: Icon(Icons.file_open), - title: Text('Install from file'), - contentPadding: EdgeInsets.zero, - ), + MenuItemButton( + leadingIcon: const Icon(Icons.file_open), + onPressed: () async { + await showInstallLocalAddonDialog(context); + }, + child: const Text('Install from file'), ), ], ); diff --git a/apps/weblibre/lib/features/bangs/data/database/database.drift.dart b/apps/weblibre/lib/features/bangs/data/database/database.drift.dart index 91054a57..4f8bb7a6 100644 --- a/apps/weblibre/lib/features/bangs/data/database/database.drift.dart +++ b/apps/weblibre/lib/features/bangs/data/database/database.drift.dart @@ -159,6 +159,11 @@ extension DefineFunctions on i6.CommonDatabase { required String Function(int, String?) lexoRankPrevious, required String Function(String?, String?) lexoRankReorderAfter, required String Function(String?, String?) lexoRankReorderBefore, + required int Function() generateContentHash, + required bool Function(String?) urlIndexable, + required String Function(String?) urlCanonical, + required String Function(String?) urlHost, + required String Function(String?) urlPath, }) { createFunction( functionName: 'lexo_rank_next', @@ -196,5 +201,44 @@ extension DefineFunctions on i6.CommonDatabase { return lexoRankReorderBefore(arg0, arg1); }, ); + createFunction( + functionName: 'generate_content_hash', + argumentCount: const i6.AllowedArgumentCount(0), + function: (args) { + return generateContentHash(); + }, + ); + createFunction( + functionName: 'url_indexable', + argumentCount: const i6.AllowedArgumentCount(1), + function: (args) { + final arg0 = args[0] as String?; + return urlIndexable(arg0); + }, + ); + createFunction( + functionName: 'url_canonical', + argumentCount: const i6.AllowedArgumentCount(1), + function: (args) { + final arg0 = args[0] as String?; + return urlCanonical(arg0); + }, + ); + createFunction( + functionName: 'url_host', + argumentCount: const i6.AllowedArgumentCount(1), + function: (args) { + final arg0 = args[0] as String?; + return urlHost(arg0); + }, + ); + createFunction( + functionName: 'url_path', + argumentCount: const i6.AllowedArgumentCount(1), + function: (args) { + final arg0 = args[0] as String?; + return urlPath(arg0); + }, + ); } } diff --git a/apps/weblibre/lib/features/bangs/data/models/bang_group.dart b/apps/weblibre/lib/features/bangs/data/models/bang_group.dart index 81b829f4..75646600 100644 --- a/apps/weblibre/lib/features/bangs/data/models/bang_group.dart +++ b/apps/weblibre/lib/features/bangs/data/models/bang_group.dart @@ -28,7 +28,8 @@ enum BangGroup { 'https://raw.githubusercontent.com/FaFre/bangs/main/data/kagi_bangs.json', bundled: 'assets/bangs/kagi_bangs.json', ), - user(remote: null, bundled: null); + user(remote: null, bundled: null), + weblibre(remote: null, bundled: null); final String? bundled; final String? remote; diff --git a/apps/weblibre/lib/features/bangs/data/models/web_search_bang.dart b/apps/weblibre/lib/features/bangs/data/models/web_search_bang.dart new file mode 100644 index 00000000..5bc87ac3 --- /dev/null +++ b/apps/weblibre/lib/features/bangs/data/models/web_search_bang.dart @@ -0,0 +1,24 @@ +import 'package:weblibre/features/bangs/data/models/bang_data.dart'; +import 'package:weblibre/features/bangs/data/models/bang_group.dart'; +import 'package:weblibre/features/bangs/data/models/bang_key.dart'; + +const webSearchBangTrigger = 'wl'; + +final webSearchBang = BangData( + websiteName: 'WebLibre Search', + domain: 'weblibre.eu', + trigger: webSearchBangTrigger, + // Placeholder template — selecting this bang routes the query through the + // token-based search backend instead of opening this URL. + urlTemplate: 'https://weblibre.eu/?q={{{s}}}', + group: BangGroup.weblibre, + searxngApi: false, +); + +final webSearchBangKey = BangKey( + group: BangGroup.weblibre, + trigger: webSearchBangTrigger, +); + +bool isWebSearchBang(BangData? bang) => + bang != null && bang.group == BangGroup.weblibre; diff --git a/apps/weblibre/lib/features/bangs/domain/providers/search.dart b/apps/weblibre/lib/features/bangs/domain/providers/search.dart index 486ae7a1..2cb24488 100644 --- a/apps/weblibre/lib/features/bangs/domain/providers/search.dart +++ b/apps/weblibre/lib/features/bangs/domain/providers/search.dart @@ -47,6 +47,12 @@ class BangSearch extends _$BangSearch { return bang.getTemplateUrl(searchQuery); } + Future removeSearchEntry(String searchQuery) { + return ref + .read(bangDataRepositoryProvider.notifier) + .removeSearchEntry(searchQuery); + } + Future search(String input) async { if (input.isNotEmpty) { await ref.read(bangDatabaseProvider).bangDao.queryBangs(input).get().then( diff --git a/apps/weblibre/lib/features/bangs/domain/providers/search.g.dart b/apps/weblibre/lib/features/bangs/domain/providers/search.g.dart index 6cfb078a..18c977b5 100644 --- a/apps/weblibre/lib/features/bangs/domain/providers/search.g.dart +++ b/apps/weblibre/lib/features/bangs/domain/providers/search.g.dart @@ -33,7 +33,7 @@ final class BangSearchProvider BangSearch create() => BangSearch(); } -String _$bangSearchHash() => r'feed24edfe703b0697f4a855be9c7359c456b0f2'; +String _$bangSearchHash() => r'a541da1cfd8155abf03bbe19ab7c7200febd821c'; abstract class _$BangSearch extends $StreamNotifier> { Stream> build(); diff --git a/apps/weblibre/lib/features/bangs/domain/repositories/data.dart b/apps/weblibre/lib/features/bangs/domain/repositories/data.dart index d283e995..c95ae840 100644 --- a/apps/weblibre/lib/features/bangs/domain/repositories/data.dart +++ b/apps/weblibre/lib/features/bangs/domain/repositories/data.dart @@ -99,11 +99,17 @@ class BangDataRepository extends _$BangDataRepository { } Stream> watchSearchHistory({required int limit}) { - return ref + final query = ref .read(bangDatabaseProvider) .definitionsDrift - .searchHistoryEntries(limit: limit) - .watch(); + .searchHistoryEntries(limit: limit); + + return () async* { + // Emit a direct read first so Recent Searches is populated immediately + // on app start instead of waiting for the next bang_history write. + yield await query.get(); + yield* query.watch(); + }(); } Future increaseFrequency(BangKey key) { diff --git a/apps/weblibre/lib/features/bangs/domain/repositories/data.g.dart b/apps/weblibre/lib/features/bangs/domain/repositories/data.g.dart index e7a85edb..c1111885 100644 --- a/apps/weblibre/lib/features/bangs/domain/repositories/data.g.dart +++ b/apps/weblibre/lib/features/bangs/domain/repositories/data.g.dart @@ -42,7 +42,7 @@ final class BangDataRepositoryProvider } String _$bangDataRepositoryHash() => - r'c562ef10d75ca6dee13805f84d2491cf2a89aaac'; + r'c8e2b17214117a8d88b5246bdcc565bc15e8e4ac'; abstract class _$BangDataRepository extends $Notifier { void build(); diff --git a/apps/weblibre/lib/features/bangs/domain/repositories/sync.dart b/apps/weblibre/lib/features/bangs/domain/repositories/sync.dart index f4466c67..da15b1a5 100644 --- a/apps/weblibre/lib/features/bangs/domain/repositories/sync.dart +++ b/apps/weblibre/lib/features/bangs/domain/repositories/sync.dart @@ -21,6 +21,7 @@ import 'package:exceptions/exceptions.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:weblibre/features/bangs/data/database/database.dart'; import 'package:weblibre/features/bangs/data/models/bang_group.dart'; +import 'package:weblibre/features/bangs/data/models/web_search_bang.dart'; import 'package:weblibre/features/bangs/data/providers.dart'; import 'package:weblibre/features/bangs/data/services/data_source.dart'; @@ -170,6 +171,21 @@ class BangSyncRepository extends _$BangSyncRepository { .watchSingleOrNull(); } + Future> _syncSyntheticBangs() async { + try { + await ref.read(bangDatabaseProvider).bangDao.upsertBang(webSearchBang); + return Result.success(null); + } catch (e) { + return Result.failure( + ErrorMessage( + message: 'Failed to sync synthetic Bangs', + source: 'BangSync', + details: e, + ), + ); + } + } + Future>> syncBundledBangGroups({ Set? groups, }) async { @@ -183,7 +199,10 @@ class BangSyncRepository extends _$BangSyncRepository { ).then((result) => MapEntry(source, result)), ); - return Map.fromEntries(await Future.wait(futures)); + return { + ...Map.fromEntries(await Future.wait(futures)), + BangGroup.weblibre: await _syncSyntheticBangs(), + }; } @override diff --git a/apps/weblibre/lib/features/bangs/domain/repositories/sync.g.dart b/apps/weblibre/lib/features/bangs/domain/repositories/sync.g.dart index 29e0cac6..941ed2a5 100644 --- a/apps/weblibre/lib/features/bangs/domain/repositories/sync.g.dart +++ b/apps/weblibre/lib/features/bangs/domain/repositories/sync.g.dart @@ -42,7 +42,7 @@ final class BangSyncRepositoryProvider } String _$bangSyncRepositoryHash() => - r'1347dcbd03f6a1a4fa3bbbae5d9302098d260c50'; + r'cdb78ef0243224fdb7b3a8660f0920c471987296'; abstract class _$BangSyncRepository extends $Notifier { void build(); diff --git a/apps/weblibre/lib/features/bangs/presentation/screens/search.dart b/apps/weblibre/lib/features/bangs/presentation/screens/search.dart index b4fc861f..435ac272 100644 --- a/apps/weblibre/lib/features/bangs/presentation/screens/search.dart +++ b/apps/weblibre/lib/features/bangs/presentation/screens/search.dart @@ -24,6 +24,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:weblibre/features/bangs/data/models/bang_data.dart'; import 'package:weblibre/features/bangs/domain/providers/search.dart'; import 'package:weblibre/features/bangs/presentation/widgets/bang_details.dart'; import 'package:weblibre/features/user/domain/providers.dart'; @@ -36,7 +37,7 @@ class BangSearchScreen extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final resultsAsync = ref.watch(bangSearchProvider); + final resultsAsync = ref.watch(seamlessBangProvider); final incognitoEnabled = ref.watch(incognitoModeEnabledProvider); final focusNode = useFocusNode(); @@ -45,11 +46,9 @@ class BangSearchScreen extends HookConsumerWidget { ); useOnListenableChange(textEditingController, () { - unawaited( - ref - .read(bangSearchProvider.notifier) - .search(textEditingController.text), - ); + ref + .read(seamlessBangProvider.notifier) + .search(textEditingController.text); }); return Scaffold( @@ -79,24 +78,26 @@ class BangSearchScreen extends HookConsumerWidget { body: SafeArea( child: resultsAsync.when( skipLoadingOnReload: true, - data: (bangs) => FadingScroll( - fadingSize: 25, - builder: (context, controller) { - return ListView.builder( - controller: controller, - itemCount: bangs.length, - itemBuilder: (context, index) { - final bang = bangs[index]; - return BangDetails( - bang, - onTap: () { - context.pop(bang.toKey()); - }, - ); - }, - ); - }, - ), + data: (bangs) { + return FadingScroll( + fadingSize: 25, + builder: (context, controller) { + return ListView.builder( + controller: controller, + itemCount: bangs.length, + itemBuilder: (context, index) { + final bang = bangs[index]; + return BangDetails( + bang, + onTap: () { + context.pop(bang.toKey()); + }, + ); + }, + ); + }, + ); + }, error: (error, stackTrace) => Center( child: FailureWidget(title: 'Bang Search failed', exception: error), ), diff --git a/apps/weblibre/lib/features/geckoview/domain/entities/browser_icon.dart b/apps/weblibre/lib/features/geckoview/domain/entities/browser_icon.dart index 89eb5e72..15cca265 100644 --- a/apps/weblibre/lib/features/geckoview/domain/entities/browser_icon.dart +++ b/apps/weblibre/lib/features/geckoview/domain/entities/browser_icon.dart @@ -31,15 +31,18 @@ class BrowserIcon with FastEquatable { final Color? dominantColor; final IconSource source; - static Future fromBytes( + static Future fromBytes( Uint8List bytes, { required Color? dominantColor, required IconSource source, }) async { final image = await tryDecodeImage(bytes); + if (image == null) { + return null; + } return BrowserIcon( - image: image!, + image: image, dominantColor: dominantColor, source: source, ); diff --git a/apps/weblibre/lib/features/geckoview/domain/providers/tab_state.dart b/apps/weblibre/lib/features/geckoview/domain/providers/tab_state.dart index 84879d9b..053c39ba 100644 --- a/apps/weblibre/lib/features/geckoview/domain/providers/tab_state.dart +++ b/apps/weblibre/lib/features/geckoview/domain/providers/tab_state.dart @@ -48,23 +48,6 @@ part 'tab_state.g.dart'; @Riverpod(keepAlive: true) class TabStates extends _$TabStates { - /// Disposes images from a TabState to free GPU memory. - void _disposeTabImages(TabState tab) { - tab.icon?.dispose(); - tab.thumbnail?.dispose(); - } - - /// Updates state while disposing images from removed tabs. - void _updateState(Map newState) { - // Find and dispose images from tabs that are being removed - for (final tabId in state.keys) { - if (!newState.containsKey(tabId)) { - _disposeTabImages(state[tabId]!); - } - } - - state = newState; - } Future _onTabContentStateChange(TabContentState contentState) async { final current = await patchedState(contentState.id); @@ -103,7 +86,7 @@ class TabStates extends _$TabStates { showToolbarAsExpanded: contentState.showToolbarAsExpanded, ); - _updateState({...state}..[contentState.id] = newState); + state = {...state}..[contentState.id] = newState; if (newState.isFinishedLoading) { ref @@ -139,9 +122,6 @@ class TabStates extends _$TabStates { final image = await bytes.mapNotNull((bytes) => tryDecodeImage(bytes)); final current = state[tabId] ?? TabState.$default(tabId); - // Dispose old icon only after successfully creating new one - current.icon?.dispose(); - state = {...state}..[tabId] = current.copyWith.icon(image); } @@ -151,9 +131,6 @@ class TabStates extends _$TabStates { final image = await bytes.mapNotNull((bytes) => tryDecodeImage(bytes)); final current = state[tabId] ?? TabState.$default(tabId); - // Dispose old thumbnail only after successfully creating new one - current.thumbnail?.dispose(); - state = {...state}..[tabId] = current.copyWith.thumbnail(image); } @@ -368,12 +345,6 @@ class TabStates extends _$TabStates { ); ref.onDispose(() async { - // Dispose all remaining tab images - for (final tab in state.values) { - _disposeTabImages(tab); - } - - // Cancel all stream subscriptions for (final sub in subscriptions) { await sub.cancel(); } diff --git a/apps/weblibre/lib/features/geckoview/domain/providers/tab_state.g.dart b/apps/weblibre/lib/features/geckoview/domain/providers/tab_state.g.dart index 460881a6..db3d2e2a 100644 --- a/apps/weblibre/lib/features/geckoview/domain/providers/tab_state.g.dart +++ b/apps/weblibre/lib/features/geckoview/domain/providers/tab_state.g.dart @@ -41,7 +41,7 @@ final class TabStatesProvider } } -String _$tabStatesHash() => r'6fc9a91b1343f9ae0b442100bed20e42448afa37'; +String _$tabStatesHash() => r'd7efef406dd132a3ef1a67da06c794d18ccb484a'; abstract class _$TabStates extends $Notifier> { Map build(); diff --git a/apps/weblibre/lib/features/geckoview/domain/providers/web_extensions_state.dart b/apps/weblibre/lib/features/geckoview/domain/providers/web_extensions_state.dart index c152c9e0..2d0c903a 100644 --- a/apps/weblibre/lib/features/geckoview/domain/providers/web_extensions_state.dart +++ b/apps/weblibre/lib/features/geckoview/domain/providers/web_extensions_state.dart @@ -35,8 +35,7 @@ part 'web_extensions_state.g.dart'; class WebExtensionsState extends _$WebExtensionsState { late final LRUCache _imageCache; - WebExtensionsState() - : _imageCache = LRUCache(50, onEvict: (image) => image.dispose()); + WebExtensionsState() : _imageCache = LRUCache(50); void _onExtensionUpdate(ExtensionDataEvent event) { if (!ref.mounted) { @@ -46,11 +45,6 @@ class WebExtensionsState extends _$WebExtensionsState { final ExtensionDataEvent(:extensionId, :data) = event; if (data != null) { - final cachedIcon = _imageCache.get(extensionId); - if (cachedIcon != null && cachedIcon.value == null) { - _imageCache.remove(extensionId); - } - final current = state[extensionId] ?? WebExtensionState( @@ -74,7 +68,6 @@ class WebExtensionsState extends _$WebExtensionsState { } else { if (state.containsKey(extensionId)) { state = {...state}..remove(extensionId); - // remove() triggers onEvict which handles disposal _imageCache.remove(extensionId); } } @@ -91,7 +84,6 @@ class WebExtensionsState extends _$WebExtensionsState { } if (image != null) { - // set() will evict the old entry via onEvict callback, which handles disposal _imageCache.set(extensionId, image); if (state.containsKey(extensionId)) { @@ -198,7 +190,6 @@ class WebExtensionsState extends _$WebExtensionsState { // Dispose all cached images _imageCache.clear(); - // Cancel all stream subscriptions for (final sub in subscriptions) { unawaited(sub.cancel()); } diff --git a/apps/weblibre/lib/features/geckoview/domain/providers/web_extensions_state.g.dart b/apps/weblibre/lib/features/geckoview/domain/providers/web_extensions_state.g.dart index d70a38fb..8cd4672b 100644 --- a/apps/weblibre/lib/features/geckoview/domain/providers/web_extensions_state.g.dart +++ b/apps/weblibre/lib/features/geckoview/domain/providers/web_extensions_state.g.dart @@ -62,7 +62,7 @@ final class WebExtensionsStateProvider } String _$webExtensionsStateHash() => - r'e067323e9a0a466e46e0c4d529667950ee62d4ca'; + r'59379bbaa9ae867f9dc50dae5c0f06b159faaedf'; final class WebExtensionsStateFamily extends $Family with diff --git a/apps/weblibre/lib/features/geckoview/domain/repositories/tab.dart b/apps/weblibre/lib/features/geckoview/domain/repositories/tab.dart index 3bc8a47e..ca3b0cde 100644 --- a/apps/weblibre/lib/features/geckoview/domain/repositories/tab.dart +++ b/apps/weblibre/lib/features/geckoview/domain/repositories/tab.dart @@ -26,6 +26,7 @@ import 'package:nullability/nullability.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:synchronized/synchronized.dart'; import 'package:weblibre/core/logger.dart'; +import 'package:weblibre/core/routing/routes.dart'; import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart'; import 'package:weblibre/features/geckoview/domain/entities/tab_container_selection.dart'; import 'package:weblibre/features/geckoview/domain/providers.dart'; @@ -49,6 +50,20 @@ import 'package:weblibre/utils/debouncer.dart'; part 'tab.g.dart'; +sealed class TabBackPromptBehavior { + const TabBackPromptBehavior(); +} + +final class BackgroundAppTabBackPromptBehavior extends TabBackPromptBehavior { + const BackgroundAppTabBackPromptBehavior(); +} + +final class ReturnToSearchTabBackPromptBehavior extends TabBackPromptBehavior { + final TabType tabType; + + const ReturnToSearchTabBackPromptBehavior({required this.tabType}); +} + @Riverpod(keepAlive: true) class TabRepository extends _$TabRepository { final _tabsService = GeckoTabService(); @@ -57,20 +72,20 @@ class TabRepository extends _$TabRepository { bool _reclosing = false; bool _suppressNextReclose = false; - final _tabFromIntent = {}; + final _tabBackPromptBehavior = {}; final _closeLock = Lock(); final _pendingIsolationCleanup = {}; - bool hasLaunchedFromIntent(String? tabId) { + TabBackPromptBehavior? backPromptBehaviorFor(String? tabId) { if (tabId == null) { - return false; + return null; } - return _tabFromIntent.contains(tabId); + return _tabBackPromptBehavior[tabId]; } - void clearLaunchedFromIntent(String tabId) { - _tabFromIntent.remove(tabId); + void clearBackPromptBehavior(String tabId) { + _tabBackPromptBehavior.remove(tabId); } Future _resolveParentIdForContext({ @@ -105,6 +120,7 @@ class TabRepository extends _$TabRepository { TabContainerSelection containerSelection = const TabContainerSelection.useSelected(), bool launchedFromIntent = false, + TabBackPromptBehavior? promptOnBackBehavior, }) async { final tabDao = ref.read(tabDatabaseProvider).tabDao; @@ -152,12 +168,43 @@ class TabRepository extends _$TabRepository { ); if (launchedFromIntent) { - _tabFromIntent.add(newTabId); + _tabBackPromptBehavior[newTabId] = + promptOnBackBehavior ?? const BackgroundAppTabBackPromptBehavior(); + } else if (promptOnBackBehavior != null) { + _tabBackPromptBehavior[newTabId] = promptOnBackBehavior; } return newTabId; } + Future _closeRestoredPrivateCaptureTabs(List tabIds) async { + if (tabIds.isEmpty) { + return false; + } + + final captureRows = await ref + .read(tabDatabaseProvider) + .captureTabDao + .findAll(); + final tabStates = ref.read(tabStatesProvider); + final restoredPrivateCaptureTabIds = captureRows + .where((row) => row.createdAt.isBefore(_sessionStartedAt)) + .map((row) => row.tabId) + .where((tabId) => tabIds.contains(tabId)) + .where((tabId) => tabStates[tabId]?.tabMode is PrivateTabMode) + .toList(growable: false); + + if (restoredPrivateCaptureTabIds.isEmpty) { + return false; + } + + await _closeTabsInternal( + restoredPrivateCaptureTabIds, + recordTombstones: false, + ); + return true; + } + Future> addMultipleTabs({ required List tabs, String? selectTabId, @@ -571,6 +618,7 @@ class TabRepository extends _$TabRepository { } for (final tabId in tabIds) { + _tabBackPromptBehavior.remove(tabId); final isolationContextId = ref .read(tabStatesProvider)[tabId] ?.isolationContextId; @@ -935,6 +983,8 @@ class TabRepository extends _$TabRepository { await _clearTombstonesForCurrentTabs(next.value); } else if (await _recloseRestoredClosedTabs(next.value)) { return; + } else if (await _closeRestoredPrivateCaptureTabs(next.value)) { + return; } //Only sync tabs if there has been a previous value or is not empty diff --git a/apps/weblibre/lib/features/geckoview/domain/repositories/tab.g.dart b/apps/weblibre/lib/features/geckoview/domain/repositories/tab.g.dart index c60e7a3d..160441fd 100644 --- a/apps/weblibre/lib/features/geckoview/domain/repositories/tab.g.dart +++ b/apps/weblibre/lib/features/geckoview/domain/repositories/tab.g.dart @@ -41,7 +41,7 @@ final class TabRepositoryProvider } } -String _$tabRepositoryHash() => r'f458baaf9061143a668470dccd57f32664728951'; +String _$tabRepositoryHash() => r'ea5caa3c9c4b19898db9de1f77d5476794b8b66f'; abstract class _$TabRepository extends $Notifier { void build(); diff --git a/apps/weblibre/lib/features/geckoview/features/bookmarks/presentation/dialogs/select_bookmark_folder_dialog.dart b/apps/weblibre/lib/features/geckoview/features/bookmarks/presentation/dialogs/select_bookmark_folder_dialog.dart index 5ff03ec3..b3c1bee7 100644 --- a/apps/weblibre/lib/features/geckoview/features/bookmarks/presentation/dialogs/select_bookmark_folder_dialog.dart +++ b/apps/weblibre/lib/features/geckoview/features/bookmarks/presentation/dialogs/select_bookmark_folder_dialog.dart @@ -17,6 +17,7 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ +import 'package:fading_scroll/fading_scroll.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; @@ -71,12 +72,18 @@ class _SelectBookmarkFolderSheet extends HookConsumerWidget { constraints: BoxConstraints( maxHeight: MediaQuery.of(context).size.height * 0.5, ), - child: SingleChildScrollView( - child: FolderTreePicker( - selectedFolderGuid: selectedGuid, - excludeFolderGuids: excludeFolderGuids, - entryGuid: BookmarkRoot.root.id, - ), + child: FadingScroll( + fadingSize: 25, + builder: (context, controller) { + return SingleChildScrollView( + controller: controller, + child: FolderTreePicker( + selectedFolderGuid: selectedGuid, + excludeFolderGuids: excludeFolderGuids, + entryGuid: BookmarkRoot.root.id, + ), + ); + }, ), ), const SizedBox(height: 16), diff --git a/apps/weblibre/lib/features/geckoview/features/browser/domain/providers.dart b/apps/weblibre/lib/features/geckoview/features/browser/domain/providers.dart index adf0643e..c0da2bae 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/domain/providers.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/domain/providers.dart @@ -45,6 +45,7 @@ import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/ge import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab_search.dart'; import 'package:weblibre/features/user/data/models/general_settings.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; +import 'package:weblibre/features/web_search/domain/controllers/sandbox_capture_controller.dart'; part 'providers.g.dart'; @@ -754,19 +755,25 @@ EquatableValue> filteredTabPreviews( return EquatableValue([]); } + final sandboxCaptureMap = + ref.watch(sandboxCaptureMapProvider).value ?? const {}; + return EquatableValue( tabSearchResults.results .where((tab) => availableTabStates.value.containsKey(tab.id)) .map((tab) { final tabState = availableTabStates.value[tab.id]!; + final sandboxSourceUri = parseSandboxSource( + sandboxCaptureMap[tab.id], + ); return TabPreview( id: tab.id, containerId: tab.containerId, title: tab.title ?? tabState.title, icon: tabState.icon, - url: tab.cleanUrl ?? tabState.url, - highlightedUrl: tab.url, + url: sandboxSourceUri ?? tab.cleanUrl ?? tabState.url, + highlightedUrl: sandboxSourceUri?.toString() ?? tab.url, extractedContent: tab.extractedContent, fullContent: tab.fullContent, sourceSearchQuery: tabSearchResults.query, diff --git a/apps/weblibre/lib/features/geckoview/features/browser/domain/providers.g.dart b/apps/weblibre/lib/features/geckoview/features/browser/domain/providers.g.dart index a3511989..d0ca8897 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/domain/providers.g.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/domain/providers.g.dart @@ -1066,7 +1066,7 @@ final class FilteredTabPreviewsProvider } String _$filteredTabPreviewsHash() => - r'2327ad86650b3baa6b9339e280abfc7463c72cf9'; + r'074df0d2000ae325fbd1db775abe4836491b32dd'; final class FilteredTabPreviewsFamily extends $Family with diff --git a/apps/weblibre/lib/features/geckoview/features/browser/domain/services/browser_data.dart b/apps/weblibre/lib/features/geckoview/features/browser/domain/services/browser_data.dart index c5fddf8c..35d8f7f7 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/domain/services/browser_data.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/domain/services/browser_data.dart @@ -19,6 +19,7 @@ */ import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/providers.dart'; import 'package:weblibre/features/user/data/models/general_settings.dart'; part 'browser_data.g.dart'; @@ -46,6 +47,7 @@ class BrowserDataService extends _$BrowserDataService { await _service.deleteTabs(); case DeleteBrowsingDataType.history: await _service.deleteBrowsingHistory(); + await ref.read(tabDatabaseProvider).historyDao.clear(); case DeleteBrowsingDataType.cookies: await _service.deleteCookiesAndSiteData(); case DeleteBrowsingDataType.cache: diff --git a/apps/weblibre/lib/features/geckoview/features/browser/domain/services/browser_data.g.dart b/apps/weblibre/lib/features/geckoview/features/browser/domain/services/browser_data.g.dart index 3d2a5823..f016e851 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/domain/services/browser_data.g.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/domain/services/browser_data.g.dart @@ -42,7 +42,7 @@ final class BrowserDataServiceProvider } String _$browserDataServiceHash() => - r'861943be10c4dea325484b6a28ba21f3ff0d29fa'; + r'b6586d14ef13ab2905072e1e26cabc17846eaf73'; abstract class _$BrowserDataService extends $Notifier { void build(); diff --git a/apps/weblibre/lib/features/geckoview/features/browser/features/contextual_toolbar/presentation/toolbar_button_registry.dart b/apps/weblibre/lib/features/geckoview/features/browser/features/contextual_toolbar/presentation/toolbar_button_registry.dart index b1f78abc..e7e51efb 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/features/contextual_toolbar/presentation/toolbar_button_registry.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/features/contextual_toolbar/presentation/toolbar_button_registry.dart @@ -52,6 +52,7 @@ import 'package:weblibre/features/user/data/models/engine_settings.dart'; import 'package:weblibre/features/user/domain/presentation/dialogs/quit_browser_dialog.dart'; import 'package:weblibre/features/user/domain/repositories/engine_settings.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; +import 'package:weblibre/features/web_search/domain/controllers/sandbox_capture_controller.dart'; import 'package:weblibre/utils/exit_app.dart'; import 'package:weblibre/utils/move_to_background.dart'; import 'package:weblibre/utils/ui_helper.dart' as ui_helper; @@ -327,14 +328,12 @@ final List toolbarButtonRegistry = [ : () async { final tabState = scope.tabState; if (tabState != null) { - final searchText = tabState.url.scheme == 'about' - ? SearchRoute.emptySearchText - : tabState.url.toString(); + final sandboxSourceUri = ref.read( + sandboxSourceUriForTabProvider(tabId: tabState.id), + ); await SearchRoute( tabId: tabState.id, - searchText: searchText.isEmpty - ? SearchRoute.emptySearchText - : searchText, + searchText: searchTextForTab(tabState, sandboxSourceUri), tabType: tabState.tabMode.toTabType(), ).push(context); } else { diff --git a/apps/weblibre/lib/features/geckoview/features/browser/features/contextual_toolbar/presentation/widgets/contextual_bar_buttons.dart b/apps/weblibre/lib/features/geckoview/features/browser/features/contextual_toolbar/presentation/widgets/contextual_bar_buttons.dart index ce221cb1..6aa1b6b3 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/features/contextual_toolbar/presentation/widgets/contextual_bar_buttons.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/features/contextual_toolbar/presentation/widgets/contextual_bar_buttons.dart @@ -36,6 +36,7 @@ import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart' as tab_data; import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; +import 'package:weblibre/features/web_search/domain/controllers/sandbox_capture_controller.dart'; import 'package:weblibre/presentation/hooks/menu_controller.dart'; import 'package:weblibre/utils/ui_helper.dart' as ui_helper; @@ -356,6 +357,12 @@ Future _cloneTabAsMode( final tabState = ref.read(tabStateProvider(selectedTabId)); if (tabState == null) return; + // Sandbox-captured tab: clone the canonical source URL so the new tab + // either re-captures or loads the real site — never the loopback loader. + final cloneUrl = + ref.read(sandboxSourceUriForTabProvider(tabId: tabState.id)) ?? + tabState.url; + final containerData = await ref .read(tab_data.tabDataRepositoryProvider.notifier) .getTabContainerData(selectedTabId); @@ -371,7 +378,7 @@ Future _cloneTabAsMode( ) : await repo.addTab( tabMode: TabMode.regular, - url: tabState.url, + url: cloneUrl, containerSelection: containerData == null ? const TabContainerSelection.unassigned() : TabContainerSelection.specific(containerData), @@ -386,7 +393,7 @@ Future _cloneTabAsMode( ) : await repo.addTab( tabMode: TabMode.private, - url: tabState.url, + url: cloneUrl, containerSelection: containerData == null ? const TabContainerSelection.unassigned() : TabContainerSelection.specific(containerData), @@ -394,7 +401,7 @@ Future _cloneTabAsMode( ), IsolatedTabMode() => await repo.addTab( tabMode: TabMode.newIsolated(), - url: tabState.url, + url: cloneUrl, containerSelection: containerData == null ? const TabContainerSelection.unassigned() : TabContainerSelection.specific(containerData), diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/dialogs/keep_tab_dialog.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/dialogs/keep_tab_dialog.dart index 7e2173f6..8f504032 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/dialogs/keep_tab_dialog.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/dialogs/keep_tab_dialog.dart @@ -19,7 +19,7 @@ */ import 'package:flutter/material.dart'; -/// Shows a dialog asking the user whether to keep a tab that was opened from another app. +/// Shows a dialog asking the user whether to keep a temporary tab. /// /// Returns true if the user wants to keep the tab, false if they want to discard it. Future showKeepTabDialog(BuildContext context) { @@ -27,9 +27,7 @@ Future showKeepTabDialog(BuildContext context) { context: context, builder: (context) => AlertDialog( title: const Text('Keep tab?'), - content: const Text( - 'This tab was opened from another app. Do you want to keep it or discard it?', - ), + content: const Text('Do you want to keep this tab or discard it?'), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(false), diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/dialogs/select_folder_dialog.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/dialogs/select_folder_dialog.dart index da0310d1..aa50361b 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/dialogs/select_folder_dialog.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/dialogs/select_folder_dialog.dart @@ -18,6 +18,7 @@ * along with this program. If not, see . */ +import 'package:fading_scroll/fading_scroll.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; @@ -58,11 +59,17 @@ class _SelectFolderSheet extends HookConsumerWidget { constraints: BoxConstraints( maxHeight: MediaQuery.of(context).size.height * 0.5, ), - child: SingleChildScrollView( - child: FolderTreePicker( - selectedFolderGuid: selectedFolderGuid, - entryGuid: BookmarkRoot.root.id, - ), + child: FadingScroll( + fadingSize: 25, + builder: (context, controller) { + return SingleChildScrollView( + controller: controller, + child: FolderTreePicker( + selectedFolderGuid: selectedFolderGuid, + entryGuid: BookmarkRoot.root.id, + ), + ); + }, ), ), const SizedBox(height: 16), diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/providers/site_settings_badge_provider.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/providers/site_settings_badge_provider.dart index 63ba3592..834f0d69 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/providers/site_settings_badge_provider.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/providers/site_settings_badge_provider.dart @@ -24,6 +24,7 @@ import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; import 'package:weblibre/features/geckoview/features/browser/domain/repositories/site_permissions.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/sheets/tracking_protection_provider.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart'; +import 'package:weblibre/features/web_search/domain/controllers/sandbox_capture_controller.dart'; part 'site_settings_badge_provider.g.dart'; @@ -49,6 +50,17 @@ Future showSiteSettingsBadge(Ref ref) async { return SiteSettingsBadgeState.hidden; } + // Sandbox-captured tabs render content from a loopback server, so + // permissions/tracking-exception lookups would key on the loader origin + // rather than the canonical site. Suppress the badge entirely — the user + // can't meaningfully change permissions for a sandboxed page anyway. + final sandboxSourceUri = ref.watch( + sandboxSourceUriForTabProvider(tabId: tabState.id), + ); + if (sandboxSourceUri != null) { + return SiteSettingsBadgeState.hidden; + } + // Check tracking protection exception final hasTrackingException = await ref.watch( hasTrackingProtectionExceptionProvider(tabState.id).future, diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/providers/site_settings_badge_provider.g.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/providers/site_settings_badge_provider.g.dart index 6887cd5e..2ee7a2bd 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/providers/site_settings_badge_provider.g.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/providers/site_settings_badge_provider.g.dart @@ -53,4 +53,4 @@ final class ShowSiteSettingsBadgeProvider } String _$showSiteSettingsBadgeHash() => - r'fbfe409cdd6656d5cd26e377a68acde6ff4294fa'; + r'6345920e4391786e47a89964c7ffa75818732584'; diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/screens/browser.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/screens/browser.dart index 8f8fb383..3942a208 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/screens/browser.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/screens/browser.dart @@ -57,6 +57,7 @@ import 'package:weblibre/features/geckoview/features/contextmenu/extensions/hit_ import 'package:weblibre/features/geckoview/features/find_in_page/presentation/controllers/find_in_page.dart'; import 'package:weblibre/features/geckoview/features/find_in_page/presentation/widgets/find_in_page.dart'; import 'package:weblibre/features/geckoview/features/readerview/presentation/controllers/readerable.dart'; +import 'package:weblibre/features/geckoview/features/search/domain/providers/search_autofocus.dart'; import 'package:weblibre/features/small_web/presentation/controllers/small_web_mode_controller.dart'; import 'package:weblibre/features/small_web/presentation/widgets/small_web_browser_overlay.dart'; import 'package:weblibre/features/sync/domain/repositories/sync.dart'; @@ -1022,7 +1023,12 @@ class _Browser extends HookConsumerWidget { return OverlayPortal( controller: overlayController, overlayChildBuilder: (context) { - return overlayBuilder!.call(context); + // The OverlayPortal lifecycle is driven by ref.listen above, but + // OverlayPortal can call this builder one extra frame after + // dismiss() set the provider back to null (esp. on rebuilds + // triggered by unrelated state changes). Render an empty box + // instead of force-unwrapping a null builder. + return overlayBuilder?.call(context) ?? const SizedBox.shrink(); }, child: Listener( onPointerDown: sheetDisplayed @@ -1035,6 +1041,9 @@ class _Browser extends HookConsumerWidget { child: BackButtonListener( onBackButtonPressed: () async { final tabState = ref.read(selectedTabStateProvider); + final promptOnBackBehavior = ref + .read(tabRepositoryProvider.notifier) + .backPromptBehaviorFor(tabState?.id); final tabCount = ref.read( tabListProvider.select((tabs) => tabs.value.length), @@ -1103,27 +1112,22 @@ class _Browser extends HookConsumerWidget { return true; } - //Go router has routes to go back to - if (context.canPop()) { - return true; - } - - if (ref - .read(tabRepositoryProvider.notifier) - .hasLaunchedFromIntent(tabState?.id)) { + if (promptOnBackBehavior != null) { if (!context.mounted) return false; final keep = await showKeepTabDialog(context); - if (keep == true) { - ref - .read(tabRepositoryProvider.notifier) - .clearLaunchedFromIntent(tabState!.id); - - await moveToBackground(); + if (keep == null) { return true; } - if (tabState != null) { + if (keep) { + if (tabState == null) { + return true; + } + ref + .read(tabRepositoryProvider.notifier) + .clearBackPromptBehavior(tabState.id); + } else if (tabState != null) { if (!await confirmIsolatedTabCloseIfNeeded(tabState.id)) { return true; } @@ -1133,7 +1137,25 @@ class _Browser extends HookConsumerWidget { .closeTab(tabState.id); } - await moveToBackground(); + if (!context.mounted) return true; + + switch (promptOnBackBehavior) { + case BackgroundAppTabBackPromptBehavior(): + await moveToBackground(); + break; + case ReturnToSearchTabBackPromptBehavior(:final tabType): + ref + .read(searchAutofocusSuppressionProvider.notifier) + .suppressNext(); + await SearchRoute(tabType: tabType).push(context); + break; + } + + return true; + } + + //Go router has routes to go back to + if (context.canPop()) { return true; } diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_home.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_home.dart index 4f48336b..9daa2e3c 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_home.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_home.dart @@ -248,6 +248,7 @@ class _ContainerHeader extends StatelessWidget { Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final containerColor = container.color; + final containerPalette = ContainerColors.palette(context, containerColor); return Container( width: 112, @@ -259,22 +260,11 @@ class _ContainerHeader extends StatelessWidget { begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [ - Color.alphaBlend( - ContainerColors.forChip(containerColor), - colorScheme.surfaceContainerHighest, - ), - Color.alphaBlend( - containerColor.withValues(alpha: 0.12), - colorScheme.surfaceContainer, - ), + containerPalette.surfaceHighColor, + containerPalette.surfaceColor, ], ), - border: Border.all( - color: Color.alphaBlend( - containerColor.withValues(alpha: 0.25), - colorScheme.outlineVariant.withValues(alpha: 0.45), - ), - ), + border: Border.all(color: containerPalette.outlineColor), boxShadow: [ BoxShadow( color: colorScheme.shadow.withValues(alpha: 0.08), diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_menu_sheet.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_menu_sheet.dart index b7e16422..7b92f0b7 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_menu_sheet.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_menu_sheet.dart @@ -22,6 +22,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:ui' as ui; +import 'package:fading_scroll/fading_scroll.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -73,6 +74,7 @@ import 'package:weblibre/features/tor/domain/services/tor_proxy.dart'; import 'package:weblibre/features/user/domain/presentation/dialogs/quit_browser_dialog.dart'; import 'package:weblibre/features/user/domain/providers.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; +import 'package:weblibre/features/web_search/domain/controllers/sandbox_capture_controller.dart'; import 'package:weblibre/presentation/controllers/website_title.dart'; import 'package:weblibre/presentation/hooks/cached_future.dart'; import 'package:weblibre/presentation/hooks/menu_controller.dart'; @@ -122,47 +124,55 @@ class _BrowserMenuSheet extends HookConsumerWidget { // Scrollable content Expanded( - child: ListView( + child: FadingScroll( controller: scrollController, - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 8, - ), - children: [ - // Quick toggles (Desktop Mode / Reader Mode) - if (selectedTabId != null) ...[ - _QuickTogglesGrid(selectedTabId: selectedTabId), - const SizedBox(height: 16), - ], + fadingSize: 25, + builder: (context, controller) { + return ListView( + controller: controller, + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + children: [ + // Quick toggles (Desktop Mode / Reader Mode) + if (selectedTabId != null) ...[ + _QuickTogglesGrid(selectedTabId: selectedTabId), + const SizedBox(height: 16), + ], - // Page actions - if (selectedTabId != null) ...[ - _PageActionsCard(selectedTabId: selectedTabId), - const SizedBox(height: 16), - ], + // Page actions + if (selectedTabId != null) ...[ + _PageActionsCard(selectedTabId: selectedTabId), + const SizedBox(height: 16), + ], - // Extensions - _ExtensionsCard(), - const SizedBox(height: 16), + // Extensions + _ExtensionsCard(), + const SizedBox(height: 16), - // Tab actions - if (selectedTabId != null) ...[ - _TabActionsCard(selectedTabId: selectedTabId), - const SizedBox(height: 16), - ], + // Tab actions + if (selectedTabId != null) ...[ + _TabActionsCard(selectedTabId: selectedTabId), + const SizedBox(height: 16), + ], - // Quick links grid - _QuickLinksGrid(showContainerUi: settings.showContainerUi), - const SizedBox(height: 16), + // Quick links grid + _QuickLinksGrid( + showContainerUi: settings.showContainerUi, + ), + const SizedBox(height: 16), - // Profile - _ProfileCard(), - const SizedBox(height: 16), + // Profile + _ProfileCard(), + const SizedBox(height: 16), - // App - const _SettingsCard(), - const SizedBox(height: 24), - ], + // App + const _SettingsCard(), + const SizedBox(height: 24), + ], + ); + }, ), ), @@ -513,12 +523,15 @@ class _PageActionsCard extends HookConsumerWidget { title: const Text('Add Bookmark'), onTap: () async { final tabState = ref.read(tabStateProvider(selectedTabId))!; + final bookmarkUrl = + ref.read(sandboxSourceUriForTabProvider(tabId: tabState.id)) ?? + tabState.url; Navigator.pop(context); await BookmarkEntryAddRoute( bookmarkInfo: jsonEncode( BookmarkInfo( title: tabState.titleOrAuthority, - url: tabState.url.toString(), + url: bookmarkUrl.toString(), ).encode(), ), ).push(context); @@ -737,7 +750,10 @@ class _PinTopSiteTile extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final tabState = ref.watch(tabStateProvider(selectedTabId)); - final url = tabState?.url; + final sandboxSourceUri = ref.watch( + sandboxSourceUriForTabProvider(tabId: selectedTabId), + ); + final url = sandboxSourceUri ?? tabState?.url; final isPinned = useCachedFuture( () => url != null @@ -978,6 +994,11 @@ class _CloneTabExpansion extends ConsumerWidget { icon: MdiIcons.tab, onTap: () async { final tabState = ref.read(tabStateProvider(selectedTabId))!; + final cloneUrl = + ref.read( + sandboxSourceUriForTabProvider(tabId: tabState.id), + ) ?? + tabState.url; final containerData = await ref .read(tabDataRepositoryProvider.notifier) .getTabContainerData(selectedTabId); @@ -987,7 +1008,7 @@ class _CloneTabExpansion extends ConsumerWidget { .read(tabRepositoryProvider.notifier) .addTab( tabMode: TabMode.regular, - url: tabState.url, + url: cloneUrl, containerSelection: containerData == null ? const TabContainerSelection.unassigned() : TabContainerSelection.specific(containerData), @@ -1017,6 +1038,11 @@ class _CloneTabExpansion extends ConsumerWidget { iconColor: appColors.privateTabPurple, onTap: () async { final tabState = ref.read(tabStateProvider(selectedTabId))!; + final cloneUrl = + ref.read( + sandboxSourceUriForTabProvider(tabId: tabState.id), + ) ?? + tabState.url; final containerData = await ref .read(tabDataRepositoryProvider.notifier) .getTabContainerData(selectedTabId); @@ -1025,7 +1051,7 @@ class _CloneTabExpansion extends ConsumerWidget { ? await ref .read(tabRepositoryProvider.notifier) .addTab( - url: tabState.url, + url: cloneUrl, tabMode: TabMode.private, containerSelection: containerData == null ? const TabContainerSelection.unassigned() @@ -1057,6 +1083,11 @@ class _CloneTabExpansion extends ConsumerWidget { iconColor: appColors.isolatedTabTeal, onTap: () async { final tabState = ref.read(tabStateProvider(selectedTabId))!; + final cloneUrl = + ref.read( + sandboxSourceUriForTabProvider(tabId: tabState.id), + ) ?? + tabState.url; final containerData = await ref .read(tabDataRepositoryProvider.notifier) .getTabContainerData(selectedTabId); @@ -1064,7 +1095,7 @@ class _CloneTabExpansion extends ConsumerWidget { final tabId = await ref .read(tabRepositoryProvider.notifier) .addTab( - url: tabState.url, + url: cloneUrl, tabMode: TabMode.newIsolated(), containerSelection: containerData == null ? const TabContainerSelection.unassigned() @@ -1289,7 +1320,12 @@ class _ShareExpansion extends HookConsumerWidget { final settings = ref.watch(generalSettingsWithDefaultsProvider); final catalogAsync = ref.watch(urlCleanerCatalogServiceProvider); final tabState = ref.watch(tabStateProvider(selectedTabId)); - final tabUrl = tabState?.url; + final sandboxSourceUri = ref.watch( + sandboxSourceUriForTabProvider(tabId: selectedTabId), + ); + // Sandbox-captured tabs: every share/copy/QR/cleaner action must operate + // on the canonical source URL — never the loopback loader. + final tabUrl = sandboxSourceUri ?? tabState?.url; final cleanedUrl = useState(null); final cleaner = useUrlCleanerController( @@ -1526,16 +1562,23 @@ class _SendToDeviceExpansion extends ConsumerWidget { ); if (tabState == null) return; + final sendUrl = + ref.read( + sandboxSourceUriForTabProvider( + tabId: tabState.id, + ), + ) ?? + tabState.url; final title = tabState.title.isNotEmpty ? tabState.title - : tabState.url.toString(); + : sendUrl.toString(); final success = await ref .read(syncRepositoryProvider.notifier) .sendTabToDevice( deviceId: device.deviceId, title: title, - url: tabState.url.toString(), + url: sendUrl.toString(), private: tabState.tabMode == TabMode.private, ); @@ -1884,7 +1927,7 @@ class _QuickLinksGrid extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final torConnected = ref.watch( + final isTorActive = ref.watch( torProxyServiceProvider.select((value) => value.value?.isRunning == true), ); @@ -1955,7 +1998,7 @@ class _QuickLinksGrid extends ConsumerWidget { Navigator.pop(context); await const TorProxyRoute().push(context); }, - badge: torConnected, + badge: isTorActive, badgeColor: AppColors.of(context).torActiveGreen, ), ), @@ -1980,8 +2023,6 @@ class _QuickLinksGrid extends ConsumerWidget { }, ), ), - const SizedBox(width: 8), - const Expanded(child: SizedBox.shrink()), ], ), ], diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/app_bar_title.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/app_bar_title.dart index 5f03553d..a0d405f4 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/app_bar_title.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/app_bar_title.dart @@ -34,7 +34,9 @@ import 'package:weblibre/features/geckoview/features/browser/presentation/provid import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_icon.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/toolbar_button.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart'; +import 'package:weblibre/features/geckoview/features/tabs/utils/container_colors.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; +import 'package:weblibre/features/web_search/domain/controllers/sandbox_capture_controller.dart'; import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart'; class CompactAppBarTitle extends ConsumerWidget { @@ -64,6 +66,10 @@ class CompactAppBarTitle extends ConsumerWidget { ); } + final sandboxSourceUri = ref.watch( + sandboxSourceUriForTabProvider(tabId: tabState.id), + ); + return CompactAppBarTitleView( tabState: tabState, isTabTunneled: @@ -71,6 +77,7 @@ class CompactAppBarTitle extends ConsumerWidget { siteSettingsBadgeState: siteSettingsBadgeState, longPressUrlCopy: settings.tabBarLongPressUrlCopy, containerColor: containerColor, + sandboxSourceUri: sandboxSourceUri, onSiteSettingsTap: () { ref .read(bottomSheetControllerProvider.notifier) @@ -79,7 +86,7 @@ class CompactAppBarTitle extends ConsumerWidget { onTitleTap: () async { await SearchRoute( tabId: tabState.id, - searchText: _searchTextForTab(tabState), + searchText: searchTextForTab(tabState, sandboxSourceUri), tabType: tabState.tabMode.toTabType(), ).push(context); }, @@ -98,6 +105,7 @@ class CompactAppBarTitleView extends StatelessWidget { this.tabIcon, this.longPressUrlCopy = true, this.containerColor, + this.sandboxSourceUri, }); final TabState tabState; @@ -108,12 +116,16 @@ class CompactAppBarTitleView extends StatelessWidget { final Widget? tabIcon; final bool longPressUrlCopy; final Color? containerColor; + final Uri? sandboxSourceUri; @override Widget build(BuildContext context) { final theme = Theme.of(context); final appColors = AppColors.of(context); final containerColor = this.containerColor; + final containerPalette = containerColor != null + ? ContainerColors.palette(context, containerColor) + : null; return Row( children: [ @@ -154,17 +166,11 @@ class CompactAppBarTitleView extends StatelessWidget { padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12), decoration: BoxDecoration( color: containerColor != null - ? Color.alphaBlend( - containerColor.withValues(alpha: 0.08), - theme.colorScheme.surfaceContainerHighest, - ) + ? containerPalette!.surfaceColor : theme.colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(24), - border: containerColor != null - ? Border.all( - color: containerColor.withValues(alpha: 0.5), - width: 2, - ) + border: containerPalette != null + ? Border.all(color: containerPalette.outlineColor) : null, ), child: Row( @@ -190,15 +196,23 @@ class CompactAppBarTitleView extends StatelessWidget { const Icon(MdiIcons.tunnelOutline, size: 16), const SizedBox(width: 4), ], - _SecurityStatusIcon( - tabState: tabState, - size: 16, - containerColor: containerColor, - ), + if (sandboxSourceUri != null) ...[ + Icon( + MdiIcons.archiveLockOutline, + color: theme.colorScheme.tertiary, + size: 16, + ), + const SizedBox(width: 4), + ] else + _SecurityStatusIcon( + tabState: tabState, + size: 16, + containerColor: containerPalette?.accentColor, + ), const SizedBox(width: 6), Flexible( child: UriBreadcrumb( - uri: tabState.url, + uri: sandboxSourceUri ?? tabState.url, showHttpScheme: false, style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurface, @@ -206,7 +220,10 @@ class CompactAppBarTitleView extends StatelessWidget { onTooltipTriggered: longPressUrlCopy ? () async { await Clipboard.setData( - ClipboardData(text: tabState.url.toString()), + ClipboardData( + text: (sandboxSourceUri ?? tabState.url) + .toString(), + ), ); } : null, @@ -250,6 +267,10 @@ class AppBarTitle extends ConsumerWidget { ); } + final sandboxSourceUri = ref.watch( + sandboxSourceUriForTabProvider(tabId: tabState.id), + ); + return AppBarTitleView( tabState: tabState, isTabTunneled: @@ -257,6 +278,7 @@ class AppBarTitle extends ConsumerWidget { siteSettingsBadgeState: siteSettingsBadgeState, longPressUrlCopy: settings.tabBarLongPressUrlCopy, containerColor: containerColor, + sandboxSourceUri: sandboxSourceUri, onSiteSettingsTap: () { ref .read(bottomSheetControllerProvider.notifier) @@ -265,7 +287,7 @@ class AppBarTitle extends ConsumerWidget { onTitleTap: () async { await SearchRoute( tabId: tabState.id, - searchText: _searchTextForTab(tabState), + searchText: searchTextForTab(tabState, sandboxSourceUri), tabType: tabState.tabMode.toTabType(), ).push(context); }, @@ -284,6 +306,7 @@ class AppBarTitleView extends StatelessWidget { required this.longPressUrlCopy, this.tabIcon, this.containerColor, + this.sandboxSourceUri, }); final TabState tabState; @@ -294,12 +317,16 @@ class AppBarTitleView extends StatelessWidget { final Widget? tabIcon; final bool longPressUrlCopy; final Color? containerColor; + final Uri? sandboxSourceUri; @override Widget build(BuildContext context) { final theme = Theme.of(context); final appColors = AppColors.of(context); final containerColor = this.containerColor; + final containerPalette = containerColor != null + ? ContainerColors.palette(context, containerColor) + : null; return Row( children: [ @@ -370,17 +397,11 @@ class AppBarTitleView extends StatelessWidget { : EdgeInsets.zero, decoration: BoxDecoration( color: containerColor != null - ? Color.alphaBlend( - containerColor.withValues(alpha: 0.08), - theme.colorScheme.surfaceContainerHighest, - ) + ? containerPalette!.surfaceColor : null, borderRadius: BorderRadius.circular(12), - border: containerColor != null - ? Border.all( - color: containerColor.withValues(alpha: 0.5), - width: 2, - ) + border: containerPalette != null + ? Border.all(color: containerPalette.outlineColor) : null, ), child: Row( @@ -404,15 +425,23 @@ class AppBarTitleView extends StatelessWidget { const Icon(MdiIcons.tunnelOutline, size: 14), const SizedBox(width: 4), ], - _SecurityStatusIcon( - tabState: tabState, - size: 14, - containerColor: containerColor, - ), + if (sandboxSourceUri != null) ...[ + Icon( + MdiIcons.archiveLockOutline, + color: theme.colorScheme.tertiary, + size: 14, + ), + const SizedBox(width: 4), + ] else + _SecurityStatusIcon( + tabState: tabState, + size: 14, + containerColor: containerPalette?.accentColor, + ), const SizedBox(width: 4), Expanded( child: UriBreadcrumb( - uri: tabState.url, + uri: sandboxSourceUri ?? tabState.url, style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurface, ), @@ -420,7 +449,8 @@ class AppBarTitleView extends StatelessWidget { ? () async { await Clipboard.setData( ClipboardData( - text: tabState.url.toString(), + text: (sandboxSourceUri ?? tabState.url) + .toString(), ), ); } @@ -509,11 +539,3 @@ class _EmptyAppBarAddressField extends StatelessWidget { ); } } - -String _searchTextForTab(TabState tabState) { - final searchText = tabState.url.scheme == 'about' - ? '' - : tabState.url.toString(); - - return searchText.isEmpty ? SearchRoute.emptySearchText : searchText; -} diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart index 1ec40d8f..80edf329 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart @@ -27,6 +27,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nullability/nullability.dart'; import 'package:weblibre/core/design/app_colors.dart'; import 'package:weblibre/features/addons/presentation/widgets/pinned_addon_bar.dart'; import 'package:weblibre/features/geckoview/domain/controllers/bottom_sheet.dart'; @@ -50,6 +51,7 @@ import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart' import 'package:weblibre/features/geckoview/features/tabs/utils/container_colors.dart'; import 'package:weblibre/features/user/data/models/general_settings.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; +import 'package:weblibre/features/web_search/domain/controllers/sandbox_capture_controller.dart'; import 'package:weblibre/presentation/widgets/selectable_chips.dart'; import 'package:weblibre/presentation/widgets/url_icon.dart'; @@ -240,6 +242,9 @@ class BrowserTabBar extends HookConsumerWidget { displayedSheet is! ViewTabsSheet) ? containerColor : null; + final effectiveContainerPalette = effectiveContainerColor != null + ? ContainerColors.palette(context, effectiveContainerColor) + : null; return BrowserTabBarView( showMainToolbar: showMainToolbar, @@ -247,7 +252,7 @@ class BrowserTabBar extends HookConsumerWidget { showQuickTabSwitcherBar: showQuickTabSwitcherBar, displayAppBar: displayAppBar, displayQuickTabSwitcher: displayQuickTabSwitcher, - backgroundColor: null, + backgroundColor: effectiveContainerPalette?.surfaceColor, title: showTabTitle ? settings.tabBarLayout == TabBarLayout.compact ? CompactAppBarTitle(containerColor: effectiveContainerColor) @@ -395,6 +400,8 @@ class BrowserTabBarView extends StatelessWidget { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; + final effectiveBackgroundColor = + backgroundColor ?? colorScheme.surfaceContainer; return GestureDetector( // Tap handling moved to AppBarTitle for split icon/title behavior @@ -402,36 +409,38 @@ class BrowserTabBarView extends StatelessWidget { onHorizontalDragEnd: onHorizontalDragEnd, onVerticalDragStart: onVerticalDragStart, onVerticalDragEnd: onVerticalDragEnd, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (showQuickTabSwitcherBar) - Visibility( - visible: displayQuickTabSwitcher, - maintainState: true, - child: quickTabSwitcher, - ), - if (showMainToolbar) - Visibility( - visible: displayAppBar, - maintainState: true, - child: AppBar( - primary: false, - automaticallyImplyLeading: false, - backgroundColor: - backgroundColor ?? colorScheme.surfaceContainer, - scrolledUnderElevation: 0, - shadowColor: Colors.transparent, - surfaceTintColor: Colors.transparent, - titleSpacing: 0.0, - leadingWidth: 40.0, - toolbarHeight: kToolbarHeight, - title: title, - actions: actions, + child: ColoredBox( + color: effectiveBackgroundColor, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (showQuickTabSwitcherBar) + Visibility( + visible: displayQuickTabSwitcher, + maintainState: true, + child: quickTabSwitcher, ), - ), - if (showContextualToolbar) contextualToolbar, - ], + if (showMainToolbar) + Visibility( + visible: displayAppBar, + maintainState: true, + child: AppBar( + primary: false, + automaticallyImplyLeading: false, + backgroundColor: Colors.transparent, + scrolledUnderElevation: 0, + shadowColor: Colors.transparent, + surfaceTintColor: Colors.transparent, + titleSpacing: 0.0, + leadingWidth: 40.0, + toolbarHeight: kToolbarHeight, + title: title, + actions: actions, + ), + ), + if (showContextualToolbar) contextualToolbar, + ], + ), ), ); } @@ -444,6 +453,7 @@ class QuickTabSwitcherItem with FastEquatable { final TabMode tabMode; final bool isHistory; final bool isPinned; + final bool isSandbox; final String title; final Uri url; final Widget avatar; @@ -458,6 +468,7 @@ class QuickTabSwitcherItem with FastEquatable { required this.title, required this.url, required this.avatar, + this.isSandbox = false, }); @override @@ -468,6 +479,7 @@ class QuickTabSwitcherItem with FastEquatable { tabMode, isHistory, isPinned, + isSandbox, title, url, avatar, @@ -504,20 +516,31 @@ class QuickTabSwitcher extends HookConsumerWidget { final historySuggestions = ref .watch(quickTabSwitcherHistorySuggestionsProvider(quickTabSwitcherMode)) .value; + final sandboxCaptureMap = + ref.watch(sandboxCaptureMapProvider).value ?? const {}; final availableItems = tabStates.value - .map( - (state) => QuickTabSwitcherItem( + .map((state) { + final sandboxSourceUri = parseSandboxSource( + sandboxCaptureMap[state.$1.id], + ); + final displayUrl = sandboxSourceUri ?? state.$1.url; + final displayTitle = + sandboxSourceUri != null && state.$1.title.isEmpty + ? sandboxSourceUri.authority + : state.$1.titleOrAuthority; + return QuickTabSwitcherItem( color: state.$2?.color, id: state.$1.id, isActive: state.$1.id == selectedTabId, - title: state.$1.titleOrAuthority, + title: displayTitle, tabMode: state.$1.tabMode, isHistory: false, isPinned: pinnedTabIds?.contains(state.$1.id) ?? false, - url: state.$1.url, + isSandbox: sandboxSourceUri != null, + url: displayUrl, avatar: TabIcon(tabState: state.$1, iconSize: 20), - ), - ) + ); + }) .followedBy( (historySuggestions ?? []).map((state) { final url = Uri.parse(state.url); @@ -694,6 +717,7 @@ class QuickTabSwitcherView extends StatelessWidget { @override Widget build(BuildContext context) { final appColors = AppColors.of(context); + final colorScheme = Theme.of(context).colorScheme; if (availableItems.isEmpty) { return const SizedBox.shrink(); @@ -715,16 +739,42 @@ class QuickTabSwitcherView extends StatelessWidget { itemId: (item) => item.id, selectedItem: activeItem, selectedBorderColor: Theme.of(context).colorScheme.primary, - labelPadding: (item) => - (!showTitles && - !item.isHistory && - !item.isPinned && - item.tabMode is! PrivateTabMode && - item.tabMode is! IsolatedTabMode) - ? EdgeInsets.zero - : null, + decoration: SelectableChipDecoration( + color: (item, isSelected) => switch (item.color) { + final color? when isSelected => ContainerColors.palette( + context, + color, + ).selectedBackgroundColor, + final color? => ContainerColors.palette( + context, + color, + ).backgroundColor, + null => null, + }, + side: (item, isSelected) => switch (item.color) { + final color? when isSelected => ContainerColors.palette( + context, + color, + ).selectedBorderSide, + final color? => ContainerColors.palette( + context, + color, + ).borderSide, + null => null, + }, + labelPadding: (item) => + (!showTitles && + !item.isHistory && + !item.isPinned && + !item.isSandbox && + item.tabMode is! PrivateTabMode && + item.tabMode is! IsolatedTabMode) + ? EdgeInsets.zero + : null, + ), itemLabel: (item) { - return Row( + final isSelected = activeItem?.id == item.id; + final row = Row( mainAxisSize: MainAxisSize.min, children: [ if (item.isHistory || showTitles) @@ -750,6 +800,15 @@ class QuickTabSwitcherView extends StatelessWidget { size: 20, ), ), + if (item.isSandbox) + Padding( + padding: const EdgeInsets.only(left: 8.0), + child: Icon( + MdiIcons.archiveLockOutline, + color: Theme.of(context).colorScheme.tertiary, + size: 20, + ), + ), if (item.isPinned) Padding( padding: const EdgeInsets.only(left: 8.0), @@ -766,11 +825,29 @@ class QuickTabSwitcherView extends StatelessWidget { ), ], ); + + return item.color.mapNotNull( + (color) => DefaultTextStyle.merge( + style: TextStyle( + color: isSelected + ? ContainerColors.palette( + context, + color, + ).selectedForegroundColor + : ContainerColors.palette( + context, + color, + ).foregroundColor, + fontWeight: isSelected + ? FontWeight.w700 + : FontWeight.w500, + ), + child: row, + ), + ) ?? + row; }, itemAvatar: (item) => item.avatar, - itemBackgroundColor: (item) => item.color != null - ? ContainerColors.forChip(item.color!) - : null, onSelected: onSelected, itemWrap: itemWrapBuilder, availableItems: availableItems, diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart index 34e41c98..d75014e7 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart @@ -50,6 +50,7 @@ import 'package:weblibre/features/geckoview/features/history/domain/repositories import 'package:weblibre/features/geckoview/features/preferences/data/repositories/preference_observer.dart'; import 'package:weblibre/features/geckoview/features/pwa/domain/providers.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/services/local_index_pruner.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selected_container.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart'; @@ -60,6 +61,7 @@ import 'package:weblibre/features/intent_gatekeeper/domain/services/native_gatek import 'package:weblibre/features/intent_gatekeeper/presentation/widgets/intent_gatekeeper_dialog.dart'; import 'package:weblibre/features/share_intent/domain/entities/intent_container_mode.dart'; import 'package:weblibre/features/share_intent/domain/entities/shared_content.dart'; +import 'package:weblibre/features/tor/domain/services/tor_proxy.dart'; import 'package:weblibre/features/user/data/models/general_settings.dart'; import 'package:weblibre/features/user/domain/providers/profile_auth.dart'; import 'package:weblibre/features/user/domain/repositories/cache.dart'; @@ -132,6 +134,8 @@ class _BrowserViewState extends ConsumerState DateTime(0), DateTime.now().subtract(settings.historyAutoCleanInterval), ); + + unawaited(ref.read(localIndexPrunerProvider.notifier).prune()); } if (settings.unassignedTabsAutoCleanInterval > Duration.zero) { @@ -631,6 +635,24 @@ class _BrowserViewState extends ConsumerState ); }, ); + + //Ensure tor events don't get dropped + ref.listenManual( + fireImmediately: true, + torProxyServiceProvider, + (previous, next) { + if (next.hasValue) { + debugPrint(next.requireValue.toString()); + } + }, + onError: (error, stackTrace) { + logger.e( + 'Error listening to torProxyServiceProvider', + error: error, + stackTrace: stackTrace, + ); + }, + ); } @override diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/certificate_tile.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/certificate_tile.dart index 4d697905..880a9e79 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/certificate_tile.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/certificate_tile.dart @@ -24,6 +24,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:skeletonizer/skeletonizer.dart'; import 'package:weblibre/extensions/uri.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; +import 'package:weblibre/features/web_search/domain/controllers/sandbox_capture_controller.dart'; class CertificateTile extends HookConsumerWidget { const CertificateTile({super.key}); @@ -36,6 +37,24 @@ class CertificateTile extends HookConsumerWidget { return const SizedBox.shrink(); } + final sandboxSourceUri = ref.watch( + sandboxSourceUriForTabProvider(tabId: tabState.id), + ); + if (sandboxSourceUri != null) { + // Sandbox-captured page is served from a loopback server; the cert + // chain shown would be for localhost, not the canonical site. + return ListTile( + leading: Icon( + MdiIcons.archiveLockOutline, + color: Theme.of(context).colorScheme.tertiary, + ), + title: const Text('Sandboxed capture'), + subtitle: const Text( + 'Page is served from an offline archive — no live connection.', + ), + ); + } + final icon = useMemoized(() { if (tabState.url.isHttp) { return ListTile( diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/menu_item_buttons.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/menu_item_buttons.dart index b530a751..1ae57a23 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/menu_item_buttons.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/menu_item_buttons.dart @@ -36,6 +36,7 @@ import 'package:weblibre/features/geckoview/features/tabs/data/database/definiti import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart'; import 'package:weblibre/features/sync/domain/repositories/sync.dart'; +import 'package:weblibre/features/web_search/domain/controllers/sandbox_capture_controller.dart'; import 'package:weblibre/presentation/hooks/cached_future.dart'; import 'package:weblibre/utils/ui_helper.dart' as ui_helper; @@ -51,8 +52,11 @@ class ShareMenuItemButton extends HookConsumerWidget { closeOnActivate: false, onPressed: () async { final tabState = ref.read(tabStateProvider(selectedTabId))!; + final shareUrl = + ref.read(sandboxSourceUriForTabProvider(tabId: tabState.id)) ?? + tabState.url; - await SharePlus.instance.share(ShareParams(uri: tabState.url)); + await SharePlus.instance.share(ShareParams(uri: shareUrl)); if (context.mounted) { MenuController.maybeOf(context)?.close(); @@ -75,8 +79,11 @@ class ShowQrCodeMenuItemButton extends HookConsumerWidget { closeOnActivate: false, onPressed: () async { final tabState = ref.read(tabStateProvider(selectedTabId))!; + final qrUrl = + ref.read(sandboxSourceUriForTabProvider(tabId: tabState.id)) ?? + tabState.url; - await showQrCode(context, tabState.url.toString()); + await showQrCode(context, qrUrl.toString()); if (context.mounted) { MenuController.maybeOf(context)?.close(); @@ -362,8 +369,11 @@ class CopyAddressMenuItemButton extends HookConsumerWidget { child: const Text('Copy Address'), onPressed: () async { final tabState = ref.read(tabStateProvider(selectedTabId))!; + final copyUrl = + ref.read(sandboxSourceUriForTabProvider(tabId: tabState.id)) ?? + tabState.url; - await Clipboard.setData(ClipboardData(text: tabState.url.toString())); + await Clipboard.setData(ClipboardData(text: copyUrl.toString())); if (context.mounted) { MenuController.maybeOf(context)?.close(); @@ -419,16 +429,21 @@ class SendTabToDeviceMenuItemButton extends HookConsumerWidget { return; } + final sendUrl = + ref.read( + sandboxSourceUriForTabProvider(tabId: tabState.id), + ) ?? + tabState.url; final title = tabState.title.isNotEmpty ? tabState.title - : tabState.url.toString(); + : sendUrl.toString(); final success = await ref .read(syncRepositoryProvider.notifier) .sendTabToDevice( deviceId: device.deviceId, title: title, - url: tabState.url.toString(), + url: sendUrl.toString(), private: tabState.tabMode == TabMode.private, ); diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/share_bottom_sheet.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/share_bottom_sheet.dart index 244be430..f46a5810 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/share_bottom_sheet.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/share_bottom_sheet.dart @@ -39,6 +39,7 @@ import 'package:weblibre/features/geckoview/features/open_link_tools/presentatio import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart'; import 'package:weblibre/features/sync/domain/repositories/sync.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; +import 'package:weblibre/features/web_search/domain/controllers/sandbox_capture_controller.dart'; import 'package:weblibre/presentation/hooks/cached_future.dart'; import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart'; import 'package:weblibre/presentation/widgets/url_icon.dart'; @@ -67,9 +68,15 @@ class ShareBottomSheet extends HookConsumerWidget { final settings = ref.watch(generalSettingsWithDefaultsProvider); final catalogAsync = ref.watch(urlCleanerCatalogServiceProvider); - final tabUrl = ref.watch( + final rawTabUrl = ref.watch( tabStateProvider(selectedTabId).select((v) => v?.url), ); + final sandboxSourceUri = ref.watch( + sandboxSourceUriForTabProvider(tabId: selectedTabId), + ); + // For sandbox-captured tabs every share/copy/QR/cleaner action must + // operate on the canonical source URL — never the loopback loader. + final tabUrl = sandboxSourceUri ?? rawTabUrl; final cleanedUrl = useState(null); final cleaner = useUrlCleanerController( @@ -421,16 +428,23 @@ class _SendToDeviceTile extends ConsumerWidget { ); if (tabState == null) return; + final sendUrl = + ref.read( + sandboxSourceUriForTabProvider( + tabId: tabState.id, + ), + ) ?? + tabState.url; final title = tabState.title.isNotEmpty ? tabState.title - : tabState.url.toString(); + : sendUrl.toString(); final success = await ref .read(syncRepositoryProvider.notifier) .sendTabToDevice( deviceId: device.deviceId, title: title, - url: tabState.url.toString(), + url: sendUrl.toString(), private: tabState.tabMode == TabMode.private, ); diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/sheets/view_tab.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/sheets/view_tab.dart index e474e2ea..c8985525 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/sheets/view_tab.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/sheets/view_tab.dart @@ -30,6 +30,7 @@ import 'package:weblibre/features/geckoview/features/browser/presentation/widget import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/sheets/permissions_section.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/sheets/tracking_protection_section.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart'; +import 'package:weblibre/features/web_search/domain/controllers/sandbox_capture_controller.dart'; import 'package:weblibre/presentation/widgets/website_title_tile.dart'; class ClampingScrollPhysicsWithoutImplicit extends ClampingScrollPhysics { @@ -112,16 +113,18 @@ class ViewTabSheetWidget extends HookConsumerWidget { // Dismiss sheet and open search screen with tab context onClose(); - // Don't pre-fill for internal URLs - final searchText = initialTabState.url.scheme == 'about' - ? '' - : initialTabState.url.toString(); + final sandboxSourceUri = ref.read( + sandboxSourceUriForTabProvider( + tabId: initialTabState.id, + ), + ); await SearchRoute( tabId: initialTabState.id, - searchText: searchText.isEmpty - ? SearchRoute.emptySearchText - : searchText, + searchText: searchTextForTab( + initialTabState, + sandboxSourceUri, + ), tabType: initialTabState.tabMode.toTabType(), ).push(context); }, diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_icon.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_icon.dart index c87e6873..1a6db6e3 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_icon.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_icon.dart @@ -25,8 +25,10 @@ import 'package:skeletonizer/skeletonizer.dart'; import 'package:weblibre/domain/entities/equatable_image.dart'; import 'package:weblibre/domain/services/generic_website.dart'; import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart'; +import 'package:weblibre/features/web_search/domain/controllers/sandbox_capture_controller.dart'; import 'package:weblibre/presentation/hooks/cached_future.dart'; import 'package:weblibre/presentation/widgets/safe_raw_image.dart'; +import 'package:weblibre/presentation/widgets/url_icon.dart'; class TabIcon extends HookConsumerWidget { final TabState tabState; @@ -37,6 +39,10 @@ class TabIcon extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final sandboxSourceUri = ref.watch( + sandboxSourceUriForTabProvider(tabId: tabState.id), + ); + final icon = useCachedFuture(() async { if (tabState.icon case final EquatableImage tabIcon when !tabIcon.isDisposed) { @@ -50,6 +56,10 @@ class TabIcon extends HookConsumerWidget { return cachedIcon?.image; }, [tabState.icon, tabState.url]); + if (sandboxSourceUri != null) { + return UrlIcon([sandboxSourceUri], iconSize: iconSize, cacheOnly: true); + } + return Skeletonizer( enabled: icon.connectionState != ConnectionState.done, child: Skeleton.replace( diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_menu.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_menu.dart index 7594b276..9043d6cb 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_menu.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_menu.dart @@ -51,6 +51,7 @@ import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart' import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; +import 'package:weblibre/features/web_search/domain/controllers/sandbox_capture_controller.dart'; import 'package:weblibre/presentation/hooks/menu_controller.dart'; import 'package:weblibre/presentation/widgets/website_feed_menu_button.dart'; import 'package:weblibre/utils/ui_helper.dart' as ui_helper; @@ -196,12 +197,17 @@ class TabMenu extends HookConsumerWidget { child: const Text('Add Bookmark'), onPressed: () async { final tabState = ref.read(tabStateProvider(selectedTabId))!; + final bookmarkUrl = + ref.read( + sandboxSourceUriForTabProvider(tabId: tabState.id), + ) ?? + tabState.url; await BookmarkEntryAddRoute( bookmarkInfo: jsonEncode( BookmarkInfo( title: tabState.titleOrAuthority, - url: tabState.url.toString(), + url: bookmarkUrl.toString(), ).encode(), ), ).push(context); @@ -248,12 +254,17 @@ class TabMenu extends HookConsumerWidget { .read(tabDataRepositoryProvider.notifier) .getTabContainerData(selectedTabId); + final cloneUrl = + ref.read( + sandboxSourceUriForTabProvider(tabId: tabState.id), + ) ?? + tabState.url; final tabId = (tabState.tabMode is! RegularTabMode) ? await ref .read(tabRepositoryProvider.notifier) .addTab( tabMode: TabMode.regular, - url: tabState.url, + url: cloneUrl, containerSelection: containerData == null ? const TabContainerSelection.unassigned() : TabContainerSelection.specific( @@ -294,11 +305,16 @@ class TabMenu extends HookConsumerWidget { .read(tabDataRepositoryProvider.notifier) .getTabContainerData(selectedTabId); + final cloneUrl = + ref.read( + sandboxSourceUriForTabProvider(tabId: tabState.id), + ) ?? + tabState.url; final tabId = (tabState.tabMode is! PrivateTabMode) ? await ref .read(tabRepositoryProvider.notifier) .addTab( - url: tabState.url, + url: cloneUrl, tabMode: TabMode.private, containerSelection: containerData == null ? const TabContainerSelection.unassigned() @@ -341,10 +357,15 @@ class TabMenu extends HookConsumerWidget { .read(tabDataRepositoryProvider.notifier) .getTabContainerData(selectedTabId); + final cloneUrl = + ref.read( + sandboxSourceUriForTabProvider(tabId: tabState.id), + ) ?? + tabState.url; final tabId = await ref .read(tabRepositoryProvider.notifier) .addTab( - url: tabState.url, + url: cloneUrl, tabMode: TabMode.newIsolated(), containerSelection: containerData == null ? const TabContainerSelection.unassigned() diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_preview.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_preview.dart index c8f6ce8d..5d6dae23 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_preview.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_preview.dart @@ -36,6 +36,7 @@ import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; +import 'package:weblibre/features/web_search/domain/controllers/sandbox_capture_controller.dart'; import 'package:weblibre/presentation/hooks/menu_controller.dart'; import 'package:weblibre/presentation/widgets/safe_raw_image.dart'; import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart'; @@ -173,6 +174,14 @@ class GridTabPreview extends HookConsumerWidget { ) ?? TabState.$default(tabId); + final sandboxSourceUri = ref.watch( + sandboxSourceUriForTabProvider(tabId: tabId), + ); + final displayUrl = sandboxSourceUri ?? tabState.url; + final displayTitle = sandboxSourceUri != null && tabState.title.isEmpty + ? sandboxSourceUri.authority + : tabState.titleOrAuthority; + final extendedDeleteMenuController = useMenuController(); // ignore: avoid_bool_literals_in_conditional_expressions @@ -249,7 +258,7 @@ class GridTabPreview extends HookConsumerWidget { menuChildren: [ MenuItemButton( onPressed: () { - onDeleteAll?.call(tabState.url.host); + onDeleteAll?.call(displayUrl.host); }, leadingIcon: const Icon(Icons.language), child: const Text('Close from Same Host'), @@ -389,7 +398,7 @@ class GridTabPreview extends HookConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - tabState.titleOrAuthority, + displayTitle, maxLines: 2, overflow: TextOverflow.ellipsis, style: textTheme.bodyMedium?.copyWith( @@ -404,7 +413,7 @@ class GridTabPreview extends HookConsumerWidget { const SizedBox(width: 4), Expanded( child: Text( - tabState.url.authority, + displayUrl.authority, maxLines: 1, overflow: TextOverflow.ellipsis, style: textTheme.bodySmall?.copyWith( @@ -477,6 +486,14 @@ class ListTabPreview extends HookConsumerWidget { ) ?? TabState.$default(tabId); + final sandboxSourceUri = ref.watch( + sandboxSourceUriForTabProvider(tabId: tabId), + ); + final displayUrl = sandboxSourceUri ?? tabState.url; + final displayTitle = sandboxSourceUri != null && tabState.title.isEmpty + ? sandboxSourceUri.authority + : tabState.titleOrAuthority; + final tabListShowFavicons = ref.watch( generalSettingsWithDefaultsProvider.select((s) => s.tabListShowFavicons), ); @@ -563,7 +580,7 @@ class ListTabPreview extends HookConsumerWidget { mainAxisAlignment: MainAxisAlignment.center, children: [ Text( - tabState.titleOrAuthority, + displayTitle, maxLines: 2, overflow: TextOverflow.ellipsis, style: textTheme.bodyMedium?.copyWith( @@ -592,7 +609,7 @@ class ListTabPreview extends HookConsumerWidget { ], Expanded( child: UriBreadcrumb( - uri: tabState.url, + uri: displayUrl, showHttpScheme: false, style: textTheme.bodySmall?.copyWith( color: subtitleColor, @@ -616,7 +633,7 @@ class ListTabPreview extends HookConsumerWidget { menuChildren: [ MenuItemButton( onPressed: () { - onDeleteAll?.call(tabState.url.host); + onDeleteAll?.call(displayUrl.host); }, leadingIcon: const Icon(Icons.language), child: const Text('Close from Same Host'), diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_tree_view.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_tree_view.dart index 930a7a29..49432ab9 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_tree_view.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_tree_view.dart @@ -20,6 +20,7 @@ import 'dart:async'; import 'dart:math' as math; +import 'package:fading_scroll/fading_scroll.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -302,18 +303,24 @@ class ViewTabTreesWidget extends HookConsumerWidget { return Padding( padding: const EdgeInsets.symmetric(horizontal: 4.0), - child: GridView.builder( + child: FadingScroll( controller: scrollController, - padding: const EdgeInsets.only(bottom: 56), - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - //Sync values for itemHeight calculation _calculateItemHeight - childAspectRatio: 0.75, - mainAxisSpacing: 8.0, - crossAxisSpacing: 8.0, - crossAxisCount: crossAxisCount, - ), - itemCount: tabs.length, - itemBuilder: (context, index) => tabs[index], + fadingSize: 5, + builder: (context, controller) { + return GridView.builder( + controller: controller, + padding: const EdgeInsets.only(bottom: 56), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + //Sync values for itemHeight calculation _calculateItemHeight + childAspectRatio: 0.75, + mainAxisSpacing: 8.0, + crossAxisSpacing: 8.0, + crossAxisCount: crossAxisCount, + ), + itemCount: tabs.length, + itemBuilder: (context, index) => tabs[index], + ); + }, ), ); }, diff --git a/apps/weblibre/lib/features/geckoview/features/contextmenu/presentation/candidates/open_in_container.dart b/apps/weblibre/lib/features/geckoview/features/contextmenu/presentation/candidates/open_in_container.dart index e4819d6e..34085574 100644 --- a/apps/weblibre/lib/features/geckoview/features/contextmenu/presentation/candidates/open_in_container.dart +++ b/apps/weblibre/lib/features/geckoview/features/contextmenu/presentation/candidates/open_in_container.dart @@ -184,7 +184,8 @@ class _ContainerPickerSheet extends HookConsumerWidget { itemBuilder: (context, index) => ContainerListTile( ContainerData( id: Namespace.nil.value, - color: Colors.transparent, + color: Theme.of(context).colorScheme.primary, + orderKey: '', ), isSelected: false, onTap: null, diff --git a/apps/weblibre/lib/features/geckoview/features/find_in_page/presentation/controllers/find_in_page.dart b/apps/weblibre/lib/features/geckoview/features/find_in_page/presentation/controllers/find_in_page.dart index 7a2c14c0..8e6ee561 100644 --- a/apps/weblibre/lib/features/geckoview/features/find_in_page/presentation/controllers/find_in_page.dart +++ b/apps/weblibre/lib/features/geckoview/features/find_in_page/presentation/controllers/find_in_page.dart @@ -17,6 +17,7 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nullability/nullability.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:weblibre/core/logger.dart'; @@ -26,7 +27,7 @@ import 'package:weblibre/features/geckoview/features/find_in_page/domain/reposit part 'find_in_page.g.dart'; -@Riverpod() +@Riverpod(keepAlive: true) class FindInPageController extends _$FindInPageController { void show() { state = state.copyWith.visible(true); @@ -49,7 +50,7 @@ class FindInPageController extends _$FindInPageController { final service = ref.read(findInPageRepositoryProvider(tabId).notifier); final hasMatches = - ref.read(selectedTabStateProvider)?.findResultState.hasMatches == true; + ref.read(tabStatesProvider)[tabId]?.findResultState.hasMatches == true; state = state.copyWith.visible(true); @@ -70,7 +71,7 @@ class FindInPageController extends _$FindInPageController { FindInPageState build(String tabId) { ref.listen( fireImmediately: true, - tabStateProvider(tabId), + tabStatesProvider.select((tabs) => tabs[tabId]), (previous, next) async { if (!ref.mounted) return; //Ensure state is already initialized @@ -89,7 +90,7 @@ class FindInPageController extends _$FindInPageController { }, onError: (error, stackTrace) { logger.e( - 'Error listening to selectedTabStateProvider', + 'Error listening to tabStatesProvider', error: error, stackTrace: stackTrace, ); diff --git a/apps/weblibre/lib/features/geckoview/features/find_in_page/presentation/controllers/find_in_page.g.dart b/apps/weblibre/lib/features/geckoview/features/find_in_page/presentation/controllers/find_in_page.g.dart index 0eca12c8..883f22fe 100644 --- a/apps/weblibre/lib/features/geckoview/features/find_in_page/presentation/controllers/find_in_page.g.dart +++ b/apps/weblibre/lib/features/geckoview/features/find_in_page/presentation/controllers/find_in_page.g.dart @@ -20,7 +20,7 @@ final class FindInPageControllerProvider }) : super( retry: null, name: r'findInPageControllerProvider', - isAutoDispose: true, + isAutoDispose: false, dependencies: null, $allTransitiveDependencies: null, ); @@ -59,7 +59,7 @@ final class FindInPageControllerProvider } String _$findInPageControllerHash() => - r'4aaa0c4b4a623e7274836e70e5f66e97a3ebf80a'; + r'21ca6178016dc141fcfcb71afc158d9b8eed0a7e'; final class FindInPageControllerFamily extends $Family with @@ -76,7 +76,7 @@ final class FindInPageControllerFamily extends $Family name: r'findInPageControllerProvider', dependencies: null, $allTransitiveDependencies: null, - isAutoDispose: true, + isAutoDispose: false, ); FindInPageControllerProvider call(String tabId) => diff --git a/apps/weblibre/lib/features/geckoview/features/find_in_page/presentation/widgets/find_in_page.dart b/apps/weblibre/lib/features/geckoview/features/find_in_page/presentation/widgets/find_in_page.dart index ea143383..703e5cad 100644 --- a/apps/weblibre/lib/features/geckoview/features/find_in_page/presentation/widgets/find_in_page.dart +++ b/apps/weblibre/lib/features/geckoview/features/find_in_page/presentation/widgets/find_in_page.dart @@ -49,6 +49,22 @@ class FindInPageWidget extends HookConsumerWidget { text: searchResult?.lastSearchText ?? findInPageState.lastSearchText, ); + // Sync text field when the find-in-page query is set externally (e.g., + // from a tab/history search hit). Comparing previous-vs-next on the + // riverpod state, rather than the controller text, lets the user keep + // an empty field after clearing without us repopulating it. + ref.listen( + findInPageControllerProvider(tabId).select((s) => s.lastSearchText), + (previous, next) { + if (next != null && next != previous && textController.text != next) { + textController.text = next; + textController.selection = TextSelection.collapsed( + offset: next.length, + ); + } + }, + ); + // Create debouncer with automatic disposal final debouncer = useDebouncer(const Duration(milliseconds: 300)); diff --git a/apps/weblibre/lib/features/geckoview/features/history/domain/repositories/history.dart b/apps/weblibre/lib/features/geckoview/features/history/domain/repositories/history.dart index 3bb372e4..2527e2e9 100644 --- a/apps/weblibre/lib/features/geckoview/features/history/domain/repositories/history.dart +++ b/apps/weblibre/lib/features/geckoview/features/history/domain/repositories/history.dart @@ -81,6 +81,73 @@ class HistoryRepository extends _$HistoryRepository { ); } + Future getLatestHistoryMetadataForUrl(String url) { + return _service.getLatestHistoryMetadataForUrl(url); + } + + Future> getLatestHistoryMetadataForUrls( + List urls, + ) { + return _service.getLatestHistoryMetadataForUrls(urls); + } + + Future> getVisited(List urls) { + return _service.getVisited(urls); + } + + Future> getSuggestions( + String query, { + int limit = 10, + }) { + return _service.getSuggestions(query, limit: limit); + } + + Future> queryHistoryMetadata( + String query, { + int limit = 10, + }) { + return _service.queryHistoryMetadata(query, limit: limit); + } + + Future recordObservation( + String url, { + String? title, + String? previewImageUrl, + }) { + return _service.recordObservation( + url, + title: title, + previewImageUrl: previewImageUrl, + ); + } + + Future noteViewTime(HistoryMetadataKey key, Duration viewTime) { + return _service.noteHistoryMetadataViewTime(key, viewTime); + } + + Future noteDocumentType( + HistoryMetadataKey key, + DocumentType documentType, + ) { + return _service.noteHistoryMetadataDocumentType(key, documentType); + } + + Future deleteVisitsFor(String url) { + return _service.deleteVisitsFor(url); + } + + Future deleteVisitsSince(DateTime since) { + return _service.deleteVisitsSince(since); + } + + Future deleteEverything() { + return _service.deleteEverything(); + } + + Future deleteHistoryMetadataOlderThan(DateTime olderThan) { + return _service.deleteHistoryMetadataOlderThan(olderThan); + } + @override void build() {} } diff --git a/apps/weblibre/lib/features/geckoview/features/history/domain/repositories/history.g.dart b/apps/weblibre/lib/features/geckoview/features/history/domain/repositories/history.g.dart index 32be288d..e877b66a 100644 --- a/apps/weblibre/lib/features/geckoview/features/history/domain/repositories/history.g.dart +++ b/apps/weblibre/lib/features/geckoview/features/history/domain/repositories/history.g.dart @@ -41,7 +41,7 @@ final class HistoryRepositoryProvider } } -String _$historyRepositoryHash() => r'414b68ec6be3cc2eca3681cbc00990e53e6127ce'; +String _$historyRepositoryHash() => r'4fec6cf4ef7cdfcabf3ccfd4a43fbf634bfe377f'; abstract class _$HistoryRepository extends $Notifier { void build(); diff --git a/apps/weblibre/lib/features/geckoview/features/open_link_tools/presentation/dialogs/open_shared_content.dart b/apps/weblibre/lib/features/geckoview/features/open_link_tools/presentation/dialogs/open_shared_content.dart index 66c7fd89..5f69b269 100644 --- a/apps/weblibre/lib/features/geckoview/features/open_link_tools/presentation/dialogs/open_shared_content.dart +++ b/apps/weblibre/lib/features/geckoview/features/open_link_tools/presentation/dialogs/open_shared_content.dart @@ -455,6 +455,7 @@ class OpenSharedContent extends HookConsumerWidget { alignment: Alignment.centerRight, child: CompactContainerSelector( selectedContainer: selectedContainer.value, + emphasizeSelection: false, onSelectionChanged: (selection) async { containerSelectionTouched.value = true; switch (selection) { diff --git a/apps/weblibre/lib/features/geckoview/features/open_link_tools/presentation/dialogs/tracking_details_dialog.dart b/apps/weblibre/lib/features/geckoview/features/open_link_tools/presentation/dialogs/tracking_details_dialog.dart index 0017b4b4..10f79202 100644 --- a/apps/weblibre/lib/features/geckoview/features/open_link_tools/presentation/dialogs/tracking_details_dialog.dart +++ b/apps/weblibre/lib/features/geckoview/features/open_link_tools/presentation/dialogs/tracking_details_dialog.dart @@ -17,6 +17,7 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ +import 'package:fading_scroll/fading_scroll.dart'; import 'package:flutter/material.dart'; import 'package:weblibre/features/geckoview/features/open_link_tools/domain/entities/url_cleaner_result.dart'; import 'package:weblibre/features/geckoview/features/open_link_tools/domain/services/url_cleaner_service.dart'; @@ -103,72 +104,79 @@ class _TrackingDetailsDialogState extends State { const SizedBox(height: 16), ConstrainedBox( constraints: const BoxConstraints(maxHeight: 220), - child: ListView.separated( - shrinkWrap: true, - itemCount: _items.length, - separatorBuilder: (context, index) => - Divider(height: 1, color: colorScheme.outlineVariant), - itemBuilder: (context, index) { - final item = _items[index]; - final display = _splitMatch(item.match); + child: FadingScroll( + fadingSize: 25, + builder: (context, controller) { + return ListView.separated( + controller: controller, + shrinkWrap: true, + itemCount: _items.length, + separatorBuilder: (context, index) => + Divider(height: 1, color: colorScheme.outlineVariant), + itemBuilder: (context, index) { + final item = _items[index]; + final display = _splitMatch(item.match); - return CheckboxListTile( - contentPadding: EdgeInsets.zero, - controlAffinity: ListTileControlAffinity.leading, - activeColor: colorScheme.primary, - checkColor: colorScheme.onPrimary, - title: Padding( - padding: const EdgeInsets.only(top: 8, bottom: 4), - child: Row( - children: [ - Expanded( - child: Text( - display.key, - style: textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - ), - if (item.type == UrlCleanerMatchType.referralRule) - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 3, - ), - decoration: BoxDecoration( - color: colorScheme.tertiaryContainer, - borderRadius: BorderRadius.circular(999), - ), - child: Text( - 'Referral marketing', - style: TextStyle( - color: colorScheme.onTertiaryContainer, - fontSize: 11, + return CheckboxListTile( + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + activeColor: colorScheme.primary, + checkColor: colorScheme.onPrimary, + title: Padding( + padding: const EdgeInsets.only(top: 8, bottom: 4), + child: Row( + children: [ + Expanded( + child: Text( + display.key, + style: textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.bold, + ), ), ), - ), - ], - ), - ), - subtitle: display.value == null - ? null - : Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Text( - display.value!, - style: textTheme.bodySmall?.copyWith( - fontFamily: 'monospace', - ), - ), + if (item.type == + UrlCleanerMatchType.referralRule) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 3, + ), + decoration: BoxDecoration( + color: colorScheme.tertiaryContainer, + borderRadius: BorderRadius.circular(999), + ), + child: Text( + 'Referral marketing', + style: TextStyle( + color: colorScheme.onTertiaryContainer, + fontSize: 11, + ), + ), + ), + ], ), - value: _selected[index], - onChanged: canApply - ? (checked) { - setState(() { - _selected[index] = checked ?? false; - }); - } - : null, + ), + subtitle: display.value == null + ? null + : Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text( + display.value!, + style: textTheme.bodySmall?.copyWith( + fontFamily: 'monospace', + ), + ), + ), + value: _selected[index], + onChanged: canApply + ? (checked) { + setState(() { + _selected[index] = checked ?? false; + }); + } + : null, + ); + }, ); }, ), diff --git a/apps/weblibre/lib/features/geckoview/features/open_link_tools/presentation/screens/unshortener_settings.dart b/apps/weblibre/lib/features/geckoview/features/open_link_tools/presentation/screens/unshortener_settings.dart index 3c061955..2925bcba 100644 --- a/apps/weblibre/lib/features/geckoview/features/open_link_tools/presentation/screens/unshortener_settings.dart +++ b/apps/weblibre/lib/features/geckoview/features/open_link_tools/presentation/screens/unshortener_settings.dart @@ -19,7 +19,6 @@ */ import 'dart:async'; -import 'package:fading_scroll/fading_scroll.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; @@ -27,37 +26,63 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:weblibre/extensions/uri.dart'; import 'package:weblibre/features/geckoview/features/open_link_tools/presentation/utils/open_in_custom_tab.dart'; import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart'; -import 'package:weblibre/features/settings/presentation/widgets/sections.dart'; +import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart'; import 'package:weblibre/features/user/data/models/general_settings.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; +const List unshortenerSettingsSections = [ + SettingsSectionDefinition( + title: 'Overview', + entries: [ + SettingsEntryDefinition( + title: 'Description', + subtitle: 'Resolve shortened URLs using the unshorten.me service', + keywords: ['short links', 'redirects'], + child: _UnshortenerDescriptionTile(), + ), + ], + ), + SettingsSectionDefinition( + title: 'Behavior', + entries: [ + SettingsEntryDefinition( + title: 'Enable Unshortener', + subtitle: 'Resolve shortened URLs to their destination', + keywords: ['short links'], + child: _UnshortenerEnabledTile(), + ), + SettingsEntryDefinition( + title: 'API Token', + subtitle: 'Optional token for higher request limits', + keywords: ['token'], + child: _UnshortenerTokenField(), + ), + ], + ), + SettingsSectionDefinition( + title: 'Attribution', + entries: [ + SettingsEntryDefinition( + title: 'Service attribution', + subtitle: 'Rate limits, service homepage, and privacy policy', + keywords: ['privacy policy', 'rate limit'], + child: _UnshortenerAttributionTile(), + ), + ], + ), +]; + class UnshortenerSettingsScreen extends StatelessWidget { const UnshortenerSettingsScreen({super.key}); @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: const Text('Unshortener')), - body: SafeArea( - child: FadingScroll( - fadingSize: 25, - builder: (context, controller) { - return ListView( - controller: controller, - padding: const EdgeInsets.symmetric(horizontal: 12.0), - children: const [ - SettingSection(name: 'Unshortener'), - _UnshortenerDescriptionTile(), - SettingSection(name: 'Behavior'), - _UnshortenerEnabledTile(), - _UnshortenerTokenField(), - SettingSection(name: 'Attribution'), - _UnshortenerAttributionTile(), - ], - ); - }, - ), - ), + return const SettingsDetailScaffold( + title: 'Unshortener', + subtitle: + 'Short-link resolution behavior, token configuration, and attribution.', + icon: MdiIcons.linkVariant, + sections: unshortenerSettingsSections, ); } } diff --git a/apps/weblibre/lib/features/geckoview/features/open_link_tools/presentation/screens/url_cleaner_settings.dart b/apps/weblibre/lib/features/geckoview/features/open_link_tools/presentation/screens/url_cleaner_settings.dart index 064e780c..d582f220 100644 --- a/apps/weblibre/lib/features/geckoview/features/open_link_tools/presentation/screens/url_cleaner_settings.dart +++ b/apps/weblibre/lib/features/geckoview/features/open_link_tools/presentation/screens/url_cleaner_settings.dart @@ -17,7 +17,6 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -import 'package:fading_scroll/fading_scroll.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; @@ -27,43 +26,88 @@ import 'package:weblibre/features/geckoview/features/open_link_tools/domain/serv import 'package:weblibre/features/geckoview/features/open_link_tools/presentation/dialogs/url_cleaner_restore_defaults_dialog.dart'; import 'package:weblibre/features/geckoview/features/open_link_tools/presentation/widgets/attribution_link.dart'; import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart'; -import 'package:weblibre/features/settings/presentation/widgets/sections.dart'; +import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart'; import 'package:weblibre/features/user/data/models/general_settings.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; import 'package:weblibre/utils/ui_helper.dart'; +const List urlCleanerSettingsSections = [ + SettingsSectionDefinition( + title: 'Overview', + entries: [ + SettingsEntryDefinition( + title: 'Description', + subtitle: 'Tracking parameter removal and offline redirect cleanup', + keywords: ['tracking parameters', 'redirects'], + child: _UrlCleanerDescriptionTile(), + ), + ], + ), + SettingsSectionDefinition( + title: 'Behavior', + entries: [ + SettingsEntryDefinition( + title: 'Enable URL Cleaner', + subtitle: 'Remove tracking parameters from URLs', + keywords: ['clean urls'], + child: _UrlCleanerEnabledTile(), + ), + SettingsEntryDefinition( + title: 'Auto-apply', + subtitle: 'Automatically replace URL with cleaned version', + keywords: ['auto apply'], + child: _UrlCleanerAutoApplyTile(), + ), + SettingsEntryDefinition( + title: 'Allow referral marketing', + subtitle: 'Keep referral and affiliate tracking parameters', + keywords: ['affiliate', 'referral'], + child: _UrlCleanerAllowReferralTile(), + ), + ], + ), + SettingsSectionDefinition( + title: 'Catalog', + entries: [ + SettingsEntryDefinition( + title: 'Auto-update catalog', + subtitle: 'Check for rule updates weekly', + child: _UrlCleanerAutoUpdateTile(), + ), + SettingsEntryDefinition( + title: 'Update catalog', + subtitle: 'Fetch the latest URL cleaner rules', + child: _UrlCleanerUpdateButton(), + ), + SettingsEntryDefinition( + title: 'Restore defaults', + subtitle: 'Reset to bundled catalog and default settings', + child: _UrlCleanerRestoreDefaultsButton(), + ), + ], + ), + SettingsSectionDefinition( + title: 'Attribution', + entries: [ + SettingsEntryDefinition( + title: 'Attribution', + subtitle: 'Credits and source links', + child: _UrlCleanerAttributionTile(), + ), + ], + ), +]; + class UrlCleanerSettingsScreen extends StatelessWidget { const UrlCleanerSettingsScreen({super.key}); @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: const Text('URL Cleaner')), - body: SafeArea( - child: FadingScroll( - fadingSize: 25, - builder: (context, controller) { - return ListView( - controller: controller, - padding: const EdgeInsets.symmetric(horizontal: 12.0), - children: const [ - SettingSection(name: 'URL Cleaner'), - _UrlCleanerDescriptionTile(), - SettingSection(name: 'Behavior'), - _UrlCleanerEnabledTile(), - _UrlCleanerAutoApplyTile(), - _UrlCleanerAllowReferralTile(), - SettingSection(name: 'Catalog'), - _UrlCleanerAutoUpdateTile(), - _UrlCleanerUpdateButton(), - _UrlCleanerRestoreDefaultsButton(), - SettingSection(name: 'Attribution'), - _UrlCleanerAttributionTile(), - ], - ); - }, - ), - ), + return const SettingsDetailScaffold( + title: 'URL Cleaner', + subtitle: 'URL cleanup behavior, rule catalog updates, and attribution.', + icon: MdiIcons.broom, + sections: urlCleanerSettingsSections, ); } } diff --git a/apps/weblibre/lib/features/geckoview/features/pwa/domain/providers.dart b/apps/weblibre/lib/features/geckoview/features/pwa/domain/providers.dart index 029eb4c1..441e4986 100644 --- a/apps/weblibre/lib/features/geckoview/features/pwa/domain/providers.dart +++ b/apps/weblibre/lib/features/geckoview/features/pwa/domain/providers.dart @@ -29,6 +29,7 @@ import 'package:weblibre/features/geckoview/domain/providers.dart'; import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; import 'package:weblibre/features/geckoview/features/pwa/domain/pwa_installability.dart'; +import 'package:weblibre/features/web_search/domain/controllers/sandbox_capture_controller.dart'; part 'providers.g.dart'; @@ -83,6 +84,15 @@ PwaManifest? currentTabManifest(Ref ref) { /// Boolean indicating if the current tab is installable as a PWA. @Riverpod() bool isCurrentTabInstallable(Ref ref) { + final selectedTabId = ref.watch(selectedTabProvider); + // Sandbox-captured tabs serve a loopback page; "installing" it would + // either pin the loopback URL (broken after the capture server cycles) + // or, worse, silently navigate the source URL. + final sandboxSourceUri = ref.watch( + sandboxSourceUriForTabProvider(tabId: selectedTabId), + ); + if (sandboxSourceUri != null) return false; + final manifest = ref.watch(currentTabManifestProvider); if (manifest == null) return false; @@ -126,6 +136,13 @@ bool isCurrentTabShortcutable(Ref ref) { final selectedTabId = ref.watch(selectedTabProvider); if (selectedTabId == null) return false; + // Same reasoning as `isCurrentTabInstallable`: never offer to pin a + // sandbox-captured tab to the home screen. + final sandboxSourceUri = ref.watch( + sandboxSourceUriForTabProvider(tabId: selectedTabId), + ); + if (sandboxSourceUri != null) return false; + final tabState = ref.watch(tabStateProvider(selectedTabId)); if (tabState == null) return false; diff --git a/apps/weblibre/lib/features/geckoview/features/pwa/domain/providers.g.dart b/apps/weblibre/lib/features/geckoview/features/pwa/domain/providers.g.dart index 9997807a..29f9abb3 100644 --- a/apps/weblibre/lib/features/geckoview/features/pwa/domain/providers.g.dart +++ b/apps/weblibre/lib/features/geckoview/features/pwa/domain/providers.g.dart @@ -207,7 +207,7 @@ final class IsCurrentTabInstallableProvider } String _$isCurrentTabInstallableHash() => - r'293cdb6dcea24446343330ccdb30dc9f21f03618'; + r'484c048dca283317d30b3847a32092784dde48be'; /// Installs the current tab as a PWA, embedding profile and container context /// in the shortcut intent so the PWA reopens with the same isolation. @@ -398,7 +398,7 @@ final class IsCurrentTabShortcutableProvider } String _$isCurrentTabShortcutableHash() => - r'a18fa837facc92397dacb79f38bdefd303ff5d00'; + r'5ffd27964eef44991d16e1c07ce4c1315a676549'; /// Creates a basic bookmark shortcut on the home screen for the current tab. diff --git a/apps/weblibre/lib/features/geckoview/features/search/domain/providers/combined_history.dart b/apps/weblibre/lib/features/geckoview/features/search/domain/providers/combined_history.dart new file mode 100644 index 00000000..2081133d --- /dev/null +++ b/apps/weblibre/lib/features/geckoview/features/search/domain/providers/combined_history.dart @@ -0,0 +1,149 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'dart:typed_data'; + +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; +import 'package:nullability/nullability.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/features/geckoview/features/search/domain/providers/engine_suggestions.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/models/history_query_result.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/history_search.dart'; +import 'package:weblibre/utils/url_canonical.dart'; + +part 'combined_history.g.dart'; + +/// Single row in the combined history section. +/// +/// Items come from one of two sources and are deduplicated by canonical +/// URL. The engine ordering is preserved; local-only matches are appended +/// after, so the user keeps their familiar frecency-ranked top of list and +/// content-search hits flesh out the long tail. +class CombinedHistoryItem { + final Uri uri; + final String? title; + final Uint8List? engineIcon; + + /// Raw highlight markers (`***foo***`) from FTS5 over `extracted_content` + /// or `full_content`. Renderer in `text_highlight.dart` converts them into + /// styled spans. `null` for engine-only items. + final String? highlightedTitle; + final String? snippet; + + /// Bookkeeping for the renderer / future "Engine"/"Local" badges. + final CombinedHistorySource source; + + const CombinedHistoryItem({ + required this.uri, + required this.title, + required this.engineIcon, + required this.highlightedTitle, + required this.snippet, + required this.source, + }); +} + +enum CombinedHistorySource { engine, local } + +/// Engine suggestions, augmented per-row with local snippet/highlight when +/// available, then padded with local-only matches. +/// +/// Implementation note: kept as a synchronous Riverpod provider that derives +/// its data from `engineSuggestionsProvider` + `historySearchRepositoryProvider`. +/// Both upstream providers are responsible for kicking off their own queries +/// when the search text changes; this one just reacts. +@Riverpod() +List combinedHistorySuggestions(Ref ref) { + final engineAsync = ref.watch(engineSuggestionsProvider); + final localAsync = ref.watch(historySearchRepositoryProvider); + + final engineSuggestions = + engineAsync.value ?? const []; + final localResults = localAsync.value?.results ?? const []; + + // Index the local rows by canonical URL so engine items can pick up + // snippet/title-highlight without an N×M scan. + final localByCanonical = {}; + for (final hit in localResults) { + localByCanonical[hit.urlCanonical] = hit; + } + + final emitted = {}; + final out = []; + + // 1. Engine suggestions in their existing order, enriched where possible. + // Single-pass filter+emit: skip suggestions that aren't usable history + // items, deduplicate by canonical URL. + for (final suggestion in engineSuggestions) { + if (suggestion.type != GeckoSuggestionType.history) continue; + if (suggestion.title?.isEmpty ?? true) continue; + if (suggestion.description?.isEmpty ?? true) continue; + + final uri = suggestion.description.mapNotNull(Uri.tryParse); + if (uri == null) continue; + final canonical = canonicalizeUrl(uri.toString())?.canonical; + if (canonical == null) continue; + if (!emitted.add(canonical)) continue; + + final local = localByCanonical[canonical]; + out.add( + CombinedHistoryItem( + uri: uri, + title: suggestion.title, + engineIcon: suggestion.icon, + highlightedTitle: local?.title, + snippet: _pickSnippet(local), + source: CombinedHistorySource.engine, + ), + ); + } + + // 2. Local-only matches: URLs the engine didn't surface (typically because + // the user's query matched only in extracted/full content rather than + // title/url, which is exactly where the local FTS earns its keep). + for (final hit in localResults) { + if (!emitted.add(hit.urlCanonical)) continue; + final uri = Uri.tryParse(hit.urlCanonical); + if (uri == null) continue; + + out.add( + CombinedHistoryItem( + uri: uri, + title: hit.title, + engineIcon: null, + highlightedTitle: hit.title, + snippet: _pickSnippet(hit), + source: CombinedHistorySource.local, + ), + ); + } + + return out; +} + +/// Prefer the extracted-content snippet (reader text) when it carries a +/// match, falling back to full content otherwise. Mirrors the heuristic in +/// the existing tab/local-history widgets. +String? _pickSnippet(HistoryQueryResult? hit) { + if (hit == null) return null; + if (hit.extractedContent?.contains(historyHighlightPrefix) ?? false) { + return hit.extractedContent; + } + return hit.fullContent; +} diff --git a/apps/weblibre/lib/features/geckoview/features/search/domain/providers/combined_history.g.dart b/apps/weblibre/lib/features/geckoview/features/search/domain/providers/combined_history.g.dart new file mode 100644 index 00000000..9d53907d --- /dev/null +++ b/apps/weblibre/lib/features/geckoview/features/search/domain/providers/combined_history.g.dart @@ -0,0 +1,81 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'combined_history.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning +/// Engine suggestions, augmented per-row with local snippet/highlight when +/// available, then padded with local-only matches. +/// +/// Implementation note: kept as a synchronous Riverpod provider that derives +/// its data from `engineSuggestionsProvider` + `historySearchRepositoryProvider`. +/// Both upstream providers are responsible for kicking off their own queries +/// when the search text changes; this one just reacts. + +@ProviderFor(combinedHistorySuggestions) +final combinedHistorySuggestionsProvider = + CombinedHistorySuggestionsProvider._(); + +/// Engine suggestions, augmented per-row with local snippet/highlight when +/// available, then padded with local-only matches. +/// +/// Implementation note: kept as a synchronous Riverpod provider that derives +/// its data from `engineSuggestionsProvider` + `historySearchRepositoryProvider`. +/// Both upstream providers are responsible for kicking off their own queries +/// when the search text changes; this one just reacts. + +final class CombinedHistorySuggestionsProvider + extends + $FunctionalProvider< + List, + List, + List + > + with $Provider> { + /// Engine suggestions, augmented per-row with local snippet/highlight when + /// available, then padded with local-only matches. + /// + /// Implementation note: kept as a synchronous Riverpod provider that derives + /// its data from `engineSuggestionsProvider` + `historySearchRepositoryProvider`. + /// Both upstream providers are responsible for kicking off their own queries + /// when the search text changes; this one just reacts. + CombinedHistorySuggestionsProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'combinedHistorySuggestionsProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$combinedHistorySuggestionsHash(); + + @$internal + @override + $ProviderElement> $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + List create(Ref ref) { + return combinedHistorySuggestions(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(List value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider>(value), + ); + } +} + +String _$combinedHistorySuggestionsHash() => + r'a2cc2bc57529cb60e9ac75e83e3a7babb7f950ea'; diff --git a/apps/weblibre/lib/features/geckoview/features/search/domain/providers/search_autofocus.dart b/apps/weblibre/lib/features/geckoview/features/search/domain/providers/search_autofocus.dart new file mode 100644 index 00000000..90eeecf9 --- /dev/null +++ b/apps/weblibre/lib/features/geckoview/features/search/domain/providers/search_autofocus.dart @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'search_autofocus.g.dart'; + +@Riverpod(keepAlive: true) +class SearchAutofocusSuppression extends _$SearchAutofocusSuppression { + void suppressNext() { + state = true; + } + + void clear() { + state = false; + } + + @override + bool build() { + return false; + } +} diff --git a/apps/weblibre/lib/features/geckoview/features/search/domain/providers/search_autofocus.g.dart b/apps/weblibre/lib/features/geckoview/features/search/domain/providers/search_autofocus.g.dart new file mode 100644 index 00000000..bef54452 --- /dev/null +++ b/apps/weblibre/lib/features/geckoview/features/search/domain/providers/search_autofocus.g.dart @@ -0,0 +1,64 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'search_autofocus.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(SearchAutofocusSuppression) +final searchAutofocusSuppressionProvider = + SearchAutofocusSuppressionProvider._(); + +final class SearchAutofocusSuppressionProvider + extends $NotifierProvider { + SearchAutofocusSuppressionProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'searchAutofocusSuppressionProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$searchAutofocusSuppressionHash(); + + @$internal + @override + SearchAutofocusSuppression create() => SearchAutofocusSuppression(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(bool value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$searchAutofocusSuppressionHash() => + r'088e84ab9af2da9a1adc2316c3c594374720cc9e'; + +abstract class _$SearchAutofocusSuppression extends $Notifier { + bool build(); + @$mustCallSuper + @override + void runBuild() { + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + bool, + Object?, + Object? + >; + element.handleCreate(ref, build); + } +} diff --git a/apps/weblibre/lib/features/geckoview/features/search/domain/providers/search_module_order.dart b/apps/weblibre/lib/features/geckoview/features/search/domain/providers/search_module_order.dart index e0311da4..0b7c0768 100644 --- a/apps/weblibre/lib/features/geckoview/features/search/domain/providers/search_module_order.dart +++ b/apps/weblibre/lib/features/geckoview/features/search/domain/providers/search_module_order.dart @@ -58,11 +58,15 @@ List _mergeWithDefaults( final defaultSet = defaults.toSet(); // Keep persisted entries that are still valid final result = persisted.where((e) => defaultSet.contains(e.type)).toList(); - // Add any new defaults not in persisted + // Insert any new defaults at their position from the defaults list so newly + // introduced modules land where they're meant to (e.g. at the top), instead + // of trailing the user's persisted order. final persistedTypes = result.map((e) => e.type).toSet(); - for (final type in defaults) { + for (var i = 0; i < defaults.length; i++) { + final type = defaults[i]; if (!persistedTypes.contains(type)) { - result.add(ModuleOrderEntry(type: type, visible: true)); + final insertAt = i.clamp(0, result.length); + result.insert(insertAt, ModuleOrderEntry(type: type, visible: true)); } } return result; diff --git a/apps/weblibre/lib/features/geckoview/features/search/domain/providers/search_module_order.g.dart b/apps/weblibre/lib/features/geckoview/features/search/domain/providers/search_module_order.g.dart index d312ff73..09d9f453 100644 --- a/apps/weblibre/lib/features/geckoview/features/search/domain/providers/search_module_order.g.dart +++ b/apps/weblibre/lib/features/geckoview/features/search/domain/providers/search_module_order.g.dart @@ -19,16 +19,22 @@ Map _$ModuleOrderEntryToJson(ModuleOrderEntry instance) => }; const _$SearchModuleTypeEnumMap = { + SearchModuleType.recentSearches: 'recentSearches', + SearchModuleType.searchProviders: 'searchProviders', + SearchModuleType.searchSuggestions: 'searchSuggestions', SearchModuleType.tabs: 'tabs', SearchModuleType.articles: 'articles', SearchModuleType.bookmarks: 'bookmarks', SearchModuleType.history: 'history', + SearchModuleType.localHistory: 'localHistory', + SearchModuleType.combinedHistory: 'combinedHistory', SearchModuleType.historyHighlights: 'historyHighlights', SearchModuleType.topSites: 'topSites', SearchModuleType.recentHistory: 'recentHistory', SearchModuleType.recentArticles: 'recentArticles', SearchModuleType.recentTabs: 'recentTabs', SearchModuleType.containers: 'containers', + SearchModuleType.frequentBangs: 'frequentBangs', }; // ************************************************************************** diff --git a/apps/weblibre/lib/features/geckoview/features/search/domain/providers/search_modules_view.dart b/apps/weblibre/lib/features/geckoview/features/search/domain/providers/search_modules_view.dart index a5f980c2..7f956ab1 100644 --- a/apps/weblibre/lib/features/geckoview/features/search/domain/providers/search_modules_view.dart +++ b/apps/weblibre/lib/features/geckoview/features/search/domain/providers/search_modules_view.dart @@ -22,28 +22,58 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'search_modules_view.g.dart'; enum SearchModuleType { + recentSearches, + searchProviders, + searchSuggestions, tabs, articles, bookmarks, + + /// Engine "History" suggestions, frecency-ranked from Places. Engine-only; + /// no local FTS hits. Superseded in the default ordering by + /// [combinedHistory] but kept as a separate module for users who want a + /// pure engine view. history, + + /// Local FTS5 hits over the indexed `extracted_content` / + /// `full_content`. Pure local view; complementary to [history]. + /// Superseded in the default ordering by [combinedHistory] which folds + /// these hits in alongside engine results — enabling both [localHistory] + /// and [combinedHistory] will surface the same local URLs in two + /// consecutive sections. + localHistory, + + /// Default "History" module: engine frecency results in their existing + /// order, augmented with local content snippets where available, then + /// padded with local-only matches at the tail. Prefer this over + /// enabling [history] and [localHistory] separately. + combinedHistory, + historyHighlights, topSites, recentHistory, recentArticles, recentTabs, - containers; + containers, + frequentBangs; String get label => switch (this) { + recentSearches => 'Recent Searches', + searchProviders => 'Search Providers', + searchSuggestions => 'Suggestions', tabs => 'Tabs', articles => 'Articles', bookmarks => 'Bookmarks', - history => 'History', + history => 'History (engine)', + localHistory => 'Local content', + combinedHistory => 'History', historyHighlights => 'History Highlights', topSites => 'Top Sites', recentHistory => 'Recent History', recentArticles => 'Recent Articles', recentTabs => 'Recent Tabs', containers => 'Containers', + frequentBangs => 'Frequent Bangs', }; } @@ -51,6 +81,8 @@ enum SearchModuleGroup { emptyState( key: 'EmptyStateModuleOrder', defaultModules: [ + SearchModuleType.recentSearches, + SearchModuleType.frequentBangs, SearchModuleType.topSites, SearchModuleType.recentArticles, SearchModuleType.recentTabs, @@ -62,10 +94,12 @@ enum SearchModuleGroup { search( key: 'SearchModuleOrder', defaultModules: [ + SearchModuleType.searchProviders, + SearchModuleType.searchSuggestions, SearchModuleType.tabs, SearchModuleType.bookmarks, SearchModuleType.articles, - SearchModuleType.history, + SearchModuleType.combinedHistory, ], ); @@ -76,16 +110,22 @@ enum SearchModuleGroup { extension SearchModuleTypeGroup on SearchModuleType { SearchModuleGroup get group => switch (this) { + SearchModuleType.recentSearches || SearchModuleType.topSites || SearchModuleType.recentArticles || SearchModuleType.recentTabs || SearchModuleType.recentHistory || SearchModuleType.historyHighlights || - SearchModuleType.containers => SearchModuleGroup.emptyState, + SearchModuleType.containers || + SearchModuleType.frequentBangs => SearchModuleGroup.emptyState, + SearchModuleType.searchProviders || + SearchModuleType.searchSuggestions || SearchModuleType.tabs || SearchModuleType.bookmarks || SearchModuleType.articles || - SearchModuleType.history => SearchModuleGroup.search, + SearchModuleType.history || + SearchModuleType.localHistory || + SearchModuleType.combinedHistory => SearchModuleGroup.search, }; } diff --git a/apps/weblibre/lib/features/geckoview/features/search/presentation/screens/search.dart b/apps/weblibre/lib/features/geckoview/features/search/presentation/screens/search.dart index b4e393ad..64a24a19 100644 --- a/apps/weblibre/lib/features/geckoview/features/search/presentation/screens/search.dart +++ b/apps/weblibre/lib/features/geckoview/features/search/presentation/screens/search.dart @@ -19,11 +19,14 @@ */ import 'dart:async'; +import 'package:fading_scroll/fading_scroll.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:weblibre/core/design/app_colors.dart'; import 'package:weblibre/core/routing/routes.dart'; +import 'package:weblibre/features/bangs/data/models/bang_data.dart'; +import 'package:weblibre/features/bangs/data/models/web_search_bang.dart'; import 'package:weblibre/features/bangs/domain/providers/bangs.dart'; import 'package:weblibre/features/bangs/domain/providers/search.dart'; import 'package:weblibre/features/geckoview/domain/controllers/bottom_sheet.dart'; @@ -33,22 +36,29 @@ import 'package:weblibre/features/geckoview/domain/providers/tab_session.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; import 'package:weblibre/features/geckoview/domain/repositories/tab.dart'; import 'package:weblibre/features/geckoview/features/browser/domain/providers.dart'; +import 'package:weblibre/features/geckoview/features/find_in_page/presentation/controllers/find_in_page.dart'; +import 'package:weblibre/features/geckoview/features/search/domain/providers/search_autofocus.dart'; import 'package:weblibre/features/geckoview/features/search/domain/providers/search_module_order.dart'; import 'package:weblibre/features/geckoview/features/search/domain/providers/search_modules_view.dart'; import 'package:weblibre/features/geckoview/features/search/presentation/widgets/animated_tab_type_switcher.dart'; import 'package:weblibre/features/geckoview/features/search/presentation/widgets/clipboard_fill.dart'; import 'package:weblibre/features/geckoview/features/search/presentation/widgets/empty_state/containers_section.dart'; +import 'package:weblibre/features/geckoview/features/search/presentation/widgets/empty_state/frequent_bangs_section.dart'; import 'package:weblibre/features/geckoview/features/search/presentation/widgets/empty_state/history_highlights_section.dart'; import 'package:weblibre/features/geckoview/features/search/presentation/widgets/empty_state/recent_feed_articles_section.dart'; import 'package:weblibre/features/geckoview/features/search/presentation/widgets/empty_state/recent_history_section.dart'; +import 'package:weblibre/features/geckoview/features/search/presentation/widgets/empty_state/recent_searches_section.dart'; import 'package:weblibre/features/geckoview/features/search/presentation/widgets/empty_state/recent_tabs_section.dart'; import 'package:weblibre/features/geckoview/features/search/presentation/widgets/empty_state/top_sites_section.dart'; import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_field.dart'; import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_module_reorder_view.dart'; import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/bookmark_search.dart'; import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/feed_search.dart'; -import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/full_search_suggestions.dart'; +import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/combined_history_suggestions.dart'; import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/history_suggestions.dart'; +import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/local_history_suggestions.dart'; +import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/search_providers_section.dart'; +import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/search_term_suggestions_section.dart'; import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/tab_search.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/entities/isolation_context.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart'; @@ -56,7 +66,14 @@ import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selec import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/compact_container_selector.dart'; import 'package:weblibre/features/tor/presentation/controllers/start_tor_proxy.dart'; import 'package:weblibre/features/tor/presentation/widgets/tor_dialog.dart'; +import 'package:weblibre/features/search_credits/domain/repositories/web_search_settings.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; +import 'package:weblibre/features/web_search/domain/controllers/search_controller.dart'; +import 'package:weblibre/features/web_search/presentation/open_in_new_tab.dart'; +import 'package:weblibre/features/web_search/presentation/widgets/route_through_tor_toggle.dart'; +import 'package:weblibre/features/web_search/presentation/widgets/search_filter_chips.dart'; +import 'package:weblibre/features/web_search/presentation/widgets/search_mode_selector.dart'; +import 'package:weblibre/features/web_search/presentation/widgets/web_search_results_section.dart'; import 'package:weblibre/presentation/hooks/on_listenable_change_selector.dart'; import 'package:weblibre/presentation/hooks/sampled_value_notifier.dart'; import 'package:weblibre/utils/input_classification.dart'; @@ -140,15 +157,24 @@ class SearchScreen extends HookConsumerWidget { final privateTabMode = effectiveTabMode is PrivateTabMode; + final previousWebSearchQuery = ref.watch( + metaSearchControllerProvider.select((s) { + if (s.status != WebSearchStatus.idle && s.query.isNotEmpty) { + return s.query; + } + return null; + }), + ); + final searchTextController = useTextEditingController( - text: initialSearchText, + text: initialSearchText ?? previousWebSearchQuery, ); final sampledSearchText = useSampledValueNotifier( source: searchTextController, sampleDuration: const Duration(milliseconds: 150), ); final hasUserProvidedInput = useState( - initialSearchText?.isNotEmpty == true, + initialSearchText?.isNotEmpty == true || previousWebSearchQuery != null, ); // Track if we started with a URL (edit mode) to show empty state initially @@ -159,6 +185,7 @@ class SearchScreen extends HookConsumerWidget { }); final hasUserModifiedInput = useState(false); final isUrlInput = useState(false); + final isEditingAfterSearch = useState(false); useOnListenableChangeSelector( searchTextController, @@ -166,6 +193,15 @@ class SearchScreen extends HookConsumerWidget { () { final text = searchTextController.text; hasUserProvidedInput.value = text.isNotEmpty; + + final metaState = ref.read(metaSearchControllerProvider); + + if (text.isEmpty && metaState.status != WebSearchStatus.idle) { + ref.read(metaSearchControllerProvider.notifier).reset(); + } else if (metaState.status != WebSearchStatus.idle) { + isEditingAfterSearch.value = text != metaState.query; + } + if (startedWithUrl) { hasUserModifiedInput.value = text != initialSearchText; } @@ -175,11 +211,22 @@ class SearchScreen extends HookConsumerWidget { }, ); + // Keep isEditingAfterSearch in sync when meta search state changes + // (e.g. after submit/ready transitions), not just on text changes. + ref.listen(metaSearchControllerProvider, (_, next) { + if (next.status == WebSearchStatus.idle) { + isEditingAfterSearch.value = false; + } else { + isEditingAfterSearch.value = searchTextController.text != next.query; + } + }); + final showNoInputSections = (startedWithUrl && !hasUserModifiedInput.value) || (!hasUserProvidedInput.value && searchTextController.text.isEmpty); final searchFocusNode = useFocusNode(); + final pauseTime = useRef(null); final textFieldKey = useMemoized(() => GlobalKey()); final preferredHeight = useState(kToolbarHeight); @@ -187,8 +234,8 @@ class SearchScreen extends HookConsumerWidget { searchFocusNode, () => searchFocusNode.hasFocus, () { - if (searchFocusNode.hasFocus && isEditMode) { - // Select all text when the field is focused + if (searchFocusNode.hasFocus && + (isEditMode || previousWebSearchQuery != null)) { searchTextController.selection = TextSelection( baseOffset: 0, extentOffset: searchTextController.text.length, @@ -198,7 +245,7 @@ class SearchScreen extends HookConsumerWidget { ); useEffect(() { - if (isEditMode) { + if (isEditMode || previousWebSearchQuery != null) { WidgetsBinding.instance.addPostFrameCallback((_) { searchTextController.selection = TextSelection( baseOffset: 0, @@ -211,6 +258,14 @@ class SearchScreen extends HookConsumerWidget { //Request initial focus in a way our useOnListenableChangeSelector is triggered useEffect(() { + if (ref.read(searchAutofocusSuppressionProvider)) { + WidgetsBinding.instance.addPostFrameCallback((_) { + ref.read(searchAutofocusSuppressionProvider.notifier).clear(); + }); + + return null; + } + //Wait for first frame then request focus unawaited( Future.delayed(const Duration(milliseconds: 1000 ~/ 60)).whenComplete( @@ -264,7 +319,16 @@ class SearchScreen extends HookConsumerWidget { case AppLifecycleState.paused: //Fixes issue with disappearing keyboard after resume (even we request focus) searchFocusNode.unfocus(); + pauseTime.value ??= DateTime.now(); case AppLifecycleState.resumed: + if (pauseTime.value == null || + DateTime.now().difference(pauseTime.value!) < + const Duration(minutes: 1)) { + pauseTime.value = null; + break; + } + + pauseTime.value = null; WidgetsBinding.instance.addPostFrameCallback((_) { searchFocusNode.requestFocus(); }); @@ -295,63 +359,15 @@ class SearchScreen extends HookConsumerWidget { return null; }, [showBangIcon]); - Future submitSearch(String query) async { - if (activeBang != null && (formKey.currentState?.validate() == true)) { - final searchUri = activeBang.getTemplateUrl(query); - - if (!privateTabMode) { - await ref - .read(bangSearchProvider.notifier) - .triggerBangSearch(activeBang, query); - } - - if (isEditMode) { - // Load into existing tab - await ref - .read(tabSessionProvider(tabId: tabId).notifier) - .loadUrl(url: searchUri); - } else { - // Create new tab - await ref - .read(tabRepositoryProvider.notifier) - .addTab( - url: searchUri, - tabMode: effectiveTabMode, - parentId: (selectedTabType.value == TabType.child) - ? ref.read(selectedTabProvider) - : null, - launchedFromIntent: launchedFromIntent, - selectTab: true, - containerSelection: selectedContainer == null - ? const TabContainerSelection.unassigned() - : TabContainerSelection.specific(selectedContainer), - ); - } - - if (context.mounted) { - ref.read(bottomSheetControllerProvider.notifier).requestDismiss(); - - const BrowserRoute().go(context); - } - } - } - - final reorderGroup = ref.watch(searchReorderModeProvider); - - final emptyStateOrder = ref.watch( - searchModuleOrderProvider(SearchModuleGroup.emptyState), - ); - final searchOrder = ref.watch( - searchModuleOrderProvider(SearchModuleGroup.search), - ); - - Future openUriInTab(Uri uri) async { + Future openUriInTab(Uri uri, {String? findInPageQuery}) async { + final String targetTabId; if (isEditMode) { + targetTabId = tabId!; await ref - .read(tabSessionProvider(tabId: tabId).notifier) + .read(tabSessionProvider(tabId: targetTabId).notifier) .loadUrl(url: uri); } else { - await ref + targetTabId = await ref .read(tabRepositoryProvider.notifier) .addTab( url: uri, @@ -367,13 +383,89 @@ class SearchScreen extends HookConsumerWidget { ); } + if (findInPageQuery != null && findInPageQuery.isNotEmpty) { + await ref + .read(findInPageControllerProvider(targetTabId).notifier) + .findAll(text: findInPageQuery); + } + if (context.mounted) { ref.read(bottomSheetControllerProvider.notifier).requestDismiss(); const BrowserRoute().go(context); } } + /// Records the bang search, dispatches to the in-app web search engine for + /// `wl` and returns null in that case, otherwise returns the resolved URI. + Future resolveSearchUri(BangData bang, String query) async { + if (!privateTabMode) { + await ref + .read(bangSearchProvider.notifier) + .triggerBangSearch(bang, query); + } + + if (isWebSearchBang(bang)) { + final settings = ref.read(webSearchSettingsControllerProvider); + await ref + .read(metaSearchControllerProvider.notifier) + .submit( + query, + mode: settings.searchMode, + language: settings.language, + region: settings.region, + safeSearch: settings.safeSearch, + timeRange: settings.timeRange, + ); + return null; + } + + return bang.getTemplateUrl(query); + } + + Future submitSearch(String query) async { + if (activeBang != null && (formKey.currentState?.validate() == true)) { + final uri = await resolveSearchUri(activeBang, query); + if (uri != null) { + await openUriInTab(uri); + } + } + } + + // Persist the results scroll offset across navigation so returning to + // the search screen after opening a result restores the user's place + // instead of jumping back to the top. The offset provider is reset on + // every fresh submit/reset of the web search controller. + final scrollController = useScrollController( + initialScrollOffset: ref.read(webSearchScrollOffsetProvider), + ); + useEffect(() { + void listener() { + if (scrollController.hasClients) { + ref + .read(webSearchScrollOffsetProvider.notifier) + .update(scrollController.offset); + } + } + + scrollController.addListener(listener); + return () => scrollController.removeListener(listener); + }, [scrollController]); + + final reorderGroup = ref.watch(searchReorderModeProvider); + + final emptyStateOrder = ref.watch( + searchModuleOrderProvider(SearchModuleGroup.emptyState), + ); + final searchOrder = ref.watch( + searchModuleOrderProvider(SearchModuleGroup.search), + ); + final emptyStateWidgets = { + SearchModuleType.recentSearches: RecentSearchesSection( + searchTextController: searchTextController, + submitSearch: submitSearch, + ), + SearchModuleType.frequentBangs: const FrequentBangsSection(), SearchModuleType.topSites: TopSitesSection(onUriSelected: openUriInTab), SearchModuleType.recentArticles: RecentFeedArticlesSection( onArticleSelected: (article) { @@ -431,6 +523,14 @@ class SearchScreen extends HookConsumerWidget { }; final searchWidgets = { + SearchModuleType.searchProviders: SearchProvidersSection( + searchTextController: searchTextController, + domain: isEditMode ? existingTabState.url.host : null, + ), + SearchModuleType.searchSuggestions: SearchTermSuggestionsSection( + searchTextController: searchTextController, + submitSearch: submitSearch, + ), SearchModuleType.tabs: TabSearch(searchTextListenable: sampledSearchText), SearchModuleType.bookmarks: BookmarkSearch( searchTextListenable: sampledSearchText, @@ -443,13 +543,35 @@ class SearchScreen extends HookConsumerWidget { searchTextListenable: sampledSearchText, onUriSelected: openUriInTab, ), + SearchModuleType.localHistory: LocalHistorySuggestions( + searchTextListenable: sampledSearchText, + onUriSelected: openUriInTab, + ), + SearchModuleType.combinedHistory: CombinedHistorySuggestions( + searchTextListenable: sampledSearchText, + onUriSelected: openUriInTab, + ), }; + bool canShowSearchModule(SearchModuleType type) { + if (!isUrlInput.value) { + return true; + } + + return switch (type) { + SearchModuleType.searchProviders || + SearchModuleType.searchSuggestions || + SearchModuleType.articles => false, + _ => true, + }; + } + return Scaffold( body: SafeArea( child: Form( key: formKey, child: CustomScrollView( + controller: scrollController, slivers: [ SliverAppBar( floating: true, @@ -525,6 +647,7 @@ class SearchScreen extends HookConsumerWidget { alignment: Alignment.centerRight, child: CompactContainerSelector( selectedContainer: selectedContainer, + emphasizeSelection: false, ), ), ), @@ -541,31 +664,15 @@ class SearchScreen extends HookConsumerWidget { textEditingController: searchTextController, focusNode: searchFocusNode, maxLines: isEditMode ? 3 : 1, - autofocus: true, label: const Text('Search or enter URL'), unfocusOnTapOutside: false, onSubmitted: (value) async { - if (value.isNotEmpty) { - final classification = classifyAddressBarInput(value); - Uri? newUrl; - String? searchQuery; + if (value.isEmpty) return; - switch (classification) { - case NavigateInputClassification(:final uri): - newUrl = uri; - case SearchInputClassification(:final query): - searchQuery = query; - case InvalidInputClassification(): - if (context.mounted) { - ui_helper.showErrorMessage( - context, - 'Invalid address', - ); - } - return; - } - - if (newUrl == null && searchQuery != null) { + switch (classifyAddressBarInput(value)) { + case NavigateInputClassification(:final uri): + await openUriInTab(uri); + case SearchInputClassification(:final query): // Read from both providers - use site if set, otherwise global final siteBang = isEditMode ? ref.read( @@ -582,52 +689,22 @@ class SearchScreen extends HookConsumerWidget { globalBang ?? await ref.read(defaultSearchBangProvider.future); - if (bang != null) { - newUrl = bang.getTemplateUrl(searchQuery); + if (bang == null) return; - if (!privateTabMode) { - await ref - .read(bangSearchProvider.notifier) - .triggerBangSearch(bang, searchQuery); - } + final uri = await resolveSearchUri(bang, query); + if (uri == null) { + // Web search dispatched in-app; reset edit state. + isEditingAfterSearch.value = false; + return; } - } - - if (newUrl != null) { - if (isEditMode) { - // Load into existing tab - await ref - .read(tabSessionProvider(tabId: tabId).notifier) - .loadUrl(url: newUrl); - } else { - // Create new tab - await ref - .read(tabRepositoryProvider.notifier) - .addTab( - url: newUrl, - tabMode: effectiveTabMode, - parentId: - (selectedTabType.value == TabType.child) - ? ref.read(selectedTabProvider) - : null, - launchedFromIntent: launchedFromIntent, - selectTab: true, - containerSelection: selectedContainer == null - ? const TabContainerSelection.unassigned() - : TabContainerSelection.specific( - selectedContainer, - ), - ); - } - + await openUriInTab(uri); + case InvalidInputClassification(): if (context.mounted) { - ref - .read(bottomSheetControllerProvider.notifier) - .requestDismiss(); - - const BrowserRoute().go(context); + ui_helper.showErrorMessage( + context, + 'Invalid address', + ); } - } } }, activeBang: activeBang, @@ -638,9 +715,46 @@ class SearchScreen extends HookConsumerWidget { SliverToBoxAdapter( child: ClipboardFillLink(controller: searchTextController), ), + if (isWebSearchBang(activeBang)) + const SliverPadding( + padding: EdgeInsets.fromLTRB(0, 8, 0, 4), + sliver: SliverToBoxAdapter( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _WebSearchOptionsRow(), + WebSearchTorBootstrapProgress(), + ], + ), + ), + ), if (reorderGroup != null) SearchModuleReorderView(group: reorderGroup) - else if (showNoInputSections) ...[ + else if (isWebSearchBang(activeBang) && + ref.watch( + metaSearchControllerProvider.select( + (s) => + s.status != WebSearchStatus.idle || + s.query.isNotEmpty, + ), + )) ...[ + // Once a web search has been dispatched, the screen shows + // the fetched results only — search suggestions and search + // providers belong to the normal search page, not the + // results view. + WebSearchResultsSection( + resolveOpenTarget: () => WebSearchOpenTarget( + tabMode: effectiveTabMode, + containerSelection: selectedContainer == null + ? const TabContainerSelection.unassigned() + : TabContainerSelection.specific(selectedContainer), + parentId: (selectedTabType.value == TabType.child) + ? ref.read(selectedTabProvider) + : null, + ), + ), + ] else if (showNoInputSections) ...[ for (final entry in emptyStateOrder) if (emptyStateWidgets.containsKey(entry.type)) emptyStateWidgets[entry.type]!, @@ -648,17 +762,9 @@ class SearchScreen extends HookConsumerWidget { group: SearchModuleGroup.emptyState, ), ] else ...[ - if (!isUrlInput.value) - FullSearchTermSuggestions( - searchTextController: searchTextController, - activeBang: activeBang, - submitSearch: submitSearch, - domain: isEditMode ? existingTabState.url.host : null, - ), for (final entry in searchOrder) if (searchWidgets.containsKey(entry.type) && - (!isUrlInput.value || - entry.type != SearchModuleType.articles)) + canShowSearchModule(entry.type)) searchWidgets[entry.type]!, const _CustomizeSectionsButton(group: SearchModuleGroup.search), ], @@ -692,3 +798,43 @@ class _CustomizeSectionsButton extends ConsumerWidget { ); } } + +/// Horizontally scrollable row of web-search filter pills. +/// +/// Order: Tor toggle first (always-visible safety control), then search +/// mode, then the locale/freshness/safety filter pills. The row itself +/// scrolls horizontally — adding more chips later doesn't break the +/// layout on narrow screens. +class _WebSearchOptionsRow extends StatelessWidget { + const _WebSearchOptionsRow(); + + @override + Widget build(BuildContext context) { + return FadingScroll( + fadingSize: 15, + builder: (context, controller) { + return SingleChildScrollView( + controller: controller, + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Row( + children: const [ + WebSearchStatusChip(), + RouteThroughTorToggle(), + SizedBox(width: 8), + SearchModeSelector(), + SizedBox(width: 8), + LanguageSelector(), + SizedBox(width: 8), + CountrySelector(), + SizedBox(width: 8), + FreshnessSelector(), + SizedBox(width: 8), + SafeSearchSelector(), + ], + ), + ); + }, + ); + } +} diff --git a/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/bang_chip_strip.dart b/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/bang_chip_strip.dart new file mode 100644 index 00000000..735c583b --- /dev/null +++ b/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/bang_chip_strip.dart @@ -0,0 +1,141 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'package:flutter/material.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; +import 'package:weblibre/features/bangs/data/models/bang_data.dart'; +import 'package:weblibre/presentation/widgets/selectable_chips.dart'; +import 'package:weblibre/presentation/widgets/url_icon.dart'; + +// These helpers are conceptually `BangChipStrip`-private — they capture +// the chip's own selection / delete-affordance rules and are not part of +// the public bang API. The `@visibleForTesting` annotation keeps them +// reachable from `frequent_bangs_section_test.dart` (which asserts the +// rules directly) without inviting unrelated call sites. +@visibleForTesting +bool isSelectedBangChip(BangData bang, BangData? selectedBang) => + selectedBang != null && bang.toKey() == selectedBang.toKey(); + +@visibleForTesting +bool canDeleteBangChip( + BangData bang, { + BangData? selectedBang, + required bool allowFrequencyResetAction, +}) => isSelectedBangChip(bang, selectedBang) || allowFrequencyResetAction; + +@visibleForTesting +IconData? bangChipDeleteIcon( + BangData bang, { + BangData? selectedBang, + required bool allowFrequencyResetAction, +}) { + if (isSelectedBangChip(bang, selectedBang)) { + return Icons.clear; + } + + return allowFrequencyResetAction ? MdiIcons.restore : null; +} + +class BangChipStrip extends StatelessWidget { + final List bangs; + final BangData? selectedBang; + final BangData? deletableSelectedBang; + final bool Function(BangData bang)? canDeleteBang; + final int? maxCount; + final List prefixItems; + final bool showTrailingMenu; + final bool sortSelectedFirst; + final bool allowFrequencyResetAction; + final VoidCallback? onMenuPressed; + final void Function(BangData bang) onSelected; + final void Function(BangData bang) onDeleted; + + const BangChipStrip({ + required this.bangs, + required this.selectedBang, + required this.onSelected, + required this.onDeleted, + this.deletableSelectedBang, + this.canDeleteBang, + this.maxCount, + this.prefixItems = const [], + this.showTrailingMenu = false, + this.sortSelectedFirst = true, + this.allowFrequencyResetAction = false, + this.onMenuPressed, + super.key, + }); + + @override + Widget build(BuildContext context) { + final hasContent = selectedBang != null || bangs.isNotEmpty; + final resolvedDeletableSelectedBang = deletableSelectedBang ?? selectedBang; + + return SizedBox( + height: 48, + child: Row( + children: [ + if (hasContent) + Expanded( + child: SelectableChips( + itemId: (bang) => bang.trigger, + itemAvatar: (bang) => + UrlIcon([bang.getDefaultUrl()], iconSize: 20), + itemLabel: (bang) => Text(bang.websiteName), + itemTooltip: (bang) => bang.trigger, + availableItems: bangs, + selectedItem: selectedBang, + maxCount: maxCount, + sortSelectedFirst: sortSelectedFirst, + decoration: SelectableChipDecoration( + canDelete: (bang) => + canDeleteBangChip( + bang, + selectedBang: resolvedDeletableSelectedBang, + allowFrequencyResetAction: allowFrequencyResetAction, + ) && + (canDeleteBang?.call(bang) ?? true), + deleteIcon: (bang) { + final icon = bangChipDeleteIcon( + bang, + selectedBang: resolvedDeletableSelectedBang, + allowFrequencyResetAction: allowFrequencyResetAction, + ); + return icon == null ? null : Icon(icon); + }, + ), + onSelected: onSelected, + onDeleted: onDeleted, + ), + ) + else if (prefixItems.isNotEmpty) ...[ + ...prefixItems, + const Spacer(), + ] else + const Spacer(), + if (showTrailingMenu) + IconButton( + onPressed: onMenuPressed, + icon: const Icon(Icons.chevron_right), + ), + ], + ), + ); + } +} diff --git a/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/empty_state/frequent_bangs_section.dart b/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/empty_state/frequent_bangs_section.dart new file mode 100644 index 00000000..60cb77d6 --- /dev/null +++ b/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/empty_state/frequent_bangs_section.dart @@ -0,0 +1,163 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:weblibre/features/bangs/data/models/bang_data.dart'; +import 'package:weblibre/features/bangs/domain/providers/bangs.dart'; +import 'package:weblibre/features/bangs/domain/repositories/data.dart'; +import 'package:weblibre/features/geckoview/features/browser/domain/providers.dart'; +import 'package:weblibre/features/geckoview/features/search/domain/providers/search_modules_view.dart'; +import 'package:weblibre/features/geckoview/features/search/presentation/dialogs/reset_bang_dialog.dart'; +import 'package:weblibre/features/geckoview/features/search/presentation/widgets/bang_chip_strip.dart'; +import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/search_module_section.dart'; + +// Both helpers are conceptually `FrequentBangsSection`-private — they +// implement the section's own display-ordering and delete-affordance +// rules. `@visibleForTesting` keeps them reachable from the section's +// test file without exposing a wider API. +@visibleForTesting +List buildFrequentBangDisplayList({ + required List frequentBangs, + BangData? selectedBang, + BangData? defaultBang, +}) { + final selectedKey = selectedBang?.toKey(); + final defaultKey = defaultBang?.toKey(); + + return [ + if (selectedBang != null) selectedBang, + ...frequentBangs.where( + (bang) => bang.toKey() != selectedKey && bang.toKey() != defaultKey, + ), + if (defaultBang != null && defaultKey != selectedKey) defaultBang, + ]; +} + +/// Whether the chip's delete affordance (selection-clear or +/// frequency-reset) should be enabled for [bang]. +/// +/// Two distinct actions hide behind the single delete affordance: +/// 1. Selection clear — always offered for the currently-selected bang. +/// 2. Frequency reset — offered for any bang that isn't the user's +/// default search bang. The default is protected to keep the +/// frecency-ranked list anchored on a stable provider. +/// +/// When no default is set yet (onboarding / explicitly cleared), no bang +/// is "protected" and frequency reset is offered for any non-selected +/// bang. +@visibleForTesting +bool canDeleteFrequentBang({ + required BangData bang, + BangData? selectedBang, + BangData? defaultBang, +}) { + final isSelected = selectedBang?.toKey() == bang.toKey(); + if (isSelected) return true; + final isProtectedDefault = + defaultBang != null && defaultBang.toKey() == bang.toKey(); + return !isProtectedDefault; +} + +class FrequentBangsSection extends ConsumerWidget { + const FrequentBangsSection({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final frequentBangs = ref.watch( + frequentBangListProvider.select((v) => v.value ?? const []), + ); + + final selectedBang = ref.watch(selectedBangDataProvider()); + final defaultBang = ref.watch( + defaultSearchBangDataProvider.select((v) => v.value), + ); + final activeBang = selectedBang ?? defaultBang; + + final bangs = buildFrequentBangDisplayList( + frequentBangs: frequentBangs, + selectedBang: selectedBang, + defaultBang: defaultBang, + ); + + void selectBang(BangData bang) { + ref.read(selectedBangTriggerProvider().notifier).setTrigger(bang.toKey()); + } + + Future handleDeletion(BangData bang) async { + final currentSelection = ref.read(selectedBangTriggerProvider()); + + if (currentSelection == bang.toKey()) { + ref.read(selectedBangTriggerProvider().notifier).clearTrigger(); + return; + } + + if (!canDeleteFrequentBang( + bang: bang, + selectedBang: selectedBang, + defaultBang: defaultBang, + )) { + return; + } + + final dialogResult = await showResetBangDialog( + context, + triggerName: bang.trigger, + ); + + if (dialogResult == true) { + await ref + .read(bangDataRepositoryProvider.notifier) + .resetFrequency(bang.trigger); + } + } + + return SearchModuleSection( + title: 'Frequent Bangs', + moduleType: SearchModuleType.frequentBangs, + totalCount: bangs.length, + contentSliverBuilder: + ({required bool isCollapsed, required int visibleCount}) => [ + if (!isCollapsed) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: BangChipStrip( + bangs: bangs, + selectedBang: activeBang, + deletableSelectedBang: selectedBang, + canDeleteBang: (bang) => canDeleteFrequentBang( + bang: bang, + selectedBang: selectedBang, + defaultBang: defaultBang, + ), + maxCount: visibleCount >= bangs.length + ? null + : visibleCount, + sortSelectedFirst: false, + allowFrequencyResetAction: true, + onSelected: selectBang, + onDeleted: handleDeletion, + ), + ), + ), + ], + ); + } +} diff --git a/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/empty_state/recent_searches_section.dart b/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/empty_state/recent_searches_section.dart new file mode 100644 index 00000000..aece77d6 --- /dev/null +++ b/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/empty_state/recent_searches_section.dart @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:weblibre/features/bangs/domain/providers/bangs.dart'; +import 'package:weblibre/features/bangs/domain/providers/search.dart'; +import 'package:weblibre/features/geckoview/features/search/domain/providers/search_modules_view.dart'; +import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/search_module_section.dart'; +import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/search_query_chips.dart'; + +class RecentSearchesSection extends ConsumerWidget { + final TextEditingController searchTextController; + final Future Function(String query) submitSearch; + + const RecentSearchesSection({ + required this.searchTextController, + required this.submitSearch, + super.key, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final searchHistory = ref.watch( + searchHistoryProvider.select((value) => value.value ?? const []), + ); + final queries = searchHistory.map((entry) => entry.searchQuery).toList(); + + return SearchModuleSection( + title: 'Recent Searches', + moduleType: SearchModuleType.recentSearches, + totalCount: queries.length, + contentSliverBuilder: + ({required bool isCollapsed, required int visibleCount}) => [ + SliverToBoxAdapter( + child: SearchQueryChips( + queries: queries, + showHistory: true, + visibleCount: visibleCount, + searchTextController: searchTextController, + submitSearch: submitSearch, + onDeleteHistory: (query) => ref + .read(bangSearchProvider.notifier) + .removeSearchEntry(query), + ), + ), + ], + ); + } +} diff --git a/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/empty_state/recent_tabs_section.dart b/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/empty_state/recent_tabs_section.dart index 0b182d09..97690c1c 100644 --- a/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/empty_state/recent_tabs_section.dart +++ b/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/empty_state/recent_tabs_section.dart @@ -23,6 +23,7 @@ import 'package:weblibre/features/geckoview/features/browser/presentation/widget import 'package:weblibre/features/geckoview/features/search/domain/providers/empty_state_content.dart'; import 'package:weblibre/features/geckoview/features/search/domain/providers/search_modules_view.dart'; import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/search_module_section.dart'; +import 'package:weblibre/features/web_search/domain/controllers/sandbox_capture_controller.dart'; import 'package:weblibre/presentation/widgets/url_list_tile.dart'; class RecentTabsSection extends ConsumerWidget { @@ -49,15 +50,23 @@ class RecentTabsSection extends ConsumerWidget { itemCount: visibleCount, itemBuilder: (context, index) { final (tabState, containerData) = tabs[index]; + final sandboxSourceUri = ref.watch( + sandboxSourceUriForTabProvider(tabId: tabState.id), + ); + final displayUrl = sandboxSourceUri ?? tabState.url; + final displayTitle = + sandboxSourceUri != null && tabState.title.isEmpty + ? sandboxSourceUri.authority + : tabState.titleOrAuthority; return UrlListTile( - title: tabState.titleOrAuthority, - uri: tabState.url, + title: displayTitle, + uri: displayUrl, leading: TabIcon( tabState: tabState, iconSize: UrlListTile.iconSize, ), - borderColor: containerData?.color, + containerColor: containerData?.color, onTap: () => onTabSelected(tabState.id), ); }, diff --git a/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/search_field.dart b/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/search_field.dart index 58ccbdb5..1ff8d0d9 100644 --- a/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/search_field.dart +++ b/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/search_field.dart @@ -26,6 +26,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:weblibre/features/bangs/data/models/bang_data.dart'; import 'package:weblibre/features/geckoview/features/search/domain/providers/engine_suggestions.dart'; import 'package:weblibre/features/user/domain/providers.dart'; +import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; import 'package:weblibre/presentation/hooks/on_listenable_change_selector.dart'; import 'package:weblibre/presentation/widgets/auto_suggest_text_field.dart'; import 'package:weblibre/presentation/widgets/qr_scanner_button.dart'; @@ -68,6 +69,11 @@ class SearchField extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final incognitoEnabled = ref.watch(incognitoModeEnabledProvider); + final acceptSuggestionOnSubmit = ref.watch( + generalSettingsWithDefaultsProvider.select( + (s) => s.acceptSuggestionOnSubmit, + ), + ); final hasText = useListenableSelector( textEditingController, @@ -120,13 +126,14 @@ class SearchField extends HookConsumerWidget { child: AutoSuggestTextField( controller: textEditingController, suggestion: suggestion.value, + acceptSuggestionOnSubmit: acceptSuggestionOnSubmit, enableIMEPersonalizedLearning: !incognitoEnabled, focusNode: safeFocusNode, maxLines: maxLines, textFieldKey: textFieldKey, textInputAction: (maxLines == null || maxLines! > 1) ? TextInputAction.done - : null, + : TextInputAction.search, minLines: minLines, autofocus: autofocus, onSuggestionDismiss: () { diff --git a/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/search_modules/combined_history_suggestions.dart b/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/search_modules/combined_history_suggestions.dart new file mode 100644 index 00000000..0b50ffc6 --- /dev/null +++ b/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/search_modules/combined_history_suggestions.dart @@ -0,0 +1,178 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nullability/nullability.dart'; +import 'package:weblibre/features/geckoview/features/search/domain/providers/combined_history.dart'; +import 'package:weblibre/features/geckoview/features/search/domain/providers/engine_suggestions.dart'; +import 'package:weblibre/features/geckoview/features/search/domain/providers/search_modules_view.dart'; +import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/history_row_icon.dart'; +import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/search_module_section.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/history_search.dart'; +import 'package:weblibre/presentation/hooks/on_listenable_change_selector.dart'; +import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart'; +import 'package:weblibre/presentation/widgets/url_icon.dart'; +import 'package:weblibre/utils/text_highlight.dart'; + +/// Combined history view: engine frecency-ranked suggestions augmented with +/// local content snippets, plus local-only content matches appended at the +/// end. Replaces the separate "History" + "Local content" sections in the +/// default search module ordering. +class CombinedHistorySuggestions extends HookConsumerWidget { + final ValueListenable searchTextListenable; + + /// Tap handler for a row's leading URL. + /// + /// [findInPageQuery] is non-null only for rows whose snippet was an FTS + /// content match — the search screen forwards it to the engine so the + /// landed page opens with a find-in-page query pre-filled. UI-level + /// concern bleeding into the callback signature is deliberate so the + /// host doesn't have to re-derive "did this row come from an FTS match" + /// when opening the URL. + final void Function(Uri uri, {String? findInPageQuery}) onUriSelected; + + const CombinedHistorySuggestions({ + super.key, + required this.onUriSelected, + required this.searchTextListenable, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + // Both upstreams need their own kick. Engine sends the suggestion + // request; local FTS runs the query against the history index. + useOnListenableChangeSelector( + searchTextListenable, + () => searchTextListenable.value.text, + () async { + final text = searchTextListenable.value.text; + if (ref.exists(engineSuggestionsProvider)) { + await ref.read(engineSuggestionsProvider.notifier).addQuery(text); + } + + if (!context.mounted) return; + + // matchPrefix/matchSuffix default to historyHighlightPrefix/Suffix, + // which is the same constant the row builder scans for below. + // Don't override the defaults here unless those scan constants + // are kept in sync. + await ref + .read(historySearchRepositoryProvider.notifier) + .addQuery(text); + }, + ); + + final items = ref.watch(combinedHistorySuggestionsProvider); + + // Pre-build the four text styles used per row. Both `bodyLarge` and + // `bodyMedium` come with a "base" variant and a bold one for the + // highlighted-substring spans. Doing this once per build() keeps the + // per-row itemBuilder from re-running four copyWith()s per visible + // item — small but completely avoidable allocation on every scroll. + final theme = Theme.of(context); + final titleBase = theme.textTheme.bodyLarge?.copyWith( + color: theme.colorScheme.onSurface, + ); + final titleHighlight = titleBase?.copyWith(fontWeight: FontWeight.bold); + final snippetBase = theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ); + final snippetHighlight = snippetBase?.copyWith(fontWeight: FontWeight.bold); + + return SearchModuleSection( + title: 'History', + moduleType: SearchModuleType.combinedHistory, + totalCount: items.length, + contentSliverBuilder: + ({required bool isCollapsed, required int visibleCount}) => [ + SliverList.builder( + itemCount: visibleCount, + itemBuilder: (context, index) { + final item = items[index]; + final titleHasMatch = + item.highlightedTitle?.contains(historyHighlightPrefix) ?? + false; + final snippetHasMatch = + item.snippet?.contains(historyHighlightPrefix) ?? false; + + return ListTile( + key: ValueKey(item.uri.toString()), + leading: HistoryRowIcon( + iconBytes: item.engineIcon, + fallback: UrlIcon([item.uri], iconSize: 24), + ), + title: titleHasMatch + ? Text.rich( + buildHighlightedText( + item.highlightedTitle!, + titleBase, + titleHighlight, + historyHighlightPrefix, + historyHighlightSuffix, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ) + : item.title.mapNotNull( + (title) => Text( + title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + subtitle: snippetHasMatch + ? Text.rich( + buildHighlightedText( + item.snippet!, + snippetBase, + snippetHighlight, + historyHighlightPrefix, + historyHighlightSuffix, + normalizeWhitespaces: true, + ), + maxLines: 3, + overflow: TextOverflow.ellipsis, + ) + : UriBreadcrumb(uri: item.uri, showHttpScheme: false), + trailing: item.source == CombinedHistorySource.local + ? Tooltip( + message: 'Content match', + child: Icon( + MdiIcons.textBoxSearchOutline, + size: 16, + color: theme.colorScheme.onSurfaceVariant, + ), + ) + : null, + onTap: () => onUriSelected( + item.uri, + findInPageQuery: snippetHasMatch + ? searchTextListenable.value.text + : null, + ), + ); + }, + ), + ], + ); + } +} diff --git a/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/search_modules/full_search_suggestions.dart b/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/search_modules/full_search_suggestions.dart deleted file mode 100644 index 464cd348..00000000 --- a/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/search_modules/full_search_suggestions.dart +++ /dev/null @@ -1,251 +0,0 @@ -/* - * Copyright (c) 2024-2026 Fabian Freund. - * - * This file is part of WebLibre - * (see https://weblibre.eu). - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ -import 'package:collection/collection.dart'; -import 'package:fading_scroll/fading_scroll.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:nullability/nullability.dart'; -import 'package:sliver_tools/sliver_tools.dart'; -import 'package:weblibre/core/providers/persisted_bool.dart'; -import 'package:weblibre/features/bangs/data/models/bang_data.dart'; -import 'package:weblibre/features/bangs/domain/providers/bangs.dart'; -import 'package:weblibre/features/bangs/domain/repositories/data.dart'; -import 'package:weblibre/features/geckoview/features/search/domain/providers/search_suggestions.dart'; -import 'package:weblibre/features/geckoview/features/search/presentation/widgets/smart_bang_selector.dart'; -import 'package:weblibre/presentation/hooks/on_listenable_change_selector.dart'; - -class FullSearchTermSuggestions extends HookConsumerWidget { - final TextEditingController searchTextController; - final Future Function(String query) submitSearch; - final BangData? activeBang; - - /// The domain to scope site-specific bangs to. - /// When null, only global bangs are shown (new tab mode). - final String? domain; - - const FullSearchTermSuggestions({ - required this.searchTextController, - required this.submitSearch, - required this.activeBang, - this.domain, - super.key, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final searchText = useListenableSelector( - searchTextController, - () => searchTextController.text, - ); - final searchTextIsNotEmpty = searchText.isNotEmpty; - - final searchSuggestions = ref.watch(searchSuggestionsProvider()); - final searchHistory = ref.watch(searchHistoryProvider); - final expanded = ref.watch( - persistedBoolProvider(PersistedBoolKey.searchSuggestionsExpanded), - ); - - useOnListenableChangeSelector( - searchTextController, - () => searchTextController.text, - () { - ref - .read(searchSuggestionsProvider().notifier) - .addQuery(searchTextController.text); - }, - ); - - final showHistory = - !searchTextIsNotEmpty && (searchHistory.value.isNotEmpty); - - final suggestionQueries = useMemoized( - () => showHistory - ? searchHistory.value!.map((e) => e.searchQuery).toList() - : [ - if (searchTextIsNotEmpty) searchText, - if (searchSuggestions.value != null) - ...searchSuggestions.value!.whereNot((s) => s == searchText), - ], - [showHistory, searchText, searchHistory.value, searchSuggestions.value], - ); - - return MultiSliver( - children: [ - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.only(left: 12.0, top: 12.0), - child: SmartBangSelector( - domain: domain, - searchTextController: searchTextController, - ), - ), - ), - SliverToBoxAdapter( - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: _SuggestionsContent( - expanded: expanded, - queries: suggestionQueries, - showHistory: showHistory, - searchTextController: searchTextController, - submitSearch: submitSearch, - onDeleteHistory: (query) => ref - .read(bangDataRepositoryProvider.notifier) - .removeSearchEntry(query), - ), - ), - Padding( - padding: const EdgeInsets.only(top: 6.0), - child: IconButton( - onPressed: ref - .read( - persistedBoolProvider( - PersistedBoolKey.searchSuggestionsExpanded, - ).notifier, - ) - .toggle, - icon: Icon(expanded ? Icons.unfold_less : Icons.unfold_more), - ), - ), - ], - ), - ), - ], - ); - } -} - -class _SuggestionsContent extends StatelessWidget { - final bool expanded; - final List queries; - final bool showHistory; - final TextEditingController searchTextController; - final Future Function(String query) submitSearch; - final Future Function(String query) onDeleteHistory; - - const _SuggestionsContent({ - required this.expanded, - required this.queries, - required this.showHistory, - required this.searchTextController, - required this.submitSearch, - required this.onDeleteHistory, - }); - - @override - Widget build(BuildContext context) { - if (expanded) { - return Padding( - padding: const EdgeInsets.only(top: 8.0), - child: ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 150), - child: FadingScroll( - fadingSize: 25, - builder: (context, controller) { - return CustomScrollView( - shrinkWrap: true, - controller: controller, - slivers: [ - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.only(left: 12.0), - child: Wrap( - spacing: 8.0, - children: [ - for (final query in queries) - _SuggestionChip( - query: query, - showHistory: showHistory, - searchTextController: searchTextController, - submitSearch: submitSearch, - onDeleteHistory: onDeleteHistory, - ), - ], - ), - ), - ), - ], - ); - }, - ), - ), - ); - } - - return Padding( - padding: const EdgeInsets.only(left: 12.0, top: 8.0), - child: SizedBox( - height: 44, - child: FadingScroll( - fadingSize: 25, - builder: (context, controller) { - return ListView.separated( - controller: controller, - scrollDirection: Axis.horizontal, - itemCount: queries.length, - separatorBuilder: (context, index) => const SizedBox(width: 8), - itemBuilder: (context, index) => _SuggestionChip( - query: queries[index], - showHistory: showHistory, - searchTextController: searchTextController, - submitSearch: submitSearch, - onDeleteHistory: onDeleteHistory, - ), - ); - }, - ), - ), - ); - } -} - -class _SuggestionChip extends StatelessWidget { - final String query; - final bool showHistory; - final TextEditingController searchTextController; - final Future Function(String query) submitSearch; - final Future Function(String query) onDeleteHistory; - - const _SuggestionChip({ - required this.query, - required this.showHistory, - required this.searchTextController, - required this.submitSearch, - required this.onDeleteHistory, - }); - - @override - Widget build(BuildContext context) { - return InkWell( - onLongPress: () => searchTextController.text = query, - child: InputChip( - avatar: Icon(showHistory ? Icons.history : Icons.search), - label: Text(query), - onSelected: (value) async { - if (value) await submitSearch(query); - }, - onDeleted: showHistory ? () => onDeleteHistory(query) : null, - ), - ); - } -} diff --git a/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/search_modules/history_row_icon.dart b/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/search_modules/history_row_icon.dart new file mode 100644 index 00000000..359c5920 --- /dev/null +++ b/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/search_modules/history_row_icon.dart @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:nullability/nullability.dart'; +import 'package:weblibre/features/geckoview/utils/image_helper.dart'; +import 'package:weblibre/presentation/hooks/cached_future.dart'; +import 'package:weblibre/presentation/widgets/safe_raw_image.dart'; + +/// Leading icon for a history-style ListTile row. +/// +/// Wraps the boilerplate that `combined_history_suggestions`, +/// `local_history_suggestions`, and `history_suggestions` would otherwise +/// each repeat: decode `engineIcon` bytes through the shared image +/// helper's LRU, render via [SafeRawImage], fall back to [fallback] when +/// either the bytes are null or decoding produced nothing. The +/// [RepaintBoundary] isolates per-row image swaps from the surrounding +/// list's paint cost. +class HistoryRowIcon extends HookWidget { + const HistoryRowIcon({ + super.key, + required this.iconBytes, + required this.fallback, + this.size = 24, + }); + + final Uint8List? iconBytes; + final Widget fallback; + final double size; + + @override + Widget build(BuildContext context) { + final cachedIcon = useCachedFuture( + () async => iconBytes.mapNotNull(tryDecodeImage), + [iconBytes], + ); + + return RepaintBoundary( + child: SafeRawImage( + image: cachedIcon.data, + height: size, + width: size, + fallback: fallback, + ), + ); + } +} diff --git a/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/search_modules/history_suggestions.dart b/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/search_modules/history_suggestions.dart index 38e91576..7ba7fecd 100644 --- a/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/search_modules/history_suggestions.dart +++ b/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/search_modules/history_suggestions.dart @@ -19,19 +19,16 @@ */ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nullability/nullability.dart'; import 'package:skeletonizer/skeletonizer.dart'; import 'package:weblibre/features/geckoview/features/search/domain/providers/engine_suggestions.dart'; import 'package:weblibre/features/geckoview/features/search/domain/providers/search_modules_view.dart'; +import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/history_row_icon.dart'; import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/search_module_section.dart'; -import 'package:weblibre/features/geckoview/utils/image_helper.dart'; -import 'package:weblibre/presentation/hooks/cached_future.dart'; import 'package:weblibre/presentation/hooks/on_listenable_change_selector.dart'; import 'package:weblibre/presentation/widgets/failure_widget.dart'; -import 'package:weblibre/presentation/widgets/safe_raw_image.dart'; import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart'; class HistorySuggestions extends HookConsumerWidget { @@ -85,51 +82,35 @@ class HistorySuggestions extends HookConsumerWidget { Uri.tryParse, ); - return HookBuilder( + return ListTile( key: ValueKey(suggestion.id), - builder: (context) { - final icon = useCachedFuture( - () async => - suggestion.icon.mapNotNull(tryDecodeImage), - [suggestion.description, suggestion.icon], - ); - - return ListTile( - leading: RepaintBoundary( - child: SafeRawImage( - image: icon.data, - height: 24, - width: 24, - fallback: const Icon(MdiIcons.web, size: 24), - ), - ), - title: suggestion.title.mapNotNull( - (title) => Text( - title, + leading: HistoryRowIcon( + iconBytes: suggestion.icon, + fallback: const Icon(MdiIcons.web, size: 24), + ), + title: suggestion.title.mapNotNull( + (title) => Text( + title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + subtitle: + uri.mapNotNull( + (uri) => + UriBreadcrumb(uri: uri, showHttpScheme: false), + ) ?? + suggestion.description.mapNotNull( + (description) => Text( + description, maxLines: 2, overflow: TextOverflow.ellipsis, ), ), - subtitle: - uri.mapNotNull( - (uri) => UriBreadcrumb( - uri: uri, - showHttpScheme: false, - ), - ) ?? - suggestion.description.mapNotNull( - (description) => Text( - description, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ), - onTap: () { - if (uri != null) { - onUriSelected(uri); - } - }, - ); + onTap: () { + if (uri != null) { + onUriSelected(uri); + } }, ); }, diff --git a/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/search_modules/local_history_suggestions.dart b/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/search_modules/local_history_suggestions.dart new file mode 100644 index 00000000..76c99d82 --- /dev/null +++ b/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/search_modules/local_history_suggestions.dart @@ -0,0 +1,175 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nullability/nullability.dart'; +import 'package:skeletonizer/skeletonizer.dart'; +import 'package:weblibre/features/geckoview/features/search/domain/providers/search_modules_view.dart'; +import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/search_module_section.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/history_search.dart'; +import 'package:weblibre/presentation/hooks/on_listenable_change_selector.dart'; +import 'package:weblibre/presentation/widgets/failure_widget.dart'; +import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart'; +import 'package:weblibre/presentation/widgets/url_icon.dart'; +import 'package:weblibre/utils/text_highlight.dart'; + +/// FTS5-backed search over the local history index. Sits next to the engine +/// "History" module (which is frecency-ranked from Places). Future iteration +/// will merge the two into a single ranked list once weights are tuned. +class LocalHistorySuggestions extends HookConsumerWidget { + final ValueListenable searchTextListenable; + final void Function(Uri uri, {String? findInPageQuery}) onUriSelected; + + const LocalHistorySuggestions({ + super.key, + required this.onUriSelected, + required this.searchTextListenable, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final searchAsync = ref.watch(historySearchRepositoryProvider); + final results = searchAsync.value?.results ?? const []; + + useOnListenableChangeSelector( + searchTextListenable, + () => searchTextListenable.value.text, + () async { + // matchPrefix/matchSuffix default to historyHighlightPrefix/Suffix + // — the same constants used below for highlight scanning. + await ref + .read(historySearchRepositoryProvider.notifier) + .addQuery(searchTextListenable.value.text); + }, + ); + + return SearchModuleSection( + title: 'Local content', + moduleType: SearchModuleType.localHistory, + totalCount: results.length, + contentSliverBuilder: + ({required bool isCollapsed, required int visibleCount}) => [ + SliverSkeletonizer( + enabled: searchAsync.isLoading, + child: searchAsync.when( + skipLoadingOnReload: true, + data: (data) { + if (data == null || data.results.isEmpty) { + return const SliverToBoxAdapter(child: SizedBox.shrink()); + } + return SliverList.builder( + itemCount: visibleCount, + itemBuilder: (context, index) { + final result = data.results[index]; + final uri = Uri.tryParse(result.urlCanonical); + + final content = + (result.extractedContent?.contains(historyHighlightPrefix) == + true) + ? result.extractedContent + : result.fullContent; + final titleHasMatch = + result.title?.contains(historyHighlightPrefix) ?? false; + final bodyHasMatch = + content?.contains(historyHighlightPrefix) ?? false; + + final theme = Theme.of(context); + + return ListTile( + leading: RepaintBoundary( + child: uri != null + ? UrlIcon([uri], iconSize: 24) + : const Icon(MdiIcons.history, size: 24), + ), + title: result.title.mapNotNull( + (title) => Text.rich( + buildHighlightedText( + title, + theme.textTheme.bodyLarge?.copyWith( + color: theme.colorScheme.onSurface, + ), + theme.textTheme.bodyLarge?.copyWith( + color: theme.colorScheme.onSurface, + fontWeight: FontWeight.bold, + ), + historyHighlightPrefix, + historyHighlightSuffix, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + subtitle: bodyHasMatch + ? Text.rich( + buildHighlightedText( + content!, + theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontWeight: FontWeight.bold, + ), + historyHighlightPrefix, + historyHighlightSuffix, + normalizeWhitespaces: true, + ), + maxLines: 3, + overflow: TextOverflow.ellipsis, + ) + : (uri != null && !titleHasMatch + ? UriBreadcrumb( + uri: uri, + showHttpScheme: false, + ) + : null), + onTap: () { + if (uri != null) { + onUriSelected( + uri, + findInPageQuery: bodyHasMatch + ? searchTextListenable.value.text + : null, + ); + } + }, + ); + }, + ); + }, + error: (error, stackTrace) => SliverToBoxAdapter( + child: FailureWidget( + title: 'Could not load local content', + exception: error, + ), + ), + loading: () => SliverList.builder( + itemCount: isCollapsed ? 0 : 5, + itemBuilder: (context, index) => + const ListTile(title: Bone.text()), + ), + ), + ), + ], + ); + } +} diff --git a/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/search_modules/search_module_header.dart b/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/search_modules/search_module_header.dart index 1bded264..f30f3aac 100644 --- a/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/search_modules/search_module_header.dart +++ b/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/search_modules/search_module_header.dart @@ -34,9 +34,15 @@ class SearchModuleHeader extends StatelessWidget { final Widget? headerTrailing; /// The maximum number of items shown in preview mode. - /// The trailing button is hidden when totalCount <= this value. + /// The trailing "Show all / Show less" button is hidden when + /// totalCount <= this value (or when [showPagination] is false). final int previewLimit; + /// Set false for modules that render a single non-paginated body + /// (e.g. a chip strip with its own scrolling). Hides the + /// "Show all N / Show less" affordance regardless of [totalCount]. + final bool showPagination; + /// Called when the header is long-pressed (e.g. to enter reorder mode). final VoidCallback? onLongPress; @@ -49,6 +55,7 @@ class SearchModuleHeader extends StatelessWidget { required this.onToggleExpansion, this.headerTrailing, this.previewLimit = 3, + this.showPagination = true, this.onLongPress, }); @@ -57,7 +64,8 @@ class SearchModuleHeader extends StatelessWidget { final disableAnimations = MediaQuery.disableAnimationsOf(context); final isCollapsed = displayState == SearchModuleDisplayState.collapsed; final isExpanded = displayState == SearchModuleDisplayState.expanded; - final showTrailing = !isCollapsed && totalCount > previewLimit; + final showTrailing = + showPagination && !isCollapsed && totalCount > previewLimit; return Padding( padding: const EdgeInsets.only(right: 8.0), diff --git a/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/search_modules/search_module_section.dart b/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/search_modules/search_module_section.dart index 29968f1c..30bae316 100644 --- a/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/search_modules/search_module_section.dart +++ b/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/search_modules/search_module_section.dart @@ -30,6 +30,13 @@ const previewItemsPerModule = 3; /// - Display state management (preview/expanded/collapsed) /// - Pinned header with collapse/expand and show-all/show-less controls /// - Visible item count calculation +/// +/// **Always render this widget, even when [totalCount] is 0.** The header +/// carries the long-press affordance that activates reorder mode and the +/// visibility toggle — short-circuiting to `SizedBox.shrink()` at the call +/// site means the user can lose their only entry-point to module +/// configuration. Set [hideWhenEmpty] explicitly if the section should +/// collapse silently. class SearchModuleSection extends ConsumerWidget { final String title; final SearchModuleType moduleType; @@ -46,6 +53,22 @@ class SearchModuleSection extends ConsumerWidget { /// The maximum number of items shown in preview mode for this section. final int previewLimit; + /// If true and [totalCount] is 0, the entire section (header included) + /// is omitted. Use sparingly — see the class doc for why hiding the + /// header is usually undesirable. + final bool hideWhenEmpty; + + /// Set false for modules whose body is a single non-paginated widget + /// (e.g. a chip strip with its own scrolling). When false: + /// - the "Show all N / Show less" affordance is suppressed, + /// - [visibleCount] passed to [contentSliverBuilder] is always + /// [totalCount] (the section can't be partially shown), + /// - the section can still be fully collapsed via the header chevron. + /// + /// This replaces the prior workaround of passing `totalCount: 0, + /// previewLimit: 0` to suppress pagination. + final bool showPagination; + final List Function({ required bool isCollapsed, required int visibleCount, @@ -60,6 +83,8 @@ class SearchModuleSection extends ConsumerWidget { required this.contentSliverBuilder, this.headerTrailing, this.previewLimit = previewItemsPerModule, + this.hideWhenEmpty = false, + this.showPagination = true, }); @override @@ -70,12 +95,17 @@ class SearchModuleSection extends ConsumerWidget { return MultiSliver(children: const []); } + if (hideWhenEmpty && totalCount == 0) { + return MultiSliver(children: const []); + } + final displayState = ref.watch( searchModuleDisplayStateControllerProvider(moduleType), ); final isCollapsed = displayState == SearchModuleDisplayState.collapsed; final showAllItems = + !showPagination || displayState == SearchModuleDisplayState.expanded || totalCount <= previewLimit; final visibleCount = isCollapsed @@ -95,6 +125,7 @@ class SearchModuleSection extends ConsumerWidget { displayState: displayState, headerTrailing: isCollapsed ? null : headerTrailing, previewLimit: previewLimit, + showPagination: showPagination, onToggleCollapse: () => ref .read( searchModuleDisplayStateControllerProvider( diff --git a/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/search_modules/search_providers_section.dart b/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/search_modules/search_providers_section.dart new file mode 100644 index 00000000..d6fa0266 --- /dev/null +++ b/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/search_modules/search_providers_section.dart @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:weblibre/features/geckoview/features/search/domain/providers/search_modules_view.dart'; +import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/search_module_section.dart'; +import 'package:weblibre/features/geckoview/features/search/presentation/widgets/smart_bang_selector.dart'; + +/// Hosts the [SmartBangSelector] inside the standard collapsible/reorderable +/// search module header. The selector renders its own empty/default state and +/// scrolls horizontally on its own, so this section runs with +/// `showPagination: false` — the header keeps its collapse / reorder +/// affordances but the "Show all N / Show less" button is suppressed. +class SearchProvidersSection extends ConsumerWidget { + final TextEditingController searchTextController; + final String? domain; + + const SearchProvidersSection({ + required this.searchTextController, + required this.domain, + super.key, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return SearchModuleSection( + title: 'Search Providers', + moduleType: SearchModuleType.searchProviders, + totalCount: 0, + showPagination: false, + contentSliverBuilder: + ({required bool isCollapsed, required int visibleCount}) => [ + if (!isCollapsed) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.only(left: 12.0, top: 8.0), + child: SmartBangSelector( + domain: domain, + searchTextController: searchTextController, + ), + ), + ), + ], + ); + } +} diff --git a/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/search_modules/search_query_chips.dart b/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/search_modules/search_query_chips.dart new file mode 100644 index 00000000..0f05e458 --- /dev/null +++ b/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/search_modules/search_query_chips.dart @@ -0,0 +1,146 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'package:fading_scroll/fading_scroll.dart'; +import 'package:flutter/material.dart'; + +class SearchQueryChips extends StatelessWidget { + final List queries; + final bool showHistory; + final int visibleCount; + final TextEditingController searchTextController; + final Future Function(String query) submitSearch; + final Future Function(String query)? onDeleteHistory; + + const SearchQueryChips({ + required this.queries, + required this.showHistory, + required this.visibleCount, + required this.searchTextController, + required this.submitSearch, + this.onDeleteHistory, + super.key, + }); + + @override + Widget build(BuildContext context) { + if (queries.isEmpty || visibleCount == 0) { + return const SizedBox.shrink(); + } + + final visibleQueries = queries.take(visibleCount).toList(); + final showExpandedLayout = visibleCount >= queries.length; + + if (showExpandedLayout) { + return Padding( + padding: const EdgeInsets.only(left: 12.0, top: 8.0), + child: Wrap( + spacing: 8.0, + runSpacing: 8.0, + children: [ + for (final query in visibleQueries) + _SearchQueryChip( + query: query, + showHistory: showHistory, + searchTextController: searchTextController, + submitSearch: submitSearch, + onDeleteHistory: onDeleteHistory, + ), + ], + ), + ); + } + + return Padding( + padding: const EdgeInsets.only(left: 12.0, top: 8.0), + child: SizedBox( + height: 44, + child: FadingScroll( + fadingSize: 15, + builder: (context, controller) { + return ListView.separated( + controller: controller, + scrollDirection: Axis.horizontal, + itemCount: visibleQueries.length, + separatorBuilder: (context, index) => const SizedBox(width: 8), + itemBuilder: (context, index) => _SearchQueryChip( + query: visibleQueries[index], + showHistory: showHistory, + searchTextController: searchTextController, + submitSearch: submitSearch, + onDeleteHistory: onDeleteHistory, + ), + ); + }, + ), + ), + ); + } +} + +class _SearchQueryChip extends StatelessWidget { + final String query; + final bool showHistory; + final TextEditingController searchTextController; + final Future Function(String query) submitSearch; + final Future Function(String query)? onDeleteHistory; + + const _SearchQueryChip({ + required this.query, + required this.showHistory, + required this.searchTextController, + required this.submitSearch, + required this.onDeleteHistory, + }); + + void _fillField() { + // Set the controller's value (not just `.text`) so the caret lands at + // the end of the inserted query — bare `.text =` resets the selection + // to offset 0, which feels broken when the user immediately tries to + // continue typing. + searchTextController.value = TextEditingValue( + text: query, + selection: TextSelection.collapsed(offset: query.length), + ); + } + + @override + Widget build(BuildContext context) { + // Tap submits the query, long-press only fills the field so the user + // can edit it before submitting. The GestureDetector and InputChip's + // own tap detector both end up in Flutter's gesture arena: after + // kLongPressTimeout the long-press wins and the tap is cancelled, so + // a single long-press never double-fires the submit. A quick tap + // resolves to onPressed before the long-press timer expires. + return GestureDetector( + onLongPress: _fillField, + child: InputChip( + avatar: Icon(showHistory ? Icons.history : Icons.search), + label: Text(query), + onPressed: () async { + _fillField(); + await submitSearch(query); + }, + onDeleted: showHistory && onDeleteHistory != null + ? () => onDeleteHistory!(query) + : null, + ), + ); + } +} diff --git a/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/search_modules/search_term_suggestions_section.dart b/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/search_modules/search_term_suggestions_section.dart new file mode 100644 index 00000000..1a1bf5f4 --- /dev/null +++ b/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/search_modules/search_term_suggestions_section.dart @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:weblibre/features/geckoview/features/search/domain/providers/search_modules_view.dart'; +import 'package:weblibre/features/geckoview/features/search/domain/providers/search_suggestions.dart'; +import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/search_module_section.dart'; +import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/search_query_chips.dart'; +import 'package:weblibre/presentation/hooks/on_listenable_change_selector.dart'; + +class SearchTermSuggestionsSection extends HookConsumerWidget { + final TextEditingController searchTextController; + final Future Function(String query) submitSearch; + + const SearchTermSuggestionsSection({ + required this.searchTextController, + required this.submitSearch, + super.key, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final searchText = useListenableSelector( + searchTextController, + () => searchTextController.text, + ); + final searchSuggestions = ref.watch( + searchSuggestionsProvider().select((value) => value.value ?? const []), + ); + + useOnListenableChangeSelector( + searchTextController, + () => searchTextController.text, + () { + ref + .read(searchSuggestionsProvider().notifier) + .addQuery(searchTextController.text); + }, + ); + + final queries = [ + if (searchText.isNotEmpty) searchText, + ...searchSuggestions.where((suggestion) => suggestion != searchText), + ]; + + return SearchModuleSection( + title: 'Suggestions', + moduleType: SearchModuleType.searchSuggestions, + totalCount: queries.length, + contentSliverBuilder: + ({required bool isCollapsed, required int visibleCount}) => [ + SliverToBoxAdapter( + child: SearchQueryChips( + queries: queries, + showHistory: false, + visibleCount: visibleCount, + searchTextController: searchTextController, + submitSearch: submitSearch, + ), + ), + ], + ); + } +} diff --git a/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/smart_bang_selector.dart b/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/smart_bang_selector.dart index 331394c7..c065c178 100644 --- a/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/smart_bang_selector.dart +++ b/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/smart_bang_selector.dart @@ -30,8 +30,8 @@ import 'package:weblibre/features/bangs/domain/providers/search.dart'; import 'package:weblibre/features/bangs/domain/repositories/data.dart'; import 'package:weblibre/features/geckoview/features/browser/domain/providers.dart'; import 'package:weblibre/features/geckoview/features/search/presentation/dialogs/reset_bang_dialog.dart'; +import 'package:weblibre/features/geckoview/features/search/presentation/widgets/bang_chip_strip.dart'; import 'package:weblibre/presentation/hooks/on_listenable_change_selector.dart'; -import 'package:weblibre/presentation/widgets/selectable_chips.dart'; import 'package:weblibre/presentation/widgets/sliding_pill_toggle.dart'; import 'package:weblibre/presentation/widgets/url_icon.dart'; import 'package:weblibre/utils/uri_parser.dart' as uri_parser; @@ -92,7 +92,6 @@ class SmartBangSelector extends HookConsumerWidget { frequentBangListProvider.select((v) => v.value ?? const []), ); - // Use search results if available, otherwise fall back to frequent bangs final globalBangs = searchBangs.isNotEmpty ? searchBangs : frequentBangs; // Trigger search when text changes @@ -164,8 +163,11 @@ class _TabbedBangSelector extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final tabController = useTabController(initialLength: 2); - final tabIndex = useState(0); + final tabController = useTabController( + initialLength: 2, + initialIndex: isSiteSelected ? 1 : 0, + ); + final tabIndex = useState(isSiteSelected ? 1 : 0); useEffect(() { void listener() => tabIndex.value = tabController.index; @@ -181,7 +183,7 @@ class _TabbedBangSelector extends HookConsumerWidget { padding: const EdgeInsets.only(right: 12.0), child: SlidingPillToggle( selectedIndex: tabIndex.value, - labels: const ['Search On This Site', 'All Providers'], + labels: const ['All Providers', 'Search On This Site'], onChanged: (index) => tabController.animateTo(index), ), ), @@ -191,15 +193,6 @@ class _TabbedBangSelector extends HookConsumerWidget { child: TabBarView( controller: tabController, children: [ - // Site tab - uses domain-scoped provider, clears global on select - _BangChipsList( - domain: domain, - siteDomain: domain, // Pass for mutual exclusion - bangs: siteBangs, - selectedBang: isSiteSelected ? activeBang : null, - searchTextController: searchTextController, - displayMenu: displayMenu, - ), // All tab - uses global provider, clears site on select _BangChipsList( domain: null, @@ -209,6 +202,15 @@ class _TabbedBangSelector extends HookConsumerWidget { searchTextController: searchTextController, displayMenu: displayMenu, ), + // Site tab - uses domain-scoped provider, clears global on select + _BangChipsList( + domain: domain, + siteDomain: domain, // Pass for mutual exclusion + bangs: siteBangs, + selectedBang: isSiteSelected ? activeBang : null, + searchTextController: searchTextController, + displayMenu: displayMenu, + ), ], ), ), @@ -273,38 +275,16 @@ class _BangChipsList extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final hasContent = selectedBang != null || bangs.isNotEmpty; - - return SizedBox( - height: 48, - child: Row( - children: [ - if (hasContent) - Expanded( - child: SelectableChips( - itemId: (bang) => bang.trigger, - itemAvatar: (bang) => - UrlIcon([bang.getDefaultUrl()], iconSize: 20), - itemLabel: (bang) => Text(bang.websiteName), - itemTooltip: (bang) => bang.trigger, - availableItems: bangs, - selectedItem: selectedBang, - onSelected: (bang) => _handleSelection(context, ref, bang), - onDeleted: (bang) => _handleDeletion(context, ref, bang), - ), - ) - else if (displayMenu) ...[ - const _DefaultSearchProviderChip(), - const Spacer(), - ] else - const Spacer(), - if (displayMenu) - IconButton( - onPressed: () => _openBangSearch(context, ref), - icon: const Icon(Icons.chevron_right), - ), - ], - ), + return BangChipStrip( + bangs: bangs, + selectedBang: selectedBang, + prefixItems: displayMenu + ? const [_DefaultSearchProviderChip()] + : const [], + showTrailingMenu: displayMenu, + onMenuPressed: displayMenu ? () => _openBangSearch(context, ref) : null, + onSelected: (bang) => _handleSelection(context, ref, bang), + onDeleted: (bang) => _handleDeletion(context, ref, bang), ); } diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/data/database/daos/capture_tab.dart b/apps/weblibre/lib/features/geckoview/features/tabs/data/database/daos/capture_tab.dart new file mode 100644 index 00000000..b758f80d --- /dev/null +++ b/apps/weblibre/lib/features/geckoview/features/tabs/data/database/daos/capture_tab.dart @@ -0,0 +1,68 @@ +import 'package:drift/drift.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/database/daos/capture_tab.drift.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/database/database.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/database/definitions.drift.dart'; + +enum CaptureTabStatus { pending, ready, failed } + +String _statusToDb(CaptureTabStatus status) => status.name; + +CaptureTabStatus _statusFromDb(String raw) => CaptureTabStatus.values + .firstWhere((s) => s.name == raw, orElse: () => CaptureTabStatus.pending); + +@DriftAccessor() +class CaptureTabDao extends DatabaseAccessor + with $CaptureTabDaoMixin { + CaptureTabDao(super.db); + + Future upsert({ + required String tabId, + required String captureId, + required String sourceUrl, + required CaptureTabStatus status, + DateTime? createdAt, + }) { + return into(db.captureTab).insert( + CaptureTabCompanion.insert( + tabId: tabId, + captureId: captureId, + sourceUrl: sourceUrl, + status: Value(_statusToDb(status)), + createdAt: createdAt ?? DateTime.now(), + ), + mode: InsertMode.insertOrReplace, + ); + } + + Future updateStatus(String tabId, CaptureTabStatus status) { + return (update(db.captureTab)..where((t) => t.tabId.equals(tabId))).write( + CaptureTabCompanion(status: Value(_statusToDb(status))), + ); + } + + Future updateCaptureId(String tabId, String captureId) { + return (update(db.captureTab)..where((t) => t.tabId.equals(tabId))).write( + CaptureTabCompanion(captureId: Value(captureId)), + ); + } + + Future deleteByTabId(String tabId) { + return (delete(db.captureTab)..where((t) => t.tabId.equals(tabId))).go(); + } + + Future findByTabId(String tabId) { + return (select( + db.captureTab, + )..where((t) => t.tabId.equals(tabId))).getSingleOrNull(); + } + + Future> findAll() { + return select(db.captureTab).get(); + } + + Stream> watchAll() { + return select(db.captureTab).watch(); + } + + CaptureTabStatus readStatus(CaptureTabData row) => _statusFromDb(row.status); +} diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/data/database/daos/capture_tab.drift.dart b/apps/weblibre/lib/features/geckoview/features/tabs/data/database/daos/capture_tab.drift.dart new file mode 100644 index 00000000..9fc5db98 --- /dev/null +++ b/apps/weblibre/lib/features/geckoview/features/tabs/data/database/daos/capture_tab.drift.dart @@ -0,0 +1,14 @@ +// dart format width=80 +// ignore_for_file: type=lint +import 'package:drift/drift.dart' as i0; +import 'package:weblibre/features/geckoview/features/tabs/data/database/database.dart' + as i1; + +mixin $CaptureTabDaoMixin on i0.DatabaseAccessor { + CaptureTabDaoManager get managers => CaptureTabDaoManager(this); +} + +class CaptureTabDaoManager { + final $CaptureTabDaoMixin _db; + CaptureTabDaoManager(this._db); +} diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/data/database/daos/container.dart b/apps/weblibre/lib/features/geckoview/features/tabs/data/database/daos/container.dart index dabc5ca4..3f589ef0 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/data/database/daos/container.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/data/database/daos/container.dart @@ -39,6 +39,8 @@ class ContainerDao extends DatabaseAccessor id: container.id, name: Value(container.name), color: container.color, + orderKey: container.orderKey, + isPinned: Value(container.isPinned), metadata: Value(container.metadata), ), ); @@ -50,11 +52,29 @@ class ContainerDao extends DatabaseAccessor id: Value(container.id), name: Value(container.name), color: Value(container.color), + orderKey: Value(container.orderKey), + isPinned: Value(container.isPinned), metadata: Value(container.metadata), ), ); } + Future assignOrderKey(String id, {required String orderKey}) { + return (update(db.container)..where((c) => c.id.equals(id))).write( + ContainerCompanion(orderKey: Value(orderKey)), + ); + } + + Future assignPinned( + String id, { + required bool isPinned, + required String orderKey, + }) { + return (update(db.container)..where((c) => c.id.equals(id))).write( + ContainerCompanion(isPinned: Value(isPinned), orderKey: Value(orderKey)), + ); + } + Future deleteContainer(String id) { return db.container.deleteOne(ContainerCompanion(id: Value(id))); } @@ -151,6 +171,46 @@ class ContainerDao extends DatabaseAccessor ); } + SingleSelectable generateLeadingContainerOrderKey({ + required bool isPinned, + int bucket = 0, + }) { + return db.definitionsDrift.leadingContainerOrderKey( + isPinned: isPinned, + bucket: bucket, + ); + } + + SingleSelectable generateTrailingContainerOrderKey({ + required bool isPinned, + int bucket = 0, + }) { + return db.definitionsDrift.trailingContainerOrderKey( + isPinned: isPinned, + bucket: bucket, + ); + } + + SingleOrNullSelectable generateOrderKeyAfterContainerId( + String containerId, { + required bool isPinned, + }) { + return db.definitionsDrift.containerOrderKeyAfter( + containerId: containerId, + isPinned: isPinned, + ); + } + + SingleSelectable generateOrderKeyBeforeContainerId( + String containerId, { + required bool isPinned, + }) { + return db.definitionsDrift.containerOrderKeyBefore( + containerId: containerId, + isPinned: isPinned, + ); + } + SingleOrNullSelectable getLastChildTabId( String? containerId, String parentId, diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/data/database/daos/history.dart b/apps/weblibre/lib/features/geckoview/features/tabs/data/database/daos/history.dart new file mode 100644 index 00000000..6acc0dbf --- /dev/null +++ b/apps/weblibre/lib/features/geckoview/features/tabs/data/database/daos/history.dart @@ -0,0 +1,136 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'package:drift/drift.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/database/daos/history.drift.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/database/database.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/models/history_query_result.dart'; + +@DriftAccessor() +class HistoryDao extends DatabaseAccessor with $HistoryDaoMixin { + HistoryDao(super.db); + + /// Search the local history index. If the FTS query is empty (e.g. all + /// tokens were below the minimum trigram length), falls back to a host + /// prefix scan so very short user input still returns something useful. + Selectable queryHistory({ + required String searchString, + required String matchPrefix, + required String matchSuffix, + required String ellipsis, + required int snippetLength, + int limit = 25, + }) { + final ftsQuery = db.buildFtsQuery(searchString); + + if (ftsQuery.isNotEmpty) { + return db.definitionsDrift.queryHistoryFullContent( + query: ftsQuery, + snippetLength: snippetLength, + beforeMatch: matchPrefix, + afterMatch: matchSuffix, + ellipsis: ellipsis, + limit: limit, + ); + } + + final trimmed = searchString.trim(); + if (trimmed.isEmpty) { + // Empty query → no results. Caller should not invoke us in this case + // but the guard avoids surfacing every host on the device. + return _emptyHistorySelectable(); + } + + return db.definitionsDrift.queryHistoryByHostPrefix( + hostPrefix: '$trimmed%', + limit: limit, + ); + } + + /// Hydrate canonical URLs (e.g. from Places' `getSuggestions`) with the + /// local content rows. Returns rows in arbitrary order; callers are + /// expected to preserve their own ordering. + Selectable hydrateByCanonicalUrls( + Iterable canonicalUrls, + ) { + final urls = canonicalUrls.toList(growable: false); + if (urls.isEmpty) { + return _emptyHistorySelectable(); + } + return db.definitionsDrift.historyByCanonicalUrls(canonicalUrls: urls); + } + + Future countRows() { + return db.definitionsDrift.countHistoryRows().getSingle(); + } + + Future clear() { + return db.definitionsDrift.clearHistory(); + } + + /// Returns a stable page over the local index, ordered oldest-first. + /// + /// The pruner advances [offset] by the number of rows that survived each + /// batch, so it can delete rows while still scanning the full table exactly + /// once. + Future> urlsPage({int limit = 200, int offset = 0}) { + return db.definitionsDrift + .historyUrlsPage(limit: limit, offset: offset) + .get(); + } + + Future deleteByCanonicalUrls(Iterable canonicalUrls) { + final urls = canonicalUrls.toList(growable: false); + if (urls.isEmpty) return Future.value(0); + return db.definitionsDrift.deleteHistoryByCanonicalUrls( + canonicalUrls: urls, + ); + } + + /// Mirror a `local_index_setting` value from the user-facing settings. + Future upsertSetting(String key, bool value) { + return db.definitionsDrift.upsertLocalIndexSetting( + key: key, + value: value ? 1 : 0, + ); + } + + /// Sentinel that cannot match any real row in `history.url_canonical`. + /// + /// Canonical URLs come from `canonicalizeUrl()` / `url_canonical()` which + /// require `uri.hasScheme` — every canonical form therefore contains a + /// `:`. The sentinel below starts with a space and contains no `:`, so + /// no canonicalization output can collide with it. Used by + /// [_emptyHistorySelectable] to coerce the IN-list query into a + /// guaranteed-empty result without inventing a new query shape. + /// + /// Note: the schema is `url_canonical TEXT PRIMARY KEY NOT NULL` with no + /// CHECK constraint, so the empty string `''` is technically a valid + /// value at the SQL level. The write path's `url_indexable()` gate + /// rejects schemeless input (including `''`), so in practice we never + /// see one — but this sentinel doesn't rely on that invariant. + static const _impossibleCanonicalUrlSentinel = ' __no_match__ '; + + Selectable _emptyHistorySelectable() => + // `IN ()` is not valid SQL — substitute a sentinel that cannot + // match any real row. See [_impossibleCanonicalUrlSentinel]. + db.definitionsDrift.historyByCanonicalUrls( + canonicalUrls: const [_impossibleCanonicalUrlSentinel], + ); +} diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/data/database/daos/history.drift.dart b/apps/weblibre/lib/features/geckoview/features/tabs/data/database/daos/history.drift.dart new file mode 100644 index 00000000..2eeca5a1 --- /dev/null +++ b/apps/weblibre/lib/features/geckoview/features/tabs/data/database/daos/history.drift.dart @@ -0,0 +1,14 @@ +// dart format width=80 +// ignore_for_file: type=lint +import 'package:drift/drift.dart' as i0; +import 'package:weblibre/features/geckoview/features/tabs/data/database/database.dart' + as i1; + +mixin $HistoryDaoMixin on i0.DatabaseAccessor { + HistoryDaoManager get managers => HistoryDaoManager(this); +} + +class HistoryDaoManager { + final $HistoryDaoMixin _db; + HistoryDaoManager(this._db); +} diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/data/database/daos/tab.dart b/apps/weblibre/lib/features/geckoview/features/tabs/data/database/daos/tab.dart index c56e2c2a..7340e0a0 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/data/database/daos/tab.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/data/database/daos/tab.dart @@ -49,6 +49,19 @@ class TabDao extends DatabaseAccessor with $TabDaoMixin { static const closedTabTombstoneTtl = Duration(hours: 24); + /// Soft cap for stored extracted/full content bytes (UTF-16 code units). + /// The trigram FTS index expansion is roughly 5–10x the source size, so an + /// uncapped multi-MB page DOM blows up the shadow tables disproportionately. + /// Anything above the cap is truncated at write time; the tail is rarely + /// useful for in-page text search anyway. + static const _contentSizeCap = 256 * 1024; + + static String? _capContent(String? value) { + if (value == null) return null; + if (value.length <= _contentSizeCap) return value; + return value.substring(0, _contentSizeCap); + } + TabDao(super.db); UpdateStatement _updateByIdStatement(String id) => @@ -695,10 +708,10 @@ class TabDao extends DatabaseAccessor with $TabDaoMixin { await statement.write( TabCompanion( isProbablyReaderable: Value(isProbablyReaderable), - extractedContentMarkdown: Value(extractedContentMarkdown), - extractedContentPlain: Value(extractedContentPlain), - fullContentMarkdown: Value(fullContentMarkdown), - fullContentPlain: Value(fullContentPlain), + extractedContentMarkdown: Value(_capContent(extractedContentMarkdown)), + extractedContentPlain: Value(_capContent(extractedContentPlain)), + fullContentMarkdown: Value(_capContent(fullContentMarkdown)), + fullContentPlain: Value(_capContent(fullContentPlain)), ), ); } diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/data/database/database.dart b/apps/weblibre/lib/features/geckoview/features/tabs/data/database/database.dart index df23b2df..63e28166 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/data/database/database.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/data/database/database.dart @@ -21,17 +21,26 @@ import 'package:drift/drift.dart'; import 'package:drift/internal/versioned_schema.dart'; import 'package:drift_dev/api/migrations_native.dart'; import 'package:flutter/foundation.dart'; +import 'package:lexo_rank/lexo_rank.dart'; +import 'package:weblibre/data/database/functions/lexo_rank_functions.dart'; +import 'package:weblibre/data/database/functions/url_functions.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/database/daos/capture_tab.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/database/daos/container.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/database/daos/history.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/database/daos/tab.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/database/database.drift.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/database/database.steps.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/database/definitions.drift.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_source.dart'; import 'package:weblibre/features/search/domain/fts_tokenizer.dart'; -@DriftDatabase(include: {'definitions.drift'}, daos: [ContainerDao, TabDao]) +@DriftDatabase( + include: {'definitions.drift'}, + daos: [ContainerDao, TabDao, CaptureTabDao, HistoryDao], +) class TabDatabase extends $TabDatabase with TrigramQueryBuilderMixin { @override - final int schemaVersion = 9; + final int schemaVersion = 13; @override final int ftsTokenLimit = 10; @@ -44,11 +53,17 @@ class TabDatabase extends $TabDatabase with TrigramQueryBuilderMixin { if (kDebugMode) { // This check pulls in a fair amount of code that's not needed // anywhere else, so we recommend only doing it in debug builds. - await validateDatabaseSchema(); + await validateDatabaseSchema( + setup: (database) { + registerLexorankFunctions(database); + registerUrlFunctions(database); + }, + ); } await customStatement('PRAGMA foreign_keys = ON'); await definitionsDrift.optimizeFtsIndex(); + await definitionsDrift.optimizeHistoryFtsIndex(); }, onUpgrade: (m, from, to) async { // Following the advice from https://drift.simonbinder.eu/Migrations/api/#general-tips @@ -140,5 +155,84 @@ class TabDatabase extends $TabDatabase with TrigramQueryBuilderMixin { from8To9: (m, schema) async { await m.create(schema.closedTabTombstone); }, + from9To10: (m, schema) async { + await m.create(schema.captureTab); + await m.create(schema.idxCaptureTabCaptureId); + }, + from10To11: (m, schema) async { + // Re-scope `tab_after_update` to fire only when FTS-relevant columns + // change. Previously every tab UPDATE (order_key reshuffle, container + // reassignment, pin toggle, timestamp touch, ...) rewrote the trigram + // shadow rows for free. + await m.drop(schema.tabAfterUpdate); + await m.create(schema.tabAfterUpdate); + }, + from11To12: (m, schema) async { + // Local search index — content companion to Mozilla Places (which + // remains SoT for visit metadata). See definitions.drift for layout. + // + // Order matters: the generated `tab.content_hash` column references + // the `content_hash()` SQL function, which is registered in the + // tab.db `setup` callback. The migration runs after `setup`, so the + // function is available here. + // + // `tab.content_hash` requires recreating the tab table because + // SQLite's ALTER TABLE only supports adding VIRTUAL generated + // columns when the column is not part of any existing index/trigger + // dependency chain — drift's TableMigration handles the rebuild. + await m.alterTable(TableMigration(schema.tab)); + + // Settings table seeded with defaults: index enabled, private tabs + // not indexed. + await m.create(schema.localIndexSetting); + await m.database.customStatement( + "INSERT INTO local_index_setting (key, value) VALUES ('enabled', 1)", + ); + await m.database.customStatement( + "INSERT INTO local_index_setting (key, value) VALUES ('index_private', 0)", + ); + + // History content table + FTS5 + maintenance triggers. + await m.create(schema.history); + await m.create(schema.idxHistoryHost); + await m.create(schema.idxHistoryObserved); + await m.create(schema.historyFts); + await m.create(schema.historyAfterInsert); + await m.create(schema.historyAfterDelete); + await m.create(schema.historyAfterUpdate); + + // tab_after_update gains a content_hash WHEN guard. Recreate. + await m.drop(schema.tabAfterUpdate); + await m.create(schema.tabAfterUpdate); + + // Tab → history fan-out triggers. + await m.create(schema.tabToHistoryOnInsert); + await m.create(schema.tabToHistoryOnUpdate); + }, + from12To13: (m, schema) async { + await m.alterTable( + TableMigration( + schema.container, + newColumns: [schema.container.orderKey, schema.container.isPinned], + columnTransformer: { + schema.container.orderKey: Constant(LexoRank.middle().value), + schema.container.isPinned: const Constant(false), + }, + ), + ); + + final database = m.database as TabDatabase; + final containerIds = await database.definitionsDrift + .containerIdsByLastUpdated() + .get(); + + var rank = LexoRank.middle(); + for (final containerId in containerIds) { + await (database.update(database.container) + ..where((container) => container.id.equals(containerId))) + .write(ContainerCompanion(orderKey: Value(rank.value))); + rank = rank.genNext(); + } + }, ); } diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/data/database/database.drift.dart b/apps/weblibre/lib/features/geckoview/features/tabs/data/database/database.drift.dart index a56537f9..bb418bd0 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/data/database/database.drift.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/data/database/database.drift.dart @@ -9,8 +9,12 @@ import 'package:weblibre/features/geckoview/features/tabs/data/database/database as i3; import 'package:weblibre/features/geckoview/features/tabs/data/database/daos/tab.dart' as i4; -import 'package:drift/internal/modular.dart' as i5; -import 'package:sqlite3/common.dart' as i6; +import 'package:weblibre/features/geckoview/features/tabs/data/database/daos/capture_tab.dart' + as i5; +import 'package:weblibre/features/geckoview/features/tabs/data/database/daos/history.dart' + as i6; +import 'package:drift/internal/modular.dart' as i7; +import 'package:sqlite3/common.dart' as i8; abstract class $TabDatabase extends i0.GeneratedDatabase { $TabDatabase(i0.QueryExecutor e) : super(e); @@ -20,12 +24,22 @@ abstract class $TabDatabase extends i0.GeneratedDatabase { late final i1.ClosedTabTombstone closedTabTombstone = i1.ClosedTabTombstone( this, ); + late final i1.CaptureTab captureTab = i1.CaptureTab(this); late final i1.TabFts tabFts = i1.TabFts(this); + late final i1.LocalIndexSetting localIndexSetting = i1.LocalIndexSetting( + this, + ); + late final i1.History history = i1.History(this); + late final i1.HistoryFts historyFts = i1.HistoryFts(this); late final i2.ContainerDao containerDao = i2.ContainerDao( this as i3.TabDatabase, ); late final i4.TabDao tabDao = i4.TabDao(this as i3.TabDatabase); - i1.DefinitionsDrift get definitionsDrift => i5.ReadDatabaseContainer( + late final i5.CaptureTabDao captureTabDao = i5.CaptureTabDao( + this as i3.TabDatabase, + ); + late final i6.HistoryDao historyDao = i6.HistoryDao(this as i3.TabDatabase); + i1.DefinitionsDrift get definitionsDrift => i7.ReadDatabaseContainer( this, ).accessor(i1.DefinitionsDrift.new); @override @@ -37,11 +51,23 @@ abstract class $TabDatabase extends i0.GeneratedDatabase { tab, closedTabTombstone, i1.idxTabParentContainer, + captureTab, + i1.idxCaptureTabCaptureId, tabFts, i1.tabMaintainParentChainOnDelete, i1.tabAfterInsert, i1.tabAfterDelete, i1.tabAfterUpdate, + localIndexSetting, + history, + i1.idxHistoryHost, + i1.idxHistoryObserved, + historyFts, + i1.historyAfterInsert, + i1.historyAfterDelete, + i1.historyAfterUpdate, + i1.tabToHistoryOnInsert, + i1.tabToHistoryOnUpdate, ]; @override i0.StreamQueryUpdateRules get streamUpdateRules => @@ -53,6 +79,13 @@ abstract class $TabDatabase extends i0.GeneratedDatabase { ), result: [i0.TableUpdate('tab', kind: i0.UpdateKind.delete)], ), + i0.WritePropagation( + on: i0.TableUpdateQuery.onTableName( + 'tab', + limitUpdateKind: i0.UpdateKind.delete, + ), + result: [i0.TableUpdate('capture_tab', kind: i0.UpdateKind.delete)], + ), i0.WritePropagation( on: i0.TableUpdateQuery.onTableName( 'tab', @@ -81,6 +114,41 @@ abstract class $TabDatabase extends i0.GeneratedDatabase { ), result: [i0.TableUpdate('tab_fts', kind: i0.UpdateKind.insert)], ), + i0.WritePropagation( + on: i0.TableUpdateQuery.onTableName( + 'history', + limitUpdateKind: i0.UpdateKind.insert, + ), + result: [i0.TableUpdate('history_fts', kind: i0.UpdateKind.insert)], + ), + i0.WritePropagation( + on: i0.TableUpdateQuery.onTableName( + 'history', + limitUpdateKind: i0.UpdateKind.delete, + ), + result: [i0.TableUpdate('history_fts', kind: i0.UpdateKind.insert)], + ), + i0.WritePropagation( + on: i0.TableUpdateQuery.onTableName( + 'history', + limitUpdateKind: i0.UpdateKind.update, + ), + result: [i0.TableUpdate('history_fts', kind: i0.UpdateKind.insert)], + ), + i0.WritePropagation( + on: i0.TableUpdateQuery.onTableName( + 'tab', + limitUpdateKind: i0.UpdateKind.insert, + ), + result: [i0.TableUpdate('history', kind: i0.UpdateKind.insert)], + ), + i0.WritePropagation( + on: i0.TableUpdateQuery.onTableName( + 'tab', + limitUpdateKind: i0.UpdateKind.update, + ), + result: [i0.TableUpdate('history', kind: i0.UpdateKind.insert)], + ), ]); } @@ -92,19 +160,32 @@ class $TabDatabaseManager { i1.$TabTableManager get tab => i1.$TabTableManager(_db, _db.tab); i1.$ClosedTabTombstoneTableManager get closedTabTombstone => i1.$ClosedTabTombstoneTableManager(_db, _db.closedTabTombstone); + i1.$CaptureTabTableManager get captureTab => + i1.$CaptureTabTableManager(_db, _db.captureTab); i1.$TabFtsTableManager get tabFts => i1.$TabFtsTableManager(_db, _db.tabFts); + i1.$LocalIndexSettingTableManager get localIndexSetting => + i1.$LocalIndexSettingTableManager(_db, _db.localIndexSetting); + i1.$HistoryTableManager get history => + i1.$HistoryTableManager(_db, _db.history); + i1.$HistoryFtsTableManager get historyFts => + i1.$HistoryFtsTableManager(_db, _db.historyFts); } -extension DefineFunctions on i6.CommonDatabase { +extension DefineFunctions on i8.CommonDatabase { void defineFunctions({ required String Function(int, String?) lexoRankNext, required String Function(int, String?) lexoRankPrevious, required String Function(String?, String?) lexoRankReorderAfter, required String Function(String?, String?) lexoRankReorderBefore, + required int Function() generateContentHash, + required bool Function(String?) urlIndexable, + required String Function(String?) urlCanonical, + required String Function(String?) urlHost, + required String Function(String?) urlPath, }) { createFunction( functionName: 'lexo_rank_next', - argumentCount: const i6.AllowedArgumentCount(2), + argumentCount: const i8.AllowedArgumentCount(2), function: (args) { final arg0 = args[0] as int; final arg1 = args[1] as String?; @@ -113,7 +194,7 @@ extension DefineFunctions on i6.CommonDatabase { ); createFunction( functionName: 'lexo_rank_previous', - argumentCount: const i6.AllowedArgumentCount(2), + argumentCount: const i8.AllowedArgumentCount(2), function: (args) { final arg0 = args[0] as int; final arg1 = args[1] as String?; @@ -122,7 +203,7 @@ extension DefineFunctions on i6.CommonDatabase { ); createFunction( functionName: 'lexo_rank_reorder_after', - argumentCount: const i6.AllowedArgumentCount(2), + argumentCount: const i8.AllowedArgumentCount(2), function: (args) { final arg0 = args[0] as String?; final arg1 = args[1] as String?; @@ -131,12 +212,51 @@ extension DefineFunctions on i6.CommonDatabase { ); createFunction( functionName: 'lexo_rank_reorder_before', - argumentCount: const i6.AllowedArgumentCount(2), + argumentCount: const i8.AllowedArgumentCount(2), function: (args) { final arg0 = args[0] as String?; final arg1 = args[1] as String?; return lexoRankReorderBefore(arg0, arg1); }, ); + createFunction( + functionName: 'generate_content_hash', + argumentCount: const i8.AllowedArgumentCount(0), + function: (args) { + return generateContentHash(); + }, + ); + createFunction( + functionName: 'url_indexable', + argumentCount: const i8.AllowedArgumentCount(1), + function: (args) { + final arg0 = args[0] as String?; + return urlIndexable(arg0); + }, + ); + createFunction( + functionName: 'url_canonical', + argumentCount: const i8.AllowedArgumentCount(1), + function: (args) { + final arg0 = args[0] as String?; + return urlCanonical(arg0); + }, + ); + createFunction( + functionName: 'url_host', + argumentCount: const i8.AllowedArgumentCount(1), + function: (args) { + final arg0 = args[0] as String?; + return urlHost(arg0); + }, + ); + createFunction( + functionName: 'url_path', + argumentCount: const i8.AllowedArgumentCount(1), + function: (args) { + final arg0 = args[0] as String?; + return urlPath(arg0); + }, + ); } } diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/data/database/database.steps.dart b/apps/weblibre/lib/features/geckoview/features/tabs/data/database/database.steps.dart index ea914b40..22b74393 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/data/database/database.steps.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/data/database/database.steps.dart @@ -931,6 +931,898 @@ i1.GeneratedColumn _column_21(String aliasedName) => type: i1.DriftSqlType.int, $customConstraints: 'NOT NULL', ); + +final class Schema10 extends i0.VersionedSchema { + Schema10({required super.database}) : super(version: 10); + @override + late final List entities = [ + container, + tab, + closedTabTombstone, + idxTabParentContainer, + captureTab, + idxCaptureTabCaptureId, + tabFts, + tabMaintainParentChainOnDelete, + tabAfterInsert, + tabAfterDelete, + tabAfterUpdate, + ]; + late final Shape0 container = Shape0( + source: i0.VersionedTable( + entityName: 'container', + withoutRowId: false, + isStrict: false, + tableConstraints: [], + columns: [_column_0, _column_1, _column_2, _column_3], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape5 tab = Shape5( + source: i0.VersionedTable( + entityName: 'tab', + withoutRowId: false, + isStrict: false, + tableConstraints: [ + 'CHECK((tab_mode = 2 AND isolation_context_id IS NOT NULL)OR(tab_mode != 2 AND isolation_context_id IS NULL))', + ], + columns: [ + _column_0, + _column_16, + _column_4, + _column_5, + _column_6, + _column_7, + _column_8, + _column_17, + _column_18, + _column_19, + _column_10, + _column_11, + _column_12, + _column_13, + _column_14, + _column_15, + ], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape6 closedTabTombstone = Shape6( + source: i0.VersionedTable( + entityName: 'closed_tab_tombstone', + withoutRowId: false, + isStrict: false, + tableConstraints: [], + columns: [_column_20, _column_21], + attachedDatabase: database, + ), + alias: null, + ); + final i1.Index idxTabParentContainer = i1.Index( + 'idx_tab_parent_container', + 'CREATE INDEX idx_tab_parent_container ON tab (parent_id, container_id)', + ); + late final Shape7 captureTab = Shape7( + source: i0.VersionedTable( + entityName: 'capture_tab', + withoutRowId: false, + isStrict: false, + tableConstraints: [], + columns: [_column_22, _column_23, _column_24, _column_25, _column_26], + attachedDatabase: database, + ), + alias: null, + ); + final i1.Index idxCaptureTabCaptureId = i1.Index( + 'idx_capture_tab_capture_id', + 'CREATE INDEX idx_capture_tab_capture_id ON capture_tab (capture_id)', + ); + late final Shape2 tabFts = Shape2( + source: i0.VersionedVirtualTable( + entityName: 'tab_fts', + moduleAndArgs: + 'fts5(title, url, extracted_content_plain, full_content_plain, content=tab, tokenize="trigram")', + columns: [_column_8, _column_7, _column_12, _column_14], + attachedDatabase: database, + ), + alias: null, + ); + final i1.Trigger tabMaintainParentChainOnDelete = i1.Trigger( + 'CREATE TRIGGER tab_maintain_parent_chain_on_delete BEFORE DELETE ON tab BEGIN UPDATE tab SET parent_id = CASE WHEN OLD.parent_id IS NOT NULL AND EXISTS (SELECT 1 FROM tab WHERE id = OLD.parent_id) THEN OLD.parent_id ELSE NULL END WHERE parent_id = OLD.id;END', + 'tab_maintain_parent_chain_on_delete', + ); + final i1.Trigger tabAfterInsert = i1.Trigger( + 'CREATE TRIGGER tab_after_insert AFTER INSERT ON tab BEGIN INSERT INTO tab_fts ("rowid", title, url, extracted_content_plain, full_content_plain) VALUES (new."rowid", new.title, new.url, new.extracted_content_plain, new.full_content_plain);END', + 'tab_after_insert', + ); + final i1.Trigger tabAfterDelete = i1.Trigger( + 'CREATE TRIGGER tab_after_delete AFTER DELETE ON tab BEGIN INSERT INTO tab_fts (tab_fts, "rowid", title, url, extracted_content_plain, full_content_plain) VALUES (\'delete\', old."rowid", old.title, old.url, old.extracted_content_plain, old.full_content_plain);END', + 'tab_after_delete', + ); + final i1.Trigger tabAfterUpdate = i1.Trigger( + 'CREATE TRIGGER tab_after_update AFTER UPDATE ON tab BEGIN INSERT INTO tab_fts (tab_fts, "rowid", title, url, extracted_content_plain, full_content_plain) VALUES (\'delete\', old."rowid", old.title, old.url, old.extracted_content_plain, old.full_content_plain);INSERT INTO tab_fts ("rowid", title, url, extracted_content_plain, full_content_plain) VALUES (new."rowid", new.title, new.url, new.extracted_content_plain, new.full_content_plain);END', + 'tab_after_update', + ); +} + +class Shape7 extends i0.VersionedTable { + Shape7({required super.source, required super.alias}) : super.aliased(); + i1.GeneratedColumn get tabId => + columnsByName['tab_id']! as i1.GeneratedColumn; + i1.GeneratedColumn get captureId => + columnsByName['capture_id']! as i1.GeneratedColumn; + i1.GeneratedColumn get sourceUrl => + columnsByName['source_url']! as i1.GeneratedColumn; + i1.GeneratedColumn get status => + columnsByName['status']! as i1.GeneratedColumn; + i1.GeneratedColumn get createdAt => + columnsByName['created_at']! as i1.GeneratedColumn; +} + +i1.GeneratedColumn _column_22(String aliasedName) => + i1.GeneratedColumn( + 'tab_id', + aliasedName, + false, + type: i1.DriftSqlType.string, + $customConstraints: + 'NOT NULL PRIMARY KEY REFERENCES tab(id)ON DELETE CASCADE', + ); +i1.GeneratedColumn _column_23(String aliasedName) => + i1.GeneratedColumn( + 'capture_id', + aliasedName, + false, + type: i1.DriftSqlType.string, + $customConstraints: 'NOT NULL', + ); +i1.GeneratedColumn _column_24(String aliasedName) => + i1.GeneratedColumn( + 'source_url', + aliasedName, + false, + type: i1.DriftSqlType.string, + $customConstraints: 'NOT NULL', + ); +i1.GeneratedColumn _column_25(String aliasedName) => + i1.GeneratedColumn( + 'status', + aliasedName, + false, + type: i1.DriftSqlType.string, + $customConstraints: 'NOT NULL DEFAULT \'pending\'', + defaultValue: const i1.CustomExpression('\'pending\''), + ); +i1.GeneratedColumn _column_26(String aliasedName) => + i1.GeneratedColumn( + 'created_at', + aliasedName, + false, + type: i1.DriftSqlType.int, + $customConstraints: 'NOT NULL', + ); + +final class Schema11 extends i0.VersionedSchema { + Schema11({required super.database}) : super(version: 11); + @override + late final List entities = [ + container, + tab, + closedTabTombstone, + idxTabParentContainer, + captureTab, + idxCaptureTabCaptureId, + tabFts, + tabMaintainParentChainOnDelete, + tabAfterInsert, + tabAfterDelete, + tabAfterUpdate, + ]; + late final Shape0 container = Shape0( + source: i0.VersionedTable( + entityName: 'container', + withoutRowId: false, + isStrict: false, + tableConstraints: [], + columns: [_column_0, _column_1, _column_2, _column_3], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape5 tab = Shape5( + source: i0.VersionedTable( + entityName: 'tab', + withoutRowId: false, + isStrict: false, + tableConstraints: [ + 'CHECK((tab_mode = 2 AND isolation_context_id IS NOT NULL)OR(tab_mode != 2 AND isolation_context_id IS NULL))', + ], + columns: [ + _column_0, + _column_16, + _column_4, + _column_5, + _column_6, + _column_7, + _column_8, + _column_17, + _column_18, + _column_19, + _column_10, + _column_11, + _column_12, + _column_13, + _column_14, + _column_15, + ], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape6 closedTabTombstone = Shape6( + source: i0.VersionedTable( + entityName: 'closed_tab_tombstone', + withoutRowId: false, + isStrict: false, + tableConstraints: [], + columns: [_column_20, _column_21], + attachedDatabase: database, + ), + alias: null, + ); + final i1.Index idxTabParentContainer = i1.Index( + 'idx_tab_parent_container', + 'CREATE INDEX idx_tab_parent_container ON tab (parent_id, container_id)', + ); + late final Shape7 captureTab = Shape7( + source: i0.VersionedTable( + entityName: 'capture_tab', + withoutRowId: false, + isStrict: false, + tableConstraints: [], + columns: [_column_22, _column_23, _column_24, _column_25, _column_26], + attachedDatabase: database, + ), + alias: null, + ); + final i1.Index idxCaptureTabCaptureId = i1.Index( + 'idx_capture_tab_capture_id', + 'CREATE INDEX idx_capture_tab_capture_id ON capture_tab (capture_id)', + ); + late final Shape2 tabFts = Shape2( + source: i0.VersionedVirtualTable( + entityName: 'tab_fts', + moduleAndArgs: + 'fts5(title, url, extracted_content_plain, full_content_plain, content=tab, tokenize="trigram")', + columns: [_column_8, _column_7, _column_12, _column_14], + attachedDatabase: database, + ), + alias: null, + ); + final i1.Trigger tabMaintainParentChainOnDelete = i1.Trigger( + 'CREATE TRIGGER tab_maintain_parent_chain_on_delete BEFORE DELETE ON tab BEGIN UPDATE tab SET parent_id = CASE WHEN OLD.parent_id IS NOT NULL AND EXISTS (SELECT 1 FROM tab WHERE id = OLD.parent_id) THEN OLD.parent_id ELSE NULL END WHERE parent_id = OLD.id;END', + 'tab_maintain_parent_chain_on_delete', + ); + final i1.Trigger tabAfterInsert = i1.Trigger( + 'CREATE TRIGGER tab_after_insert AFTER INSERT ON tab BEGIN INSERT INTO tab_fts ("rowid", title, url, extracted_content_plain, full_content_plain) VALUES (new."rowid", new.title, new.url, new.extracted_content_plain, new.full_content_plain);END', + 'tab_after_insert', + ); + final i1.Trigger tabAfterDelete = i1.Trigger( + 'CREATE TRIGGER tab_after_delete AFTER DELETE ON tab BEGIN INSERT INTO tab_fts (tab_fts, "rowid", title, url, extracted_content_plain, full_content_plain) VALUES (\'delete\', old."rowid", old.title, old.url, old.extracted_content_plain, old.full_content_plain);END', + 'tab_after_delete', + ); + final i1.Trigger tabAfterUpdate = i1.Trigger( + 'CREATE TRIGGER tab_after_update AFTER UPDATE OF title, url, extracted_content_plain, full_content_plain ON tab BEGIN INSERT INTO tab_fts (tab_fts, "rowid", title, url, extracted_content_plain, full_content_plain) VALUES (\'delete\', old."rowid", old.title, old.url, old.extracted_content_plain, old.full_content_plain);INSERT INTO tab_fts ("rowid", title, url, extracted_content_plain, full_content_plain) VALUES (new."rowid", new.title, new.url, new.extracted_content_plain, new.full_content_plain);END', + 'tab_after_update', + ); +} + +final class Schema12 extends i0.VersionedSchema { + Schema12({required super.database}) : super(version: 12); + @override + late final List entities = [ + container, + tab, + closedTabTombstone, + idxTabParentContainer, + captureTab, + idxCaptureTabCaptureId, + tabFts, + tabMaintainParentChainOnDelete, + tabAfterInsert, + tabAfterDelete, + tabAfterUpdate, + localIndexSetting, + history, + idxHistoryHost, + idxHistoryObserved, + historyFts, + historyAfterInsert, + historyAfterDelete, + historyAfterUpdate, + tabToHistoryOnInsert, + tabToHistoryOnUpdate, + ]; + late final Shape0 container = Shape0( + source: i0.VersionedTable( + entityName: 'container', + withoutRowId: false, + isStrict: false, + tableConstraints: [], + columns: [_column_0, _column_1, _column_2, _column_3], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape8 tab = Shape8( + source: i0.VersionedTable( + entityName: 'tab', + withoutRowId: false, + isStrict: false, + tableConstraints: [ + 'CHECK((tab_mode = 2 AND isolation_context_id IS NOT NULL)OR(tab_mode != 2 AND isolation_context_id IS NULL))', + ], + columns: [ + _column_0, + _column_16, + _column_4, + _column_5, + _column_6, + _column_7, + _column_8, + _column_17, + _column_18, + _column_19, + _column_10, + _column_11, + _column_12, + _column_13, + _column_14, + _column_15, + _column_27, + ], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape6 closedTabTombstone = Shape6( + source: i0.VersionedTable( + entityName: 'closed_tab_tombstone', + withoutRowId: false, + isStrict: false, + tableConstraints: [], + columns: [_column_20, _column_21], + attachedDatabase: database, + ), + alias: null, + ); + final i1.Index idxTabParentContainer = i1.Index( + 'idx_tab_parent_container', + 'CREATE INDEX idx_tab_parent_container ON tab (parent_id, container_id)', + ); + late final Shape7 captureTab = Shape7( + source: i0.VersionedTable( + entityName: 'capture_tab', + withoutRowId: false, + isStrict: false, + tableConstraints: [], + columns: [_column_22, _column_23, _column_24, _column_25, _column_26], + attachedDatabase: database, + ), + alias: null, + ); + final i1.Index idxCaptureTabCaptureId = i1.Index( + 'idx_capture_tab_capture_id', + 'CREATE INDEX idx_capture_tab_capture_id ON capture_tab (capture_id)', + ); + late final Shape2 tabFts = Shape2( + source: i0.VersionedVirtualTable( + entityName: 'tab_fts', + moduleAndArgs: + 'fts5(title, url, extracted_content_plain, full_content_plain, content=tab, tokenize="trigram")', + columns: [_column_8, _column_7, _column_12, _column_14], + attachedDatabase: database, + ), + alias: null, + ); + final i1.Trigger tabMaintainParentChainOnDelete = i1.Trigger( + 'CREATE TRIGGER tab_maintain_parent_chain_on_delete BEFORE DELETE ON tab BEGIN UPDATE tab SET parent_id = CASE WHEN OLD.parent_id IS NOT NULL AND EXISTS (SELECT 1 FROM tab WHERE id = OLD.parent_id) THEN OLD.parent_id ELSE NULL END WHERE parent_id = OLD.id;END', + 'tab_maintain_parent_chain_on_delete', + ); + final i1.Trigger tabAfterInsert = i1.Trigger( + 'CREATE TRIGGER tab_after_insert AFTER INSERT ON tab BEGIN INSERT INTO tab_fts ("rowid", title, url, extracted_content_plain, full_content_plain) VALUES (new."rowid", new.title, new.url, new.extracted_content_plain, new.full_content_plain);END', + 'tab_after_insert', + ); + final i1.Trigger tabAfterDelete = i1.Trigger( + 'CREATE TRIGGER tab_after_delete AFTER DELETE ON tab BEGIN INSERT INTO tab_fts (tab_fts, "rowid", title, url, extracted_content_plain, full_content_plain) VALUES (\'delete\', old."rowid", old.title, old.url, old.extracted_content_plain, old.full_content_plain);END', + 'tab_after_delete', + ); + final i1.Trigger tabAfterUpdate = i1.Trigger( + 'CREATE TRIGGER tab_after_update AFTER UPDATE OF title, url, extracted_content_plain, full_content_plain ON tab WHEN OLD.content_hash IS NOT NEW.content_hash OR OLD.url IS NOT NEW.url BEGIN INSERT INTO tab_fts (tab_fts, "rowid", title, url, extracted_content_plain, full_content_plain) VALUES (\'delete\', old."rowid", old.title, old.url, old.extracted_content_plain, old.full_content_plain);INSERT INTO tab_fts ("rowid", title, url, extracted_content_plain, full_content_plain) VALUES (new."rowid", new.title, new.url, new.extracted_content_plain, new.full_content_plain);END', + 'tab_after_update', + ); + late final Shape9 localIndexSetting = Shape9( + source: i0.VersionedTable( + entityName: 'local_index_setting', + withoutRowId: false, + isStrict: true, + tableConstraints: [], + columns: [_column_28, _column_29], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape10 history = Shape10( + source: i0.VersionedTable( + entityName: 'history', + withoutRowId: false, + isStrict: false, + tableConstraints: [], + columns: [ + _column_30, + _column_31, + _column_32, + _column_8, + _column_10, + _column_11, + _column_12, + _column_13, + _column_14, + _column_33, + _column_34, + _column_35, + ], + attachedDatabase: database, + ), + alias: null, + ); + final i1.Index idxHistoryHost = i1.Index( + 'idx_history_host', + 'CREATE INDEX idx_history_host ON history (url_host)', + ); + final i1.Index idxHistoryObserved = i1.Index( + 'idx_history_observed', + 'CREATE INDEX idx_history_observed ON history (observed_at DESC)', + ); + late final Shape11 historyFts = Shape11( + source: i0.VersionedVirtualTable( + entityName: 'history_fts', + moduleAndArgs: + 'fts5(title, url_host, url_path, extracted_content_plain, full_content_plain, content=history, tokenize="trigram")', + columns: [_column_8, _column_36, _column_32, _column_12, _column_14], + attachedDatabase: database, + ), + alias: null, + ); + final i1.Trigger historyAfterInsert = i1.Trigger( + 'CREATE TRIGGER history_after_insert AFTER INSERT ON history BEGIN INSERT INTO history_fts ("rowid", title, url_host, url_path, extracted_content_plain, full_content_plain) VALUES (new."rowid", new.title, new.url_host, new.url_path, new.extracted_content_plain, new.full_content_plain);END', + 'history_after_insert', + ); + final i1.Trigger historyAfterDelete = i1.Trigger( + 'CREATE TRIGGER history_after_delete AFTER DELETE ON history BEGIN INSERT INTO history_fts (history_fts, "rowid", title, url_host, url_path, extracted_content_plain, full_content_plain) VALUES (\'delete\', old."rowid", old.title, old.url_host, old.url_path, old.extracted_content_plain, old.full_content_plain);END', + 'history_after_delete', + ); + final i1.Trigger historyAfterUpdate = i1.Trigger( + 'CREATE TRIGGER history_after_update AFTER UPDATE OF title, url_host, url_path, extracted_content_plain, full_content_plain ON history BEGIN INSERT INTO history_fts (history_fts, "rowid", title, url_host, url_path, extracted_content_plain, full_content_plain) VALUES (\'delete\', old."rowid", old.title, old.url_host, old.url_path, old.extracted_content_plain, old.full_content_plain);INSERT INTO history_fts ("rowid", title, url_host, url_path, extracted_content_plain, full_content_plain) VALUES (new."rowid", new.title, new.url_host, new.url_path, new.extracted_content_plain, new.full_content_plain);END', + 'history_after_update', + ); + final i1.Trigger tabToHistoryOnInsert = i1.Trigger( + 'CREATE TRIGGER tab_to_history_on_insert AFTER INSERT ON tab WHEN NEW.url IS NOT NULL AND url_indexable(CAST(NEW.url AS TEXT)) = 1 AND (SELECT value FROM local_index_setting WHERE "key" = \'enabled\') = 1 AND(NEW.tab_mode != 1 OR (SELECT value FROM local_index_setting WHERE "key" = \'index_private\') = 1)BEGIN INSERT INTO history (url_canonical, url_host, url_path, title, is_probably_readerable, extracted_content_markdown, extracted_content_plain, full_content_markdown, full_content_plain, content_hash, observed_at, observed_count) VALUES (url_canonical(CAST(NEW.url AS TEXT)), url_host(CAST(NEW.url AS TEXT)), url_path(CAST(NEW.url AS TEXT)), NEW.title, NEW.is_probably_readerable, NEW.extracted_content_markdown, NEW.extracted_content_plain, NEW.full_content_markdown, NEW.full_content_plain, NEW.content_hash, strftime(\'%s\', \'now\') * 1000, 1) ON CONFLICT (url_canonical) DO UPDATE SET title = COALESCE(excluded.title, history.title), is_probably_readerable = excluded.is_probably_readerable, extracted_content_markdown = excluded.extracted_content_markdown, extracted_content_plain = excluded.extracted_content_plain, full_content_markdown = excluded.full_content_markdown, full_content_plain = excluded.full_content_plain, content_hash = excluded.content_hash, observed_at = excluded.observed_at, observed_count = history.observed_count + 1 WHERE history.content_hash IS NOT excluded.content_hash;END', + 'tab_to_history_on_insert', + ); + final i1.Trigger tabToHistoryOnUpdate = i1.Trigger( + 'CREATE TRIGGER tab_to_history_on_update AFTER UPDATE OF title, url, extracted_content_plain, extracted_content_markdown, full_content_plain, full_content_markdown, is_probably_readerable ON tab WHEN NEW.url IS NOT NULL AND url_indexable(CAST(NEW.url AS TEXT)) = 1 AND(OLD.content_hash IS NOT NEW.content_hash OR OLD.url IS NOT NEW.url)AND (SELECT value FROM local_index_setting WHERE "key" = \'enabled\') = 1 AND(NEW.tab_mode != 1 OR (SELECT value FROM local_index_setting WHERE "key" = \'index_private\') = 1)BEGIN INSERT INTO history (url_canonical, url_host, url_path, title, is_probably_readerable, extracted_content_markdown, extracted_content_plain, full_content_markdown, full_content_plain, content_hash, observed_at, observed_count) VALUES (url_canonical(CAST(NEW.url AS TEXT)), url_host(CAST(NEW.url AS TEXT)), url_path(CAST(NEW.url AS TEXT)), NEW.title, NEW.is_probably_readerable, NEW.extracted_content_markdown, NEW.extracted_content_plain, NEW.full_content_markdown, NEW.full_content_plain, NEW.content_hash, strftime(\'%s\', \'now\') * 1000, 1) ON CONFLICT (url_canonical) DO UPDATE SET title = COALESCE(excluded.title, history.title), is_probably_readerable = excluded.is_probably_readerable, extracted_content_markdown = excluded.extracted_content_markdown, extracted_content_plain = excluded.extracted_content_plain, full_content_markdown = excluded.full_content_markdown, full_content_plain = excluded.full_content_plain, content_hash = excluded.content_hash, observed_at = excluded.observed_at, observed_count = history.observed_count + 1 WHERE history.content_hash IS NOT excluded.content_hash;END', + 'tab_to_history_on_update', + ); +} + +class Shape8 extends i0.VersionedTable { + Shape8({required super.source, required super.alias}) : super.aliased(); + i1.GeneratedColumn get id => + columnsByName['id']! as i1.GeneratedColumn; + i1.GeneratedColumn get source => + columnsByName['source']! as i1.GeneratedColumn; + i1.GeneratedColumn get parentId => + columnsByName['parent_id']! as i1.GeneratedColumn; + i1.GeneratedColumn get containerId => + columnsByName['container_id']! as i1.GeneratedColumn; + i1.GeneratedColumn get orderKey => + columnsByName['order_key']! as i1.GeneratedColumn; + i1.GeneratedColumn get url => + columnsByName['url']! as i1.GeneratedColumn; + i1.GeneratedColumn get title => + columnsByName['title']! as i1.GeneratedColumn; + i1.GeneratedColumn get tabMode => + columnsByName['tab_mode']! as i1.GeneratedColumn; + i1.GeneratedColumn get isolationContextId => + columnsByName['isolation_context_id']! as i1.GeneratedColumn; + i1.GeneratedColumn get isPinned => + columnsByName['is_pinned']! as i1.GeneratedColumn; + i1.GeneratedColumn get isProbablyReaderable => + columnsByName['is_probably_readerable']! as i1.GeneratedColumn; + i1.GeneratedColumn get extractedContentMarkdown => + columnsByName['extracted_content_markdown']! + as i1.GeneratedColumn; + i1.GeneratedColumn get extractedContentPlain => + columnsByName['extracted_content_plain']! as i1.GeneratedColumn; + i1.GeneratedColumn get fullContentMarkdown => + columnsByName['full_content_markdown']! as i1.GeneratedColumn; + i1.GeneratedColumn get fullContentPlain => + columnsByName['full_content_plain']! as i1.GeneratedColumn; + i1.GeneratedColumn get timestamp => + columnsByName['timestamp']! as i1.GeneratedColumn; + i1.GeneratedColumn get contentHash => + columnsByName['content_hash']! as i1.GeneratedColumn; +} + +i1.GeneratedColumn _column_27( + String aliasedName, +) => i1.GeneratedColumn( + 'content_hash', + aliasedName, + true, + generatedAs: i1.GeneratedAs( + const i1.CustomExpression( + 'generate_content_hash(title, extracted_content_plain, full_content_plain)', + ), + false, + ), + type: i1.DriftSqlType.int, + $customConstraints: + 'GENERATED ALWAYS AS (generate_content_hash(title, extracted_content_plain, full_content_plain)) VIRTUAL', +); + +class Shape9 extends i0.VersionedTable { + Shape9({required super.source, required super.alias}) : super.aliased(); + i1.GeneratedColumn get key => + columnsByName['key']! as i1.GeneratedColumn; + i1.GeneratedColumn get value => + columnsByName['value']! as i1.GeneratedColumn; +} + +i1.GeneratedColumn _column_28(String aliasedName) => + i1.GeneratedColumn( + 'key', + aliasedName, + false, + type: i1.DriftSqlType.string, + $customConstraints: 'PRIMARY KEY NOT NULL', + ); +i1.GeneratedColumn _column_29(String aliasedName) => + i1.GeneratedColumn( + 'value', + aliasedName, + false, + type: i1.DriftSqlType.int, + $customConstraints: 'NOT NULL', + ); + +class Shape10 extends i0.VersionedTable { + Shape10({required super.source, required super.alias}) : super.aliased(); + i1.GeneratedColumn get urlCanonical => + columnsByName['url_canonical']! as i1.GeneratedColumn; + i1.GeneratedColumn get urlHost => + columnsByName['url_host']! as i1.GeneratedColumn; + i1.GeneratedColumn get urlPath => + columnsByName['url_path']! as i1.GeneratedColumn; + i1.GeneratedColumn get title => + columnsByName['title']! as i1.GeneratedColumn; + i1.GeneratedColumn get isProbablyReaderable => + columnsByName['is_probably_readerable']! as i1.GeneratedColumn; + i1.GeneratedColumn get extractedContentMarkdown => + columnsByName['extracted_content_markdown']! + as i1.GeneratedColumn; + i1.GeneratedColumn get extractedContentPlain => + columnsByName['extracted_content_plain']! as i1.GeneratedColumn; + i1.GeneratedColumn get fullContentMarkdown => + columnsByName['full_content_markdown']! as i1.GeneratedColumn; + i1.GeneratedColumn get fullContentPlain => + columnsByName['full_content_plain']! as i1.GeneratedColumn; + i1.GeneratedColumn get contentHash => + columnsByName['content_hash']! as i1.GeneratedColumn; + i1.GeneratedColumn get observedAt => + columnsByName['observed_at']! as i1.GeneratedColumn; + i1.GeneratedColumn get observedCount => + columnsByName['observed_count']! as i1.GeneratedColumn; +} + +i1.GeneratedColumn _column_30(String aliasedName) => + i1.GeneratedColumn( + 'url_canonical', + aliasedName, + false, + type: i1.DriftSqlType.string, + $customConstraints: 'PRIMARY KEY NOT NULL', + ); +i1.GeneratedColumn _column_31(String aliasedName) => + i1.GeneratedColumn( + 'url_host', + aliasedName, + false, + type: i1.DriftSqlType.string, + $customConstraints: 'NOT NULL', + ); +i1.GeneratedColumn _column_32(String aliasedName) => + i1.GeneratedColumn( + 'url_path', + aliasedName, + true, + type: i1.DriftSqlType.string, + $customConstraints: '', + ); +i1.GeneratedColumn _column_33(String aliasedName) => + i1.GeneratedColumn( + 'content_hash', + aliasedName, + true, + type: i1.DriftSqlType.int, + $customConstraints: '', + ); +i1.GeneratedColumn _column_34(String aliasedName) => + i1.GeneratedColumn( + 'observed_at', + aliasedName, + false, + type: i1.DriftSqlType.int, + $customConstraints: 'NOT NULL', + ); +i1.GeneratedColumn _column_35(String aliasedName) => + i1.GeneratedColumn( + 'observed_count', + aliasedName, + false, + type: i1.DriftSqlType.int, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const i1.CustomExpression('1'), + ); + +class Shape11 extends i0.VersionedVirtualTable { + Shape11({required super.source, required super.alias}) : super.aliased(); + i1.GeneratedColumn get title => + columnsByName['title']! as i1.GeneratedColumn; + i1.GeneratedColumn get urlHost => + columnsByName['url_host']! as i1.GeneratedColumn; + i1.GeneratedColumn get urlPath => + columnsByName['url_path']! as i1.GeneratedColumn; + i1.GeneratedColumn get extractedContentPlain => + columnsByName['extracted_content_plain']! as i1.GeneratedColumn; + i1.GeneratedColumn get fullContentPlain => + columnsByName['full_content_plain']! as i1.GeneratedColumn; +} + +i1.GeneratedColumn _column_36(String aliasedName) => + i1.GeneratedColumn( + 'url_host', + aliasedName, + true, + type: i1.DriftSqlType.string, + $customConstraints: '', + ); + +final class Schema13 extends i0.VersionedSchema { + Schema13({required super.database}) : super(version: 13); + @override + late final List entities = [ + container, + tab, + closedTabTombstone, + idxTabParentContainer, + captureTab, + idxCaptureTabCaptureId, + tabFts, + tabMaintainParentChainOnDelete, + tabAfterInsert, + tabAfterDelete, + tabAfterUpdate, + localIndexSetting, + history, + idxHistoryHost, + idxHistoryObserved, + historyFts, + historyAfterInsert, + historyAfterDelete, + historyAfterUpdate, + tabToHistoryOnInsert, + tabToHistoryOnUpdate, + ]; + late final Shape12 container = Shape12( + source: i0.VersionedTable( + entityName: 'container', + withoutRowId: false, + isStrict: false, + tableConstraints: [], + columns: [ + _column_0, + _column_1, + _column_2, + _column_6, + _column_19, + _column_3, + ], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape8 tab = Shape8( + source: i0.VersionedTable( + entityName: 'tab', + withoutRowId: false, + isStrict: false, + tableConstraints: [ + 'CHECK((tab_mode = 2 AND isolation_context_id IS NOT NULL)OR(tab_mode != 2 AND isolation_context_id IS NULL))', + ], + columns: [ + _column_0, + _column_16, + _column_4, + _column_5, + _column_6, + _column_7, + _column_8, + _column_17, + _column_18, + _column_19, + _column_10, + _column_11, + _column_12, + _column_13, + _column_14, + _column_15, + _column_27, + ], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape6 closedTabTombstone = Shape6( + source: i0.VersionedTable( + entityName: 'closed_tab_tombstone', + withoutRowId: false, + isStrict: false, + tableConstraints: [], + columns: [_column_20, _column_21], + attachedDatabase: database, + ), + alias: null, + ); + final i1.Index idxTabParentContainer = i1.Index( + 'idx_tab_parent_container', + 'CREATE INDEX idx_tab_parent_container ON tab (parent_id, container_id)', + ); + late final Shape7 captureTab = Shape7( + source: i0.VersionedTable( + entityName: 'capture_tab', + withoutRowId: false, + isStrict: false, + tableConstraints: [], + columns: [_column_22, _column_23, _column_24, _column_25, _column_26], + attachedDatabase: database, + ), + alias: null, + ); + final i1.Index idxCaptureTabCaptureId = i1.Index( + 'idx_capture_tab_capture_id', + 'CREATE INDEX idx_capture_tab_capture_id ON capture_tab (capture_id)', + ); + late final Shape2 tabFts = Shape2( + source: i0.VersionedVirtualTable( + entityName: 'tab_fts', + moduleAndArgs: + 'fts5(title, url, extracted_content_plain, full_content_plain, content=tab, tokenize="trigram")', + columns: [_column_8, _column_7, _column_12, _column_14], + attachedDatabase: database, + ), + alias: null, + ); + final i1.Trigger tabMaintainParentChainOnDelete = i1.Trigger( + 'CREATE TRIGGER tab_maintain_parent_chain_on_delete BEFORE DELETE ON tab BEGIN UPDATE tab SET parent_id = CASE WHEN OLD.parent_id IS NOT NULL AND EXISTS (SELECT 1 FROM tab WHERE id = OLD.parent_id) THEN OLD.parent_id ELSE NULL END WHERE parent_id = OLD.id;END', + 'tab_maintain_parent_chain_on_delete', + ); + final i1.Trigger tabAfterInsert = i1.Trigger( + 'CREATE TRIGGER tab_after_insert AFTER INSERT ON tab BEGIN INSERT INTO tab_fts ("rowid", title, url, extracted_content_plain, full_content_plain) VALUES (new."rowid", new.title, new.url, new.extracted_content_plain, new.full_content_plain);END', + 'tab_after_insert', + ); + final i1.Trigger tabAfterDelete = i1.Trigger( + 'CREATE TRIGGER tab_after_delete AFTER DELETE ON tab BEGIN INSERT INTO tab_fts (tab_fts, "rowid", title, url, extracted_content_plain, full_content_plain) VALUES (\'delete\', old."rowid", old.title, old.url, old.extracted_content_plain, old.full_content_plain);END', + 'tab_after_delete', + ); + final i1.Trigger tabAfterUpdate = i1.Trigger( + 'CREATE TRIGGER tab_after_update AFTER UPDATE OF title, url, extracted_content_plain, full_content_plain ON tab WHEN OLD.content_hash IS NOT NEW.content_hash OR OLD.url IS NOT NEW.url BEGIN INSERT INTO tab_fts (tab_fts, "rowid", title, url, extracted_content_plain, full_content_plain) VALUES (\'delete\', old."rowid", old.title, old.url, old.extracted_content_plain, old.full_content_plain);INSERT INTO tab_fts ("rowid", title, url, extracted_content_plain, full_content_plain) VALUES (new."rowid", new.title, new.url, new.extracted_content_plain, new.full_content_plain);END', + 'tab_after_update', + ); + late final Shape9 localIndexSetting = Shape9( + source: i0.VersionedTable( + entityName: 'local_index_setting', + withoutRowId: false, + isStrict: true, + tableConstraints: [], + columns: [_column_28, _column_29], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape10 history = Shape10( + source: i0.VersionedTable( + entityName: 'history', + withoutRowId: false, + isStrict: false, + tableConstraints: [], + columns: [ + _column_30, + _column_31, + _column_32, + _column_8, + _column_10, + _column_11, + _column_12, + _column_13, + _column_14, + _column_33, + _column_34, + _column_35, + ], + attachedDatabase: database, + ), + alias: null, + ); + final i1.Index idxHistoryHost = i1.Index( + 'idx_history_host', + 'CREATE INDEX idx_history_host ON history (url_host)', + ); + final i1.Index idxHistoryObserved = i1.Index( + 'idx_history_observed', + 'CREATE INDEX idx_history_observed ON history (observed_at DESC)', + ); + late final Shape11 historyFts = Shape11( + source: i0.VersionedVirtualTable( + entityName: 'history_fts', + moduleAndArgs: + 'fts5(title, url_host, url_path, extracted_content_plain, full_content_plain, content=history, tokenize="trigram")', + columns: [_column_8, _column_36, _column_32, _column_12, _column_14], + attachedDatabase: database, + ), + alias: null, + ); + final i1.Trigger historyAfterInsert = i1.Trigger( + 'CREATE TRIGGER history_after_insert AFTER INSERT ON history BEGIN INSERT INTO history_fts ("rowid", title, url_host, url_path, extracted_content_plain, full_content_plain) VALUES (new."rowid", new.title, new.url_host, new.url_path, new.extracted_content_plain, new.full_content_plain);END', + 'history_after_insert', + ); + final i1.Trigger historyAfterDelete = i1.Trigger( + 'CREATE TRIGGER history_after_delete AFTER DELETE ON history BEGIN INSERT INTO history_fts (history_fts, "rowid", title, url_host, url_path, extracted_content_plain, full_content_plain) VALUES (\'delete\', old."rowid", old.title, old.url_host, old.url_path, old.extracted_content_plain, old.full_content_plain);END', + 'history_after_delete', + ); + final i1.Trigger historyAfterUpdate = i1.Trigger( + 'CREATE TRIGGER history_after_update AFTER UPDATE OF title, url_host, url_path, extracted_content_plain, full_content_plain ON history BEGIN INSERT INTO history_fts (history_fts, "rowid", title, url_host, url_path, extracted_content_plain, full_content_plain) VALUES (\'delete\', old."rowid", old.title, old.url_host, old.url_path, old.extracted_content_plain, old.full_content_plain);INSERT INTO history_fts ("rowid", title, url_host, url_path, extracted_content_plain, full_content_plain) VALUES (new."rowid", new.title, new.url_host, new.url_path, new.extracted_content_plain, new.full_content_plain);END', + 'history_after_update', + ); + final i1.Trigger tabToHistoryOnInsert = i1.Trigger( + 'CREATE TRIGGER tab_to_history_on_insert AFTER INSERT ON tab WHEN NEW.url IS NOT NULL AND url_indexable(CAST(NEW.url AS TEXT)) = 1 AND (SELECT value FROM local_index_setting WHERE "key" = \'enabled\') = 1 AND(NEW.tab_mode != 1 OR (SELECT value FROM local_index_setting WHERE "key" = \'index_private\') = 1)BEGIN INSERT INTO history (url_canonical, url_host, url_path, title, is_probably_readerable, extracted_content_markdown, extracted_content_plain, full_content_markdown, full_content_plain, content_hash, observed_at, observed_count) VALUES (url_canonical(CAST(NEW.url AS TEXT)), url_host(CAST(NEW.url AS TEXT)), url_path(CAST(NEW.url AS TEXT)), NEW.title, NEW.is_probably_readerable, NEW.extracted_content_markdown, NEW.extracted_content_plain, NEW.full_content_markdown, NEW.full_content_plain, NEW.content_hash, strftime(\'%s\', \'now\') * 1000, 1) ON CONFLICT (url_canonical) DO UPDATE SET title = COALESCE(excluded.title, history.title), is_probably_readerable = excluded.is_probably_readerable, extracted_content_markdown = excluded.extracted_content_markdown, extracted_content_plain = excluded.extracted_content_plain, full_content_markdown = excluded.full_content_markdown, full_content_plain = excluded.full_content_plain, content_hash = excluded.content_hash, observed_at = excluded.observed_at, observed_count = history.observed_count + 1 WHERE history.content_hash IS NOT excluded.content_hash;END', + 'tab_to_history_on_insert', + ); + final i1.Trigger tabToHistoryOnUpdate = i1.Trigger( + 'CREATE TRIGGER tab_to_history_on_update AFTER UPDATE OF title, url, extracted_content_plain, extracted_content_markdown, full_content_plain, full_content_markdown, is_probably_readerable ON tab WHEN NEW.url IS NOT NULL AND url_indexable(CAST(NEW.url AS TEXT)) = 1 AND(OLD.content_hash IS NOT NEW.content_hash OR OLD.url IS NOT NEW.url)AND (SELECT value FROM local_index_setting WHERE "key" = \'enabled\') = 1 AND(NEW.tab_mode != 1 OR (SELECT value FROM local_index_setting WHERE "key" = \'index_private\') = 1)BEGIN INSERT INTO history (url_canonical, url_host, url_path, title, is_probably_readerable, extracted_content_markdown, extracted_content_plain, full_content_markdown, full_content_plain, content_hash, observed_at, observed_count) VALUES (url_canonical(CAST(NEW.url AS TEXT)), url_host(CAST(NEW.url AS TEXT)), url_path(CAST(NEW.url AS TEXT)), NEW.title, NEW.is_probably_readerable, NEW.extracted_content_markdown, NEW.extracted_content_plain, NEW.full_content_markdown, NEW.full_content_plain, NEW.content_hash, strftime(\'%s\', \'now\') * 1000, 1) ON CONFLICT (url_canonical) DO UPDATE SET title = COALESCE(excluded.title, history.title), is_probably_readerable = excluded.is_probably_readerable, extracted_content_markdown = excluded.extracted_content_markdown, extracted_content_plain = excluded.extracted_content_plain, full_content_markdown = excluded.full_content_markdown, full_content_plain = excluded.full_content_plain, content_hash = excluded.content_hash, observed_at = excluded.observed_at, observed_count = history.observed_count + 1 WHERE history.content_hash IS NOT excluded.content_hash;END', + 'tab_to_history_on_update', + ); +} + +class Shape12 extends i0.VersionedTable { + Shape12({required super.source, required super.alias}) : super.aliased(); + i1.GeneratedColumn get id => + columnsByName['id']! as i1.GeneratedColumn; + i1.GeneratedColumn get name => + columnsByName['name']! as i1.GeneratedColumn; + i1.GeneratedColumn get color => + columnsByName['color']! as i1.GeneratedColumn; + i1.GeneratedColumn get orderKey => + columnsByName['order_key']! as i1.GeneratedColumn; + i1.GeneratedColumn get isPinned => + columnsByName['is_pinned']! as i1.GeneratedColumn; + i1.GeneratedColumn get metadata => + columnsByName['metadata']! as i1.GeneratedColumn; +} + i0.MigrationStepWithVersion migrationSteps({ required Future Function(i1.Migrator m, Schema3 schema) from2To3, required Future Function(i1.Migrator m, Schema4 schema) from3To4, @@ -939,6 +1831,10 @@ i0.MigrationStepWithVersion migrationSteps({ required Future Function(i1.Migrator m, Schema7 schema) from6To7, required Future Function(i1.Migrator m, Schema8 schema) from7To8, required Future Function(i1.Migrator m, Schema9 schema) from8To9, + required Future Function(i1.Migrator m, Schema10 schema) from9To10, + required Future Function(i1.Migrator m, Schema11 schema) from10To11, + required Future Function(i1.Migrator m, Schema12 schema) from11To12, + required Future Function(i1.Migrator m, Schema13 schema) from12To13, }) { return (currentVersion, database) async { switch (currentVersion) { @@ -977,6 +1873,26 @@ i0.MigrationStepWithVersion migrationSteps({ final migrator = i1.Migrator(database, schema); await from8To9(migrator, schema); return 9; + case 9: + final schema = Schema10(database: database); + final migrator = i1.Migrator(database, schema); + await from9To10(migrator, schema); + return 10; + case 10: + final schema = Schema11(database: database); + final migrator = i1.Migrator(database, schema); + await from10To11(migrator, schema); + return 11; + case 11: + final schema = Schema12(database: database); + final migrator = i1.Migrator(database, schema); + await from11To12(migrator, schema); + return 12; + case 12: + final schema = Schema13(database: database); + final migrator = i1.Migrator(database, schema); + await from12To13(migrator, schema); + return 13; default: throw ArgumentError.value('Unknown migration from $currentVersion'); } @@ -991,6 +1907,10 @@ i1.OnUpgrade stepByStep({ required Future Function(i1.Migrator m, Schema7 schema) from6To7, required Future Function(i1.Migrator m, Schema8 schema) from7To8, required Future Function(i1.Migrator m, Schema9 schema) from8To9, + required Future Function(i1.Migrator m, Schema10 schema) from9To10, + required Future Function(i1.Migrator m, Schema11 schema) from10To11, + required Future Function(i1.Migrator m, Schema12 schema) from11To12, + required Future Function(i1.Migrator m, Schema13 schema) from12To13, }) => i0.VersionedSchema.stepByStepHelper( step: migrationSteps( from2To3: from2To3, @@ -1000,5 +1920,9 @@ i1.OnUpgrade stepByStep({ from6To7: from6To7, from7To8: from7To8, from8To9: from8To9, + from9To10: from9To10, + from10To11: from10To11, + from11To12: from11To12, + from12To13: from12To13, ), ); diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/data/database/definitions.drift b/apps/weblibre/lib/features/geckoview/features/tabs/data/database/definitions.drift index ced61f9c..ed49c363 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/data/database/definitions.drift +++ b/apps/weblibre/lib/features/geckoview/features/tabs/data/database/definitions.drift @@ -2,6 +2,7 @@ import 'package:weblibre/data/database/converters/color.dart'; import 'package:weblibre/data/database/converters/uri.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/models/site_assignment.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/models/history_query_result.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/models/tab_query_result.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/database/converters/container_metadata_converter.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart'; @@ -11,6 +12,8 @@ CREATE TABLE container ( id TEXT PRIMARY KEY NOT NULL, name TEXT, color INTEGER NOT NULL MAPPED BY `const ColorConverter()`, + order_key TEXT NOT NULL, + is_pinned BOOL NOT NULL DEFAULT 0, metadata TEXT MAPPED BY `const ContainerMetadataConverter()` ) WITH ContainerData; @@ -31,6 +34,14 @@ CREATE TABLE tab( full_content_markdown TEXT, full_content_plain TEXT, timestamp DATETIME NOT NULL, + -- xxh3-64 over (title, extracted_content_plain, full_content_plain). + -- Used by the FTS update trigger and the tab→history fan-out trigger to + -- short-circuit when the row's UPDATE didn't actually change content. + -- VIRTUAL (computed on read) to avoid recreating the table on migration; + -- recomputation cost is negligible vs. the FTS rewrite it skips. + content_hash INTEGER GENERATED ALWAYS AS ( + generate_content_hash(title, extracted_content_plain, full_content_plain) + ) VIRTUAL, CHECK ( (tab_mode = 2 AND isolation_context_id IS NOT NULL) OR (tab_mode != 2 AND isolation_context_id IS NULL) @@ -49,6 +60,16 @@ CREATE TABLE closed_tab_tombstone( -- degrade to per-row scans on large containers. CREATE INDEX idx_tab_parent_container ON tab(parent_id, container_id); +CREATE TABLE capture_tab ( + tab_id TEXT NOT NULL PRIMARY KEY REFERENCES tab (id) ON DELETE CASCADE, + capture_id TEXT NOT NULL, + source_url TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + created_at DATETIME NOT NULL +); + +CREATE INDEX idx_capture_tab_capture_id ON capture_tab(capture_id); + CREATE VIRTUAL TABLE tab_fts USING fts5( title, @@ -84,18 +105,286 @@ CREATE TRIGGER tab_after_delete AFTER DELETE ON tab BEGIN tab_fts(tab_fts, rowid, title, url, extracted_content_plain, full_content_plain) VALUES('delete', old.rowid, old.title, old.url, old.extracted_content_plain, old.full_content_plain); END; -CREATE TRIGGER tab_after_update AFTER UPDATE ON tab BEGIN - INSERT INTO - tab_fts(tab_fts, rowid, title, url, extracted_content_plain, full_content_plain) +-- Scoped to FTS-relevant columns so unrelated tab updates (order_key, +-- parent_id, container_id, is_pinned, timestamp, ...) don't trigger a +-- full trigram reindex of the row. Additionally gated on content_hash so +-- rewrites of identical content (page reload re-emitting the same extract) +-- skip the FTS rewrite. URL changes are caught separately because the hash +-- doesn't include URL. +CREATE TRIGGER tab_after_update AFTER UPDATE OF + title, url, extracted_content_plain, full_content_plain +ON tab +WHEN OLD.content_hash IS NOT NEW.content_hash + OR OLD.url IS NOT NEW.url +BEGIN + INSERT INTO + tab_fts(tab_fts, rowid, title, url, extracted_content_plain, full_content_plain) VALUES('delete', old.rowid, old.title, old.url, old.extracted_content_plain, old.full_content_plain); - INSERT INTO - tab_fts(rowid, title, url, extracted_content_plain, full_content_plain) + INSERT INTO + tab_fts(rowid, title, url, extracted_content_plain, full_content_plain) VALUES (new.rowid, new.title, new.url, new.extracted_content_plain, new.full_content_plain); END; optimizeFtsIndex: INSERT INTO tab_fts(tab_fts) VALUES ('optimize'); +-- Single-row settings table used as the SQL-side gate for the local search +-- index. Mirrors the user-facing toggles in general settings. Updated by the +-- settings repository; read by `tab_to_history_on_*` triggers. +-- +-- Keys: 'enabled' (0/1), 'index_private' (0/1). +CREATE TABLE local_index_setting ( + "key" TEXT PRIMARY KEY NOT NULL, + value INTEGER NOT NULL +) STRICT; + +-- Local content index. Keyed by canonical URL. Visit metadata (visit_count, +-- last_visit, frecency, view_time, sync) lives in Mozilla Places — this +-- table only carries the content needed for FTS and snippet/highlight +-- rendering. `observed_*` are diagnostic counters tracking how often we +-- (re)wrote content, NOT visit count. +CREATE TABLE history ( + url_canonical TEXT PRIMARY KEY NOT NULL, + url_host TEXT NOT NULL, + url_path TEXT, + title TEXT, + is_probably_readerable BOOL, + extracted_content_markdown TEXT, + extracted_content_plain TEXT, + full_content_markdown TEXT, + full_content_plain TEXT, + content_hash INTEGER, + observed_at DATETIME NOT NULL, + observed_count INTEGER NOT NULL DEFAULT 1 +); + +CREATE INDEX idx_history_host ON history(url_host); +CREATE INDEX idx_history_observed ON history(observed_at DESC); + +CREATE VIRTUAL TABLE history_fts + USING fts5( + title, + url_host, + url_path, + extracted_content_plain, + full_content_plain, + content=history, + tokenize="trigram" + ); + +-- Mirror of the tab_fts trigger pattern, scoped to FTS-relevant columns so +-- non-content updates (observed_at touches, observed_count bumps) skip the +-- trigram reindex. +CREATE TRIGGER history_after_insert AFTER INSERT ON history BEGIN + INSERT INTO + history_fts(rowid, title, url_host, url_path, extracted_content_plain, full_content_plain) + VALUES (new.rowid, new.title, new.url_host, new.url_path, new.extracted_content_plain, new.full_content_plain); +END; +CREATE TRIGGER history_after_delete AFTER DELETE ON history BEGIN + INSERT INTO + history_fts(history_fts, rowid, title, url_host, url_path, extracted_content_plain, full_content_plain) + VALUES('delete', old.rowid, old.title, old.url_host, old.url_path, old.extracted_content_plain, old.full_content_plain); +END; +CREATE TRIGGER history_after_update AFTER UPDATE OF + title, url_host, url_path, extracted_content_plain, full_content_plain +ON history BEGIN + INSERT INTO + history_fts(history_fts, rowid, title, url_host, url_path, extracted_content_plain, full_content_plain) + VALUES('delete', old.rowid, old.title, old.url_host, old.url_path, old.extracted_content_plain, old.full_content_plain); + INSERT INTO + history_fts(rowid, title, url_host, url_path, extracted_content_plain, full_content_plain) + VALUES (new.rowid, new.title, new.url_host, new.url_path, new.extracted_content_plain, new.full_content_plain); +END; + +optimizeHistoryFtsIndex: + INSERT INTO history_fts(history_fts) VALUES ('optimize'); + +-- Local content FTS query against the history table. Mirrors +-- `queryTabsFullContent` but with weights tuned for history semantics: +-- title and host dominate, path adds a smaller boost, content rounds out +-- recall. Frecency / recency from Places is layered on at the call site +-- because Places (not us) owns visit metadata. +queryHistoryFullContent WITH HistoryQueryResult: + WITH weights AS ( + SELECT + 10.0 as title_weight, -- Title matches dominate. + 8.0 as host_weight, -- Domain is the next strongest signal. + 2.0 as path_weight, -- URL path; small boost. + 3.0 as extracted_weight, -- Extracted (reader) content. + 1.0 as full_weight -- Full plain content. + ) + SELECT + h.url_canonical, + h.url_host, + h.url_path, + highlight(history_fts, 0, :beforeMatch, :afterMatch) AS title, + snippet(history_fts, 3, :beforeMatch, :afterMatch, :ellipsis, :snippetLength) AS extracted_content, + snippet(history_fts, 4, :beforeMatch, :afterMatch, :ellipsis, :snippetLength) AS full_content, + h.observed_at, + bm25(history_fts, + weights.title_weight, + weights.host_weight, + weights.path_weight, + weights.extracted_weight, + weights.full_weight) AS weighted_rank + FROM history_fts(:query) fts + INNER JOIN history h ON h.rowid = fts.rowid + CROSS JOIN weights + ORDER BY + weighted_rank ASC, + h.observed_at DESC + LIMIT :limit; + +-- Sub-3-char fallback (FTS query empty after tokenization). Hits the host +-- index instead of doing a LIKE-on-trigram scan. Returns rows ordered by +-- recency. +queryHistoryByHostPrefix WITH HistoryQueryResult: + SELECT + h.url_canonical, + h.url_host, + h.url_path, + h.title, + NULL AS extracted_content, + NULL AS full_content, + h.observed_at, + 0.0 AS weighted_rank + FROM history h + WHERE h.url_host LIKE :hostPrefix + ORDER BY h.observed_at DESC + LIMIT :limit; + +-- Bulk hydration: returns content rows for the given canonical URLs. +-- Used after Places' frecency-ranked autocomplete so we can attach local +-- titles/snippets without re-running FTS. +historyByCanonicalUrls WITH HistoryQueryResult: + SELECT + h.url_canonical, + h.url_host, + h.url_path, + h.title, + NULL AS extracted_content, + NULL AS full_content, + h.observed_at, + 0.0 AS weighted_rank + FROM history h + WHERE h.url_canonical IN :canonicalUrls; + +-- Settings sync. Called from Dart whenever the user toggles either flag. +upsertLocalIndexSetting: + INSERT INTO local_index_setting ("key", value) VALUES (:key, :value) + ON CONFLICT("key") DO UPDATE SET value = excluded.value; + +-- Diagnostic: count of indexed pages (settings UI shows this). +countHistoryRows: + SELECT COUNT(*) AS count FROM history; + +-- Manual reset path; settings UI offers a "Clear local search index" button. +clearHistory: + DELETE FROM history; + +-- Pruner: stable oldest-first pagination over the local index. +-- The `url_canonical` tiebreaker is critical: `observed_at` is a +-- coarse timestamp (multiple rows commonly share the same value), so +-- without a deterministic secondary sort, ties can land in different +-- orders across two queries — and the pruner's offset arithmetic +-- (advance by candidates.length - deleted) would then either skip +-- rows or re-scan ones it already examined. +historyUrlsPage: + SELECT url_canonical FROM history + ORDER BY observed_at ASC, url_canonical ASC + LIMIT :limit OFFSET :offset; + +deleteHistoryByCanonicalUrls: + DELETE FROM history WHERE url_canonical IN :canonicalUrls; + +-- Tab → history fan-out. Fires when content-bearing columns change AND the +-- content actually changed (content_hash gate). Settings are read from +-- local_index_setting at SQL level so the trigger is the single source of +-- truth for indexing eligibility. tab_mode = 1 is the private tab marker; +-- private tabs are skipped unless `index_private` is enabled. +CREATE TRIGGER tab_to_history_on_insert AFTER INSERT ON tab +WHEN NEW.url IS NOT NULL + AND url_indexable(CAST(NEW.url AS TEXT)) = 1 + AND (SELECT value FROM local_index_setting WHERE "key" = 'enabled') = 1 + AND (NEW.tab_mode != 1 + OR (SELECT value FROM local_index_setting WHERE "key" = 'index_private') = 1) +BEGIN + INSERT INTO history ( + url_canonical, url_host, url_path, title, is_probably_readerable, + extracted_content_markdown, extracted_content_plain, + full_content_markdown, full_content_plain, + content_hash, observed_at, observed_count + ) VALUES ( + url_canonical(CAST(NEW.url AS TEXT)), + url_host(CAST(NEW.url AS TEXT)), + url_path(CAST(NEW.url AS TEXT)), + NEW.title, + NEW.is_probably_readerable, + NEW.extracted_content_markdown, + NEW.extracted_content_plain, + NEW.full_content_markdown, + NEW.full_content_plain, + NEW.content_hash, + strftime('%s','now') * 1000, + 1 + ) + ON CONFLICT(url_canonical) DO UPDATE SET + title = COALESCE(excluded.title, history.title), + is_probably_readerable = excluded.is_probably_readerable, + extracted_content_markdown = excluded.extracted_content_markdown, + extracted_content_plain = excluded.extracted_content_plain, + full_content_markdown = excluded.full_content_markdown, + full_content_plain = excluded.full_content_plain, + content_hash = excluded.content_hash, + observed_at = excluded.observed_at, + observed_count = history.observed_count + 1 + WHERE history.content_hash IS NOT excluded.content_hash; +END; + +CREATE TRIGGER tab_to_history_on_update AFTER UPDATE OF + title, url, extracted_content_plain, extracted_content_markdown, + full_content_plain, full_content_markdown, is_probably_readerable +ON tab +WHEN NEW.url IS NOT NULL + AND url_indexable(CAST(NEW.url AS TEXT)) = 1 + AND (OLD.content_hash IS NOT NEW.content_hash + OR OLD.url IS NOT NEW.url) + AND (SELECT value FROM local_index_setting WHERE "key" = 'enabled') = 1 + AND (NEW.tab_mode != 1 + OR (SELECT value FROM local_index_setting WHERE "key" = 'index_private') = 1) +BEGIN + INSERT INTO history ( + url_canonical, url_host, url_path, title, is_probably_readerable, + extracted_content_markdown, extracted_content_plain, + full_content_markdown, full_content_plain, + content_hash, observed_at, observed_count + ) VALUES ( + url_canonical(CAST(NEW.url AS TEXT)), + url_host(CAST(NEW.url AS TEXT)), + url_path(CAST(NEW.url AS TEXT)), + NEW.title, + NEW.is_probably_readerable, + NEW.extracted_content_markdown, + NEW.extracted_content_plain, + NEW.full_content_markdown, + NEW.full_content_plain, + NEW.content_hash, + strftime('%s','now') * 1000, + 1 + ) + ON CONFLICT(url_canonical) DO UPDATE SET + title = COALESCE(excluded.title, history.title), + is_probably_readerable = excluded.is_probably_readerable, + extracted_content_markdown = excluded.extracted_content_markdown, + extracted_content_plain = excluded.extracted_content_plain, + full_content_markdown = excluded.full_content_markdown, + full_content_plain = excluded.full_content_plain, + content_hash = excluded.content_hash, + observed_at = excluded.observed_at, + observed_count = history.observed_count + 1 + WHERE history.content_hash IS NOT excluded.content_hash; +END; + containersWithCount WITH ContainerDataWithCount: SELECT container.*, @@ -109,7 +398,67 @@ containersWithCount WITH ContainerDataWithCount: FROM tab GROUP BY container_id ) AS tab_agg ON container.id = tab_agg.container_id - ORDER BY tab_agg.last_updated DESC NULLS LAST; + ORDER BY container.is_pinned DESC, container.order_key ASC; + +containerIdsByLastUpdated: + SELECT container.id + FROM container + LEFT JOIN ( + SELECT + container_id, + MAX(timestamp) AS last_updated + FROM tab + GROUP BY container_id + ) AS tab_agg ON container.id = tab_agg.container_id + ORDER BY tab_agg.last_updated DESC NULLS LAST, container.rowid ASC; + +leadingContainerOrderKey(:is_pinned AS BOOL, :bucket AS INTEGER): + SELECT lexo_rank_previous( + :bucket, + ( + SELECT order_key + FROM container + WHERE is_pinned = :is_pinned + ORDER BY order_key + LIMIT 1 + ) + ); + +trailingContainerOrderKey(:is_pinned AS BOOL, :bucket AS INTEGER): + SELECT lexo_rank_next( + :bucket, + ( + SELECT order_key + FROM container + WHERE is_pinned = :is_pinned + ORDER BY order_key DESC + LIMIT 1 + ) + ); + +containerOrderKeyAfter(:container_id AS TEXT, :is_pinned AS BOOL): + WITH ordered_table AS ( + SELECT id, + order_key, + LEAD(order_key) OVER (ORDER BY order_key) AS next_order_key + FROM container + WHERE is_pinned = :is_pinned + ) + SELECT lexo_rank_reorder_after(order_key, next_order_key) + FROM ordered_table + WHERE id = :container_id; + +containerOrderKeyBefore(:container_id AS TEXT, :is_pinned AS BOOL): + WITH ordered_table AS ( + SELECT id, + order_key, + LAG(order_key) OVER (ORDER BY order_key) AS prev_order_key + FROM container + WHERE is_pinned = :is_pinned + ) + SELECT lexo_rank_reorder_before(order_key, prev_order_key) + FROM ordered_table + WHERE id = :container_id; leadingOrderKey(REQUIRED :container_id AS TEXT OR NULL, :bucket AS INTEGER): SELECT lexo_rank_previous( diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/data/database/definitions.drift.dart b/apps/weblibre/lib/features/geckoview/features/tabs/data/database/definitions.drift.dart index e7cc496d..47367792 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/data/database/definitions.drift.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/data/database/definitions.drift.dart @@ -15,16 +15,20 @@ import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode as i7; import 'package:weblibre/data/database/converters/uri.dart' as i8; import 'package:drift/internal/modular.dart' as i9; -import 'package:weblibre/features/geckoview/features/tabs/data/models/tab_query_result.dart' +import 'package:weblibre/features/geckoview/features/tabs/data/models/history_query_result.dart' as i10; -import 'package:weblibre/features/geckoview/features/tabs/data/models/site_assignment.dart' +import 'package:weblibre/features/geckoview/features/tabs/data/models/tab_query_result.dart' as i11; +import 'package:weblibre/features/geckoview/features/tabs/data/models/site_assignment.dart' + as i12; typedef $ContainerCreateCompanionBuilder = i3.ContainerCompanion Function({ required String id, i0.Value name, required i2.Color color, + required String orderKey, + i0.Value isPinned, i0.Value metadata, i0.Value rowid, }); @@ -33,6 +37,8 @@ typedef $ContainerUpdateCompanionBuilder = i0.Value id, i0.Value name, i0.Value color, + i0.Value orderKey, + i0.Value isPinned, i0.Value metadata, i0.Value rowid, }); @@ -96,6 +102,16 @@ class $ContainerFilterComposer builder: (column) => i0.ColumnWithTypeConverterFilters(column), ); + i0.ColumnFilters get orderKey => $composableBuilder( + column: $table.orderKey, + builder: (column) => i0.ColumnFilters(column), + ); + + i0.ColumnFilters get isPinned => $composableBuilder( + column: $table.isPinned, + builder: (column) => i0.ColumnFilters(column), + ); + i0.ColumnWithTypeConverterFilters< i1.ContainerMetadata?, i1.ContainerMetadata, @@ -156,6 +172,16 @@ class $ContainerOrderingComposer builder: (column) => i0.ColumnOrderings(column), ); + i0.ColumnOrderings get orderKey => $composableBuilder( + column: $table.orderKey, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get isPinned => $composableBuilder( + column: $table.isPinned, + builder: (column) => i0.ColumnOrderings(column), + ); + i0.ColumnOrderings get metadata => $composableBuilder( column: $table.metadata, builder: (column) => i0.ColumnOrderings(column), @@ -180,6 +206,12 @@ class $ContainerAnnotationComposer i0.GeneratedColumnWithTypeConverter get color => $composableBuilder(column: $table.color, builder: (column) => column); + i0.GeneratedColumn get orderKey => + $composableBuilder(column: $table.orderKey, builder: (column) => column); + + i0.GeneratedColumn get isPinned => + $composableBuilder(column: $table.isPinned, builder: (column) => column); + i0.GeneratedColumnWithTypeConverter get metadata => $composableBuilder(column: $table.metadata, builder: (column) => column); @@ -241,6 +273,8 @@ class $ContainerTableManager i0.Value id = const i0.Value.absent(), i0.Value name = const i0.Value.absent(), i0.Value color = const i0.Value.absent(), + i0.Value orderKey = const i0.Value.absent(), + i0.Value isPinned = const i0.Value.absent(), i0.Value metadata = const i0.Value.absent(), i0.Value rowid = const i0.Value.absent(), @@ -248,6 +282,8 @@ class $ContainerTableManager id: id, name: name, color: color, + orderKey: orderKey, + isPinned: isPinned, metadata: metadata, rowid: rowid, ), @@ -256,6 +292,8 @@ class $ContainerTableManager required String id, i0.Value name = const i0.Value.absent(), required i2.Color color, + required String orderKey, + i0.Value isPinned = const i0.Value.absent(), i0.Value metadata = const i0.Value.absent(), i0.Value rowid = const i0.Value.absent(), @@ -263,6 +301,8 @@ class $ContainerTableManager id: id, name: name, color: color, + orderKey: orderKey, + isPinned: isPinned, metadata: metadata, rowid: rowid, ), @@ -394,6 +434,34 @@ final class $TabReferences manager.$state.copyWith(prefetchedData: [item]), ); } + + static i0.MultiTypedResultKey> + _captureTabRefsTable(i0.GeneratedDatabase db) => + i0.MultiTypedResultKey.fromTable( + i9.ReadDatabaseContainer(db).resultSet('capture_tab'), + aliasName: i0.$_aliasNameGenerator( + i9.ReadDatabaseContainer(db).resultSet('tab').id, + i9.ReadDatabaseContainer( + db, + ).resultSet('capture_tab').tabId, + ), + ); + + i3.$CaptureTabProcessedTableManager get captureTabRefs { + final manager = i3 + .$CaptureTabTableManager( + $_db, + i9.ReadDatabaseContainer( + $_db, + ).resultSet('capture_tab'), + ) + .filter((f) => f.tabId.id.sqlEquals($_itemColumn('id')!)); + + final cache = $_typedResult.readTableOrNull(_captureTabRefsTable($_db)); + return i0.ProcessedTableManager( + manager.$state.copyWith(prefetchedData: cache), + ); + } } class $TabFilterComposer extends i0.Composer { @@ -482,6 +550,11 @@ class $TabFilterComposer extends i0.Composer { builder: (column) => i0.ColumnFilters(column), ); + i0.ColumnFilters get contentHash => $composableBuilder( + column: $table.contentHash, + builder: (column) => i0.ColumnFilters(column), + ); + i3.$ContainerFilterComposer get containerId { final i3.$ContainerFilterComposer composer = $composerBuilder( composer: this, @@ -508,6 +581,35 @@ class $TabFilterComposer extends i0.Composer { ); return composer; } + + i0.Expression captureTabRefs( + i0.Expression Function(i3.$CaptureTabFilterComposer f) f, + ) { + final i3.$CaptureTabFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: i9.ReadDatabaseContainer( + $db, + ).resultSet('capture_tab'), + getReferencedColumn: (t) => t.tabId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => i3.$CaptureTabFilterComposer( + $db: $db, + $table: i9.ReadDatabaseContainer( + $db, + ).resultSet('capture_tab'), + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } } class $TabOrderingComposer extends i0.Composer { @@ -593,6 +695,11 @@ class $TabOrderingComposer extends i0.Composer { builder: (column) => i0.ColumnOrderings(column), ); + i0.ColumnOrderings get contentHash => $composableBuilder( + column: $table.contentHash, + builder: (column) => i0.ColumnOrderings(column), + ); + i3.$ContainerOrderingComposer get containerId { final i3.$ContainerOrderingComposer composer = $composerBuilder( composer: this, @@ -686,6 +793,11 @@ class $TabAnnotationComposer extends i0.Composer { i0.GeneratedColumn get timestamp => $composableBuilder(column: $table.timestamp, builder: (column) => column); + i0.GeneratedColumn get contentHash => $composableBuilder( + column: $table.contentHash, + builder: (column) => column, + ); + i3.$ContainerAnnotationComposer get containerId { final i3.$ContainerAnnotationComposer composer = $composerBuilder( composer: this, @@ -712,6 +824,35 @@ class $TabAnnotationComposer extends i0.Composer { ); return composer; } + + i0.Expression captureTabRefs( + i0.Expression Function(i3.$CaptureTabAnnotationComposer a) f, + ) { + final i3.$CaptureTabAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: i9.ReadDatabaseContainer( + $db, + ).resultSet('capture_tab'), + getReferencedColumn: (t) => t.tabId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => i3.$CaptureTabAnnotationComposer( + $db: $db, + $table: i9.ReadDatabaseContainer( + $db, + ).resultSet('capture_tab'), + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } } class $TabTableManager @@ -727,7 +868,7 @@ class $TabTableManager $TabUpdateCompanionBuilder, (i3.TabData, i3.$TabReferences), i3.TabData, - i0.PrefetchHooks Function({bool containerId}) + i0.PrefetchHooks Function({bool containerId, bool captureTabRefs}) > { $TabTableManager(i0.GeneratedDatabase db, i3.Tab table) : super( @@ -823,47 +964,71 @@ class $TabTableManager withReferenceMapper: (p0) => p0 .map((e) => (e.readTable(table), i3.$TabReferences(db, table, e))) .toList(), - prefetchHooksCallback: ({containerId = false}) { - return i0.PrefetchHooks( - db: db, - explicitlyWatchedTables: [], - addJoins: - < - T extends i0.TableManagerState< - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic - > - >(state) { - if (containerId) { - state = - state.withJoin( - currentTable: table, - currentColumn: table.containerId, - referencedTable: i3.$TabReferences - ._containerIdTable(db), - referencedColumn: i3.$TabReferences - ._containerIdTable(db) - .id, - ) - as T; - } + prefetchHooksCallback: + ({containerId = false, captureTabRefs = false}) { + return i0.PrefetchHooks( + db: db, + explicitlyWatchedTables: [ + if (captureTabRefs) + i9.ReadDatabaseContainer( + db, + ).resultSet('capture_tab'), + ], + addJoins: + < + T extends i0.TableManagerState< + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic + > + >(state) { + if (containerId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.containerId, + referencedTable: i3.$TabReferences + ._containerIdTable(db), + referencedColumn: i3.$TabReferences + ._containerIdTable(db) + .id, + ) + as T; + } - return state; + return state; + }, + getPrefetchedDataCallback: (items) async { + return [ + if (captureTabRefs) + await i0.$_getPrefetchedData< + i3.TabData, + i3.Tab, + i3.CaptureTabData + >( + currentTable: table, + referencedTable: i3.$TabReferences + ._captureTabRefsTable(db), + managerFromTypedResult: (p0) => + i3.$TabReferences(db, table, p0).captureTabRefs, + referencedItemsForCurrentItem: + (item, referencedItems) => referencedItems.where( + (e) => e.tabId == item.id, + ), + typedResults: items, + ), + ]; }, - getPrefetchedDataCallback: (items) async { - return []; + ); }, - ); - }, ), ); } @@ -880,7 +1045,7 @@ typedef $TabProcessedTableManager = $TabUpdateCompanionBuilder, (i3.TabData, i3.$TabReferences), i3.TabData, - i0.PrefetchHooks Function({bool containerId}) + i0.PrefetchHooks Function({bool containerId, bool captureTabRefs}) >; typedef $ClosedTabTombstoneCreateCompanionBuilder = i3.ClosedTabTombstoneCompanion Function({ @@ -1035,6 +1200,339 @@ typedef $ClosedTabTombstoneProcessedTableManager = i3.ClosedTabTombstoneData, i0.PrefetchHooks Function() >; +typedef $CaptureTabCreateCompanionBuilder = + i3.CaptureTabCompanion Function({ + required String tabId, + required String captureId, + required String sourceUrl, + i0.Value status, + required DateTime createdAt, + i0.Value rowid, + }); +typedef $CaptureTabUpdateCompanionBuilder = + i3.CaptureTabCompanion Function({ + i0.Value tabId, + i0.Value captureId, + i0.Value sourceUrl, + i0.Value status, + i0.Value createdAt, + i0.Value rowid, + }); + +final class $CaptureTabReferences + extends + i0.BaseReferences< + i0.GeneratedDatabase, + i3.CaptureTab, + i3.CaptureTabData + > { + $CaptureTabReferences(super.$_db, super.$_table, super.$_typedResult); + + static i3.Tab _tabIdTable(i0.GeneratedDatabase db) => + i9.ReadDatabaseContainer(db) + .resultSet('tab') + .createAlias( + i0.$_aliasNameGenerator( + i9.ReadDatabaseContainer( + db, + ).resultSet('capture_tab').tabId, + i9.ReadDatabaseContainer(db).resultSet('tab').id, + ), + ); + + i3.$TabProcessedTableManager get tabId { + final $_column = $_itemColumn('tab_id')!; + + final manager = i3 + .$TabTableManager( + $_db, + i9.ReadDatabaseContainer($_db).resultSet('tab'), + ) + .filter((f) => f.id.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_tabIdTable($_db)); + if (item == null) return manager; + return i0.ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item]), + ); + } +} + +class $CaptureTabFilterComposer + extends i0.Composer { + $CaptureTabFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + i0.ColumnFilters get captureId => $composableBuilder( + column: $table.captureId, + builder: (column) => i0.ColumnFilters(column), + ); + + i0.ColumnFilters get sourceUrl => $composableBuilder( + column: $table.sourceUrl, + builder: (column) => i0.ColumnFilters(column), + ); + + i0.ColumnFilters get status => $composableBuilder( + column: $table.status, + builder: (column) => i0.ColumnFilters(column), + ); + + i0.ColumnFilters get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => i0.ColumnFilters(column), + ); + + i3.$TabFilterComposer get tabId { + final i3.$TabFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.tabId, + referencedTable: i9.ReadDatabaseContainer($db).resultSet('tab'), + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => i3.$TabFilterComposer( + $db: $db, + $table: i9.ReadDatabaseContainer($db).resultSet('tab'), + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $CaptureTabOrderingComposer + extends i0.Composer { + $CaptureTabOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + i0.ColumnOrderings get captureId => $composableBuilder( + column: $table.captureId, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get sourceUrl => $composableBuilder( + column: $table.sourceUrl, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get status => $composableBuilder( + column: $table.status, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => i0.ColumnOrderings(column), + ); + + i3.$TabOrderingComposer get tabId { + final i3.$TabOrderingComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.tabId, + referencedTable: i9.ReadDatabaseContainer($db).resultSet('tab'), + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => i3.$TabOrderingComposer( + $db: $db, + $table: i9.ReadDatabaseContainer($db).resultSet('tab'), + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $CaptureTabAnnotationComposer + extends i0.Composer { + $CaptureTabAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + i0.GeneratedColumn get captureId => + $composableBuilder(column: $table.captureId, builder: (column) => column); + + i0.GeneratedColumn get sourceUrl => + $composableBuilder(column: $table.sourceUrl, builder: (column) => column); + + i0.GeneratedColumn get status => + $composableBuilder(column: $table.status, builder: (column) => column); + + i0.GeneratedColumn get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => column); + + i3.$TabAnnotationComposer get tabId { + final i3.$TabAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.tabId, + referencedTable: i9.ReadDatabaseContainer($db).resultSet('tab'), + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => i3.$TabAnnotationComposer( + $db: $db, + $table: i9.ReadDatabaseContainer($db).resultSet('tab'), + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $CaptureTabTableManager + extends + i0.RootTableManager< + i0.GeneratedDatabase, + i3.CaptureTab, + i3.CaptureTabData, + i3.$CaptureTabFilterComposer, + i3.$CaptureTabOrderingComposer, + i3.$CaptureTabAnnotationComposer, + $CaptureTabCreateCompanionBuilder, + $CaptureTabUpdateCompanionBuilder, + (i3.CaptureTabData, i3.$CaptureTabReferences), + i3.CaptureTabData, + i0.PrefetchHooks Function({bool tabId}) + > { + $CaptureTabTableManager(i0.GeneratedDatabase db, i3.CaptureTab table) + : super( + i0.TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + i3.$CaptureTabFilterComposer($db: db, $table: table), + createOrderingComposer: () => + i3.$CaptureTabOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + i3.$CaptureTabAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + i0.Value tabId = const i0.Value.absent(), + i0.Value captureId = const i0.Value.absent(), + i0.Value sourceUrl = const i0.Value.absent(), + i0.Value status = const i0.Value.absent(), + i0.Value createdAt = const i0.Value.absent(), + i0.Value rowid = const i0.Value.absent(), + }) => i3.CaptureTabCompanion( + tabId: tabId, + captureId: captureId, + sourceUrl: sourceUrl, + status: status, + createdAt: createdAt, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String tabId, + required String captureId, + required String sourceUrl, + i0.Value status = const i0.Value.absent(), + required DateTime createdAt, + i0.Value rowid = const i0.Value.absent(), + }) => i3.CaptureTabCompanion.insert( + tabId: tabId, + captureId: captureId, + sourceUrl: sourceUrl, + status: status, + createdAt: createdAt, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map( + (e) => ( + e.readTable(table), + i3.$CaptureTabReferences(db, table, e), + ), + ) + .toList(), + prefetchHooksCallback: ({tabId = false}) { + return i0.PrefetchHooks( + db: db, + explicitlyWatchedTables: [], + addJoins: + < + T extends i0.TableManagerState< + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic + > + >(state) { + if (tabId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.tabId, + referencedTable: i3.$CaptureTabReferences + ._tabIdTable(db), + referencedColumn: i3.$CaptureTabReferences + ._tabIdTable(db) + .id, + ) + as T; + } + + return state; + }, + getPrefetchedDataCallback: (items) async { + return []; + }, + ); + }, + ), + ); +} + +typedef $CaptureTabProcessedTableManager = + i0.ProcessedTableManager< + i0.GeneratedDatabase, + i3.CaptureTab, + i3.CaptureTabData, + i3.$CaptureTabFilterComposer, + i3.$CaptureTabOrderingComposer, + i3.$CaptureTabAnnotationComposer, + $CaptureTabCreateCompanionBuilder, + $CaptureTabUpdateCompanionBuilder, + (i3.CaptureTabData, i3.$CaptureTabReferences), + i3.CaptureTabData, + i0.PrefetchHooks Function({bool tabId}) + >; typedef $TabFtsCreateCompanionBuilder = i3.TabFtsCompanion Function({ required String title, @@ -1218,6 +1716,723 @@ typedef $TabFtsProcessedTableManager = i3.TabFt, i0.PrefetchHooks Function() >; +typedef $LocalIndexSettingCreateCompanionBuilder = + i3.LocalIndexSettingCompanion Function({ + required String key, + required int value, + i0.Value rowid, + }); +typedef $LocalIndexSettingUpdateCompanionBuilder = + i3.LocalIndexSettingCompanion Function({ + i0.Value key, + i0.Value value, + i0.Value rowid, + }); + +class $LocalIndexSettingFilterComposer + extends i0.Composer { + $LocalIndexSettingFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + i0.ColumnFilters get key => $composableBuilder( + column: $table.key, + builder: (column) => i0.ColumnFilters(column), + ); + + i0.ColumnFilters get value => $composableBuilder( + column: $table.value, + builder: (column) => i0.ColumnFilters(column), + ); +} + +class $LocalIndexSettingOrderingComposer + extends i0.Composer { + $LocalIndexSettingOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + i0.ColumnOrderings get key => $composableBuilder( + column: $table.key, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get value => $composableBuilder( + column: $table.value, + builder: (column) => i0.ColumnOrderings(column), + ); +} + +class $LocalIndexSettingAnnotationComposer + extends i0.Composer { + $LocalIndexSettingAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + i0.GeneratedColumn get key => + $composableBuilder(column: $table.key, builder: (column) => column); + + i0.GeneratedColumn get value => + $composableBuilder(column: $table.value, builder: (column) => column); +} + +class $LocalIndexSettingTableManager + extends + i0.RootTableManager< + i0.GeneratedDatabase, + i3.LocalIndexSetting, + i3.LocalIndexSettingData, + i3.$LocalIndexSettingFilterComposer, + i3.$LocalIndexSettingOrderingComposer, + i3.$LocalIndexSettingAnnotationComposer, + $LocalIndexSettingCreateCompanionBuilder, + $LocalIndexSettingUpdateCompanionBuilder, + ( + i3.LocalIndexSettingData, + i0.BaseReferences< + i0.GeneratedDatabase, + i3.LocalIndexSetting, + i3.LocalIndexSettingData + >, + ), + i3.LocalIndexSettingData, + i0.PrefetchHooks Function() + > { + $LocalIndexSettingTableManager( + i0.GeneratedDatabase db, + i3.LocalIndexSetting table, + ) : super( + i0.TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + i3.$LocalIndexSettingFilterComposer($db: db, $table: table), + createOrderingComposer: () => + i3.$LocalIndexSettingOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + i3.$LocalIndexSettingAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + i0.Value key = const i0.Value.absent(), + i0.Value value = const i0.Value.absent(), + i0.Value rowid = const i0.Value.absent(), + }) => i3.LocalIndexSettingCompanion( + key: key, + value: value, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String key, + required int value, + i0.Value rowid = const i0.Value.absent(), + }) => i3.LocalIndexSettingCompanion.insert( + key: key, + value: value, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), i0.BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $LocalIndexSettingProcessedTableManager = + i0.ProcessedTableManager< + i0.GeneratedDatabase, + i3.LocalIndexSetting, + i3.LocalIndexSettingData, + i3.$LocalIndexSettingFilterComposer, + i3.$LocalIndexSettingOrderingComposer, + i3.$LocalIndexSettingAnnotationComposer, + $LocalIndexSettingCreateCompanionBuilder, + $LocalIndexSettingUpdateCompanionBuilder, + ( + i3.LocalIndexSettingData, + i0.BaseReferences< + i0.GeneratedDatabase, + i3.LocalIndexSetting, + i3.LocalIndexSettingData + >, + ), + i3.LocalIndexSettingData, + i0.PrefetchHooks Function() + >; +typedef $HistoryCreateCompanionBuilder = + i3.HistoryCompanion Function({ + required String urlCanonical, + required String urlHost, + i0.Value urlPath, + i0.Value title, + i0.Value isProbablyReaderable, + i0.Value extractedContentMarkdown, + i0.Value extractedContentPlain, + i0.Value fullContentMarkdown, + i0.Value fullContentPlain, + i0.Value contentHash, + required DateTime observedAt, + i0.Value observedCount, + i0.Value rowid, + }); +typedef $HistoryUpdateCompanionBuilder = + i3.HistoryCompanion Function({ + i0.Value urlCanonical, + i0.Value urlHost, + i0.Value urlPath, + i0.Value title, + i0.Value isProbablyReaderable, + i0.Value extractedContentMarkdown, + i0.Value extractedContentPlain, + i0.Value fullContentMarkdown, + i0.Value fullContentPlain, + i0.Value contentHash, + i0.Value observedAt, + i0.Value observedCount, + i0.Value rowid, + }); + +class $HistoryFilterComposer + extends i0.Composer { + $HistoryFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + i0.ColumnFilters get urlCanonical => $composableBuilder( + column: $table.urlCanonical, + builder: (column) => i0.ColumnFilters(column), + ); + + i0.ColumnFilters get urlHost => $composableBuilder( + column: $table.urlHost, + builder: (column) => i0.ColumnFilters(column), + ); + + i0.ColumnFilters get urlPath => $composableBuilder( + column: $table.urlPath, + builder: (column) => i0.ColumnFilters(column), + ); + + i0.ColumnFilters get title => $composableBuilder( + column: $table.title, + builder: (column) => i0.ColumnFilters(column), + ); + + i0.ColumnFilters get isProbablyReaderable => $composableBuilder( + column: $table.isProbablyReaderable, + builder: (column) => i0.ColumnFilters(column), + ); + + i0.ColumnFilters get extractedContentMarkdown => $composableBuilder( + column: $table.extractedContentMarkdown, + builder: (column) => i0.ColumnFilters(column), + ); + + i0.ColumnFilters get extractedContentPlain => $composableBuilder( + column: $table.extractedContentPlain, + builder: (column) => i0.ColumnFilters(column), + ); + + i0.ColumnFilters get fullContentMarkdown => $composableBuilder( + column: $table.fullContentMarkdown, + builder: (column) => i0.ColumnFilters(column), + ); + + i0.ColumnFilters get fullContentPlain => $composableBuilder( + column: $table.fullContentPlain, + builder: (column) => i0.ColumnFilters(column), + ); + + i0.ColumnFilters get contentHash => $composableBuilder( + column: $table.contentHash, + builder: (column) => i0.ColumnFilters(column), + ); + + i0.ColumnFilters get observedAt => $composableBuilder( + column: $table.observedAt, + builder: (column) => i0.ColumnFilters(column), + ); + + i0.ColumnFilters get observedCount => $composableBuilder( + column: $table.observedCount, + builder: (column) => i0.ColumnFilters(column), + ); +} + +class $HistoryOrderingComposer + extends i0.Composer { + $HistoryOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + i0.ColumnOrderings get urlCanonical => $composableBuilder( + column: $table.urlCanonical, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get urlHost => $composableBuilder( + column: $table.urlHost, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get urlPath => $composableBuilder( + column: $table.urlPath, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get title => $composableBuilder( + column: $table.title, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get isProbablyReaderable => $composableBuilder( + column: $table.isProbablyReaderable, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get extractedContentMarkdown => $composableBuilder( + column: $table.extractedContentMarkdown, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get extractedContentPlain => $composableBuilder( + column: $table.extractedContentPlain, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get fullContentMarkdown => $composableBuilder( + column: $table.fullContentMarkdown, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get fullContentPlain => $composableBuilder( + column: $table.fullContentPlain, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get contentHash => $composableBuilder( + column: $table.contentHash, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get observedAt => $composableBuilder( + column: $table.observedAt, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get observedCount => $composableBuilder( + column: $table.observedCount, + builder: (column) => i0.ColumnOrderings(column), + ); +} + +class $HistoryAnnotationComposer + extends i0.Composer { + $HistoryAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + i0.GeneratedColumn get urlCanonical => $composableBuilder( + column: $table.urlCanonical, + builder: (column) => column, + ); + + i0.GeneratedColumn get urlHost => + $composableBuilder(column: $table.urlHost, builder: (column) => column); + + i0.GeneratedColumn get urlPath => + $composableBuilder(column: $table.urlPath, builder: (column) => column); + + i0.GeneratedColumn get title => + $composableBuilder(column: $table.title, builder: (column) => column); + + i0.GeneratedColumn get isProbablyReaderable => $composableBuilder( + column: $table.isProbablyReaderable, + builder: (column) => column, + ); + + i0.GeneratedColumn get extractedContentMarkdown => $composableBuilder( + column: $table.extractedContentMarkdown, + builder: (column) => column, + ); + + i0.GeneratedColumn get extractedContentPlain => $composableBuilder( + column: $table.extractedContentPlain, + builder: (column) => column, + ); + + i0.GeneratedColumn get fullContentMarkdown => $composableBuilder( + column: $table.fullContentMarkdown, + builder: (column) => column, + ); + + i0.GeneratedColumn get fullContentPlain => $composableBuilder( + column: $table.fullContentPlain, + builder: (column) => column, + ); + + i0.GeneratedColumn get contentHash => $composableBuilder( + column: $table.contentHash, + builder: (column) => column, + ); + + i0.GeneratedColumn get observedAt => $composableBuilder( + column: $table.observedAt, + builder: (column) => column, + ); + + i0.GeneratedColumn get observedCount => $composableBuilder( + column: $table.observedCount, + builder: (column) => column, + ); +} + +class $HistoryTableManager + extends + i0.RootTableManager< + i0.GeneratedDatabase, + i3.History, + i3.HistoryData, + i3.$HistoryFilterComposer, + i3.$HistoryOrderingComposer, + i3.$HistoryAnnotationComposer, + $HistoryCreateCompanionBuilder, + $HistoryUpdateCompanionBuilder, + ( + i3.HistoryData, + i0.BaseReferences, + ), + i3.HistoryData, + i0.PrefetchHooks Function() + > { + $HistoryTableManager(i0.GeneratedDatabase db, i3.History table) + : super( + i0.TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + i3.$HistoryFilterComposer($db: db, $table: table), + createOrderingComposer: () => + i3.$HistoryOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + i3.$HistoryAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + i0.Value urlCanonical = const i0.Value.absent(), + i0.Value urlHost = const i0.Value.absent(), + i0.Value urlPath = const i0.Value.absent(), + i0.Value title = const i0.Value.absent(), + i0.Value isProbablyReaderable = const i0.Value.absent(), + i0.Value extractedContentMarkdown = + const i0.Value.absent(), + i0.Value extractedContentPlain = + const i0.Value.absent(), + i0.Value fullContentMarkdown = const i0.Value.absent(), + i0.Value fullContentPlain = const i0.Value.absent(), + i0.Value contentHash = const i0.Value.absent(), + i0.Value observedAt = const i0.Value.absent(), + i0.Value observedCount = const i0.Value.absent(), + i0.Value rowid = const i0.Value.absent(), + }) => i3.HistoryCompanion( + urlCanonical: urlCanonical, + urlHost: urlHost, + urlPath: urlPath, + title: title, + isProbablyReaderable: isProbablyReaderable, + extractedContentMarkdown: extractedContentMarkdown, + extractedContentPlain: extractedContentPlain, + fullContentMarkdown: fullContentMarkdown, + fullContentPlain: fullContentPlain, + contentHash: contentHash, + observedAt: observedAt, + observedCount: observedCount, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String urlCanonical, + required String urlHost, + i0.Value urlPath = const i0.Value.absent(), + i0.Value title = const i0.Value.absent(), + i0.Value isProbablyReaderable = const i0.Value.absent(), + i0.Value extractedContentMarkdown = + const i0.Value.absent(), + i0.Value extractedContentPlain = + const i0.Value.absent(), + i0.Value fullContentMarkdown = const i0.Value.absent(), + i0.Value fullContentPlain = const i0.Value.absent(), + i0.Value contentHash = const i0.Value.absent(), + required DateTime observedAt, + i0.Value observedCount = const i0.Value.absent(), + i0.Value rowid = const i0.Value.absent(), + }) => i3.HistoryCompanion.insert( + urlCanonical: urlCanonical, + urlHost: urlHost, + urlPath: urlPath, + title: title, + isProbablyReaderable: isProbablyReaderable, + extractedContentMarkdown: extractedContentMarkdown, + extractedContentPlain: extractedContentPlain, + fullContentMarkdown: fullContentMarkdown, + fullContentPlain: fullContentPlain, + contentHash: contentHash, + observedAt: observedAt, + observedCount: observedCount, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), i0.BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $HistoryProcessedTableManager = + i0.ProcessedTableManager< + i0.GeneratedDatabase, + i3.History, + i3.HistoryData, + i3.$HistoryFilterComposer, + i3.$HistoryOrderingComposer, + i3.$HistoryAnnotationComposer, + $HistoryCreateCompanionBuilder, + $HistoryUpdateCompanionBuilder, + ( + i3.HistoryData, + i0.BaseReferences, + ), + i3.HistoryData, + i0.PrefetchHooks Function() + >; +typedef $HistoryFtsCreateCompanionBuilder = + i3.HistoryFtsCompanion Function({ + required String title, + required String urlHost, + required String urlPath, + required String extractedContentPlain, + required String fullContentPlain, + i0.Value rowid, + }); +typedef $HistoryFtsUpdateCompanionBuilder = + i3.HistoryFtsCompanion Function({ + i0.Value title, + i0.Value urlHost, + i0.Value urlPath, + i0.Value extractedContentPlain, + i0.Value fullContentPlain, + i0.Value rowid, + }); + +class $HistoryFtsFilterComposer + extends i0.Composer { + $HistoryFtsFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + i0.ColumnFilters get title => $composableBuilder( + column: $table.title, + builder: (column) => i0.ColumnFilters(column), + ); + + i0.ColumnFilters get urlHost => $composableBuilder( + column: $table.urlHost, + builder: (column) => i0.ColumnFilters(column), + ); + + i0.ColumnFilters get urlPath => $composableBuilder( + column: $table.urlPath, + builder: (column) => i0.ColumnFilters(column), + ); + + i0.ColumnFilters get extractedContentPlain => $composableBuilder( + column: $table.extractedContentPlain, + builder: (column) => i0.ColumnFilters(column), + ); + + i0.ColumnFilters get fullContentPlain => $composableBuilder( + column: $table.fullContentPlain, + builder: (column) => i0.ColumnFilters(column), + ); +} + +class $HistoryFtsOrderingComposer + extends i0.Composer { + $HistoryFtsOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + i0.ColumnOrderings get title => $composableBuilder( + column: $table.title, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get urlHost => $composableBuilder( + column: $table.urlHost, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get urlPath => $composableBuilder( + column: $table.urlPath, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get extractedContentPlain => $composableBuilder( + column: $table.extractedContentPlain, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get fullContentPlain => $composableBuilder( + column: $table.fullContentPlain, + builder: (column) => i0.ColumnOrderings(column), + ); +} + +class $HistoryFtsAnnotationComposer + extends i0.Composer { + $HistoryFtsAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + i0.GeneratedColumn get title => + $composableBuilder(column: $table.title, builder: (column) => column); + + i0.GeneratedColumn get urlHost => + $composableBuilder(column: $table.urlHost, builder: (column) => column); + + i0.GeneratedColumn get urlPath => + $composableBuilder(column: $table.urlPath, builder: (column) => column); + + i0.GeneratedColumn get extractedContentPlain => $composableBuilder( + column: $table.extractedContentPlain, + builder: (column) => column, + ); + + i0.GeneratedColumn get fullContentPlain => $composableBuilder( + column: $table.fullContentPlain, + builder: (column) => column, + ); +} + +class $HistoryFtsTableManager + extends + i0.RootTableManager< + i0.GeneratedDatabase, + i3.HistoryFts, + i3.HistoryFt, + i3.$HistoryFtsFilterComposer, + i3.$HistoryFtsOrderingComposer, + i3.$HistoryFtsAnnotationComposer, + $HistoryFtsCreateCompanionBuilder, + $HistoryFtsUpdateCompanionBuilder, + ( + i3.HistoryFt, + i0.BaseReferences< + i0.GeneratedDatabase, + i3.HistoryFts, + i3.HistoryFt + >, + ), + i3.HistoryFt, + i0.PrefetchHooks Function() + > { + $HistoryFtsTableManager(i0.GeneratedDatabase db, i3.HistoryFts table) + : super( + i0.TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + i3.$HistoryFtsFilterComposer($db: db, $table: table), + createOrderingComposer: () => + i3.$HistoryFtsOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + i3.$HistoryFtsAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + i0.Value title = const i0.Value.absent(), + i0.Value urlHost = const i0.Value.absent(), + i0.Value urlPath = const i0.Value.absent(), + i0.Value extractedContentPlain = + const i0.Value.absent(), + i0.Value fullContentPlain = const i0.Value.absent(), + i0.Value rowid = const i0.Value.absent(), + }) => i3.HistoryFtsCompanion( + title: title, + urlHost: urlHost, + urlPath: urlPath, + extractedContentPlain: extractedContentPlain, + fullContentPlain: fullContentPlain, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String title, + required String urlHost, + required String urlPath, + required String extractedContentPlain, + required String fullContentPlain, + i0.Value rowid = const i0.Value.absent(), + }) => i3.HistoryFtsCompanion.insert( + title: title, + urlHost: urlHost, + urlPath: urlPath, + extractedContentPlain: extractedContentPlain, + fullContentPlain: fullContentPlain, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), i0.BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $HistoryFtsProcessedTableManager = + i0.ProcessedTableManager< + i0.GeneratedDatabase, + i3.HistoryFts, + i3.HistoryFt, + i3.$HistoryFtsFilterComposer, + i3.$HistoryFtsOrderingComposer, + i3.$HistoryFtsAnnotationComposer, + $HistoryFtsCreateCompanionBuilder, + $HistoryFtsUpdateCompanionBuilder, + ( + i3.HistoryFt, + i0.BaseReferences, + ), + i3.HistoryFt, + i0.PrefetchHooks Function() + >; class Container extends i0.Table with i0.TableInfo { @@ -1250,6 +2465,23 @@ class Container extends i0.Table requiredDuringInsert: true, $customConstraints: 'NOT NULL', ).withConverter(i3.Container.$convertercolor); + late final i0.GeneratedColumn orderKey = i0.GeneratedColumn( + 'order_key', + aliasedName, + false, + type: i0.DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final i0.GeneratedColumn isPinned = i0.GeneratedColumn( + 'is_pinned', + aliasedName, + false, + type: i0.DriftSqlType.bool, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0', + defaultValue: const i0.CustomExpression('0'), + ); late final i0.GeneratedColumnWithTypeConverter metadata = i0.GeneratedColumn( 'metadata', @@ -1260,7 +2492,14 @@ class Container extends i0.Table $customConstraints: '', ).withConverter(i3.Container.$convertermetadatan); @override - List get $columns => [id, name, color, metadata]; + List get $columns => [ + id, + name, + color, + orderKey, + isPinned, + metadata, + ]; @override String get aliasedName => _alias ?? actualTableName; @override @@ -1286,6 +2525,14 @@ class Container extends i0.Table data['${effectivePrefix}color'], )!, ), + orderKey: attachedDatabase.typeMapping.read( + i0.DriftSqlType.string, + data['${effectivePrefix}order_key'], + )!, + isPinned: attachedDatabase.typeMapping.read( + i0.DriftSqlType.bool, + data['${effectivePrefix}is_pinned'], + )!, metadata: i3.Container.$convertermetadatan.fromSql( attachedDatabase.typeMapping.read( i0.DriftSqlType.string, @@ -1314,12 +2561,16 @@ class ContainerCompanion extends i0.UpdateCompanion { final i0.Value id; final i0.Value name; final i0.Value color; + final i0.Value orderKey; + final i0.Value isPinned; final i0.Value metadata; final i0.Value rowid; const ContainerCompanion({ this.id = const i0.Value.absent(), this.name = const i0.Value.absent(), this.color = const i0.Value.absent(), + this.orderKey = const i0.Value.absent(), + this.isPinned = const i0.Value.absent(), this.metadata = const i0.Value.absent(), this.rowid = const i0.Value.absent(), }); @@ -1327,14 +2578,19 @@ class ContainerCompanion extends i0.UpdateCompanion { required String id, this.name = const i0.Value.absent(), required i2.Color color, + required String orderKey, + this.isPinned = const i0.Value.absent(), this.metadata = const i0.Value.absent(), this.rowid = const i0.Value.absent(), }) : id = i0.Value(id), - color = i0.Value(color); + color = i0.Value(color), + orderKey = i0.Value(orderKey); static i0.Insertable custom({ i0.Expression? id, i0.Expression? name, i0.Expression? color, + i0.Expression? orderKey, + i0.Expression? isPinned, i0.Expression? metadata, i0.Expression? rowid, }) { @@ -1342,6 +2598,8 @@ class ContainerCompanion extends i0.UpdateCompanion { if (id != null) 'id': id, if (name != null) 'name': name, if (color != null) 'color': color, + if (orderKey != null) 'order_key': orderKey, + if (isPinned != null) 'is_pinned': isPinned, if (metadata != null) 'metadata': metadata, if (rowid != null) 'rowid': rowid, }); @@ -1351,6 +2609,8 @@ class ContainerCompanion extends i0.UpdateCompanion { i0.Value? id, i0.Value? name, i0.Value? color, + i0.Value? orderKey, + i0.Value? isPinned, i0.Value? metadata, i0.Value? rowid, }) { @@ -1358,6 +2618,8 @@ class ContainerCompanion extends i0.UpdateCompanion { id: id ?? this.id, name: name ?? this.name, color: color ?? this.color, + orderKey: orderKey ?? this.orderKey, + isPinned: isPinned ?? this.isPinned, metadata: metadata ?? this.metadata, rowid: rowid ?? this.rowid, ); @@ -1377,6 +2639,12 @@ class ContainerCompanion extends i0.UpdateCompanion { i3.Container.$convertercolor.toSql(color.value), ); } + if (orderKey.present) { + map['order_key'] = i0.Variable(orderKey.value); + } + if (isPinned.present) { + map['is_pinned'] = i0.Variable(isPinned.value); + } if (metadata.present) { map['metadata'] = i0.Variable( i3.Container.$convertermetadatan.toSql(metadata.value), @@ -1394,6 +2662,8 @@ class ContainerCompanion extends i0.UpdateCompanion { ..write('id: $id, ') ..write('name: $name, ') ..write('color: $color, ') + ..write('orderKey: $orderKey, ') + ..write('isPinned: $isPinned, ') ..write('metadata: $metadata, ') ..write('rowid: $rowid') ..write(')')) @@ -1547,6 +2817,21 @@ class Tab extends i0.Table with i0.TableInfo { requiredDuringInsert: true, $customConstraints: 'NOT NULL', ); + late final i0.GeneratedColumn contentHash = i0.GeneratedColumn( + 'content_hash', + aliasedName, + true, + generatedAs: i0.GeneratedAs( + const i0.CustomExpression( + 'generate_content_hash(title, extracted_content_plain, full_content_plain)', + ), + false, + ), + type: i0.DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: + 'GENERATED ALWAYS AS (generate_content_hash(title, extracted_content_plain, full_content_plain)) VIRTUAL', + ); @override List get $columns => [ id, @@ -1565,6 +2850,7 @@ class Tab extends i0.Table with i0.TableInfo { fullContentMarkdown, fullContentPlain, timestamp, + contentHash, ]; @override String get aliasedName => _alias ?? actualTableName; @@ -1647,6 +2933,10 @@ class Tab extends i0.Table with i0.TableInfo { i0.DriftSqlType.dateTime, data['${effectivePrefix}timestamp'], )!, + contentHash: attachedDatabase.typeMapping.read( + i0.DriftSqlType.int, + data['${effectivePrefix}content_hash'], + ), ); } @@ -1686,6 +2976,13 @@ class TabData extends i0.DataClass implements i0.Insertable { final String? fullContentMarkdown; final String? fullContentPlain; final DateTime timestamp; + + /// xxh3-64 over (title, extracted_content_plain, full_content_plain). + /// Used by the FTS update trigger and the tab→history fan-out trigger to + /// short-circuit when the row's UPDATE didn't actually change content. + /// VIRTUAL (computed on read) to avoid recreating the table on migration; + /// recomputation cost is negligible vs. the FTS rewrite it skips. + final int? contentHash; const TabData({ required this.id, required this.source, @@ -1703,6 +3000,7 @@ class TabData extends i0.DataClass implements i0.Insertable { this.fullContentMarkdown, this.fullContentPlain, required this.timestamp, + this.contentHash, }); @override Map toColumns(bool nullToAbsent) { @@ -1794,6 +3092,7 @@ class TabData extends i0.DataClass implements i0.Insertable { json['full_content_plain'], ), timestamp: serializer.fromJson(json['timestamp']), + contentHash: serializer.fromJson(json['content_hash']), ); } @override @@ -1822,6 +3121,7 @@ class TabData extends i0.DataClass implements i0.Insertable { 'full_content_markdown': serializer.toJson(fullContentMarkdown), 'full_content_plain': serializer.toJson(fullContentPlain), 'timestamp': serializer.toJson(timestamp), + 'content_hash': serializer.toJson(contentHash), }; } @@ -1842,6 +3142,7 @@ class TabData extends i0.DataClass implements i0.Insertable { i0.Value fullContentMarkdown = const i0.Value.absent(), i0.Value fullContentPlain = const i0.Value.absent(), DateTime? timestamp, + i0.Value contentHash = const i0.Value.absent(), }) => i3.TabData( id: id ?? this.id, source: source ?? this.source, @@ -1871,42 +3172,8 @@ class TabData extends i0.DataClass implements i0.Insertable { ? fullContentPlain.value : this.fullContentPlain, timestamp: timestamp ?? this.timestamp, + contentHash: contentHash.present ? contentHash.value : this.contentHash, ); - TabData copyWithCompanion(i3.TabCompanion data) { - return TabData( - id: data.id.present ? data.id.value : this.id, - source: data.source.present ? data.source.value : this.source, - parentId: data.parentId.present ? data.parentId.value : this.parentId, - containerId: data.containerId.present - ? data.containerId.value - : this.containerId, - orderKey: data.orderKey.present ? data.orderKey.value : this.orderKey, - url: data.url.present ? data.url.value : this.url, - title: data.title.present ? data.title.value : this.title, - tabMode: data.tabMode.present ? data.tabMode.value : this.tabMode, - isolationContextId: data.isolationContextId.present - ? data.isolationContextId.value - : this.isolationContextId, - isPinned: data.isPinned.present ? data.isPinned.value : this.isPinned, - isProbablyReaderable: data.isProbablyReaderable.present - ? data.isProbablyReaderable.value - : this.isProbablyReaderable, - extractedContentMarkdown: data.extractedContentMarkdown.present - ? data.extractedContentMarkdown.value - : this.extractedContentMarkdown, - extractedContentPlain: data.extractedContentPlain.present - ? data.extractedContentPlain.value - : this.extractedContentPlain, - fullContentMarkdown: data.fullContentMarkdown.present - ? data.fullContentMarkdown.value - : this.fullContentMarkdown, - fullContentPlain: data.fullContentPlain.present - ? data.fullContentPlain.value - : this.fullContentPlain, - timestamp: data.timestamp.present ? data.timestamp.value : this.timestamp, - ); - } - @override String toString() { return (StringBuffer('TabData(') @@ -1925,7 +3192,8 @@ class TabData extends i0.DataClass implements i0.Insertable { ..write('extractedContentPlain: $extractedContentPlain, ') ..write('fullContentMarkdown: $fullContentMarkdown, ') ..write('fullContentPlain: $fullContentPlain, ') - ..write('timestamp: $timestamp') + ..write('timestamp: $timestamp, ') + ..write('contentHash: $contentHash') ..write(')')) .toString(); } @@ -1948,6 +3216,7 @@ class TabData extends i0.DataClass implements i0.Insertable { fullContentMarkdown, fullContentPlain, timestamp, + contentHash, ); @override bool operator ==(Object other) => @@ -1968,7 +3237,8 @@ class TabData extends i0.DataClass implements i0.Insertable { other.extractedContentPlain == this.extractedContentPlain && other.fullContentMarkdown == this.fullContentMarkdown && other.fullContentPlain == this.fullContentPlain && - other.timestamp == this.timestamp); + other.timestamp == this.timestamp && + other.contentHash == this.contentHash); } class TabCompanion extends i0.UpdateCompanion { @@ -2408,6 +3678,310 @@ i0.Index get idxTabParentContainer => i0.Index( 'CREATE INDEX idx_tab_parent_container ON tab (parent_id, container_id)', ); +class CaptureTab extends i0.Table + with i0.TableInfo { + @override + final i0.GeneratedDatabase attachedDatabase; + final String? _alias; + CaptureTab(this.attachedDatabase, [this._alias]); + late final i0.GeneratedColumn tabId = i0.GeneratedColumn( + 'tab_id', + aliasedName, + false, + type: i0.DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: + 'NOT NULL PRIMARY KEY REFERENCES tab(id)ON DELETE CASCADE', + ); + late final i0.GeneratedColumn captureId = i0.GeneratedColumn( + 'capture_id', + aliasedName, + false, + type: i0.DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final i0.GeneratedColumn sourceUrl = i0.GeneratedColumn( + 'source_url', + aliasedName, + false, + type: i0.DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final i0.GeneratedColumn status = i0.GeneratedColumn( + 'status', + aliasedName, + false, + type: i0.DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT \'pending\'', + defaultValue: const i0.CustomExpression('\'pending\''), + ); + late final i0.GeneratedColumn createdAt = + i0.GeneratedColumn( + 'created_at', + aliasedName, + false, + type: i0.DriftSqlType.dateTime, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + @override + List get $columns => [ + tabId, + captureId, + sourceUrl, + status, + createdAt, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'capture_tab'; + @override + Set get $primaryKey => {tabId}; + @override + i3.CaptureTabData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return i3.CaptureTabData( + tabId: attachedDatabase.typeMapping.read( + i0.DriftSqlType.string, + data['${effectivePrefix}tab_id'], + )!, + captureId: attachedDatabase.typeMapping.read( + i0.DriftSqlType.string, + data['${effectivePrefix}capture_id'], + )!, + sourceUrl: attachedDatabase.typeMapping.read( + i0.DriftSqlType.string, + data['${effectivePrefix}source_url'], + )!, + status: attachedDatabase.typeMapping.read( + i0.DriftSqlType.string, + data['${effectivePrefix}status'], + )!, + createdAt: attachedDatabase.typeMapping.read( + i0.DriftSqlType.dateTime, + data['${effectivePrefix}created_at'], + )!, + ); + } + + @override + CaptureTab createAlias(String alias) { + return CaptureTab(attachedDatabase, alias); + } + + @override + bool get dontWriteConstraints => true; +} + +class CaptureTabData extends i0.DataClass + implements i0.Insertable { + final String tabId; + final String captureId; + final String sourceUrl; + final String status; + final DateTime createdAt; + const CaptureTabData({ + required this.tabId, + required this.captureId, + required this.sourceUrl, + required this.status, + required this.createdAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['tab_id'] = i0.Variable(tabId); + map['capture_id'] = i0.Variable(captureId); + map['source_url'] = i0.Variable(sourceUrl); + map['status'] = i0.Variable(status); + map['created_at'] = i0.Variable(createdAt); + return map; + } + + factory CaptureTabData.fromJson( + Map json, { + i0.ValueSerializer? serializer, + }) { + serializer ??= i0.driftRuntimeOptions.defaultSerializer; + return CaptureTabData( + tabId: serializer.fromJson(json['tab_id']), + captureId: serializer.fromJson(json['capture_id']), + sourceUrl: serializer.fromJson(json['source_url']), + status: serializer.fromJson(json['status']), + createdAt: serializer.fromJson(json['created_at']), + ); + } + @override + Map toJson({i0.ValueSerializer? serializer}) { + serializer ??= i0.driftRuntimeOptions.defaultSerializer; + return { + 'tab_id': serializer.toJson(tabId), + 'capture_id': serializer.toJson(captureId), + 'source_url': serializer.toJson(sourceUrl), + 'status': serializer.toJson(status), + 'created_at': serializer.toJson(createdAt), + }; + } + + i3.CaptureTabData copyWith({ + String? tabId, + String? captureId, + String? sourceUrl, + String? status, + DateTime? createdAt, + }) => i3.CaptureTabData( + tabId: tabId ?? this.tabId, + captureId: captureId ?? this.captureId, + sourceUrl: sourceUrl ?? this.sourceUrl, + status: status ?? this.status, + createdAt: createdAt ?? this.createdAt, + ); + CaptureTabData copyWithCompanion(i3.CaptureTabCompanion data) { + return CaptureTabData( + tabId: data.tabId.present ? data.tabId.value : this.tabId, + captureId: data.captureId.present ? data.captureId.value : this.captureId, + sourceUrl: data.sourceUrl.present ? data.sourceUrl.value : this.sourceUrl, + status: data.status.present ? data.status.value : this.status, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + ); + } + + @override + String toString() { + return (StringBuffer('CaptureTabData(') + ..write('tabId: $tabId, ') + ..write('captureId: $captureId, ') + ..write('sourceUrl: $sourceUrl, ') + ..write('status: $status, ') + ..write('createdAt: $createdAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => + Object.hash(tabId, captureId, sourceUrl, status, createdAt); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is i3.CaptureTabData && + other.tabId == this.tabId && + other.captureId == this.captureId && + other.sourceUrl == this.sourceUrl && + other.status == this.status && + other.createdAt == this.createdAt); +} + +class CaptureTabCompanion extends i0.UpdateCompanion { + final i0.Value tabId; + final i0.Value captureId; + final i0.Value sourceUrl; + final i0.Value status; + final i0.Value createdAt; + final i0.Value rowid; + const CaptureTabCompanion({ + this.tabId = const i0.Value.absent(), + this.captureId = const i0.Value.absent(), + this.sourceUrl = const i0.Value.absent(), + this.status = const i0.Value.absent(), + this.createdAt = const i0.Value.absent(), + this.rowid = const i0.Value.absent(), + }); + CaptureTabCompanion.insert({ + required String tabId, + required String captureId, + required String sourceUrl, + this.status = const i0.Value.absent(), + required DateTime createdAt, + this.rowid = const i0.Value.absent(), + }) : tabId = i0.Value(tabId), + captureId = i0.Value(captureId), + sourceUrl = i0.Value(sourceUrl), + createdAt = i0.Value(createdAt); + static i0.Insertable custom({ + i0.Expression? tabId, + i0.Expression? captureId, + i0.Expression? sourceUrl, + i0.Expression? status, + i0.Expression? createdAt, + i0.Expression? rowid, + }) { + return i0.RawValuesInsertable({ + if (tabId != null) 'tab_id': tabId, + if (captureId != null) 'capture_id': captureId, + if (sourceUrl != null) 'source_url': sourceUrl, + if (status != null) 'status': status, + if (createdAt != null) 'created_at': createdAt, + if (rowid != null) 'rowid': rowid, + }); + } + + i3.CaptureTabCompanion copyWith({ + i0.Value? tabId, + i0.Value? captureId, + i0.Value? sourceUrl, + i0.Value? status, + i0.Value? createdAt, + i0.Value? rowid, + }) { + return i3.CaptureTabCompanion( + tabId: tabId ?? this.tabId, + captureId: captureId ?? this.captureId, + sourceUrl: sourceUrl ?? this.sourceUrl, + status: status ?? this.status, + createdAt: createdAt ?? this.createdAt, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (tabId.present) { + map['tab_id'] = i0.Variable(tabId.value); + } + if (captureId.present) { + map['capture_id'] = i0.Variable(captureId.value); + } + if (sourceUrl.present) { + map['source_url'] = i0.Variable(sourceUrl.value); + } + if (status.present) { + map['status'] = i0.Variable(status.value); + } + if (createdAt.present) { + map['created_at'] = i0.Variable(createdAt.value); + } + if (rowid.present) { + map['rowid'] = i0.Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('CaptureTabCompanion(') + ..write('tabId: $tabId, ') + ..write('captureId: $captureId, ') + ..write('sourceUrl: $sourceUrl, ') + ..write('status: $status, ') + ..write('createdAt: $createdAt, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +i0.Index get idxCaptureTabCaptureId => i0.Index( + 'idx_capture_tab_capture_id', + 'CREATE INDEX idx_capture_tab_capture_id ON capture_tab (capture_id)', +); + class TabFts extends i0.Table with i0.TableInfo, i0.VirtualTableInfo { @override @@ -2699,10 +4273,1177 @@ i0.Trigger get tabAfterDelete => i0.Trigger( 'tab_after_delete', ); i0.Trigger get tabAfterUpdate => i0.Trigger( - 'CREATE TRIGGER tab_after_update AFTER UPDATE ON tab BEGIN INSERT INTO tab_fts (tab_fts, "rowid", title, url, extracted_content_plain, full_content_plain) VALUES (\'delete\', old."rowid", old.title, old.url, old.extracted_content_plain, old.full_content_plain);INSERT INTO tab_fts ("rowid", title, url, extracted_content_plain, full_content_plain) VALUES (new."rowid", new.title, new.url, new.extracted_content_plain, new.full_content_plain);END', + 'CREATE TRIGGER tab_after_update AFTER UPDATE OF title, url, extracted_content_plain, full_content_plain ON tab WHEN OLD.content_hash IS NOT NEW.content_hash OR OLD.url IS NOT NEW.url BEGIN INSERT INTO tab_fts (tab_fts, "rowid", title, url, extracted_content_plain, full_content_plain) VALUES (\'delete\', old."rowid", old.title, old.url, old.extracted_content_plain, old.full_content_plain);INSERT INTO tab_fts ("rowid", title, url, extracted_content_plain, full_content_plain) VALUES (new."rowid", new.title, new.url, new.extracted_content_plain, new.full_content_plain);END', 'tab_after_update', ); +class LocalIndexSetting extends i0.Table + with i0.TableInfo { + @override + final i0.GeneratedDatabase attachedDatabase; + final String? _alias; + LocalIndexSetting(this.attachedDatabase, [this._alias]); + late final i0.GeneratedColumn key = i0.GeneratedColumn( + 'key', + aliasedName, + false, + type: i0.DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'PRIMARY KEY NOT NULL', + ); + late final i0.GeneratedColumn value = i0.GeneratedColumn( + 'value', + aliasedName, + false, + type: i0.DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + @override + List get $columns => [key, value]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'local_index_setting'; + @override + Set get $primaryKey => {key}; + @override + i3.LocalIndexSettingData map( + Map data, { + String? tablePrefix, + }) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return i3.LocalIndexSettingData( + key: attachedDatabase.typeMapping.read( + i0.DriftSqlType.string, + data['${effectivePrefix}key'], + )!, + value: attachedDatabase.typeMapping.read( + i0.DriftSqlType.int, + data['${effectivePrefix}value'], + )!, + ); + } + + @override + LocalIndexSetting createAlias(String alias) { + return LocalIndexSetting(attachedDatabase, alias); + } + + @override + bool get isStrict => true; + @override + bool get dontWriteConstraints => true; +} + +class LocalIndexSettingData extends i0.DataClass + implements i0.Insertable { + final String key; + final int value; + const LocalIndexSettingData({required this.key, required this.value}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['key'] = i0.Variable(key); + map['value'] = i0.Variable(value); + return map; + } + + factory LocalIndexSettingData.fromJson( + Map json, { + i0.ValueSerializer? serializer, + }) { + serializer ??= i0.driftRuntimeOptions.defaultSerializer; + return LocalIndexSettingData( + key: serializer.fromJson(json['key']), + value: serializer.fromJson(json['value']), + ); + } + @override + Map toJson({i0.ValueSerializer? serializer}) { + serializer ??= i0.driftRuntimeOptions.defaultSerializer; + return { + 'key': serializer.toJson(key), + 'value': serializer.toJson(value), + }; + } + + i3.LocalIndexSettingData copyWith({String? key, int? value}) => + i3.LocalIndexSettingData( + key: key ?? this.key, + value: value ?? this.value, + ); + LocalIndexSettingData copyWithCompanion(i3.LocalIndexSettingCompanion data) { + return LocalIndexSettingData( + key: data.key.present ? data.key.value : this.key, + value: data.value.present ? data.value.value : this.value, + ); + } + + @override + String toString() { + return (StringBuffer('LocalIndexSettingData(') + ..write('key: $key, ') + ..write('value: $value') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(key, value); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is i3.LocalIndexSettingData && + other.key == this.key && + other.value == this.value); +} + +class LocalIndexSettingCompanion + extends i0.UpdateCompanion { + final i0.Value key; + final i0.Value value; + final i0.Value rowid; + const LocalIndexSettingCompanion({ + this.key = const i0.Value.absent(), + this.value = const i0.Value.absent(), + this.rowid = const i0.Value.absent(), + }); + LocalIndexSettingCompanion.insert({ + required String key, + required int value, + this.rowid = const i0.Value.absent(), + }) : key = i0.Value(key), + value = i0.Value(value); + static i0.Insertable custom({ + i0.Expression? key, + i0.Expression? value, + i0.Expression? rowid, + }) { + return i0.RawValuesInsertable({ + if (key != null) 'key': key, + if (value != null) 'value': value, + if (rowid != null) 'rowid': rowid, + }); + } + + i3.LocalIndexSettingCompanion copyWith({ + i0.Value? key, + i0.Value? value, + i0.Value? rowid, + }) { + return i3.LocalIndexSettingCompanion( + key: key ?? this.key, + value: value ?? this.value, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (key.present) { + map['key'] = i0.Variable(key.value); + } + if (value.present) { + map['value'] = i0.Variable(value.value); + } + if (rowid.present) { + map['rowid'] = i0.Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('LocalIndexSettingCompanion(') + ..write('key: $key, ') + ..write('value: $value, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class History extends i0.Table with i0.TableInfo { + @override + final i0.GeneratedDatabase attachedDatabase; + final String? _alias; + History(this.attachedDatabase, [this._alias]); + late final i0.GeneratedColumn urlCanonical = + i0.GeneratedColumn( + 'url_canonical', + aliasedName, + false, + type: i0.DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'PRIMARY KEY NOT NULL', + ); + late final i0.GeneratedColumn urlHost = i0.GeneratedColumn( + 'url_host', + aliasedName, + false, + type: i0.DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final i0.GeneratedColumn urlPath = i0.GeneratedColumn( + 'url_path', + aliasedName, + true, + type: i0.DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final i0.GeneratedColumn title = i0.GeneratedColumn( + 'title', + aliasedName, + true, + type: i0.DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final i0.GeneratedColumn isProbablyReaderable = + i0.GeneratedColumn( + 'is_probably_readerable', + aliasedName, + true, + type: i0.DriftSqlType.bool, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final i0.GeneratedColumn extractedContentMarkdown = + i0.GeneratedColumn( + 'extracted_content_markdown', + aliasedName, + true, + type: i0.DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final i0.GeneratedColumn extractedContentPlain = + i0.GeneratedColumn( + 'extracted_content_plain', + aliasedName, + true, + type: i0.DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final i0.GeneratedColumn fullContentMarkdown = + i0.GeneratedColumn( + 'full_content_markdown', + aliasedName, + true, + type: i0.DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final i0.GeneratedColumn fullContentPlain = + i0.GeneratedColumn( + 'full_content_plain', + aliasedName, + true, + type: i0.DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final i0.GeneratedColumn contentHash = i0.GeneratedColumn( + 'content_hash', + aliasedName, + true, + type: i0.DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final i0.GeneratedColumn observedAt = + i0.GeneratedColumn( + 'observed_at', + aliasedName, + false, + type: i0.DriftSqlType.dateTime, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final i0.GeneratedColumn observedCount = i0.GeneratedColumn( + 'observed_count', + aliasedName, + false, + type: i0.DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const i0.CustomExpression('1'), + ); + @override + List get $columns => [ + urlCanonical, + urlHost, + urlPath, + title, + isProbablyReaderable, + extractedContentMarkdown, + extractedContentPlain, + fullContentMarkdown, + fullContentPlain, + contentHash, + observedAt, + observedCount, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'history'; + @override + Set get $primaryKey => {urlCanonical}; + @override + i3.HistoryData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return i3.HistoryData( + urlCanonical: attachedDatabase.typeMapping.read( + i0.DriftSqlType.string, + data['${effectivePrefix}url_canonical'], + )!, + urlHost: attachedDatabase.typeMapping.read( + i0.DriftSqlType.string, + data['${effectivePrefix}url_host'], + )!, + urlPath: attachedDatabase.typeMapping.read( + i0.DriftSqlType.string, + data['${effectivePrefix}url_path'], + ), + title: attachedDatabase.typeMapping.read( + i0.DriftSqlType.string, + data['${effectivePrefix}title'], + ), + isProbablyReaderable: attachedDatabase.typeMapping.read( + i0.DriftSqlType.bool, + data['${effectivePrefix}is_probably_readerable'], + ), + extractedContentMarkdown: attachedDatabase.typeMapping.read( + i0.DriftSqlType.string, + data['${effectivePrefix}extracted_content_markdown'], + ), + extractedContentPlain: attachedDatabase.typeMapping.read( + i0.DriftSqlType.string, + data['${effectivePrefix}extracted_content_plain'], + ), + fullContentMarkdown: attachedDatabase.typeMapping.read( + i0.DriftSqlType.string, + data['${effectivePrefix}full_content_markdown'], + ), + fullContentPlain: attachedDatabase.typeMapping.read( + i0.DriftSqlType.string, + data['${effectivePrefix}full_content_plain'], + ), + contentHash: attachedDatabase.typeMapping.read( + i0.DriftSqlType.int, + data['${effectivePrefix}content_hash'], + ), + observedAt: attachedDatabase.typeMapping.read( + i0.DriftSqlType.dateTime, + data['${effectivePrefix}observed_at'], + )!, + observedCount: attachedDatabase.typeMapping.read( + i0.DriftSqlType.int, + data['${effectivePrefix}observed_count'], + )!, + ); + } + + @override + History createAlias(String alias) { + return History(attachedDatabase, alias); + } + + @override + bool get dontWriteConstraints => true; +} + +class HistoryData extends i0.DataClass + implements i0.Insertable { + final String urlCanonical; + final String urlHost; + final String? urlPath; + final String? title; + final bool? isProbablyReaderable; + final String? extractedContentMarkdown; + final String? extractedContentPlain; + final String? fullContentMarkdown; + final String? fullContentPlain; + final int? contentHash; + final DateTime observedAt; + final int observedCount; + const HistoryData({ + required this.urlCanonical, + required this.urlHost, + this.urlPath, + this.title, + this.isProbablyReaderable, + this.extractedContentMarkdown, + this.extractedContentPlain, + this.fullContentMarkdown, + this.fullContentPlain, + this.contentHash, + required this.observedAt, + required this.observedCount, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['url_canonical'] = i0.Variable(urlCanonical); + map['url_host'] = i0.Variable(urlHost); + if (!nullToAbsent || urlPath != null) { + map['url_path'] = i0.Variable(urlPath); + } + if (!nullToAbsent || title != null) { + map['title'] = i0.Variable(title); + } + if (!nullToAbsent || isProbablyReaderable != null) { + map['is_probably_readerable'] = i0.Variable(isProbablyReaderable); + } + if (!nullToAbsent || extractedContentMarkdown != null) { + map['extracted_content_markdown'] = i0.Variable( + extractedContentMarkdown, + ); + } + if (!nullToAbsent || extractedContentPlain != null) { + map['extracted_content_plain'] = i0.Variable( + extractedContentPlain, + ); + } + if (!nullToAbsent || fullContentMarkdown != null) { + map['full_content_markdown'] = i0.Variable(fullContentMarkdown); + } + if (!nullToAbsent || fullContentPlain != null) { + map['full_content_plain'] = i0.Variable(fullContentPlain); + } + if (!nullToAbsent || contentHash != null) { + map['content_hash'] = i0.Variable(contentHash); + } + map['observed_at'] = i0.Variable(observedAt); + map['observed_count'] = i0.Variable(observedCount); + return map; + } + + factory HistoryData.fromJson( + Map json, { + i0.ValueSerializer? serializer, + }) { + serializer ??= i0.driftRuntimeOptions.defaultSerializer; + return HistoryData( + urlCanonical: serializer.fromJson(json['url_canonical']), + urlHost: serializer.fromJson(json['url_host']), + urlPath: serializer.fromJson(json['url_path']), + title: serializer.fromJson(json['title']), + isProbablyReaderable: serializer.fromJson( + json['is_probably_readerable'], + ), + extractedContentMarkdown: serializer.fromJson( + json['extracted_content_markdown'], + ), + extractedContentPlain: serializer.fromJson( + json['extracted_content_plain'], + ), + fullContentMarkdown: serializer.fromJson( + json['full_content_markdown'], + ), + fullContentPlain: serializer.fromJson( + json['full_content_plain'], + ), + contentHash: serializer.fromJson(json['content_hash']), + observedAt: serializer.fromJson(json['observed_at']), + observedCount: serializer.fromJson(json['observed_count']), + ); + } + @override + Map toJson({i0.ValueSerializer? serializer}) { + serializer ??= i0.driftRuntimeOptions.defaultSerializer; + return { + 'url_canonical': serializer.toJson(urlCanonical), + 'url_host': serializer.toJson(urlHost), + 'url_path': serializer.toJson(urlPath), + 'title': serializer.toJson(title), + 'is_probably_readerable': serializer.toJson(isProbablyReaderable), + 'extracted_content_markdown': serializer.toJson( + extractedContentMarkdown, + ), + 'extracted_content_plain': serializer.toJson( + extractedContentPlain, + ), + 'full_content_markdown': serializer.toJson(fullContentMarkdown), + 'full_content_plain': serializer.toJson(fullContentPlain), + 'content_hash': serializer.toJson(contentHash), + 'observed_at': serializer.toJson(observedAt), + 'observed_count': serializer.toJson(observedCount), + }; + } + + i3.HistoryData copyWith({ + String? urlCanonical, + String? urlHost, + i0.Value urlPath = const i0.Value.absent(), + i0.Value title = const i0.Value.absent(), + i0.Value isProbablyReaderable = const i0.Value.absent(), + i0.Value extractedContentMarkdown = const i0.Value.absent(), + i0.Value extractedContentPlain = const i0.Value.absent(), + i0.Value fullContentMarkdown = const i0.Value.absent(), + i0.Value fullContentPlain = const i0.Value.absent(), + i0.Value contentHash = const i0.Value.absent(), + DateTime? observedAt, + int? observedCount, + }) => i3.HistoryData( + urlCanonical: urlCanonical ?? this.urlCanonical, + urlHost: urlHost ?? this.urlHost, + urlPath: urlPath.present ? urlPath.value : this.urlPath, + title: title.present ? title.value : this.title, + isProbablyReaderable: isProbablyReaderable.present + ? isProbablyReaderable.value + : this.isProbablyReaderable, + extractedContentMarkdown: extractedContentMarkdown.present + ? extractedContentMarkdown.value + : this.extractedContentMarkdown, + extractedContentPlain: extractedContentPlain.present + ? extractedContentPlain.value + : this.extractedContentPlain, + fullContentMarkdown: fullContentMarkdown.present + ? fullContentMarkdown.value + : this.fullContentMarkdown, + fullContentPlain: fullContentPlain.present + ? fullContentPlain.value + : this.fullContentPlain, + contentHash: contentHash.present ? contentHash.value : this.contentHash, + observedAt: observedAt ?? this.observedAt, + observedCount: observedCount ?? this.observedCount, + ); + HistoryData copyWithCompanion(i3.HistoryCompanion data) { + return HistoryData( + urlCanonical: data.urlCanonical.present + ? data.urlCanonical.value + : this.urlCanonical, + urlHost: data.urlHost.present ? data.urlHost.value : this.urlHost, + urlPath: data.urlPath.present ? data.urlPath.value : this.urlPath, + title: data.title.present ? data.title.value : this.title, + isProbablyReaderable: data.isProbablyReaderable.present + ? data.isProbablyReaderable.value + : this.isProbablyReaderable, + extractedContentMarkdown: data.extractedContentMarkdown.present + ? data.extractedContentMarkdown.value + : this.extractedContentMarkdown, + extractedContentPlain: data.extractedContentPlain.present + ? data.extractedContentPlain.value + : this.extractedContentPlain, + fullContentMarkdown: data.fullContentMarkdown.present + ? data.fullContentMarkdown.value + : this.fullContentMarkdown, + fullContentPlain: data.fullContentPlain.present + ? data.fullContentPlain.value + : this.fullContentPlain, + contentHash: data.contentHash.present + ? data.contentHash.value + : this.contentHash, + observedAt: data.observedAt.present + ? data.observedAt.value + : this.observedAt, + observedCount: data.observedCount.present + ? data.observedCount.value + : this.observedCount, + ); + } + + @override + String toString() { + return (StringBuffer('HistoryData(') + ..write('urlCanonical: $urlCanonical, ') + ..write('urlHost: $urlHost, ') + ..write('urlPath: $urlPath, ') + ..write('title: $title, ') + ..write('isProbablyReaderable: $isProbablyReaderable, ') + ..write('extractedContentMarkdown: $extractedContentMarkdown, ') + ..write('extractedContentPlain: $extractedContentPlain, ') + ..write('fullContentMarkdown: $fullContentMarkdown, ') + ..write('fullContentPlain: $fullContentPlain, ') + ..write('contentHash: $contentHash, ') + ..write('observedAt: $observedAt, ') + ..write('observedCount: $observedCount') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + urlCanonical, + urlHost, + urlPath, + title, + isProbablyReaderable, + extractedContentMarkdown, + extractedContentPlain, + fullContentMarkdown, + fullContentPlain, + contentHash, + observedAt, + observedCount, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is i3.HistoryData && + other.urlCanonical == this.urlCanonical && + other.urlHost == this.urlHost && + other.urlPath == this.urlPath && + other.title == this.title && + other.isProbablyReaderable == this.isProbablyReaderable && + other.extractedContentMarkdown == this.extractedContentMarkdown && + other.extractedContentPlain == this.extractedContentPlain && + other.fullContentMarkdown == this.fullContentMarkdown && + other.fullContentPlain == this.fullContentPlain && + other.contentHash == this.contentHash && + other.observedAt == this.observedAt && + other.observedCount == this.observedCount); +} + +class HistoryCompanion extends i0.UpdateCompanion { + final i0.Value urlCanonical; + final i0.Value urlHost; + final i0.Value urlPath; + final i0.Value title; + final i0.Value isProbablyReaderable; + final i0.Value extractedContentMarkdown; + final i0.Value extractedContentPlain; + final i0.Value fullContentMarkdown; + final i0.Value fullContentPlain; + final i0.Value contentHash; + final i0.Value observedAt; + final i0.Value observedCount; + final i0.Value rowid; + const HistoryCompanion({ + this.urlCanonical = const i0.Value.absent(), + this.urlHost = const i0.Value.absent(), + this.urlPath = const i0.Value.absent(), + this.title = const i0.Value.absent(), + this.isProbablyReaderable = const i0.Value.absent(), + this.extractedContentMarkdown = const i0.Value.absent(), + this.extractedContentPlain = const i0.Value.absent(), + this.fullContentMarkdown = const i0.Value.absent(), + this.fullContentPlain = const i0.Value.absent(), + this.contentHash = const i0.Value.absent(), + this.observedAt = const i0.Value.absent(), + this.observedCount = const i0.Value.absent(), + this.rowid = const i0.Value.absent(), + }); + HistoryCompanion.insert({ + required String urlCanonical, + required String urlHost, + this.urlPath = const i0.Value.absent(), + this.title = const i0.Value.absent(), + this.isProbablyReaderable = const i0.Value.absent(), + this.extractedContentMarkdown = const i0.Value.absent(), + this.extractedContentPlain = const i0.Value.absent(), + this.fullContentMarkdown = const i0.Value.absent(), + this.fullContentPlain = const i0.Value.absent(), + this.contentHash = const i0.Value.absent(), + required DateTime observedAt, + this.observedCount = const i0.Value.absent(), + this.rowid = const i0.Value.absent(), + }) : urlCanonical = i0.Value(urlCanonical), + urlHost = i0.Value(urlHost), + observedAt = i0.Value(observedAt); + static i0.Insertable custom({ + i0.Expression? urlCanonical, + i0.Expression? urlHost, + i0.Expression? urlPath, + i0.Expression? title, + i0.Expression? isProbablyReaderable, + i0.Expression? extractedContentMarkdown, + i0.Expression? extractedContentPlain, + i0.Expression? fullContentMarkdown, + i0.Expression? fullContentPlain, + i0.Expression? contentHash, + i0.Expression? observedAt, + i0.Expression? observedCount, + i0.Expression? rowid, + }) { + return i0.RawValuesInsertable({ + if (urlCanonical != null) 'url_canonical': urlCanonical, + if (urlHost != null) 'url_host': urlHost, + if (urlPath != null) 'url_path': urlPath, + if (title != null) 'title': title, + if (isProbablyReaderable != null) + 'is_probably_readerable': isProbablyReaderable, + if (extractedContentMarkdown != null) + 'extracted_content_markdown': extractedContentMarkdown, + if (extractedContentPlain != null) + 'extracted_content_plain': extractedContentPlain, + if (fullContentMarkdown != null) + 'full_content_markdown': fullContentMarkdown, + if (fullContentPlain != null) 'full_content_plain': fullContentPlain, + if (contentHash != null) 'content_hash': contentHash, + if (observedAt != null) 'observed_at': observedAt, + if (observedCount != null) 'observed_count': observedCount, + if (rowid != null) 'rowid': rowid, + }); + } + + i3.HistoryCompanion copyWith({ + i0.Value? urlCanonical, + i0.Value? urlHost, + i0.Value? urlPath, + i0.Value? title, + i0.Value? isProbablyReaderable, + i0.Value? extractedContentMarkdown, + i0.Value? extractedContentPlain, + i0.Value? fullContentMarkdown, + i0.Value? fullContentPlain, + i0.Value? contentHash, + i0.Value? observedAt, + i0.Value? observedCount, + i0.Value? rowid, + }) { + return i3.HistoryCompanion( + urlCanonical: urlCanonical ?? this.urlCanonical, + urlHost: urlHost ?? this.urlHost, + urlPath: urlPath ?? this.urlPath, + title: title ?? this.title, + isProbablyReaderable: isProbablyReaderable ?? this.isProbablyReaderable, + extractedContentMarkdown: + extractedContentMarkdown ?? this.extractedContentMarkdown, + extractedContentPlain: + extractedContentPlain ?? this.extractedContentPlain, + fullContentMarkdown: fullContentMarkdown ?? this.fullContentMarkdown, + fullContentPlain: fullContentPlain ?? this.fullContentPlain, + contentHash: contentHash ?? this.contentHash, + observedAt: observedAt ?? this.observedAt, + observedCount: observedCount ?? this.observedCount, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (urlCanonical.present) { + map['url_canonical'] = i0.Variable(urlCanonical.value); + } + if (urlHost.present) { + map['url_host'] = i0.Variable(urlHost.value); + } + if (urlPath.present) { + map['url_path'] = i0.Variable(urlPath.value); + } + if (title.present) { + map['title'] = i0.Variable(title.value); + } + if (isProbablyReaderable.present) { + map['is_probably_readerable'] = i0.Variable( + isProbablyReaderable.value, + ); + } + if (extractedContentMarkdown.present) { + map['extracted_content_markdown'] = i0.Variable( + extractedContentMarkdown.value, + ); + } + if (extractedContentPlain.present) { + map['extracted_content_plain'] = i0.Variable( + extractedContentPlain.value, + ); + } + if (fullContentMarkdown.present) { + map['full_content_markdown'] = i0.Variable( + fullContentMarkdown.value, + ); + } + if (fullContentPlain.present) { + map['full_content_plain'] = i0.Variable(fullContentPlain.value); + } + if (contentHash.present) { + map['content_hash'] = i0.Variable(contentHash.value); + } + if (observedAt.present) { + map['observed_at'] = i0.Variable(observedAt.value); + } + if (observedCount.present) { + map['observed_count'] = i0.Variable(observedCount.value); + } + if (rowid.present) { + map['rowid'] = i0.Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('HistoryCompanion(') + ..write('urlCanonical: $urlCanonical, ') + ..write('urlHost: $urlHost, ') + ..write('urlPath: $urlPath, ') + ..write('title: $title, ') + ..write('isProbablyReaderable: $isProbablyReaderable, ') + ..write('extractedContentMarkdown: $extractedContentMarkdown, ') + ..write('extractedContentPlain: $extractedContentPlain, ') + ..write('fullContentMarkdown: $fullContentMarkdown, ') + ..write('fullContentPlain: $fullContentPlain, ') + ..write('contentHash: $contentHash, ') + ..write('observedAt: $observedAt, ') + ..write('observedCount: $observedCount, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +i0.Index get idxHistoryHost => i0.Index( + 'idx_history_host', + 'CREATE INDEX idx_history_host ON history (url_host)', +); +i0.Index get idxHistoryObserved => i0.Index( + 'idx_history_observed', + 'CREATE INDEX idx_history_observed ON history (observed_at DESC)', +); + +class HistoryFts extends i0.Table + with + i0.TableInfo, + i0.VirtualTableInfo { + @override + final i0.GeneratedDatabase attachedDatabase; + final String? _alias; + HistoryFts(this.attachedDatabase, [this._alias]); + late final i0.GeneratedColumn title = i0.GeneratedColumn( + 'title', + aliasedName, + false, + type: i0.DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: '', + ); + late final i0.GeneratedColumn urlHost = i0.GeneratedColumn( + 'url_host', + aliasedName, + false, + type: i0.DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: '', + ); + late final i0.GeneratedColumn urlPath = i0.GeneratedColumn( + 'url_path', + aliasedName, + false, + type: i0.DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: '', + ); + late final i0.GeneratedColumn extractedContentPlain = + i0.GeneratedColumn( + 'extracted_content_plain', + aliasedName, + false, + type: i0.DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: '', + ); + late final i0.GeneratedColumn fullContentPlain = + i0.GeneratedColumn( + 'full_content_plain', + aliasedName, + false, + type: i0.DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: '', + ); + @override + List get $columns => [ + title, + urlHost, + urlPath, + extractedContentPlain, + fullContentPlain, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'history_fts'; + @override + Set get $primaryKey => const {}; + @override + i3.HistoryFt map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return i3.HistoryFt( + title: attachedDatabase.typeMapping.read( + i0.DriftSqlType.string, + data['${effectivePrefix}title'], + )!, + urlHost: attachedDatabase.typeMapping.read( + i0.DriftSqlType.string, + data['${effectivePrefix}url_host'], + )!, + urlPath: attachedDatabase.typeMapping.read( + i0.DriftSqlType.string, + data['${effectivePrefix}url_path'], + )!, + extractedContentPlain: attachedDatabase.typeMapping.read( + i0.DriftSqlType.string, + data['${effectivePrefix}extracted_content_plain'], + )!, + fullContentPlain: attachedDatabase.typeMapping.read( + i0.DriftSqlType.string, + data['${effectivePrefix}full_content_plain'], + )!, + ); + } + + @override + HistoryFts createAlias(String alias) { + return HistoryFts(attachedDatabase, alias); + } + + @override + bool get dontWriteConstraints => true; + @override + String get moduleAndArgs => + 'fts5(title, url_host, url_path, extracted_content_plain, full_content_plain, content=history, tokenize="trigram")'; +} + +class HistoryFt extends i0.DataClass implements i0.Insertable { + final String title; + final String urlHost; + final String urlPath; + final String extractedContentPlain; + final String fullContentPlain; + const HistoryFt({ + required this.title, + required this.urlHost, + required this.urlPath, + required this.extractedContentPlain, + required this.fullContentPlain, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['title'] = i0.Variable(title); + map['url_host'] = i0.Variable(urlHost); + map['url_path'] = i0.Variable(urlPath); + map['extracted_content_plain'] = i0.Variable(extractedContentPlain); + map['full_content_plain'] = i0.Variable(fullContentPlain); + return map; + } + + factory HistoryFt.fromJson( + Map json, { + i0.ValueSerializer? serializer, + }) { + serializer ??= i0.driftRuntimeOptions.defaultSerializer; + return HistoryFt( + title: serializer.fromJson(json['title']), + urlHost: serializer.fromJson(json['url_host']), + urlPath: serializer.fromJson(json['url_path']), + extractedContentPlain: serializer.fromJson( + json['extracted_content_plain'], + ), + fullContentPlain: serializer.fromJson(json['full_content_plain']), + ); + } + @override + Map toJson({i0.ValueSerializer? serializer}) { + serializer ??= i0.driftRuntimeOptions.defaultSerializer; + return { + 'title': serializer.toJson(title), + 'url_host': serializer.toJson(urlHost), + 'url_path': serializer.toJson(urlPath), + 'extracted_content_plain': serializer.toJson( + extractedContentPlain, + ), + 'full_content_plain': serializer.toJson(fullContentPlain), + }; + } + + i3.HistoryFt copyWith({ + String? title, + String? urlHost, + String? urlPath, + String? extractedContentPlain, + String? fullContentPlain, + }) => i3.HistoryFt( + title: title ?? this.title, + urlHost: urlHost ?? this.urlHost, + urlPath: urlPath ?? this.urlPath, + extractedContentPlain: extractedContentPlain ?? this.extractedContentPlain, + fullContentPlain: fullContentPlain ?? this.fullContentPlain, + ); + HistoryFt copyWithCompanion(i3.HistoryFtsCompanion data) { + return HistoryFt( + title: data.title.present ? data.title.value : this.title, + urlHost: data.urlHost.present ? data.urlHost.value : this.urlHost, + urlPath: data.urlPath.present ? data.urlPath.value : this.urlPath, + extractedContentPlain: data.extractedContentPlain.present + ? data.extractedContentPlain.value + : this.extractedContentPlain, + fullContentPlain: data.fullContentPlain.present + ? data.fullContentPlain.value + : this.fullContentPlain, + ); + } + + @override + String toString() { + return (StringBuffer('HistoryFt(') + ..write('title: $title, ') + ..write('urlHost: $urlHost, ') + ..write('urlPath: $urlPath, ') + ..write('extractedContentPlain: $extractedContentPlain, ') + ..write('fullContentPlain: $fullContentPlain') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + title, + urlHost, + urlPath, + extractedContentPlain, + fullContentPlain, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is i3.HistoryFt && + other.title == this.title && + other.urlHost == this.urlHost && + other.urlPath == this.urlPath && + other.extractedContentPlain == this.extractedContentPlain && + other.fullContentPlain == this.fullContentPlain); +} + +class HistoryFtsCompanion extends i0.UpdateCompanion { + final i0.Value title; + final i0.Value urlHost; + final i0.Value urlPath; + final i0.Value extractedContentPlain; + final i0.Value fullContentPlain; + final i0.Value rowid; + const HistoryFtsCompanion({ + this.title = const i0.Value.absent(), + this.urlHost = const i0.Value.absent(), + this.urlPath = const i0.Value.absent(), + this.extractedContentPlain = const i0.Value.absent(), + this.fullContentPlain = const i0.Value.absent(), + this.rowid = const i0.Value.absent(), + }); + HistoryFtsCompanion.insert({ + required String title, + required String urlHost, + required String urlPath, + required String extractedContentPlain, + required String fullContentPlain, + this.rowid = const i0.Value.absent(), + }) : title = i0.Value(title), + urlHost = i0.Value(urlHost), + urlPath = i0.Value(urlPath), + extractedContentPlain = i0.Value(extractedContentPlain), + fullContentPlain = i0.Value(fullContentPlain); + static i0.Insertable custom({ + i0.Expression? title, + i0.Expression? urlHost, + i0.Expression? urlPath, + i0.Expression? extractedContentPlain, + i0.Expression? fullContentPlain, + i0.Expression? rowid, + }) { + return i0.RawValuesInsertable({ + if (title != null) 'title': title, + if (urlHost != null) 'url_host': urlHost, + if (urlPath != null) 'url_path': urlPath, + if (extractedContentPlain != null) + 'extracted_content_plain': extractedContentPlain, + if (fullContentPlain != null) 'full_content_plain': fullContentPlain, + if (rowid != null) 'rowid': rowid, + }); + } + + i3.HistoryFtsCompanion copyWith({ + i0.Value? title, + i0.Value? urlHost, + i0.Value? urlPath, + i0.Value? extractedContentPlain, + i0.Value? fullContentPlain, + i0.Value? rowid, + }) { + return i3.HistoryFtsCompanion( + title: title ?? this.title, + urlHost: urlHost ?? this.urlHost, + urlPath: urlPath ?? this.urlPath, + extractedContentPlain: + extractedContentPlain ?? this.extractedContentPlain, + fullContentPlain: fullContentPlain ?? this.fullContentPlain, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (title.present) { + map['title'] = i0.Variable(title.value); + } + if (urlHost.present) { + map['url_host'] = i0.Variable(urlHost.value); + } + if (urlPath.present) { + map['url_path'] = i0.Variable(urlPath.value); + } + if (extractedContentPlain.present) { + map['extracted_content_plain'] = i0.Variable( + extractedContentPlain.value, + ); + } + if (fullContentPlain.present) { + map['full_content_plain'] = i0.Variable(fullContentPlain.value); + } + if (rowid.present) { + map['rowid'] = i0.Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('HistoryFtsCompanion(') + ..write('title: $title, ') + ..write('urlHost: $urlHost, ') + ..write('urlPath: $urlPath, ') + ..write('extractedContentPlain: $extractedContentPlain, ') + ..write('fullContentPlain: $fullContentPlain, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +i0.Trigger get historyAfterInsert => i0.Trigger( + 'CREATE TRIGGER history_after_insert AFTER INSERT ON history BEGIN INSERT INTO history_fts ("rowid", title, url_host, url_path, extracted_content_plain, full_content_plain) VALUES (new."rowid", new.title, new.url_host, new.url_path, new.extracted_content_plain, new.full_content_plain);END', + 'history_after_insert', +); +i0.Trigger get historyAfterDelete => i0.Trigger( + 'CREATE TRIGGER history_after_delete AFTER DELETE ON history BEGIN INSERT INTO history_fts (history_fts, "rowid", title, url_host, url_path, extracted_content_plain, full_content_plain) VALUES (\'delete\', old."rowid", old.title, old.url_host, old.url_path, old.extracted_content_plain, old.full_content_plain);END', + 'history_after_delete', +); +i0.Trigger get historyAfterUpdate => i0.Trigger( + 'CREATE TRIGGER history_after_update AFTER UPDATE OF title, url_host, url_path, extracted_content_plain, full_content_plain ON history BEGIN INSERT INTO history_fts (history_fts, "rowid", title, url_host, url_path, extracted_content_plain, full_content_plain) VALUES (\'delete\', old."rowid", old.title, old.url_host, old.url_path, old.extracted_content_plain, old.full_content_plain);INSERT INTO history_fts ("rowid", title, url_host, url_path, extracted_content_plain, full_content_plain) VALUES (new."rowid", new.title, new.url_host, new.url_path, new.extracted_content_plain, new.full_content_plain);END', + 'history_after_update', +); +i0.Trigger get tabToHistoryOnInsert => i0.Trigger( + 'CREATE TRIGGER tab_to_history_on_insert AFTER INSERT ON tab WHEN NEW.url IS NOT NULL AND url_indexable(CAST(NEW.url AS TEXT)) = 1 AND (SELECT value FROM local_index_setting WHERE "key" = \'enabled\') = 1 AND(NEW.tab_mode != 1 OR (SELECT value FROM local_index_setting WHERE "key" = \'index_private\') = 1)BEGIN INSERT INTO history (url_canonical, url_host, url_path, title, is_probably_readerable, extracted_content_markdown, extracted_content_plain, full_content_markdown, full_content_plain, content_hash, observed_at, observed_count) VALUES (url_canonical(CAST(NEW.url AS TEXT)), url_host(CAST(NEW.url AS TEXT)), url_path(CAST(NEW.url AS TEXT)), NEW.title, NEW.is_probably_readerable, NEW.extracted_content_markdown, NEW.extracted_content_plain, NEW.full_content_markdown, NEW.full_content_plain, NEW.content_hash, strftime(\'%s\', \'now\') * 1000, 1) ON CONFLICT (url_canonical) DO UPDATE SET title = COALESCE(excluded.title, history.title), is_probably_readerable = excluded.is_probably_readerable, extracted_content_markdown = excluded.extracted_content_markdown, extracted_content_plain = excluded.extracted_content_plain, full_content_markdown = excluded.full_content_markdown, full_content_plain = excluded.full_content_plain, content_hash = excluded.content_hash, observed_at = excluded.observed_at, observed_count = history.observed_count + 1 WHERE history.content_hash IS NOT excluded.content_hash;END', + 'tab_to_history_on_insert', +); +i0.Trigger get tabToHistoryOnUpdate => i0.Trigger( + 'CREATE TRIGGER tab_to_history_on_update AFTER UPDATE OF title, url, extracted_content_plain, extracted_content_markdown, full_content_plain, full_content_markdown, is_probably_readerable ON tab WHEN NEW.url IS NOT NULL AND url_indexable(CAST(NEW.url AS TEXT)) = 1 AND(OLD.content_hash IS NOT NEW.content_hash OR OLD.url IS NOT NEW.url)AND (SELECT value FROM local_index_setting WHERE "key" = \'enabled\') = 1 AND(NEW.tab_mode != 1 OR (SELECT value FROM local_index_setting WHERE "key" = \'index_private\') = 1)BEGIN INSERT INTO history (url_canonical, url_host, url_path, title, is_probably_readerable, extracted_content_markdown, extracted_content_plain, full_content_markdown, full_content_plain, content_hash, observed_at, observed_count) VALUES (url_canonical(CAST(NEW.url AS TEXT)), url_host(CAST(NEW.url AS TEXT)), url_path(CAST(NEW.url AS TEXT)), NEW.title, NEW.is_probably_readerable, NEW.extracted_content_markdown, NEW.extracted_content_plain, NEW.full_content_markdown, NEW.full_content_plain, NEW.content_hash, strftime(\'%s\', \'now\') * 1000, 1) ON CONFLICT (url_canonical) DO UPDATE SET title = COALESCE(excluded.title, history.title), is_probably_readerable = excluded.is_probably_readerable, extracted_content_markdown = excluded.extracted_content_markdown, extracted_content_plain = excluded.extracted_content_plain, full_content_markdown = excluded.full_content_markdown, full_content_plain = excluded.full_content_plain, content_hash = excluded.content_hash, observed_at = excluded.observed_at, observed_count = history.observed_count + 1 WHERE history.content_hash IS NOT excluded.content_hash;END', + 'tab_to_history_on_update', +); + class DefinitionsDrift extends i9.ModularAccessor { DefinitionsDrift(i0.GeneratedDatabase db) : super(db); Future optimizeFtsIndex() { @@ -2713,9 +5454,155 @@ class DefinitionsDrift extends i9.ModularAccessor { ); } + Future optimizeHistoryFtsIndex() { + return customInsert( + 'INSERT INTO history_fts (history_fts) VALUES (\'optimize\')', + variables: [], + updates: {historyFts}, + ); + } + + i0.Selectable queryHistoryFullContent({ + required String beforeMatch, + required String afterMatch, + required String ellipsis, + required int snippetLength, + required String query, + required int limit, + }) { + return customSelect( + 'WITH weights AS (SELECT 10.0 AS title_weight, 8.0 AS host_weight, 2.0 AS path_weight, 3.0 AS extracted_weight, 1.0 AS full_weight) SELECT h.url_canonical, h.url_host, h.url_path, highlight(history_fts, 0, ?1, ?2) AS title, snippet(history_fts, 3, ?1, ?2, ?3, ?4) AS extracted_content, snippet(history_fts, 4, ?1, ?2, ?3, ?4) AS full_content, h.observed_at, bm25(history_fts, weights.title_weight, weights.host_weight, weights.path_weight, weights.extracted_weight, weights.full_weight) AS weighted_rank FROM history_fts(?5)AS fts INNER JOIN history AS h ON h."rowid" = fts."rowid" CROSS JOIN weights ORDER BY weighted_rank ASC, h.observed_at DESC LIMIT ?6', + variables: [ + i0.Variable(beforeMatch), + i0.Variable(afterMatch), + i0.Variable(ellipsis), + i0.Variable(snippetLength), + i0.Variable(query), + i0.Variable(limit), + ], + readsFrom: {history, historyFts}, + ).map( + (i0.QueryRow row) => i10.HistoryQueryResult( + urlCanonical: row.read('url_canonical'), + urlHost: row.read('url_host'), + urlPath: row.readNullable('url_path'), + title: row.readNullable('title'), + extractedContent: row.readNullable('extracted_content'), + fullContent: row.readNullable('full_content'), + weightedRank: row.read('weighted_rank'), + observedAt: row.read('observed_at'), + ), + ); + } + + i0.Selectable queryHistoryByHostPrefix({ + required String hostPrefix, + required int limit, + }) { + return customSelect( + 'SELECT h.url_canonical, h.url_host, h.url_path, h.title, NULL AS extracted_content, NULL AS full_content, h.observed_at, 0.0 AS weighted_rank FROM history AS h WHERE h.url_host LIKE ?1 ORDER BY h.observed_at DESC LIMIT ?2', + variables: [i0.Variable(hostPrefix), i0.Variable(limit)], + readsFrom: {history}, + ).map( + (i0.QueryRow row) => i10.HistoryQueryResult( + urlCanonical: row.read('url_canonical'), + urlHost: row.read('url_host'), + urlPath: row.readNullable('url_path'), + title: row.readNullable('title'), + extractedContent: row.readNullable('extracted_content'), + fullContent: row.readNullable('full_content'), + weightedRank: row.read('weighted_rank'), + observedAt: row.read('observed_at'), + ), + ); + } + + i0.Selectable historyByCanonicalUrls({ + required List canonicalUrls, + }) { + var $arrayStartIndex = 1; + final expandedcanonicalUrls = $expandVar( + $arrayStartIndex, + canonicalUrls.length, + ); + $arrayStartIndex += canonicalUrls.length; + return customSelect( + 'SELECT h.url_canonical, h.url_host, h.url_path, h.title, NULL AS extracted_content, NULL AS full_content, h.observed_at, 0.0 AS weighted_rank FROM history AS h WHERE h.url_canonical IN ($expandedcanonicalUrls)', + variables: [for (var $ in canonicalUrls) i0.Variable($)], + readsFrom: {history}, + ).map( + (i0.QueryRow row) => i10.HistoryQueryResult( + urlCanonical: row.read('url_canonical'), + urlHost: row.read('url_host'), + urlPath: row.readNullable('url_path'), + title: row.readNullable('title'), + extractedContent: row.readNullable('extracted_content'), + fullContent: row.readNullable('full_content'), + weightedRank: row.read('weighted_rank'), + observedAt: row.read('observed_at'), + ), + ); + } + + Future upsertLocalIndexSetting({ + required String key, + required int value, + }) { + return customInsert( + 'INSERT INTO local_index_setting ("key", value) VALUES (?1, ?2) ON CONFLICT ("key") DO UPDATE SET value = excluded.value', + variables: [i0.Variable(key), i0.Variable(value)], + updates: {localIndexSetting}, + ); + } + + i0.Selectable countHistoryRows() { + return customSelect( + 'SELECT COUNT(*) AS count FROM history', + variables: [], + readsFrom: {history}, + ).map((i0.QueryRow row) => row.read('count')); + } + + Future clearHistory() { + return customUpdate( + 'DELETE FROM history', + variables: [], + updates: {history}, + updateKind: i0.UpdateKind.delete, + ); + } + + i0.Selectable historyUrlsPage({ + required int limit, + required int offset, + }) { + return customSelect( + 'SELECT url_canonical FROM history ORDER BY observed_at ASC, url_canonical ASC LIMIT ?1 OFFSET ?2', + variables: [i0.Variable(limit), i0.Variable(offset)], + readsFrom: {history}, + ).map((i0.QueryRow row) => row.read('url_canonical')); + } + + Future deleteHistoryByCanonicalUrls({ + required List canonicalUrls, + }) { + var $arrayStartIndex = 1; + final expandedcanonicalUrls = $expandVar( + $arrayStartIndex, + canonicalUrls.length, + ); + $arrayStartIndex += canonicalUrls.length; + return customUpdate( + 'DELETE FROM history WHERE url_canonical IN ($expandedcanonicalUrls)', + variables: [for (var $ in canonicalUrls) i0.Variable($)], + updates: {history}, + updateKind: i0.UpdateKind.delete, + ); + } + i0.Selectable containersWithCount() { return customSelect( - 'SELECT container.*, tab_agg.tab_count FROM container LEFT JOIN (SELECT container_id, COUNT(*) AS tab_count, MAX(timestamp) AS last_updated FROM tab GROUP BY container_id) AS tab_agg ON container.id = tab_agg.container_id ORDER BY tab_agg.last_updated DESC NULLS LAST', + 'SELECT container.*, tab_agg.tab_count FROM container LEFT JOIN (SELECT container_id, COUNT(*) AS tab_count, MAX(timestamp) AS last_updated FROM tab GROUP BY container_id) AS tab_agg ON container.id = tab_agg.container_id ORDER BY container.is_pinned DESC, container.order_key ASC', variables: [], readsFrom: {container, tab}, ).map( @@ -2723,6 +5610,8 @@ class DefinitionsDrift extends i9.ModularAccessor { id: row.read('id'), name: row.readNullable('name'), color: i3.Container.$convertercolor.fromSql(row.read('color')), + orderKey: row.read('order_key'), + isPinned: row.read('is_pinned'), metadata: i0.NullAwareTypeConverter.wrapFromSql( i3.Container.$convertermetadata, row.readNullable('metadata'), @@ -2732,6 +5621,64 @@ class DefinitionsDrift extends i9.ModularAccessor { ); } + i0.Selectable containerIdsByLastUpdated() { + return customSelect( + 'SELECT container.id FROM container LEFT JOIN (SELECT container_id, MAX(timestamp) AS last_updated FROM tab GROUP BY container_id) AS tab_agg ON container.id = tab_agg.container_id ORDER BY tab_agg.last_updated DESC NULLS LAST, container."rowid" ASC', + variables: [], + readsFrom: {container, tab}, + ).map((i0.QueryRow row) => row.read('id')); + } + + i0.Selectable leadingContainerOrderKey({ + required int bucket, + required bool isPinned, + }) { + return customSelect( + 'SELECT lexo_rank_previous(?1, (SELECT order_key FROM container WHERE is_pinned = ?2 ORDER BY order_key LIMIT 1)) AS _c0', + variables: [i0.Variable(bucket), i0.Variable(isPinned)], + readsFrom: {container}, + ).map((i0.QueryRow row) => row.read('_c0')); + } + + i0.Selectable trailingContainerOrderKey({ + required int bucket, + required bool isPinned, + }) { + return customSelect( + 'SELECT lexo_rank_next(?1, (SELECT order_key FROM container WHERE is_pinned = ?2 ORDER BY order_key DESC LIMIT 1)) AS _c0', + variables: [i0.Variable(bucket), i0.Variable(isPinned)], + readsFrom: {container}, + ).map((i0.QueryRow row) => row.read('_c0')); + } + + i0.Selectable containerOrderKeyAfter({ + required bool isPinned, + required String containerId, + }) { + return customSelect( + 'WITH ordered_table AS (SELECT id, order_key, LEAD(order_key)OVER (ORDER BY order_key RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE NO OTHERS) AS next_order_key FROM container WHERE is_pinned = ?1) SELECT lexo_rank_reorder_after(order_key, next_order_key) AS _c0 FROM ordered_table WHERE id = ?2', + variables: [ + i0.Variable(isPinned), + i0.Variable(containerId), + ], + readsFrom: {container}, + ).map((i0.QueryRow row) => row.read('_c0')); + } + + i0.Selectable containerOrderKeyBefore({ + required bool isPinned, + required String containerId, + }) { + return customSelect( + 'WITH ordered_table AS (SELECT id, order_key, LAG(order_key)OVER (ORDER BY order_key RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE NO OTHERS) AS prev_order_key FROM container WHERE is_pinned = ?1) SELECT lexo_rank_reorder_before(order_key, prev_order_key) AS _c0 FROM ordered_table WHERE id = ?2', + variables: [ + i0.Variable(isPinned), + i0.Variable(containerId), + ], + readsFrom: {container}, + ).map((i0.QueryRow row) => row.read('_c0')); + } + i0.Selectable leadingOrderKey({ required int bucket, required String? containerId, @@ -2790,7 +5737,7 @@ class DefinitionsDrift extends i9.ModularAccessor { ).map((i0.QueryRow row) => row.read('_c0')); } - i0.Selectable queryTabsBasic({ + i0.Selectable queryTabsBasic({ required String query, required int limit, }) { @@ -2799,7 +5746,7 @@ class DefinitionsDrift extends i9.ModularAccessor { variables: [i0.Variable(query), i0.Variable(limit)], readsFrom: {tab, tabFts}, ).map( - (i0.QueryRow row) => i10.TabQueryResult( + (i0.QueryRow row) => i11.TabQueryResult( id: row.read('id'), containerId: row.readNullable('container_id'), tabMode: i3.Tab.$convertertabMode.fromSql(row.read('tab_mode')), @@ -2813,7 +5760,7 @@ class DefinitionsDrift extends i9.ModularAccessor { ); } - i0.Selectable queryTabsFullContent({ + i0.Selectable queryTabsFullContent({ required String beforeMatch, required String afterMatch, required String ellipsis, @@ -2833,7 +5780,7 @@ class DefinitionsDrift extends i9.ModularAccessor { ], readsFrom: {tab, tabFts}, ).map( - (i0.QueryRow row) => i10.TabQueryResult( + (i0.QueryRow row) => i11.TabQueryResult( id: row.read('id'), containerId: row.readNullable('container_id'), tabMode: i3.Tab.$convertertabMode.fromSql(row.read('tab_mode')), @@ -2962,13 +5909,13 @@ class DefinitionsDrift extends i9.ModularAccessor { ).map((i0.QueryRow row) => row.read('existing')); } - i0.Selectable allAssignedSites() { + i0.Selectable allAssignedSites() { return customSelect( 'SELECT container.id, COALESCE(container.metadata ->> \'\$.contextualIdentity\', \'general\') AS contextualIdentity, value AS assigned_site FROM container CROSS JOIN json_each(container.metadata, \'\$.assignedSites\')WHERE value IS NOT NULL', variables: [], readsFrom: {container}, ).map( - (i0.QueryRow row) => i11.SiteAssignment( + (i0.QueryRow row) => i12.SiteAssignment( id: row.read('id'), contextualIdentity: row.read('contextualIdentity'), assignedSite: row.readNullable('assigned_site'), @@ -3046,6 +5993,15 @@ class DefinitionsDrift extends i9.ModularAccessor { i3.TabFts get tabFts => i9.ReadDatabaseContainer( attachedDatabase, ).resultSet('tab_fts'); + i3.HistoryFts get historyFts => i9.ReadDatabaseContainer( + attachedDatabase, + ).resultSet('history_fts'); + i3.History get history => i9.ReadDatabaseContainer( + attachedDatabase, + ).resultSet('history'); + i3.LocalIndexSetting get localIndexSetting => i9.ReadDatabaseContainer( + attachedDatabase, + ).resultSet('local_index_setting'); i3.Container get container => i9.ReadDatabaseContainer( attachedDatabase, ).resultSet('container'); diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/data/models/container_data.dart b/apps/weblibre/lib/features/geckoview/features/tabs/data/models/container_data.dart index 84d8a252..4a5e539e 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/data/models/container_data.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/data/models/container_data.dart @@ -85,12 +85,20 @@ class ContainerData with FastEquatable { final String? name; @ColorJsonConverter() final Color color; + + final String orderKey; + + @JsonKey(defaultValue: false) + final bool isPinned; + final ContainerMetadata metadata; ContainerData({ required this.id, this.name, required this.color, + required this.orderKey, + this.isPinned = false, ContainerMetadata? metadata, }) : metadata = metadata ?? ContainerMetadata.withDefaults(); @@ -100,7 +108,14 @@ class ContainerData with FastEquatable { Map toJson() => _$ContainerDataToJson(this); @override - List get hashParameters => [id, name, color, metadata]; + List get hashParameters => [ + id, + name, + color, + orderKey, + isPinned, + metadata, + ]; } @JsonSerializable() @@ -111,6 +126,8 @@ class ContainerDataWithCount extends ContainerData { required super.id, super.name, required super.color, + required super.orderKey, + super.isPinned, super.metadata, required this.tabCount, }); diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/data/models/container_data.g.dart b/apps/weblibre/lib/features/geckoview/features/tabs/data/models/container_data.g.dart index d88b53fb..c088faa9 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/data/models/container_data.g.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/data/models/container_data.g.dart @@ -115,6 +115,10 @@ abstract class _$ContainerDataCWProxy { ContainerData color(Color color); + ContainerData orderKey(String orderKey); + + ContainerData isPinned(bool isPinned); + ContainerData metadata(ContainerMetadata? metadata); /// Creates a new instance with the provided field values. @@ -128,6 +132,8 @@ abstract class _$ContainerDataCWProxy { String id, String? name, Color color, + String orderKey, + bool isPinned, ContainerMetadata? metadata, }); } @@ -148,6 +154,12 @@ class _$ContainerDataCWProxyImpl implements _$ContainerDataCWProxy { @override ContainerData color(Color color) => call(color: color); + @override + ContainerData orderKey(String orderKey) => call(orderKey: orderKey); + + @override + ContainerData isPinned(bool isPinned) => call(isPinned: isPinned); + @override ContainerData metadata(ContainerMetadata? metadata) => call(metadata: metadata); @@ -164,6 +176,8 @@ class _$ContainerDataCWProxyImpl implements _$ContainerDataCWProxy { Object? id = const $CopyWithPlaceholder(), Object? name = const $CopyWithPlaceholder(), Object? color = const $CopyWithPlaceholder(), + Object? orderKey = const $CopyWithPlaceholder(), + Object? isPinned = const $CopyWithPlaceholder(), Object? metadata = const $CopyWithPlaceholder(), }) { return ContainerData( @@ -179,6 +193,14 @@ class _$ContainerDataCWProxyImpl implements _$ContainerDataCWProxy { ? _value.color // ignore: cast_nullable_to_non_nullable : color as Color, + orderKey: orderKey == const $CopyWithPlaceholder() || orderKey == null + ? _value.orderKey + // ignore: cast_nullable_to_non_nullable + : orderKey as String, + isPinned: isPinned == const $CopyWithPlaceholder() || isPinned == null + ? _value.isPinned + // ignore: cast_nullable_to_non_nullable + : isPinned as bool, metadata: metadata == const $CopyWithPlaceholder() ? _value.metadata // ignore: cast_nullable_to_non_nullable @@ -241,6 +263,8 @@ ContainerData _$ContainerDataFromJson( id: json['id'] as String, name: json['name'] as String?, color: const ColorJsonConverter().fromJson((json['color'] as num).toInt()), + orderKey: json['orderKey'] as String, + isPinned: json['isPinned'] as bool? ?? false, metadata: json['metadata'] == null ? null : ContainerMetadata.fromJson(json['metadata'] as Map), @@ -251,6 +275,8 @@ Map _$ContainerDataToJson(ContainerData instance) => 'id': instance.id, 'name': instance.name, 'color': const ColorJsonConverter().toJson(instance.color), + 'orderKey': instance.orderKey, + 'isPinned': instance.isPinned, 'metadata': instance.metadata.toJson(), }; @@ -260,6 +286,8 @@ ContainerDataWithCount _$ContainerDataWithCountFromJson( id: json['id'] as String, name: json['name'] as String?, color: const ColorJsonConverter().fromJson((json['color'] as num).toInt()), + orderKey: json['orderKey'] as String, + isPinned: json['isPinned'] as bool? ?? false, metadata: json['metadata'] == null ? null : ContainerMetadata.fromJson(json['metadata'] as Map), @@ -272,6 +300,8 @@ Map _$ContainerDataWithCountToJson( 'id': instance.id, 'name': instance.name, 'color': const ColorJsonConverter().toJson(instance.color), + 'orderKey': instance.orderKey, + 'isPinned': instance.isPinned, 'metadata': instance.metadata.toJson(), 'tabCount': instance.tabCount, }; diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/data/models/history_query_result.dart b/apps/weblibre/lib/features/geckoview/features/tabs/data/models/history_query_result.dart new file mode 100644 index 00000000..e9671d60 --- /dev/null +++ b/apps/weblibre/lib/features/geckoview/features/tabs/data/models/history_query_result.dart @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'package:fast_equatable/fast_equatable.dart'; + +/// FTS5 hit against the local history index. Highlight/snippet markers are +/// embedded inline (the same `***`-style markers used for tab results) so the +/// renderer in `utils/text_highlight.dart` can be reused as-is. +class HistoryQueryResult with FastEquatable { + final String urlCanonical; + final String urlHost; + final String? urlPath; + final String? title; + + /// `snippet()` over `extracted_content_plain`. May contain highlight + /// markers if the FTS query matched within the extracted content. + final String? extractedContent; + + /// `snippet()` over `full_content_plain`. + final String? fullContent; + + /// Smaller is more relevant (BM25 convention). Combine with frecency from + /// Places at the call site to produce the final ranking. + final double weightedRank; + + final DateTime observedAt; + + HistoryQueryResult({ + required this.urlCanonical, + required this.urlHost, + required this.urlPath, + required this.title, + required this.extractedContent, + required this.fullContent, + required this.weightedRank, + required this.observedAt, + }); + + @override + List get hashParameters => [ + urlCanonical, + urlHost, + urlPath, + title, + extractedContent, + fullContent, + weightedRank, + observedAt, + ]; +} diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/data/providers.dart b/apps/weblibre/lib/features/geckoview/features/tabs/data/providers.dart index d88fee8e..57a5d605 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/data/providers.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/data/providers.dart @@ -27,6 +27,7 @@ import 'package:sqlite3_flutter_libs/sqlite3_flutter_libs.dart'; import 'package:weblibre/core/database_registry.dart'; import 'package:weblibre/core/filesystem.dart'; import 'package:weblibre/data/database/functions/lexo_rank_functions.dart'; +import 'package:weblibre/data/database/functions/url_functions.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/database/database.dart'; part 'providers.g.dart'; @@ -46,6 +47,7 @@ TabDatabase tabDatabase(Ref ref) { file, setup: (database) { registerLexorankFunctions(database); + registerUrlFunctions(database); }, ); }), diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/data/providers.g.dart b/apps/weblibre/lib/features/geckoview/features/tabs/data/providers.g.dart index 91b90dbb..53453f6e 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/data/providers.g.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/data/providers.g.dart @@ -48,4 +48,4 @@ final class TabDatabaseProvider } } -String _$tabDatabaseHash() => r'62466f063f5eae2a32c37dce0496b8e47e7cb953'; +String _$tabDatabaseHash() => r'983f15b87d8d8bdd52b45d79fe78a1cb4b241f6e'; diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/domain/repositories/container.dart b/apps/weblibre/lib/features/geckoview/features/tabs/domain/repositories/container.dart index b9d0fa84..1f97a88f 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/domain/repositories/container.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/domain/repositories/container.dart @@ -43,6 +43,14 @@ class ContainerRepository extends _$ContainerRepository { } } + if (container.orderKey.isEmpty) { + throw ArgumentError.value( + container.orderKey, + 'container.orderKey', + 'Containers require an explicit order key', + ); + } + return ref.read(tabDatabaseProvider).containerDao.addContainer(container); } @@ -70,6 +78,21 @@ class ContainerRepository extends _$ContainerRepository { .replaceContainer(container); } + Future assignContainerOrderKey(String id, {required String orderKey}) { + return ref + .read(tabDatabaseProvider) + .containerDao + .assignOrderKey(id, orderKey: orderKey); + } + + Future setContainerPinned(String id, {required bool isPinned}) async { + final orderKey = await getTrailingContainerOrderKey(isPinned: isPinned); + return ref + .read(tabDatabaseProvider) + .containerDao + .assignPinned(id, isPinned: isPinned, orderKey: orderKey); + } + Future getContainerData(String id) { return ref .read(tabDatabaseProvider) @@ -125,6 +148,113 @@ class ContainerRepository extends _$ContainerRepository { .getSingle(); } + Future getLeadingContainerOrderKey({required bool isPinned}) { + return ref + .read(tabDatabaseProvider) + .containerDao + .generateLeadingContainerOrderKey(isPinned: isPinned) + .getSingle(); + } + + Future getTrailingContainerOrderKey({required bool isPinned}) { + return ref + .read(tabDatabaseProvider) + .containerDao + .generateTrailingContainerOrderKey(isPinned: isPinned) + .getSingle(); + } + + Future getOrderKeyAfterContainer( + String containerId, { + required bool isPinned, + }) { + return ref + .read(tabDatabaseProvider) + .containerDao + .generateOrderKeyAfterContainerId(containerId, isPinned: isPinned) + .getSingleOrNull(); + } + + Future getOrderKeyBeforeContainer( + String containerId, { + required bool isPinned, + }) { + return ref + .read(tabDatabaseProvider) + .containerDao + .generateOrderKeyBeforeContainerId(containerId, isPinned: isPinned) + .getSingle(); + } + + Future reorderContainer( + List containers, + int oldIndex, + int newIndex, + ) async { + if (containers.isEmpty || oldIndex == newIndex) return; + + var targetIndex = newIndex; + if (targetIndex > oldIndex) { + targetIndex -= 1; + } + targetIndex = targetIndex.clamp(0, containers.length - 1); + if (targetIndex == oldIndex) return; + + final movingContainer = containers[oldIndex]; + final scopedContainers = containers + .where((container) => container.isPinned == movingContainer.isPinned) + .toList(); + final scopedOldIndex = scopedContainers.indexWhere( + (container) => container.id == movingContainer.id, + ); + if (scopedOldIndex < 0) return; + + final containersWithoutMoving = containers.toList()..removeAt(oldIndex); + final scopedTargetIndex = containersWithoutMoving + .take(targetIndex) + .where((container) => container.isPinned == movingContainer.isPinned) + .length + .clamp(0, scopedContainers.length - 1) + .toInt(); + if (scopedTargetIndex == scopedOldIndex) return; + + final orderKey = await _orderKeyForReorder( + scopedContainers, + scopedOldIndex, + scopedTargetIndex, + movingContainer.isPinned, + ); + await assignContainerOrderKey(movingContainer.id, orderKey: orderKey); + } + + Future _orderKeyForReorder( + List containers, + int oldIndex, + int targetIndex, + bool isPinned, + ) async { + if (targetIndex <= 0) { + return getLeadingContainerOrderKey(isPinned: isPinned); + } + + if (targetIndex >= containers.length - 1) { + return getTrailingContainerOrderKey(isPinned: isPinned); + } + + if (targetIndex < oldIndex) { + return await getOrderKeyAfterContainer( + containers[targetIndex - 1].id, + isPinned: isPinned, + ) ?? + await getLeadingContainerOrderKey(isPinned: isPinned); + } + + return getOrderKeyBeforeContainer( + containers[targetIndex + 1].id, + isPinned: isPinned, + ); + } + Future getOrderKeyAfterTab(String tabId, String? containerId) { return ref .read(tabDatabaseProvider) @@ -142,12 +272,19 @@ class ContainerRepository extends _$ContainerRepository { } Future unusedRandomContainerColor() async { - final allColors = colorTypes.flattened.toList(); final usedColors = await getDistinctColors(); + final unusedColorTypes = colorTypes.where((colors) { + return !shadingTypes( + colors, + ).any((shade) => usedColors.contains(shade.keys.first)); + }).toList(); + final availableColors = + (unusedColorTypes.isNotEmpty ? unusedColorTypes : colorTypes).flattened + .toList(); Color randomColor; do { - randomColor = randomColorShade(allColors); + randomColor = randomColorShade(availableColors); } while (usedColors.contains(randomColor)); return randomColor; @@ -155,8 +292,13 @@ class ContainerRepository extends _$ContainerRepository { Future createNewContainer() async { final initialColor = await unusedRandomContainerColor(); + final orderKey = await getTrailingContainerOrderKey(isPinned: false); - return ContainerData(id: uuid.v7(), color: initialColor); + return ContainerData( + id: uuid.v7(), + color: initialColor, + orderKey: orderKey, + ); } Future isSiteAssignedToContainer(Uri uri) async { diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/domain/repositories/container.g.dart b/apps/weblibre/lib/features/geckoview/features/tabs/domain/repositories/container.g.dart index 7bd9a643..8717e89e 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/domain/repositories/container.g.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/domain/repositories/container.g.dart @@ -42,7 +42,7 @@ final class ContainerRepositoryProvider } String _$containerRepositoryHash() => - r'55c3e897f4f42d01dab43114ef00075536b74cf7'; + r'a708f298d7bd50f1823029e00f8ed295b9f81f4e'; abstract class _$ContainerRepository extends $Notifier { void build(); diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/domain/repositories/history_search.dart b/apps/weblibre/lib/features/geckoview/features/tabs/domain/repositories/history_search.dart new file mode 100644 index 00000000..1b8a69c9 --- /dev/null +++ b/apps/weblibre/lib/features/geckoview/features/tabs/domain/repositories/history_search.dart @@ -0,0 +1,216 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart' + show HistoryMetadata; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/core/logger.dart'; +import 'package:weblibre/features/geckoview/features/history/domain/repositories/history.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/models/history_query_result.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/providers.dart'; + +part 'history_search.g.dart'; + +/// Canonical highlight markers wrapped around FTS5 snippet matches. +/// +/// `historyHighlightPrefix` and `historyHighlightSuffix` are baked into the +/// `snippet()` text returned by [HistorySearchRepository.addQuery]; the +/// search UI scans for these exact strings to convert them into styled +/// spans (see `utils/text_highlight.dart`). Producer and consumers MUST +/// reference the same constants — if the strings ever diverge, highlight +/// rendering silently breaks (no error, just plain text). +/// +/// The two are intentionally identical today (`***` on both sides). They +/// are kept as two constants so a future change can use distinct +/// open/close markers without rewriting every call site. +const historyHighlightPrefix = '***'; +const historyHighlightSuffix = '***'; + +// Tunables for the frecency-aware re-rank. Smaller `weighted_rank` is more +// relevant (BM25 convention). Frecency boosts subtract from the rank so a +// frequently-/recently-visited page floats up. + +/// Weight on log-scaled total view time (seconds). +const double _viewTimeRankWeight = 0.5; + +/// Weight on the recency decay term. +const double _recencyRankWeight = 0.5; + +/// Half-life for the recency decay (days). +const double _recencyHalfLifeDays = 14; + +/// Search the local history content index. Mirrors `TabSearchRepository`'s +/// shape so the two integrate symmetrically into the search UI. +/// +/// Visit metadata is fetched from Places at query time and folded into the +/// score before emitting state. The DAO returns rows ordered by raw BM25; +/// Places adds the frecency / recency signal we don't track locally. +@Riverpod() +class HistorySearchRepository extends _$HistorySearchRepository { + Future addQuery( + String input, { + int snippetLength = 120, + int maxResults = 25, + String matchPrefix = historyHighlightPrefix, + String matchSuffix = historyHighlightSuffix, + String ellipsis = '…', + }) async { + if (input.isEmpty) { + state = const AsyncValue.data(null); + return; + } + + final result = await AsyncValue.guard(() async { + final localHits = await ref + .read(tabDatabaseProvider) + .historyDao + .queryHistory( + searchString: input, + matchPrefix: matchPrefix, + matchSuffix: matchSuffix, + ellipsis: ellipsis, + snippetLength: snippetLength, + limit: maxResults, + ) + .get(); + + // Drop rows Places has forgotten (deleted via "Clear browsing data", + // expired by the engine's pruner, etc.). Local index can outlive + // Places' record for the same URL; this keeps the visible results + // aligned with what the engine considers "visited". + final filtered = await _filterByPlacesVisited(localHits); + + // Hydrate per-URL frecency signal from Places and re-rank in place. + final reranked = await _rerankWithPlacesMetadata(filtered); + + return (query: input, results: reranked); + }); + + if (!ref.mounted) return; + + state = result; + } + + Future> _filterByPlacesVisited( + List hits, + ) async { + if (hits.isEmpty) return hits; + final urls = hits.map((h) => h.urlCanonical).toList(growable: false); + final visited = await ref + .read(historyRepositoryProvider.notifier) + .getVisited(urls); + if (visited.length != hits.length) { + // Bridge contract violation: getVisited is supposed to return + // exactly one bool per input URL. Surface all hits rather than + // dropping them silently (better UX = the user still sees results), + // but log loudly so the regression doesn't go unnoticed in the + // wild — searches would otherwise stop being filtered against the + // engine's view of "did I visit this?" with no symptom. + logger.e( + 'PlacesHistory.getVisited contract violation: returned ' + '${visited.length} bools for ${hits.length} URLs; falling back ' + 'to unfiltered results.', + ); + return hits; + } + return [ + for (var i = 0; i < hits.length; i++) + if (visited[i]) hits[i], + ]; + } + + /// For each hit, fetches the latest Places metadata, computes a combined + /// score, and returns hits ordered ascending (smallest = most relevant). + /// Metadata is fetched in a single bulk call to keep the IPC cost flat. + /// On bridge error, falls back to the raw BM25 ordering for the whole set. + Future> _rerankWithPlacesMetadata( + List hits, + ) async { + if (hits.isEmpty) return hits; + + final urls = hits.map((h) => h.urlCanonical).toList(growable: false); + final List metadata; + try { + metadata = await ref + .read(historyRepositoryProvider.notifier) + .getLatestHistoryMetadataForUrls(urls); + } catch (e, s) { + // Places bridge failure (IPC error, etc.) — fall back to the raw + // BM25 ordering rather than blanking the search results. Log + // loudly so a transport-level regression doesn't quietly silence + // every frecency re-rank in the app. + logger.e( + 'PlacesHistory.getLatestHistoryMetadataForUrls threw; ' + 'falling back to BM25-only ordering.', + error: e, + stackTrace: s, + ); + return hits; + } + if (metadata.length != hits.length) { + logger.e( + 'PlacesHistory.getLatestHistoryMetadataForUrls contract ' + 'violation: returned ${metadata.length} entries for ' + '${hits.length} URLs; falling back to BM25-only ordering.', + ); + return hits; + } + + final now = DateTime.now(); + final scored = <(double, HistoryQueryResult)>[ + for (var i = 0; i < hits.length; i++) + (_combinedScore(hits[i], metadata[i], now), hits[i]), + ]; + scored.sort((a, b) => a.$1.compareTo(b.$1)); + return [for (final entry in scored) entry.$2]; + } + + double _combinedScore( + HistoryQueryResult hit, + HistoryMetadata? metadata, + DateTime now, + ) { + var score = hit.weightedRank; + + if (metadata != null) { + // ln(1 + viewTime_seconds): heavy reading dominates lookup-style hits. + final viewSeconds = metadata.totalViewTime / 1000.0; + score -= _viewTimeRankWeight * math.log(1 + viewSeconds); + + // Exponential recency decay anchored on Places' updatedAt — i.e. the + // last time the engine observed *anything* about this URL. + final updatedAt = DateTime.fromMillisecondsSinceEpoch(metadata.updatedAt); + final ageDays = now.difference(updatedAt).inHours / 24.0; + final decay = math.exp( + -math.max(0.0, ageDays) / _recencyHalfLifeDays, + ); + score -= _recencyRankWeight * decay; + } + + return score; + } + + @override + Future<({String query, List results})?> build() { + return Future.value(); + } +} diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/domain/repositories/history_search.g.dart b/apps/weblibre/lib/features/geckoview/features/tabs/domain/repositories/history_search.g.dart new file mode 100644 index 00000000..9daa90d2 --- /dev/null +++ b/apps/weblibre/lib/features/geckoview/features/tabs/domain/repositories/history_search.g.dart @@ -0,0 +1,94 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'history_search.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning +/// Search the local history content index. Mirrors `TabSearchRepository`'s +/// shape so the two integrate symmetrically into the search UI. +/// +/// Visit metadata is fetched from Places at query time and folded into the +/// score before emitting state. The DAO returns rows ordered by raw BM25; +/// Places adds the frecency / recency signal we don't track locally. + +@ProviderFor(HistorySearchRepository) +final historySearchRepositoryProvider = HistorySearchRepositoryProvider._(); + +/// Search the local history content index. Mirrors `TabSearchRepository`'s +/// shape so the two integrate symmetrically into the search UI. +/// +/// Visit metadata is fetched from Places at query time and folded into the +/// score before emitting state. The DAO returns rows ordered by raw BM25; +/// Places adds the frecency / recency signal we don't track locally. +final class HistorySearchRepositoryProvider + extends + $AsyncNotifierProvider< + HistorySearchRepository, + ({String query, List results})? + > { + /// Search the local history content index. Mirrors `TabSearchRepository`'s + /// shape so the two integrate symmetrically into the search UI. + /// + /// Visit metadata is fetched from Places at query time and folded into the + /// score before emitting state. The DAO returns rows ordered by raw BM25; + /// Places adds the frecency / recency signal we don't track locally. + HistorySearchRepositoryProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'historySearchRepositoryProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$historySearchRepositoryHash(); + + @$internal + @override + HistorySearchRepository create() => HistorySearchRepository(); +} + +String _$historySearchRepositoryHash() => + r'd999bfaf32268363f51a3c6831fffa1b14420b07'; + +/// Search the local history content index. Mirrors `TabSearchRepository`'s +/// shape so the two integrate symmetrically into the search UI. +/// +/// Visit metadata is fetched from Places at query time and folded into the +/// score before emitting state. The DAO returns rows ordered by raw BM25; +/// Places adds the frecency / recency signal we don't track locally. + +abstract class _$HistorySearchRepository + extends + $AsyncNotifier<({String query, List results})?> { + FutureOr<({String query, List results})?> build(); + @$mustCallSuper + @override + void runBuild() { + final ref = + this.ref + as $Ref< + AsyncValue<({String query, List results})?>, + ({String query, List results})? + >; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier< + AsyncValue<({String query, List results})?>, + ({String query, List results})? + >, + AsyncValue<({String query, List results})?>, + Object?, + Object? + >; + element.handleCreate(ref, build); + } +} diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/domain/services/local_index_pruner.dart b/apps/weblibre/lib/features/geckoview/features/tabs/domain/services/local_index_pruner.dart new file mode 100644 index 00000000..88417afb --- /dev/null +++ b/apps/weblibre/lib/features/geckoview/features/tabs/domain/services/local_index_pruner.dart @@ -0,0 +1,127 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/core/logger.dart'; +import 'package:weblibre/features/geckoview/features/history/domain/repositories/history.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/providers.dart'; + +part 'local_index_pruner.g.dart'; + +/// Drops local history rows that the engine no longer remembers (the user +/// cleared browsing data, the engine's own retention pruned them, etc.). +/// +/// Triggered manually — call `prune()` from a periodic timer (cold-start +/// once per app launch is enough). The sweep walks the whole table in batches +/// so stale rows don't get stranded behind an always-valid oldest page. +@Riverpod(keepAlive: true) +class LocalIndexPruner extends _$LocalIndexPruner { + /// Examines the whole local index in [batchSize] chunks. Anything Places + /// returns `false` for via `getVisited` is removed. Returns the total + /// number of deleted rows. + /// + /// A single batch failing (DB hiccup, Places bridge transient) is + /// logged and skipped — the sweep advances by `batchSize` so a stuck + /// page can't block later pages from being pruned. A contract + /// violation (getVisited returning a different list length than asked + /// for) stops the sweep, because under that condition we can no + /// longer trust the per-row visited bits. + Future prune({int batchSize = 200}) async { + final dao = ref.read(tabDatabaseProvider).historyDao; + var totalDeleted = 0; + var offset = 0; + + while (true) { + final List candidates; + try { + candidates = await dao.urlsPage(limit: batchSize, offset: offset); + } catch (e, s) { + logger.e( + 'Local index pruner: urlsPage failed at offset=$offset', + error: e, + stackTrace: s, + ); + return totalDeleted; + } + + if (candidates.isEmpty) { + return totalDeleted; + } + + final int deleted; + try { + final visited = await ref + .read(historyRepositoryProvider.notifier) + .getVisited(candidates); + if (visited.length != candidates.length) { + // Bridge contract violation — see history_search.dart for the + // same shape. Bail instead of advancing offset: the visited + // bits no longer line up with `candidates`. + logger.e( + 'Local index pruner: getVisited returned ${visited.length} ' + 'for ${candidates.length} URLs — stopping after deleting ' + '$totalDeleted rows', + ); + return totalDeleted; + } + + final toDelete = [ + for (var i = 0; i < candidates.length; i++) + if (!visited[i]) candidates[i], + ]; + + deleted = toDelete.isEmpty + ? 0 + : await dao.deleteByCanonicalUrls(toDelete); + totalDeleted += deleted; + } catch (e, s) { + // Transient failure for this batch (Places bridge IPC error, + // SQLite hiccup, etc.). Skip the batch and continue — advance by + // a full `batchSize` so we don't re-examine the same page on + // the next iteration. + logger.e( + 'Local index pruner: batch at offset=$offset failed; skipping', + error: e, + stackTrace: s, + ); + offset += batchSize; + continue; + } + + if (candidates.length < batchSize) { + return totalDeleted; + } + + // We processed `candidates.length` rows; `deleted` of them were + // removed, so the table shrunk by `deleted`. The next page should + // start at `offset + candidates.length - deleted` to skip exactly + // the rows we kept. SQLite re-numbers OFFSET against the + // post-delete row positions, so this lands at the first row we + // haven't seen yet. + offset += candidates.length - deleted; + + // Yield to the event loop between batches so a large sweep + // doesn't monopolise the main isolate. + await Future.delayed(Duration.zero); + } + } + + @override + void build() {} +} diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/domain/services/local_index_pruner.g.dart b/apps/weblibre/lib/features/geckoview/features/tabs/domain/services/local_index_pruner.g.dart new file mode 100644 index 00000000..6914a763 --- /dev/null +++ b/apps/weblibre/lib/features/geckoview/features/tabs/domain/services/local_index_pruner.g.dart @@ -0,0 +1,87 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'local_index_pruner.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning +/// Drops local history rows that the engine no longer remembers (the user +/// cleared browsing data, the engine's own retention pruned them, etc.). +/// +/// Triggered manually — call `prune()` from a periodic timer (cold-start +/// once per app launch is enough). The sweep walks the whole table in batches +/// so stale rows don't get stranded behind an always-valid oldest page. + +@ProviderFor(LocalIndexPruner) +final localIndexPrunerProvider = LocalIndexPrunerProvider._(); + +/// Drops local history rows that the engine no longer remembers (the user +/// cleared browsing data, the engine's own retention pruned them, etc.). +/// +/// Triggered manually — call `prune()` from a periodic timer (cold-start +/// once per app launch is enough). The sweep walks the whole table in batches +/// so stale rows don't get stranded behind an always-valid oldest page. +final class LocalIndexPrunerProvider + extends $NotifierProvider { + /// Drops local history rows that the engine no longer remembers (the user + /// cleared browsing data, the engine's own retention pruned them, etc.). + /// + /// Triggered manually — call `prune()` from a periodic timer (cold-start + /// once per app launch is enough). The sweep walks the whole table in batches + /// so stale rows don't get stranded behind an always-valid oldest page. + LocalIndexPrunerProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'localIndexPrunerProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$localIndexPrunerHash(); + + @$internal + @override + LocalIndexPruner create() => LocalIndexPruner(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(void value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$localIndexPrunerHash() => r'471b82b87fdc9e075d7afed042682c9592c8735a'; + +/// Drops local history rows that the engine no longer remembers (the user +/// cleared browsing data, the engine's own retention pruned them, etc.). +/// +/// Triggered manually — call `prune()` from a periodic timer (cold-start +/// once per app launch is enough). The sweep walks the whole table in batches +/// so stale rows don't get stranded behind an always-valid oldest page. + +abstract class _$LocalIndexPruner extends $Notifier { + void build(); + @$mustCallSuper + @override + void runBuild() { + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + void, + Object?, + Object? + >; + element.handleCreate(ref, build); + } +} diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/domain/services/local_index_settings_sync.dart b/apps/weblibre/lib/features/geckoview/features/tabs/domain/services/local_index_settings_sync.dart new file mode 100644 index 00000000..f1031994 --- /dev/null +++ b/apps/weblibre/lib/features/geckoview/features/tabs/domain/services/local_index_settings_sync.dart @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'dart:async'; + +// `riverpod/riverpod.dart` is what carries the `.select` extension on +// providers; `riverpod_annotation` re-exports `Ref` but not the +// ProviderListenable extensions, so this import isn't redundant despite +// the unused-import lint's opinion. +import 'package:riverpod/riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:synchronized/synchronized.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/providers.dart'; +import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; + +part 'local_index_settings_sync.g.dart'; + +/// Setting keys in `local_index_setting`. Kept as constants here because +/// the SQL triggers in `definitions.drift` reference the same literal +/// strings (`'enabled'`, `'index_private'`) — a divergence between the +/// Dart writer and the SQL reader would silently disable the trigger +/// gate. +const _kEnabledKey = 'enabled'; +const _kIndexPrivateKey = 'index_private'; + +/// Mirrors `enableLocalSearchIndex` / `indexPrivateTabs` from user.db +/// settings into tab.db's `local_index_setting` rows. The trigger reads +/// from `local_index_setting` exclusively, so this provider is the bridge +/// keeping both in sync. +/// +/// Scheduled as a `keepAlive` provider that's read at app start +/// (`main.dart`) so the listener is wired before the first tab event +/// reaches the database. +@Riverpod(keepAlive: true) +class LocalIndexSettingsSync extends _$LocalIndexSettingsSync { + /// Serializes the two-row upsert per settings change. Without this, two + /// rapid setting flips could interleave their `enabled` / `index_private` + /// writes such that the final state on disk doesn't match the user's + /// last click. The lock is fine-grained per provider instance. + final _writeLock = Lock(); + + Future _push({ + required bool enabled, + required bool indexPrivate, + }) => _writeLock.synchronized(() async { + final dao = ref.read(tabDatabaseProvider).historyDao; + await dao.upsertSetting(_kEnabledKey, enabled); + await dao.upsertSetting(_kIndexPrivateKey, indexPrivate); + }); + + @override + void build() { + ref.listen( + generalSettingsWithDefaultsProvider.select( + (s) => ( + enabled: s.enableLocalSearchIndex, + indexPrivate: s.indexPrivateTabs, + ), + ), + (previous, next) { + if (previous == next) return; + // Fire-and-forget: `_writeLock` ensures the second flip queues + // behind the first instead of racing it. + unawaited(_push(enabled: next.enabled, indexPrivate: next.indexPrivate)); + }, + fireImmediately: true, + ); + } +} diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/domain/services/local_index_settings_sync.g.dart b/apps/weblibre/lib/features/geckoview/features/tabs/domain/services/local_index_settings_sync.g.dart new file mode 100644 index 00000000..91db9def --- /dev/null +++ b/apps/weblibre/lib/features/geckoview/features/tabs/domain/services/local_index_settings_sync.g.dart @@ -0,0 +1,96 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'local_index_settings_sync.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning +/// Mirrors `enableLocalSearchIndex` / `indexPrivateTabs` from user.db +/// settings into tab.db's `local_index_setting` rows. The trigger reads +/// from `local_index_setting` exclusively, so this provider is the bridge +/// keeping both in sync. +/// +/// Scheduled as a `keepAlive` provider that's read at app start +/// (`main.dart`) so the listener is wired before the first tab event +/// reaches the database. + +@ProviderFor(LocalIndexSettingsSync) +final localIndexSettingsSyncProvider = LocalIndexSettingsSyncProvider._(); + +/// Mirrors `enableLocalSearchIndex` / `indexPrivateTabs` from user.db +/// settings into tab.db's `local_index_setting` rows. The trigger reads +/// from `local_index_setting` exclusively, so this provider is the bridge +/// keeping both in sync. +/// +/// Scheduled as a `keepAlive` provider that's read at app start +/// (`main.dart`) so the listener is wired before the first tab event +/// reaches the database. +final class LocalIndexSettingsSyncProvider + extends $NotifierProvider { + /// Mirrors `enableLocalSearchIndex` / `indexPrivateTabs` from user.db + /// settings into tab.db's `local_index_setting` rows. The trigger reads + /// from `local_index_setting` exclusively, so this provider is the bridge + /// keeping both in sync. + /// + /// Scheduled as a `keepAlive` provider that's read at app start + /// (`main.dart`) so the listener is wired before the first tab event + /// reaches the database. + LocalIndexSettingsSyncProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'localIndexSettingsSyncProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$localIndexSettingsSyncHash(); + + @$internal + @override + LocalIndexSettingsSync create() => LocalIndexSettingsSync(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(void value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$localIndexSettingsSyncHash() => + r'f1e1878741020de80640da72905fce4e56d13789'; + +/// Mirrors `enableLocalSearchIndex` / `indexPrivateTabs` from user.db +/// settings into tab.db's `local_index_setting` rows. The trigger reads +/// from `local_index_setting` exclusively, so this provider is the bridge +/// keeping both in sync. +/// +/// Scheduled as a `keepAlive` provider that's read at app start +/// (`main.dart`) so the listener is wired before the first tab event +/// reaches the database. + +abstract class _$LocalIndexSettingsSync extends $Notifier { + void build(); + @$mustCallSuper + @override + void runBuild() { + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + void, + Object?, + Object? + >; + element.handleCreate(ref, build); + } +} diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/presentation/screens/container_edit.dart b/apps/weblibre/lib/features/geckoview/features/tabs/presentation/screens/container_edit.dart index 00bfc1af..8c73c3b3 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/presentation/screens/container_edit.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/presentation/screens/container_edit.dart @@ -31,8 +31,10 @@ import 'package:weblibre/features/geckoview/features/tabs/presentation/controlle import 'package:weblibre/features/geckoview/features/tabs/presentation/dialogs/delete_container_dialog.dart'; import 'package:weblibre/features/geckoview/features/tabs/presentation/dialogs/discard_changes_dialog.dart'; import 'package:weblibre/features/geckoview/features/tabs/presentation/screens/container_sites.dart'; +import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/container_icon_picker_sheet.dart'; import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/color_picker_dialog.dart'; import 'package:weblibre/features/geckoview/features/tabs/utils/container_colors.dart'; +import 'package:weblibre/features/geckoview/features/tabs/utils/container_icons.dart'; import 'package:weblibre/presentation/icons/tor_icons.dart'; enum _DialogMode { create, edit } @@ -72,7 +74,11 @@ class ContainerEditScreen extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final disableAnimations = MediaQuery.disableAnimationsOf(context); + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final selectedColor = useState(initialContainer.color); + final selectedIcon = useState(initialContainer.metadata.iconData); final contextualIdentity = useState( initialContainer.metadata.contextualIdentity, ); @@ -92,6 +98,7 @@ class ContainerEditScreen extends HookConsumerWidget { color: selectedColor.value, metadata: initialContainer.metadata.copyWith( contextualIdentity: contextualIdentity.value, + iconData: selectedIcon.value, useProxy: useProxy.value && contextualIdentity.value != null, clearDataOnExit: clearDataOnExit.value && contextualIdentity.value != null, @@ -115,9 +122,101 @@ class ContainerEditScreen extends HookConsumerWidget { return container; } + Future saveAndClose() async { + final container = await saveContainer(); + if (context.mounted) { + context.pop(container); + } + } + + Future openColorPicker() async { + final color = await showDialog( + context: context, + builder: (context) => ColorPickerDialog(selectedColor.value), + ); + + if (color != null) { + selectedColor.value = color; + } + } + + Future openIconPicker() async { + final icon = await showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + builder: (context) => FractionallySizedBox( + heightFactor: 0.92, + child: ContainerIconPickerSheet( + selectedColor: selectedColor.value, + selectedIcon: resolveContainerIcon(selectedIcon.value), + onSelected: (iconData) => Navigator.of(context).pop(iconData), + ), + ), + ); + + if (icon != null) { + selectedIcon.value = icon; + } + } + + Future openAppearanceMenu() async { + await showModalBottomSheet( + context: context, + useSafeArea: true, + builder: (context) { + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 8), + ListTile( + leading: const Icon(Icons.palette_outlined), + title: const Text('Change Color'), + onTap: () { + Navigator.of(context).pop(); + openColorPicker(); + }, + ), + ListTile( + leading: Icon(resolveContainerIcon(selectedIcon.value)), + title: const Text('Change Icon'), + onTap: () { + Navigator.of(context).pop(); + openIconPicker(); + }, + ), + const SizedBox(height: 8), + ], + ), + ); + }, + ); + } + + Future deleteContainer() async { + final result = await showDeleteContainerDialog(context); + + if (result == true) { + await ref + .read(containerRepositoryProvider.notifier) + .deleteContainer(initialContainer.id); + + if (context.mounted) { + context.pop(); + } + } + } + final container = buildContainer(); //Empty copy to create comparable container with same type final comparison = initialContainer.copyWith(); + final previewIcon = resolveContainerIcon(selectedIcon.value); + final previewPalette = ContainerColors.palette( + context, + selectedColor.value, + ); + final assignedSiteCount = assignedSites.value?.length ?? 0; return PopScope( canPop: container == comparison, @@ -133,10 +232,7 @@ class ContainerEditScreen extends HookConsumerWidget { context.pop(); } case DiscardChangesChoice.save: - final container = await saveContainer(); - if (context.mounted) { - context.pop(container); - } + await saveAndClose(); } }, child: Scaffold( @@ -146,180 +242,238 @@ class ContainerEditScreen extends HookConsumerWidget { _DialogMode.edit => 'Edit Container', }), actions: [ - IconButton( - onPressed: () async { - final container = await saveContainer(); - if (context.mounted) { - context.pop(container); - } - }, - icon: const Icon(Icons.check), - ), + IconButton(onPressed: saveAndClose, icon: const Icon(Icons.check)), ], ), - body: SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Column( - children: [ - Expanded( - child: ListView( - children: [ - TextField( - decoration: InputDecoration( - prefixIcon: Padding( - padding: const EdgeInsets.all(10.0), - child: AnimatedContainer( - duration: disableAnimations - ? Duration.zero - : const Duration(milliseconds: 300), - height: 24, - width: 24, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: ContainerColors.preview( - selectedColor.value, + body: Column( + children: [ + Expanded( + child: ListView( + padding: const EdgeInsets.all(20), + children: [ + Card.filled( + margin: EdgeInsets.zero, + color: colorScheme.surfaceContainer, + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + GestureDetector( + onTap: openAppearanceMenu, + child: Stack( + alignment: Alignment.bottomRight, + children: [ + AnimatedContainer( + duration: disableAnimations + ? Duration.zero + : const Duration(milliseconds: 200), + curve: Curves.easeInOut, + width: 72, + height: 72, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: previewPalette.avatarBackgroundColor, + border: Border.all( + color: previewPalette.outlineColor, + width: 2, + ), + ), + child: Icon( + previewIcon, + color: previewPalette.avatarForegroundColor, + size: 34, + ), + ), + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: colorScheme.primary, + shape: BoxShape.circle, + border: Border.all( + color: theme.scaffoldBackgroundColor, + width: 2, + ), + ), + child: Icon( + Icons.edit, + size: 14, + color: colorScheme.onPrimary, + ), + ), + ], + ), + ), + const SizedBox(width: 20), + Expanded( + child: TextField( + controller: textController, + style: theme.textTheme.titleLarge, + decoration: InputDecoration( + labelText: 'Container Name', + filled: true, + fillColor: colorScheme.surfaceContainerHighest + .withValues(alpha: 0.6), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide.none, + ), + suffixIcon: _buildMagicWandButton( + context, + ref, + textController, ), ), ), ), - label: const Text('Name'), - suffixIcon: _buildMagicWandButton( - context, - ref, - textController, - ), - ), - controller: textController, + ], ), - TextButton.icon( - label: const Text('Select Color'), - icon: const Icon(Icons.colorize), - onPressed: () async { - final color = await showDialog( - context: context, - builder: (context) => - ColorPickerDialog(selectedColor.value), - ); - - if (color != null) { - selectedColor.value = color; - } - }, - ), - SwitchListTile.adaptive( - value: contextualIdentity.value != null, - title: const Text('Cookie Isolation'), - secondary: const Icon(MdiIcons.cookieLock), - contentPadding: EdgeInsets.zero, - onChanged: (_mode == _DialogMode.create) - ? (value) { - contextualIdentity.value = value - ? initialContainer - .metadata - .contextualIdentity ?? - uuid.v4() - : null; - - if (!value && useProxy.value) { - useProxy.value = false; - } - } - : null, - ), - SwitchListTile.adaptive( - value: useProxy.value, - title: const Text('Use Tor™ Proxy'), - secondary: const Icon(TorIcons.onionAlt), - contentPadding: EdgeInsets.zero, - onChanged: switch (_mode) { - _DialogMode.create => (value) { - if (value && contextualIdentity.value == null) { - contextualIdentity.value = - initialContainer - .metadata - .contextualIdentity ?? - uuid.v4(); - } - - useProxy.value = value; - }, - _DialogMode.edit => - (contextualIdentity.value != null) - ? (value) { - useProxy.value = value; - } - : null, - }, - ), - SwitchListTile.adaptive( - value: clearDataOnExit.value, - title: const Text('Clear Data on Exit'), - subtitle: const Text( - 'Clear cookies and site data when app closes', - ), - secondary: const Icon(MdiIcons.databaseRemove), - contentPadding: EdgeInsets.zero, - onChanged: (contextualIdentity.value != null) - ? (value) { - clearDataOnExit.value = value; - } - : null, - ), - ListTile( - leading: const Icon(Icons.web), - title: const Text('Assigned Sites'), - trailing: const Icon(Icons.chevron_right), - contentPadding: EdgeInsets.zero, - onTap: () async { - final result = await showDialog>( - context: context, - builder: (context) => ContainerSitesScreen( - initialSites: assignedSites.value?.toSet() ?? {}, - ), - ); - - if (result.isEmpty) { - assignedSites.value = null; - } else { - assignedSites.value = result!.toList(); - } - }, - ), - ], + ), ), - ), - if (_mode == _DialogMode.edit) - SizedBox( - width: double.infinity, - child: OutlinedButton.icon( - style: OutlinedButton.styleFrom( - side: BorderSide( - color: Theme.of(context).colorScheme.error, + const SizedBox(height: 28), + Text( + 'Privacy & Security', + style: theme.textTheme.titleSmall?.copyWith( + color: colorScheme.primary, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 12), + Card.filled( + margin: EdgeInsets.zero, + color: colorScheme.surfaceContainer, + clipBehavior: Clip.antiAlias, + child: Column( + children: [ + SwitchListTile.adaptive( + value: contextualIdentity.value != null, + title: const Text('Cookie Isolation'), + secondary: const Icon(MdiIcons.cookieLock), + onChanged: (_mode == _DialogMode.create) + ? (value) { + contextualIdentity.value = value + ? initialContainer + .metadata + .contextualIdentity ?? + uuid.v4() + : null; + + if (!value && useProxy.value) { + useProxy.value = false; + } + + if (!value && clearDataOnExit.value) { + clearDataOnExit.value = false; + } + } + : null, ), - foregroundColor: Theme.of(context).colorScheme.error, - iconColor: Theme.of(context).colorScheme.error, - ), - label: const Text('Delete'), - icon: const Icon(Icons.delete), - onPressed: () async { - final result = await showDeleteContainerDialog(context); + const Divider(height: 1, indent: 56), + SwitchListTile.adaptive( + value: useProxy.value, + title: const Text('Use Tor™ Proxy'), + secondary: const Icon(TorIcons.onionAlt), + onChanged: switch (_mode) { + _DialogMode.create => (value) { + if (value && contextualIdentity.value == null) { + contextualIdentity.value = + initialContainer + .metadata + .contextualIdentity ?? + uuid.v4(); + } - if (result == true) { - await ref - .read(containerRepositoryProvider.notifier) - .deleteContainer(initialContainer.id); + useProxy.value = value; + }, + _DialogMode.edit => + (contextualIdentity.value != null) + ? (value) { + useProxy.value = value; + } + : null, + }, + ), + const Divider(height: 1, indent: 56), + SwitchListTile.adaptive( + value: clearDataOnExit.value, + title: const Text('Clear Data on Exit'), + subtitle: const Text( + 'Clear cookies and site data when app closes', + ), + secondary: const Icon(MdiIcons.databaseRemove), + onChanged: (contextualIdentity.value != null) + ? (value) { + clearDataOnExit.value = value; + } + : null, + ), + ], + ), + ), + const SizedBox(height: 24), + Text( + 'Assignments', + style: theme.textTheme.titleSmall?.copyWith( + color: colorScheme.primary, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 12), + Card.filled( + margin: EdgeInsets.zero, + color: colorScheme.surfaceContainer, + clipBehavior: Clip.antiAlias, + child: ListTile( + leading: const Icon(Icons.web), + title: const Text('Assigned Sites'), + subtitle: assignedSiteCount > 0 + ? Text( + '$assignedSiteCount ${assignedSiteCount == 1 ? 'rule' : 'rules'} configured', + ) + : const Text( + 'Route matching origins into this container', + ), + trailing: const Icon(Icons.chevron_right), + onTap: () async { + final result = await showDialog>( + context: context, + builder: (context) => ContainerSitesScreen( + initialSites: assignedSites.value?.toSet() ?? {}, + ), + ); - if (context.mounted) { - context.pop(); - } + if (result == null || result.isEmpty) { + assignedSites.value = null; + } else { + assignedSites.value = result.toList(); } }, ), ), - ], + ], + ), ), - ), + if (_mode == _DialogMode.edit) + SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 20), + child: SizedBox( + width: double.infinity, + height: 52, + child: FilledButton.tonalIcon( + onPressed: deleteContainer, + icon: const Icon(Icons.delete_outline), + label: const Text('Delete Container'), + style: FilledButton.styleFrom( + backgroundColor: colorScheme.errorContainer, + foregroundColor: colorScheme.onErrorContainer, + ), + ), + ), + ), + ), + ], ), ), ); diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/presentation/screens/container_list.dart b/apps/weblibre/lib/features/geckoview/features/tabs/presentation/screens/container_list.dart index 369dddcf..889f706b 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/presentation/screens/container_list.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/presentation/screens/container_list.dart @@ -17,11 +17,10 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ +import 'dart:async'; import 'dart:convert'; -import 'package:fading_scroll/fading_scroll.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_slidable/flutter_slidable.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:skeletonizer/skeletonizer.dart'; import 'package:weblibre/core/routing/routes.dart'; @@ -29,7 +28,9 @@ import 'package:weblibre/features/geckoview/features/tabs/data/models/container_ import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selected_container.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart'; -import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/container_list_tile.dart'; +import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/container_title.dart'; +import 'package:weblibre/features/geckoview/features/tabs/utils/container_colors.dart'; +import 'package:weblibre/features/geckoview/features/tabs/utils/container_icons.dart'; import 'package:weblibre/features/tor/presentation/controllers/start_tor_proxy.dart'; import 'package:weblibre/features/tor/presentation/widgets/tor_dialog.dart'; import 'package:weblibre/presentation/widgets/failure_widget.dart'; @@ -41,137 +42,118 @@ class ContainerListScreen extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final containersAsync = ref.watch(watchContainersWithCountProvider); final selectedContainer = ref.watch(selectedContainerProvider); + final repository = ref.watch(containerRepositoryProvider.notifier); - return Scaffold( - appBar: AppBar(title: const Text('Containers')), - body: SafeArea( - child: Skeletonizer( - enabled: containersAsync.isLoading, - child: containersAsync.when( - skipLoadingOnReload: true, - data: (containers) => FadingScroll( - fadingSize: 25, - builder: (context, controller) { - return ListView.builder( - controller: controller, - itemCount: containers.length, - itemBuilder: (context, index) { - final container = containers[index]; - return Slidable( - key: ValueKey(container.id), - startActionPane: ActionPane( - motion: const ScrollMotion(), - children: [ - if (container.id != selectedContainer) - SlidableAction( - onPressed: (context) async { - final result = await ref - .read(selectedContainerProvider.notifier) - .setContainerId(container.id); + Future setSelectedContainer(ContainerDataWithCount container) async { + final result = await ref + .read(selectedContainerProvider.notifier) + .setContainerId(container.id); - if (context.mounted && - result == - SetContainerResult.successHasProxy) { - final shouldStartProxy = await ref - .read( - startProxyControllerProvider.notifier, - ) - .shouldPromptProxyStart(); + if (context.mounted && result == SetContainerResult.successHasProxy) { + final shouldStartProxy = await ref + .read(startProxyControllerProvider.notifier) + .shouldPromptProxyStart(); - if (!context.mounted || !shouldStartProxy) { - return; - } + if (!context.mounted || !shouldStartProxy) { + return; + } - final dialogResult = await showDialog( - context: context, - builder: (context) { - return const TorDialog(); - }, - ); + final dialogResult = await showDialog( + context: context, + builder: (context) { + return const TorDialog(); + }, + ); - if (dialogResult == true) { - await ref - .read( - startProxyControllerProvider.notifier, - ) - .startProxy(); - } - } - }, - foregroundColor: Theme.of( - context, - ).colorScheme.onPrimaryContainer, - backgroundColor: Theme.of( - context, - ).colorScheme.primaryContainer, - icon: Icons.check, - label: 'Select', - ) - else - SlidableAction( - onPressed: (context) { - ref - .read(selectedContainerProvider.notifier) - .clearContainer(); - }, - foregroundColor: Theme.of( - context, - ).colorScheme.onPrimaryContainer, - backgroundColor: Theme.of( - context, - ).colorScheme.primaryContainer, - icon: Icons.close, - label: 'Unselect', - ), - ], - ), - endActionPane: ActionPane( - motion: const ScrollMotion(), - children: [ - SlidableAction( - onPressed: (context) async { - await ref - .read(containerRepositoryProvider.notifier) - .deleteContainer(container.id); - }, - backgroundColor: Theme.of( - context, - ).colorScheme.errorContainer, - foregroundColor: Theme.of( - context, - ).colorScheme.onErrorContainer, - icon: Icons.delete, - label: 'Delete', - ), - ], - ), - child: ContainerListTile( - container, - isSelected: container.id == selectedContainer, - onTap: () async { - await ContainerEditRoute( - containerData: jsonEncode(container.toJson()), - ).push(context); - }, - ), - ); - }, - ); - }, - ), - error: (error, stackTrace) => Center( - child: FailureWidget( - title: 'Failed to load containers', - exception: error, - onRetry: () => ref.invalidate(watchContainersWithCountProvider), + if (dialogResult == true) { + await ref.read(startProxyControllerProvider.notifier).startProxy(); + } + } + } + + Future editContainer(ContainerDataWithCount container) async { + await ContainerEditRoute( + containerData: jsonEncode(container.toJson()), + ).push(context); + } + + Widget buildList(List containers) { + return CustomScrollView( + slivers: [ + const SliverAppBar.large(title: Text('Containers')), + if (containers.isEmpty) + SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Text( + 'No containers yet.', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + ), + ) + else + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 96), + sliver: SliverReorderableList( + itemCount: containers.length, + onReorder: (oldIndex, newIndex) { + unawaited( + repository.reorderContainer(containers, oldIndex, newIndex), + ); + }, + itemBuilder: (context, index) { + final container = containers[index]; + final isSelected = container.id == selectedContainer; + return Padding( + key: ValueKey(container.id), + padding: const EdgeInsets.only(bottom: 12), + child: _ContainerCard( + container: container, + index: index, + isSelected: isSelected, + onTap: () => editContainer(container), + onSelect: isSelected + ? () => ref + .read(selectedContainerProvider.notifier) + .clearContainer() + : () => setSelectedContainer(container), + // onDelete: () => repository.deleteContainer(container.id), + ), + ); + }, ), ), - loading: () => ListView.builder( - itemCount: 3, - itemBuilder: (context, index) => ContainerListTile( - ContainerData(id: 'null', color: Colors.transparent), - onTap: null, - isSelected: false, + ], + ); + } + + return Scaffold( + body: Skeletonizer( + enabled: containersAsync.isLoading, + child: containersAsync.when( + skipLoadingOnReload: true, + data: buildList, + error: (error, stackTrace) => Center( + child: FailureWidget( + title: 'Failed to load containers', + exception: error, + onRetry: () => ref.invalidate(watchContainersWithCountProvider), + ), + ), + loading: () => buildList( + List.generate( + 3, + (index) => ContainerDataWithCount( + id: 'loading-$index', + name: 'Container', + color: Colors.transparent, + orderKey: '', + tabCount: 0, ), ), ), @@ -195,3 +177,186 @@ class ContainerListScreen extends HookConsumerWidget { ); } } + +class _ContainerCard extends HookConsumerWidget { + const _ContainerCard({ + required this.container, + required this.index, + required this.isSelected, + required this.onTap, + required this.onSelect, + this.onDelete, + }); + + final ContainerDataWithCount container; + final int index; + final bool isSelected; + final VoidCallback onTap; + final VoidCallback onSelect; + final VoidCallback? onDelete; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final containerColor = container.color; + final tabCount = container.tabCount ?? 0; + final palette = ContainerColors.palette(context, containerColor); + + return AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + decoration: BoxDecoration( + color: isSelected + ? palette.surfaceHighColor + : colorScheme.surfaceContainer, + borderRadius: BorderRadius.circular(20), + border: isSelected + ? Border.fromBorderSide(palette.borderSide) + : Border.all( + color: colorScheme.outlineVariant.withValues(alpha: 0.4), + ), + ), + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(20), + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CircleAvatar( + radius: 22, + backgroundColor: palette.avatarBackgroundColor, + foregroundColor: palette.avatarForegroundColor, + child: Icon( + resolveContainerIcon(container.metadata.iconData), + size: 22, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DefaultTextStyle.merge( + style: theme.textTheme.titleMedium?.copyWith( + color: colorScheme.onSurface, + fontWeight: FontWeight.w600, + ), + child: ContainerTitle(container: container), + ), + const SizedBox(height: 6), + Wrap( + spacing: 8, + runSpacing: 6, + children: [ + _ContainerInfoChip( + icon: Icons.tab_outlined, + label: + '$tabCount ${tabCount == 1 ? 'tab' : 'tabs'}', + ), + if (container.metadata.contextualIdentity != null) + const _ContainerInfoChip( + icon: Icons.cookie_outlined, + label: 'Isolated', + ), + if (container.metadata.useProxy) + const _ContainerInfoChip( + icon: Icons.route_outlined, + label: 'Proxy', + ), + if (container.metadata.clearDataOnExit) + const _ContainerInfoChip( + icon: Icons.cleaning_services_outlined, + label: 'Clear on exit', + ), + ], + ), + ], + ), + ), + ReorderableDragStartListener( + index: index, + child: Padding( + padding: const EdgeInsets.only(left: 8, bottom: 8), + child: Icon( + Icons.drag_indicator, + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + const SizedBox(height: 14), + Row( + children: [ + if (isSelected) + Chip( + avatar: Icon( + Icons.check, + size: 16, + color: palette.onContainerColor, + ), + label: const Text('Active'), + side: BorderSide.none, + visualDensity: VisualDensity.compact, + backgroundColor: palette.containerColor, + labelStyle: TextStyle(color: palette.onContainerColor), + ) + else + const SizedBox.shrink(), + const Spacer(), + if (onDelete != null) ...[ + IconButton( + tooltip: 'Delete', + color: colorScheme.error, + onPressed: onDelete, + icon: const Icon(Icons.delete_outline), + ), + const SizedBox(width: 4), + ], + FilledButton.tonalIcon( + onPressed: onSelect, + icon: Icon(isSelected ? Icons.close : Icons.check), + label: Text(isSelected ? 'Unselect' : 'Select'), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } +} + +class _ContainerInfoChip extends StatelessWidget { + const _ContainerInfoChip({required this.icon, required this.label}); + + final IconData icon; + final String label; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return IconTheme.merge( + data: IconThemeData(size: 14, color: theme.colorScheme.onSurfaceVariant), + child: DefaultTextStyle.merge( + style: theme.textTheme.labelMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [Icon(icon), const SizedBox(width: 4), Text(label)], + ), + ), + ); + } +} diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/presentation/screens/container_selection.dart b/apps/weblibre/lib/features/geckoview/features/tabs/presentation/screens/container_selection.dart index 503ea39d..530f9f6e 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/presentation/screens/container_selection.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/presentation/screens/container_selection.dart @@ -19,7 +19,6 @@ */ import 'dart:convert'; -import 'package:fading_scroll/fading_scroll.dart'; import 'package:flutter/material.dart'; import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; import 'package:go_router/go_router.dart'; @@ -32,7 +31,9 @@ import 'package:weblibre/features/geckoview/features/tabs/domain/entities/contai import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selected_container.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart'; -import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/container_list_tile.dart'; +import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/container_title.dart'; +import 'package:weblibre/features/geckoview/features/tabs/utils/container_colors.dart'; +import 'package:weblibre/features/geckoview/features/tabs/utils/container_icons.dart'; import 'package:weblibre/presentation/widgets/failure_widget.dart'; class ContainerSelectionScreen extends HookConsumerWidget { @@ -43,76 +44,70 @@ class ContainerSelectionScreen extends HookConsumerWidget { final containersAsync = ref.watch(watchContainersWithCountProvider); final selectedContainerId = ref.watch(selectedContainerProvider); - return Scaffold( - appBar: AppBar(title: const Text('Select Container')), - body: SafeArea( - child: Skeletonizer( - enabled: containersAsync.isLoading, - child: containersAsync.when( - skipLoadingOnReload: true, - data: (containers) => FadingScroll( - fadingSize: 25, - builder: (context, controller) { - return ListView.builder( - controller: controller, - itemCount: containers.length + 1, - itemBuilder: (context, index) { - if (index == 0) { - return ListTileTheme( - selectedColor: Theme.of( - context, - ).colorScheme.onPrimaryContainer, - selectedTileColor: Theme.of( - context, - ).colorScheme.primaryContainer, - child: ListTile( - selected: selectedContainerId == null, - leading: CircleAvatar( - backgroundColor: Theme.of( - context, - ).colorScheme.surfaceContainerHighest, - child: const Icon(MdiIcons.folderHidden), - ), - title: const Text('Unassigned'), - onTap: () { - context.pop( - const ContainerSelectionResult.unassigned(), - ); - }, - ), - ); - } - - final container = containers[index - 1]; - return ContainerListTile( - container, - isSelected: container.id == selectedContainerId, + Widget buildList(List containers) { + return CustomScrollView( + slivers: [ + const SliverAppBar.large(title: Text('Select Container')), + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 96), + sliver: SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + if (index == 0) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: _UnassignedSelectionCard( + isSelected: selectedContainerId == null, onTap: () { context.pop( - ContainerSelectionResult.selected(container.id), + const ContainerSelectionResult.unassigned(), ); }, - ); - }, + ), + ); + } + + final container = containers[index - 1]; + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: _SelectionContainerCard( + container: container, + isSelected: container.id == selectedContainerId, + onTap: () { + context.pop( + ContainerSelectionResult.selected(container.id), + ); + }, + ), ); - }, + }, childCount: containers.length + 1), ), - error: (error, stackTrace) => Center( - child: FailureWidget( - title: 'Failed to load containers', - exception: error, - onRetry: () => ref.invalidate(watchContainersWithCountProvider), - ), + ), + ], + ); + } + + return Scaffold( + body: Skeletonizer( + enabled: containersAsync.isLoading, + child: containersAsync.when( + skipLoadingOnReload: true, + data: buildList, + error: (error, stackTrace) => Center( + child: FailureWidget( + title: 'Failed to load containers', + exception: error, + onRetry: () => ref.invalidate(watchContainersWithCountProvider), ), - loading: () => ListView.builder( - itemCount: 3, - itemBuilder: (context, index) => ContainerListTile( - ContainerData( - id: Namespace.nil.value, - color: Colors.transparent, - ), - isSelected: false, - onTap: null, + ), + loading: () => buildList( + List.generate( + 3, + (index) => ContainerDataWithCount( + id: Namespace.nil.value, + name: 'Container', + color: Colors.transparent, + orderKey: '', + tabCount: 0, ), ), ), @@ -136,3 +131,240 @@ class ContainerSelectionScreen extends HookConsumerWidget { ); } } + +class _UnassignedSelectionCard extends StatelessWidget { + const _UnassignedSelectionCard({ + required this.isSelected, + required this.onTap, + }); + + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final accentColor = colorScheme.primary; + final palette = ContainerColors.palette(context, accentColor); + + return AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + decoration: BoxDecoration( + color: isSelected + ? palette.surfaceHighColor + : colorScheme.surfaceContainer, + borderRadius: BorderRadius.circular(20), + border: isSelected + ? Border.fromBorderSide(palette.borderSide) + : Border.all( + color: colorScheme.outlineVariant.withValues(alpha: 0.4), + ), + ), + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(20), + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + CircleAvatar( + radius: 22, + backgroundColor: colorScheme.surfaceContainerHighest, + foregroundColor: colorScheme.onSurfaceVariant, + child: const Icon(MdiIcons.folderHidden, size: 22), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Unassigned', + style: Theme.of(context).textTheme.titleMedium + ?.copyWith( + color: colorScheme.onSurface, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 6), + Text( + 'Tabs without a container', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + if (isSelected) + Chip( + avatar: Icon( + Icons.check, + size: 16, + color: palette.onContainerColor, + ), + label: const Text('Active'), + side: BorderSide.none, + visualDensity: VisualDensity.compact, + backgroundColor: palette.containerColor, + labelStyle: TextStyle(color: palette.onContainerColor), + ), + ], + ), + ), + ), + ), + ); + } +} + +class _SelectionContainerCard extends StatelessWidget { + const _SelectionContainerCard({ + required this.container, + required this.isSelected, + required this.onTap, + }); + + final ContainerDataWithCount container; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final containerColor = container.color; + final tabCount = container.tabCount ?? 0; + final palette = ContainerColors.palette(context, containerColor); + + return AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + decoration: BoxDecoration( + color: isSelected + ? palette.surfaceHighColor + : colorScheme.surfaceContainer, + borderRadius: BorderRadius.circular(20), + border: isSelected + ? Border.fromBorderSide(palette.borderSide) + : Border.all( + color: colorScheme.outlineVariant.withValues(alpha: 0.4), + ), + ), + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(20), + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CircleAvatar( + radius: 22, + backgroundColor: palette.avatarBackgroundColor, + foregroundColor: palette.avatarForegroundColor, + child: Icon( + resolveContainerIcon(container.metadata.iconData), + size: 22, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DefaultTextStyle.merge( + style: theme.textTheme.titleMedium?.copyWith( + color: colorScheme.onSurface, + fontWeight: FontWeight.w600, + ), + child: IconTheme.merge( + data: IconThemeData(color: colorScheme.onSurface), + child: ContainerTitle(container: container), + ), + ), + const SizedBox(height: 6), + Wrap( + spacing: 8, + runSpacing: 6, + children: [ + _SelectionInfoChip( + icon: Icons.tab_outlined, + label: + '$tabCount ${tabCount == 1 ? 'tab' : 'tabs'}', + ), + if (container.metadata.contextualIdentity != null) + const _SelectionInfoChip( + icon: Icons.cookie_outlined, + label: 'Isolated', + ), + if (container.metadata.useProxy) + const _SelectionInfoChip( + icon: Icons.route_outlined, + label: 'Proxy', + ), + if (container.metadata.clearDataOnExit) + const _SelectionInfoChip( + icon: Icons.cleaning_services_outlined, + label: 'Clear on exit', + ), + ], + ), + ], + ), + ), + if (isSelected) + Chip( + avatar: Icon( + Icons.check, + size: 16, + color: palette.onContainerColor, + ), + label: const Text('Active'), + side: BorderSide.none, + visualDensity: VisualDensity.compact, + backgroundColor: palette.containerColor, + labelStyle: TextStyle(color: palette.onContainerColor), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } +} + +class _SelectionInfoChip extends StatelessWidget { + const _SelectionInfoChip({required this.icon, required this.label}); + + final IconData icon; + final String label; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return IconTheme.merge( + data: IconThemeData(size: 14, color: theme.colorScheme.onSurfaceVariant), + child: DefaultTextStyle.merge( + style: theme.textTheme.labelMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [Icon(icon), const SizedBox(width: 4), Text(label)], + ), + ), + ); + } +} diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/presentation/widgets/color_picker_dialog.dart b/apps/weblibre/lib/features/geckoview/features/tabs/presentation/widgets/color_picker_dialog.dart index 3116492a..82c0ca67 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/presentation/widgets/color_picker_dialog.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/presentation/widgets/color_picker_dialog.dart @@ -48,7 +48,9 @@ class ColorPickerDialog extends HookWidget { onColorChanged: (value) { selectedColor.value = value; }, - displayAlpha: ContainerColors.defaultAlpha, + displayColorBuilder: (context, color) { + return ContainerColors.palette(context, color).containerColor; + }, ), actions: [ TextButton( diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/presentation/widgets/compact_container_selector.dart b/apps/weblibre/lib/features/geckoview/features/tabs/presentation/widgets/compact_container_selector.dart index 801ce118..cccdd1d7 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/presentation/widgets/compact_container_selector.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/presentation/widgets/compact_container_selector.dart @@ -22,11 +22,12 @@ import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nullability/nullability.dart'; import 'package:weblibre/core/routing/routes.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/entities/container_selection_result.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selected_container.dart'; -import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/container_title.dart'; +import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/container_chip_content.dart'; import 'package:weblibre/features/geckoview/features/tabs/utils/container_colors.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; @@ -38,11 +39,13 @@ class CompactContainerSelector extends ConsumerWidget { final ContainerData? selectedContainer; final Future Function(ContainerSelectionResult selection)? onSelectionChanged; + final bool emphasizeSelection; const CompactContainerSelector({ super.key, this.selectedContainer, this.onSelectionChanged, + this.emphasizeSelection = true, }); @override @@ -56,8 +59,11 @@ class CompactContainerSelector extends ConsumerWidget { return const SizedBox.shrink(); } - final theme = Theme.of(context); + final colorScheme = Theme.of(context).colorScheme; final isSelected = selectedContainer != null; + final accentColor = selectedContainer?.color ?? colorScheme.primary; + final showSelectedHighlight = isSelected && emphasizeSelection; + final palette = ContainerColors.palette(context, accentColor); return GestureDetector( onLongPress: isSelected @@ -67,16 +73,38 @@ class CompactContainerSelector extends ConsumerWidget { ).push(context); } : null, - child: ActionChip( - avatar: isSelected ? null : const Icon(MdiIcons.folderHidden), - label: isSelected - ? ContainerTitle(container: selectedContainer!) - : const Text('Unassigned'), - backgroundColor: isSelected - ? ContainerColors.forChip(selectedContainer!.color) + child: FilterChip( + avatar: isSelected + ? buildContainerChipAvatar( + context, + selectedContainer!, + showSelectedHighlight, + ) : null, - side: isSelected ? BorderSide(color: theme.colorScheme.primary) : null, - onPressed: () async { + label: isSelected + ? buildContainerChipLabel( + context, + selectedContainer!, + showSelectedHighlight, + ) + : const Text('Unassigned'), + color: WidgetStatePropertyAll( + isSelected + ? (showSelectedHighlight + ? palette.selectedBackgroundColor + : palette.backgroundColor) + : colorScheme.surfaceContainer, + ), + side: isSelected + ? (showSelectedHighlight + ? palette.selectedBorderSide + : palette.borderSide) + : BorderSide( + color: colorScheme.outlineVariant.withValues(alpha: 0.4), + ), + selected: false, + showCheckmark: false, + onSelected: (_) async { final selection = await const ContainerSelectionRoute() .push(context); if (selection == null) { diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/presentation/widgets/container_chip_content.dart b/apps/weblibre/lib/features/geckoview/features/tabs/presentation/widgets/container_chip_content.dart new file mode 100644 index 00000000..a0e9d91a --- /dev/null +++ b/apps/weblibre/lib/features/geckoview/features/tabs/presentation/widgets/container_chip_content.dart @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'package:flutter/material.dart'; +import 'package:nullability/nullability.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart'; +import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/container_title.dart'; +import 'package:weblibre/features/geckoview/features/tabs/utils/container_colors.dart'; +import 'package:weblibre/features/geckoview/features/tabs/utils/container_icons.dart'; + +Widget? buildContainerChipAvatar( + BuildContext context, + ContainerData container, + bool isSelected, { + double size = 18, +}) { + final palette = ContainerColors.palette(context, container.color); + + return chipContainerIcon(container.metadata.iconData).mapNotNull( + (iconData) => Icon( + iconData, + size: size, + color: isSelected ? palette.selectedAvatarColor : palette.avatarColor, + ), + ); +} + +Widget buildContainerChipLabel( + BuildContext context, + ContainerData container, + bool isSelected, { + Widget? trailing, +}) { + final palette = ContainerColors.palette(context, container.color); + final foregroundColor = isSelected + ? palette.selectedForegroundColor + : palette.foregroundColor; + + return DefaultTextStyle.merge( + style: TextStyle( + color: foregroundColor, + fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500, + ), + child: IconTheme.merge( + data: IconThemeData(color: foregroundColor), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible(child: ContainerTitle(container: container)), + if (trailing != null) ...[const SizedBox(width: 6), trailing], + ], + ), + ), + ); +} diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/presentation/widgets/container_chips.dart b/apps/weblibre/lib/features/geckoview/features/tabs/presentation/widgets/container_chips.dart index 03079460..1592ffe3 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/presentation/widgets/container_chips.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/presentation/widgets/container_chips.dart @@ -17,8 +17,11 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ +import 'dart:async'; + import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -31,12 +34,37 @@ import 'package:weblibre/features/geckoview/features/tabs/data/entities/containe import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/gecko_inference.dart'; +import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/container_chip_content.dart'; import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/container_title.dart'; import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/tab_drag_container_target.dart'; import 'package:weblibre/features/geckoview/features/tabs/utils/container_colors.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; +import 'package:weblibre/presentation/widgets/inline_count_badge.dart'; import 'package:weblibre/presentation/widgets/selectable_chips.dart'; +ContainerColorPalette _palette(BuildContext context, Color color) { + return ContainerColors.palette(context, color); +} + +Color _chipColor(BuildContext context, Color color, bool isSelected) { + final palette = _palette(context, color); + return isSelected ? palette.selectedBackgroundColor : palette.backgroundColor; +} + +BorderSide _chipSide(BuildContext context, Color color, bool isSelected) { + final palette = _palette(context, color); + return isSelected ? palette.selectedBorderSide : palette.borderSide; +} + +InlineCountBadge _countBadge(BuildContext context, Color color, int count) { + final palette = _palette(context, color); + return InlineCountBadge( + count: count, + backgroundColor: palette.badgeBackgroundColor, + foregroundColor: palette.badgeForegroundColor, + ); +} + class _UnassignedContainerChip extends ConsumerWidget { final int? Function()? containerBadgeCount; final bool selected; @@ -61,11 +89,22 @@ class _UnassignedContainerChip extends ConsumerWidget { return FilterChip( avatar: const Icon(MdiIcons.folderHidden), - labelPadding: (tabCount > 0) ? null : const EdgeInsets.only(right: 2.0), - label: (tabCount > 0) - ? Text(tabCount.toString()) - : const SizedBox.shrink(), + labelPadding: const EdgeInsets.only(left: 6), + label: SizedBox( + height: 20, + child: Center( + child: _countBadge( + context, + Theme.of(context).colorScheme.primary, + tabCount, + ), + ), + ), + color: WidgetStatePropertyAll( + _chipColor(context, Theme.of(context).colorScheme.primary, selected), + ), selected: selected, + side: _chipSide(context, Theme.of(context).colorScheme.primary, selected), showCheckmark: false, onSelected: (value) { if (value) { @@ -91,9 +130,14 @@ class _SyncedTabsChip extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { return FilterChip( avatar: const Icon(Icons.devices_other), - labelPadding: count > 0 ? null : const EdgeInsets.only(right: 2.0), - label: count > 0 ? Text(count.toString()) : const SizedBox.shrink(), + label: count > 0 + ? _countBadge(context, Theme.of(context).colorScheme.primary, count) + : const SizedBox.shrink(), + color: WidgetStatePropertyAll( + _chipColor(context, Theme.of(context).colorScheme.primary, selected), + ), selected: selected, + side: _chipSide(context, Theme.of(context).colorScheme.primary, selected), showCheckmark: false, onSelected: (value) { if (value) { @@ -134,6 +178,14 @@ class _ContainerSuggestionsChip extends ConsumerWidget { return FilterChip( avatar: const Icon(MdiIcons.autoFix), label: Text(data!.length.toString()), + color: WidgetStatePropertyAll( + _chipColor(context, Theme.of(context).colorScheme.primary, false), + ), + side: _chipSide( + context, + Theme.of(context).colorScheme.primary, + false, + ), showCheckmark: false, onSelected: (_) async { await const ContainerDraftRoute().push(context); @@ -149,9 +201,17 @@ class _ContainerSuggestionsChip extends ConsumerWidget { return const SizedBox.shrink(); }, loading: () { - return const FilterChip( - avatar: Icon(MdiIcons.autoFix), - label: Skeletonizer(child: Text('0')), + return FilterChip( + avatar: const Icon(MdiIcons.autoFix), + label: const Skeletonizer(child: Text('0')), + color: WidgetStatePropertyAll( + _chipColor(context, Theme.of(context).colorScheme.primary, false), + ), + side: _chipSide( + context, + Theme.of(context).colorScheme.primary, + false, + ), showCheckmark: false, onSelected: null, ); @@ -215,11 +275,80 @@ class ContainerChips extends HookConsumerWidget { searchTextListenable, () => searchTextListenable?.value.text, ); + final chipScrollController = useScrollController(); + final activeItemKey = useRef(GlobalKey()); + final isUserScrolling = useRef(false); + final userScrollTimer = useRef(null); + + useEffect(() { + return userScrollTimer.value?.cancel; + }, []); final containersAsync = ref.watch( matchSortedContainersWithCountProvider(searchText), ); + useEffect(() { + final selectedId = selectedContainer?.id; + if (selectedId == null || isUserScrolling.value) { + return null; + } + + final renderedContainers = + containerFilter.mapNotNull( + (filter) => containersAsync.value?.where(filter).toList(), + ) ?? + containersAsync.value; + + WidgetsBinding.instance.addPostFrameCallback((_) { + final activeContext = activeItemKey.value.currentContext; + if (activeContext != null) { + Scrollable.ensureVisible( + activeContext, + alignment: 0.5, + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + ); + return; + } + + if (!chipScrollController.hasClients || renderedContainers == null) { + return; + } + + final activeIndex = renderedContainers.indexWhere( + (container) => container.id == selectedId, + ); + if (activeIndex < 0) { + return; + } + + final totalItems = renderedContainers.length; + final maxExtent = chipScrollController.position.maxScrollExtent; + if (totalItems <= 0 || maxExtent <= 0) { + return; + } + + chipScrollController.jumpTo( + (activeIndex / totalItems * maxExtent).clamp(0.0, maxExtent), + ); + + WidgetsBinding.instance.addPostFrameCallback((_) { + final retryContext = activeItemKey.value.currentContext; + if (retryContext != null) { + Scrollable.ensureVisible( + retryContext, + alignment: 0.5, + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + ); + } + }); + }); + + return null; + }, [selectedContainer?.id, containersAsync.value]); + return containersAsync.when( skipLoadingOnReload: true, data: (containers) { @@ -235,50 +364,100 @@ class ContainerChips extends HookConsumerWidget { return const SizedBox.shrink(); } - return SizedBox( - height: 48, - child: Row( - children: [ - Expanded( - child: - SelectableChips< - ContainerDataWithCount, - ContainerData, - String - >( - enableDelete: false, - maxCount: null, - itemId: (container) => container.id, - itemBackgroundColor: (container) => - ContainerColors.forChip(container.color), - selectedBorderColor: Theme.of( - context, - ).colorScheme.primary, - itemLabel: (container) => - ContainerTitle(container: container), - itemBadgeCount: (container) => - containerBadgeCount?.call(container) ?? - container.tabCount, - itemWrap: enableDragAndDrop - ? (child, container) { - return TabDragContainerTarget( - container: container, - child: child, - ); - } - : null, - prefixListItems: [ - if (showSyncedChip) - _SyncedTabsChip( - selected: syncedChipSelected, - count: syncedTabCount, - onSelected: onSyncedChipSelected ?? () {}, - ), - if (showUnassignedChip) - enableDragAndDrop - ? TabDragContainerTarget( - container: null, - child: _UnassignedContainerChip( + return NotificationListener( + onNotification: (notification) { + userScrollTimer.value?.cancel(); + if (notification.direction != ScrollDirection.idle) { + isUserScrolling.value = true; + } + userScrollTimer.value = Timer( + const Duration(milliseconds: 1500), + () { + isUserScrolling.value = false; + }, + ); + return false; + }, + child: SizedBox( + height: 48, + child: Row( + children: [ + Expanded( + child: + SelectableChips< + ContainerDataWithCount, + ContainerData, + String + >( + enableDelete: false, + maxCount: null, + scrollController: chipScrollController, + activeItemKey: activeItemKey.value, + cacheExtent: 500, + itemId: (container) => container.id, + decoration: SelectableChipDecoration( + color: (container, isSelected) => + _chipColor(context, container.color, isSelected), + side: (container, isSelected) => + _chipSide(context, container.color, isSelected), + ), + itemAvatar: (container) { + final isSelected = + selectedContainer?.id == container.id; + return buildContainerChipAvatar( + context, + container, + isSelected, + ); + }, + selectedBorderColor: Theme.of( + context, + ).colorScheme.primary, + itemLabel: (container) { + final isSelected = + selectedContainer?.id == container.id; + final count = + containerBadgeCount?.call(container) ?? + container.tabCount; + return buildContainerChipLabel( + context, + container, + isSelected, + trailing: count != null && count > 0 + ? _countBadge(context, container.color, count) + : null, + ); + }, + itemWrap: enableDragAndDrop + ? (child, container) { + return TabDragContainerTarget( + container: container, + child: child, + ); + } + : null, + prefixListItems: [ + if (showSyncedChip) + _SyncedTabsChip( + selected: syncedChipSelected, + count: syncedTabCount, + onSelected: onSyncedChipSelected ?? () {}, + ), + if (showUnassignedChip) + enableDragAndDrop + ? TabDragContainerTarget( + container: null, + child: _UnassignedContainerChip( + containerBadgeCount: () => + containerBadgeCount?.call(null), + selected: + unassignedChipSelected ?? + (selectedContainer == null && + !syncedChipSelected), + onSelected: onSelected, + ), + ) + : _UnassignedContainerChip( containerBadgeCount: () => containerBadgeCount?.call(null), selected: @@ -287,35 +466,26 @@ class ContainerChips extends HookConsumerWidget { !syncedChipSelected), onSelected: onSelected, ), - ) - : _UnassignedContainerChip( - containerBadgeCount: () => - containerBadgeCount?.call(null), - selected: - unassignedChipSelected ?? - (selectedContainer == null && - !syncedChipSelected), - onSelected: onSelected, - ), - if (showGroupSuggestions) - const _ContainerSuggestionsChip(), - ], - availableItems: availableContainers, - selectedItem: selectedContainer, - onSelected: onSelected, - onDeleted: onDeleted, - onLongPress: onLongPress, - ), - ), - if (displayMenu) - IconButton( - // visualDensity: VisualDensity.compact, - onPressed: () async { - await const ContainerListRoute().push(context); - }, - icon: const Icon(Icons.chevron_right), + if (showGroupSuggestions) + const _ContainerSuggestionsChip(), + ], + availableItems: availableContainers, + selectedItem: selectedContainer, + onSelected: onSelected, + onDeleted: onDeleted, + onLongPress: onLongPress, + ), ), - ], + if (displayMenu) + IconButton( + // visualDensity: VisualDensity.compact, + onPressed: () async { + await const ContainerListRoute().push(context); + }, + icon: const Icon(Icons.chevron_right), + ), + ], + ), ), ); }, diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/presentation/widgets/container_icon_picker_sheet.dart b/apps/weblibre/lib/features/geckoview/features/tabs/presentation/widgets/container_icon_picker_sheet.dart new file mode 100644 index 00000000..5cfa3ec5 --- /dev/null +++ b/apps/weblibre/lib/features/geckoview/features/tabs/presentation/widgets/container_icon_picker_sheet.dart @@ -0,0 +1,234 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; +import 'package:weblibre/features/geckoview/features/tabs/utils/container_colors.dart'; +import 'package:weblibre/features/geckoview/features/tabs/utils/container_icons.dart'; + +final List _mdiContainerIconOptions = List.unmodifiable([ + for (final iconData in MdiIcons.values) + if (iconData.mdiMetadata case final metadata?) + ContainerIconOption( + iconData: iconData, + name: metadata.name, + searchText: [ + metadata.name, + ...?metadata.tags, + ...?metadata.styles, + ].join(' ').toLowerCase(), + ), +]); + +class ContainerIconPickerSheet extends HookWidget { + const ContainerIconPickerSheet({ + required this.selectedColor, + required this.selectedIcon, + required this.onSelected, + super.key, + }); + + final Color selectedColor; + final IconData selectedIcon; + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final palette = ContainerColors.palette(context, selectedColor); + final searchController = useTextEditingController(); + useListenable(searchController); + + final query = searchController.text.trim().toLowerCase(); + final filteredIcons = useMemoized(() { + if (query.isEmpty) { + return _mdiContainerIconOptions; + } + + return _mdiContainerIconOptions + .where((icon) => icon.searchText.contains(query)) + .toList(growable: false); + }, [query]); + + return Material( + color: theme.colorScheme.surface, + child: SafeArea( + child: Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.viewInsetsOf(context).bottom, + ), + child: Column( + children: [ + const SizedBox(height: 12), + Container( + width: 32, + height: 4, + decoration: BoxDecoration( + color: theme.colorScheme.onSurfaceVariant.withValues( + alpha: 0.4, + ), + borderRadius: BorderRadius.circular(999), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 20, 20, 8), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Choose Icon', + style: theme.textTheme.titleMedium, + ), + Text( + '${filteredIcons.length} mdi icons', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + Container( + decoration: BoxDecoration( + color: palette.avatarBackgroundColor, + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: palette.outlineColor, + width: 2, + ), + ), + padding: const EdgeInsets.all(10), + child: Icon( + selectedIcon, + color: palette.avatarForegroundColor, + ), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: SearchBar( + controller: searchController, + hintText: 'Search MDI icons', + leading: const Icon(Icons.search), + trailing: [ + if (searchController.text.isNotEmpty) + IconButton( + onPressed: searchController.clear, + icon: const Icon(Icons.close), + ), + ], + elevation: const WidgetStatePropertyAll(0), + backgroundColor: WidgetStatePropertyAll( + theme.colorScheme.surfaceContainerHigh, + ), + ), + ), + Expanded( + child: filteredIcons.isEmpty + ? Center( + child: Text( + 'No icons found.', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ) + : LayoutBuilder( + builder: (context, constraints) { + final columnCount = (constraints.maxWidth / 76) + .floor() + .clamp(4, 7); + + return GridView.builder( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 24), + keyboardDismissBehavior: + ScrollViewKeyboardDismissBehavior.onDrag, + gridDelegate: + SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: columnCount, + crossAxisSpacing: 12, + mainAxisSpacing: 12, + ), + itemCount: filteredIcons.length, + itemBuilder: (context, index) { + final option = filteredIcons[index]; + final isSelected = + option.iconData == selectedIcon; + + return Tooltip( + message: option.name, + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(18), + onTap: () => onSelected(option.iconData), + child: AnimatedContainer( + duration: const Duration( + milliseconds: 160, + ), + curve: Curves.easeInOut, + decoration: BoxDecoration( + color: isSelected + ? palette.surfaceHighColor + : theme + .colorScheme + .surfaceContainer, + borderRadius: BorderRadius.circular(18), + border: isSelected + ? Border.all( + color: palette.outlineColor, + width: 2, + ) + : Border.all( + color: theme + .colorScheme + .outlineVariant + .withValues(alpha: 0.35), + ), + ), + child: Icon( + option.iconData, + color: isSelected + ? palette.avatarForegroundColor + : theme + .colorScheme + .onSurfaceVariant, + ), + ), + ), + ), + ); + }, + ); + }, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/presentation/widgets/container_list_tile.dart b/apps/weblibre/lib/features/geckoview/features/tabs/presentation/widgets/container_list_tile.dart index 2e3f82c9..c80a4d4c 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/presentation/widgets/container_list_tile.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/presentation/widgets/container_list_tile.dart @@ -22,6 +22,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart'; import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/container_title.dart'; import 'package:weblibre/features/geckoview/features/tabs/utils/container_colors.dart'; +import 'package:weblibre/features/geckoview/features/tabs/utils/container_icons.dart'; class ContainerListTile extends HookWidget { final ContainerData container; @@ -37,13 +38,17 @@ class ContainerListTile extends HookWidget { @override Widget build(BuildContext context) { + final palette = ContainerColors.palette(context, container.color); + return ListTileTheme( - selectedColor: Theme.of(context).colorScheme.onPrimaryContainer, - selectedTileColor: Theme.of(context).colorScheme.primaryContainer, + selectedColor: palette.onContainerColor, + selectedTileColor: palette.containerColor, child: ListTile( selected: isSelected, leading: CircleAvatar( - backgroundColor: ContainerColors.preview(container.color), + backgroundColor: palette.avatarBackgroundColor, + foregroundColor: palette.avatarForegroundColor, + child: Icon(resolveContainerIcon(container.metadata.iconData)), ), title: ContainerTitle(container: container), onTap: onTap, diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/presentation/widgets/material_color_picker.dart b/apps/weblibre/lib/features/geckoview/features/tabs/presentation/widgets/material_color_picker.dart index 02103c32..72a602a2 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/presentation/widgets/material_color_picker.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/presentation/widgets/material_color_picker.dart @@ -33,6 +33,7 @@ class MaterialPicker extends StatefulWidget { this.enableLabel = false, this.portraitOnly = false, this.displayAlpha, + this.displayColorBuilder, }); final Color pickerColor; @@ -41,6 +42,7 @@ class MaterialPicker extends StatefulWidget { final bool enableLabel; final bool portraitOnly; final double? displayAlpha; + final Color Function(BuildContext context, Color color)? displayColorBuilder; @override State createState() => _MaterialPickerState(); @@ -72,6 +74,17 @@ class _MaterialPickerState extends State { MediaQuery.of(context).orientation == Orientation.portrait || widget.portraitOnly; + Color resolveDisplayColor(Color color) { + final displayColorBuilder = widget.displayColorBuilder; + if (displayColorBuilder != null) { + return displayColorBuilder(context, color); + } + + return widget.displayAlpha != null + ? color.withValues(alpha: widget.displayAlpha) + : color; + } + Widget colorList() { return Container( clipBehavior: Clip.hardEdge, @@ -127,9 +140,9 @@ class _MaterialPickerState extends State { const Padding(padding: EdgeInsets.only(left: 7)), ...colorTypes.map((List colors) { final Color colorType = colors[0]; - final Color displayColorType = widget.displayAlpha != null - ? colorType.withValues(alpha: widget.displayAlpha) - : colorType; + final Color displayColorType = resolveDisplayColor( + colorType, + ); return GestureDetector( onTap: () { if (widget.onPrimaryChanged != null) { @@ -219,9 +232,7 @@ class _MaterialPickerState extends State { Map colors, ) { final Color color = colors.keys.first; - final Color displayColor = widget.displayAlpha != null - ? color.withValues(alpha: widget.displayAlpha) - : color; + final Color displayColor = resolveDisplayColor(color); return GestureDetector( onTap: () { setState(() => _currentShading = color); diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/presentation/widgets/tab_drag_container_target.dart b/apps/weblibre/lib/features/geckoview/features/tabs/presentation/widgets/tab_drag_container_target.dart index fd92a5be..7961882b 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/presentation/widgets/tab_drag_container_target.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/presentation/widgets/tab_drag_container_target.dart @@ -38,6 +38,7 @@ class TabDragContainerTarget extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final overlayController = useOverlayPortalController(); + final layerLink = useMemoized(LayerLink.new); return DragTarget( onMove: (details) { @@ -67,28 +68,28 @@ class TabDragContainerTarget extends HookConsumerWidget { } }, builder: (context, candidateData, rejectedData) { - final renderBox = context.findRenderObject() as RenderBox?; - final position = renderBox?.localToGlobal(Offset.zero) ?? Offset.zero; - return OverlayPortal( controller: overlayController, - overlayChildBuilder: (context) => Positioned( - top: position.dy, - left: position.dx, + overlayChildBuilder: (context) => CompositedTransformFollower( + link: layerLink, + showWhenUnlinked: false, child: IgnorePointer( child: Transform.scale(scale: 1.1, child: child), ), ), - child: Consumer( - child: child, - builder: (context, ref, child) { - final dragTabId = ref.watch(willAcceptDropProvider); + child: CompositedTransformTarget( + link: layerLink, + child: Consumer( + child: child, + builder: (context, ref, child) { + final dragTabId = ref.watch(willAcceptDropProvider); - return Opacity( - opacity: (dragTabId == null) ? 1.0 : 0.0, - child: child, - ); - }, + return Opacity( + opacity: (dragTabId == null) ? 1.0 : 0.0, + child: child, + ); + }, + ), ), ); }, diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/utils/container_colors.dart b/apps/weblibre/lib/features/geckoview/features/tabs/utils/container_colors.dart index 54670e6c..cea52d4f 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/utils/container_colors.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/utils/container_colors.dart @@ -19,55 +19,101 @@ */ import 'package:flutter/material.dart'; +class ContainerColorPalette { + const ContainerColorPalette({ + required this.accentColor, + required this.onAccentColor, + required this.containerColor, + required this.onContainerColor, + required this.surfaceColor, + required this.surfaceHighColor, + required this.outlineColor, + required this.backgroundColor, + required this.selectedBackgroundColor, + required this.borderSide, + required this.selectedBorderSide, + required this.foregroundColor, + required this.selectedForegroundColor, + required this.badgeBackgroundColor, + required this.badgeForegroundColor, + required this.avatarColor, + required this.selectedAvatarColor, + required this.avatarBackgroundColor, + required this.avatarForegroundColor, + }); + + final Color accentColor; + final Color onAccentColor; + final Color containerColor; + final Color onContainerColor; + final Color surfaceColor; + final Color surfaceHighColor; + final Color outlineColor; + final Color backgroundColor; + final Color selectedBackgroundColor; + final BorderSide borderSide; + final BorderSide selectedBorderSide; + final Color foregroundColor; + final Color selectedForegroundColor; + final Color badgeBackgroundColor; + final Color badgeForegroundColor; + final Color avatarColor; + final Color selectedAvatarColor; + final Color avatarBackgroundColor; + final Color avatarForegroundColor; +} + /// Centralized helper for container color display and theming. /// -/// This class handles the conversion of stored container colors (full opacity) -/// to their display variants (with transparency) for consistent appearance -/// across the application. +/// This class converts the stored container seed color into Material 3 roles +/// used consistently across the application. class ContainerColors { ContainerColors._(); - /// Default alpha value for container color display (33% opacity) - static const double defaultAlpha = 0.33; + static const double surfaceAlpha = 0.18; + static const double surfaceHighAlpha = 0.28; + static const double outlineBorderAlpha = 0.5; - /// Returns the display color for container chips and backgrounds. - /// - /// Applies semi-transparent overlay that works well for backgrounds - /// while maintaining color distinction between containers. - /// - /// [baseColor] The stored container color (typically full opacity) - static Color forChip(Color baseColor) { - return baseColor.withValues(alpha: defaultAlpha); - } + static ContainerColorPalette palette(BuildContext context, Color seedColor) { + final theme = Theme.of(context); + final appScheme = theme.colorScheme; + final containerScheme = ColorScheme.fromSeed( + seedColor: fullOpacity(seedColor), + brightness: theme.brightness, + ); + final surfaceColor = Color.alphaBlend( + containerScheme.primaryContainer.withValues(alpha: surfaceAlpha), + appScheme.surfaceContainer, + ); + final surfaceHighColor = Color.alphaBlend( + containerScheme.primaryContainer.withValues(alpha: surfaceHighAlpha), + appScheme.surfaceContainerHighest, + ); + final outlineColor = containerScheme.primary.withValues( + alpha: outlineBorderAlpha, + ); - /// Returns the display color for container app bar backgrounds. - /// - /// Uses the same transparency as chips for visual consistency. - /// - /// [baseColor] The stored container color (typically full opacity) - static Color forAppBar(Color baseColor) { - return baseColor.withValues(alpha: defaultAlpha); - } - - /// Returns the display color with theme-aware blending. - /// - /// Uses Material Design's color blending algorithm for proper color mixing - /// with the surface color, ensuring better visual appearance across themes. - /// - /// [baseColor] The stored container color (typically full opacity) - /// [surface] The surface color to blend with (typically from theme) - static Color forSurface(Color baseColor, Color surface) { - return Color.alphaBlend(baseColor.withValues(alpha: defaultAlpha), surface); - } - - /// Returns the preview color showing how the color will appear in the UI. - /// - /// This is useful in color pickers to show users the actual appearance - /// before they confirm their selection. - /// - /// [baseColor] The color being previewed - static Color preview(Color baseColor) { - return baseColor.withValues(alpha: defaultAlpha); + return ContainerColorPalette( + accentColor: containerScheme.primary, + onAccentColor: containerScheme.onPrimary, + containerColor: containerScheme.primaryContainer, + onContainerColor: containerScheme.onPrimaryContainer, + surfaceColor: surfaceColor, + surfaceHighColor: surfaceHighColor, + outlineColor: outlineColor, + backgroundColor: surfaceColor, + selectedBackgroundColor: containerScheme.primaryContainer, + borderSide: BorderSide(color: outlineColor), + selectedBorderSide: const BorderSide(color: Colors.transparent), + foregroundColor: appScheme.onSurfaceVariant, + selectedForegroundColor: containerScheme.onPrimaryContainer, + badgeBackgroundColor: containerScheme.primary, + badgeForegroundColor: containerScheme.onPrimary, + avatarColor: containerScheme.primary, + selectedAvatarColor: containerScheme.onPrimaryContainer, + avatarBackgroundColor: surfaceHighColor, + avatarForegroundColor: containerScheme.primary, + ); } /// Returns the full opacity version of a container color. diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/utils/container_icons.dart b/apps/weblibre/lib/features/geckoview/features/tabs/utils/container_icons.dart new file mode 100644 index 00000000..04bd6fa3 --- /dev/null +++ b/apps/weblibre/lib/features/geckoview/features/tabs/utils/container_icons.dart @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'package:flutter/widgets.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; + +const IconData defaultContainerIcon = MdiIcons.folderOutline; + +/// Always returns a non-null icon, defaulting to [defaultContainerIcon] +/// when the container has no icon set. Use this for any rendering surface +/// where the slot can't be empty (settings rows, full-size container +/// displays, the icon-picker preview). +IconData resolveContainerIcon(IconData? iconData) { + return iconData ?? defaultContainerIcon; +} + +bool isDefaultContainerIcon(IconData? iconData) { + return resolveContainerIcon(iconData) == defaultContainerIcon; +} + +/// Returns null when the container is using the default folder icon, and +/// the resolved icon otherwise. Use this for compact surfaces (chips, +/// breadcrumb-style strips) that should omit the icon slot rather than +/// render the generic folder placeholder. +IconData? chipContainerIcon(IconData? iconData) { + return isDefaultContainerIcon(iconData) + ? null + : resolveContainerIcon(iconData); +} + +class ContainerIconOption { + const ContainerIconOption({ + required this.iconData, + required this.name, + required this.searchText, + }); + + final IconData iconData; + final String name; + final String searchText; +} diff --git a/apps/weblibre/lib/features/geckoview/features/top_sites/data/database/database.drift.dart b/apps/weblibre/lib/features/geckoview/features/top_sites/data/database/database.drift.dart index 1705cbf6..f0c17509 100644 --- a/apps/weblibre/lib/features/geckoview/features/top_sites/data/database/database.drift.dart +++ b/apps/weblibre/lib/features/geckoview/features/top_sites/data/database/database.drift.dart @@ -52,6 +52,11 @@ extension DefineFunctions on i6.CommonDatabase { required String Function(int, String?) lexoRankPrevious, required String Function(String?, String?) lexoRankReorderAfter, required String Function(String?, String?) lexoRankReorderBefore, + required int Function() generateContentHash, + required bool Function(String?) urlIndexable, + required String Function(String?) urlCanonical, + required String Function(String?) urlHost, + required String Function(String?) urlPath, }) { createFunction( functionName: 'lexo_rank_next', @@ -89,5 +94,44 @@ extension DefineFunctions on i6.CommonDatabase { return lexoRankReorderBefore(arg0, arg1); }, ); + createFunction( + functionName: 'generate_content_hash', + argumentCount: const i6.AllowedArgumentCount(0), + function: (args) { + return generateContentHash(); + }, + ); + createFunction( + functionName: 'url_indexable', + argumentCount: const i6.AllowedArgumentCount(1), + function: (args) { + final arg0 = args[0] as String?; + return urlIndexable(arg0); + }, + ); + createFunction( + functionName: 'url_canonical', + argumentCount: const i6.AllowedArgumentCount(1), + function: (args) { + final arg0 = args[0] as String?; + return urlCanonical(arg0); + }, + ); + createFunction( + functionName: 'url_host', + argumentCount: const i6.AllowedArgumentCount(1), + function: (args) { + final arg0 = args[0] as String?; + return urlHost(arg0); + }, + ); + createFunction( + functionName: 'url_path', + argumentCount: const i6.AllowedArgumentCount(1), + function: (args) { + final arg0 = args[0] as String?; + return urlPath(arg0); + }, + ); } } diff --git a/apps/weblibre/lib/features/geckoview/utils/image_helper.dart b/apps/weblibre/lib/features/geckoview/utils/image_helper.dart index d15d8515..22c97638 100644 --- a/apps/weblibre/lib/features/geckoview/utils/image_helper.dart +++ b/apps/weblibre/lib/features/geckoview/utils/image_helper.dart @@ -17,15 +17,19 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ +import 'dart:convert'; +import 'dart:math'; import 'dart:typed_data'; import 'dart:ui'; import 'package:fast_equatable/hash.dart'; +import 'package:flutter_svg/flutter_svg.dart' as svg; import 'package:weblibre/core/logger.dart'; import 'package:weblibre/domain/entities/equatable_image.dart'; import 'package:weblibre/utils/lru_cache.dart'; final _cache = LRUCache(100); +const _defaultSvgIconSize = 32; /// Clears the global image decode cache. void clearImageCache() { @@ -63,8 +67,141 @@ Future tryDecodeImage( return image; } } catch (e, s) { + if (_isLikelySvg(bytes)) { + try { + final svgImage = await _tryDecodeSvg( + bytes, + hash: digest, + targetWidth: targetWidth, + targetHeight: targetHeight, + ); + if (svgImage != null) { + _cache.set(digest, svgImage); + return svgImage; + } + } catch (svgError, svgStackTrace) { + logger.w( + 'Failed to rasterize SVG image', + error: svgError, + stackTrace: svgStackTrace, + ); + } + } + logger.w('Failed to decode image', error: e, stackTrace: s); } return null; } + +bool _isLikelySvg(Uint8List bytes) { + final prefix = utf8 + .decode(bytes.sublist(0, min(bytes.length, 512)), allowMalformed: true) + .trimLeft(); + final normalized = prefix.isNotEmpty && prefix.codeUnitAt(0) == 0xFEFF + ? prefix.substring(1) + : prefix; + final lower = normalized.toLowerCase(); + + return lower.contains(' _tryDecodeSvg( + Uint8List bytes, { + required int hash, + int? targetWidth, + int? targetHeight, +}) async { + final pictureInfo = await svg.vg.loadPicture(svg.SvgBytesLoader(bytes), null); + + try { + final dimensions = _resolveSvgRasterDimensions( + pictureInfo.size, + targetWidth: targetWidth, + targetHeight: targetHeight, + ); + + final sourceWidth = pictureInfo.size.width > 0 + ? pictureInfo.size.width + : dimensions.width.toDouble(); + final sourceHeight = pictureInfo.size.height > 0 + ? pictureInfo.size.height + : dimensions.height.toDouble(); + final recorder = PictureRecorder(); + final canvas = Canvas(recorder); + + canvas.clipRect( + Rect.fromLTWH( + 0, + 0, + dimensions.width.toDouble(), + dimensions.height.toDouble(), + ), + ); + canvas.scale( + dimensions.width / sourceWidth, + dimensions.height / sourceHeight, + ); + canvas.drawPicture(pictureInfo.picture); + + final scaledPicture = recorder.endRecording(); + + try { + final rasterized = await scaledPicture.toImage( + dimensions.width, + dimensions.height, + ); + return EquatableImage(rasterized, hash: hash); + } finally { + scaledPicture.dispose(); + } + } finally { + pictureInfo.picture.dispose(); + } +} + +({int width, int height}) _resolveSvgRasterDimensions( + Size sourceSize, { + int? targetWidth, + int? targetHeight, +}) { + if (targetWidth != null && targetHeight != null) { + return (width: max(1, targetWidth), height: max(1, targetHeight)); + } + + final fallbackSize = targetWidth ?? targetHeight ?? _defaultSvgIconSize; + final sourceWidth = sourceSize.width > 0 + ? sourceSize.width + : fallbackSize.toDouble(); + final sourceHeight = sourceSize.height > 0 + ? sourceSize.height + : fallbackSize.toDouble(); + + if (targetWidth != null) { + return ( + width: max(1, targetWidth), + height: max(1, (targetWidth * sourceHeight / sourceWidth).round()), + ); + } + + if (targetHeight != null) { + return ( + width: max(1, (targetHeight * sourceWidth / sourceHeight).round()), + height: max(1, targetHeight), + ); + } + + if (sourceWidth >= sourceHeight) { + return ( + width: fallbackSize, + height: max(1, (fallbackSize * sourceHeight / sourceWidth).round()), + ); + } + + return ( + width: max(1, (fallbackSize * sourceWidth / sourceHeight).round()), + height: fallbackSize, + ); +} diff --git a/apps/weblibre/lib/features/onboarding/presentation/pages/welcome.dart b/apps/weblibre/lib/features/onboarding/presentation/pages/welcome.dart index 40ce0385..13fa9c61 100644 --- a/apps/weblibre/lib/features/onboarding/presentation/pages/welcome.dart +++ b/apps/weblibre/lib/features/onboarding/presentation/pages/welcome.dart @@ -19,6 +19,7 @@ */ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:fading_scroll/fading_scroll.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:weblibre/core/design/app_colors.dart'; @@ -409,7 +410,13 @@ class _EulaCheckbox extends StatelessWidget { ), const Divider(height: 1), Expanded( - child: Markdown(controller: scrollController, data: content), + child: FadingScroll( + controller: scrollController, + fadingSize: 25, + builder: (context, controller) { + return Markdown(controller: controller, data: content); + }, + ), ), ], ), diff --git a/apps/weblibre/lib/features/quotes/data/database/database.drift.dart b/apps/weblibre/lib/features/quotes/data/database/database.drift.dart index c70c3be9..2d5065c0 100644 --- a/apps/weblibre/lib/features/quotes/data/database/database.drift.dart +++ b/apps/weblibre/lib/features/quotes/data/database/database.drift.dart @@ -31,6 +31,11 @@ extension DefineFunctions on i4.CommonDatabase { required String Function(int, String?) lexoRankPrevious, required String Function(String?, String?) lexoRankReorderAfter, required String Function(String?, String?) lexoRankReorderBefore, + required int Function() generateContentHash, + required bool Function(String?) urlIndexable, + required String Function(String?) urlCanonical, + required String Function(String?) urlHost, + required String Function(String?) urlPath, }) { createFunction( functionName: 'lexo_rank_next', @@ -68,5 +73,44 @@ extension DefineFunctions on i4.CommonDatabase { return lexoRankReorderBefore(arg0, arg1); }, ); + createFunction( + functionName: 'generate_content_hash', + argumentCount: const i4.AllowedArgumentCount(0), + function: (args) { + return generateContentHash(); + }, + ); + createFunction( + functionName: 'url_indexable', + argumentCount: const i4.AllowedArgumentCount(1), + function: (args) { + final arg0 = args[0] as String?; + return urlIndexable(arg0); + }, + ); + createFunction( + functionName: 'url_canonical', + argumentCount: const i4.AllowedArgumentCount(1), + function: (args) { + final arg0 = args[0] as String?; + return urlCanonical(arg0); + }, + ); + createFunction( + functionName: 'url_host', + argumentCount: const i4.AllowedArgumentCount(1), + function: (args) { + final arg0 = args[0] as String?; + return urlHost(arg0); + }, + ); + createFunction( + functionName: 'url_path', + argumentCount: const i4.AllowedArgumentCount(1), + function: (args) { + final arg0 = args[0] as String?; + return urlPath(arg0); + }, + ); } } diff --git a/apps/weblibre/lib/features/search/domain/fts_tokenizer.dart b/apps/weblibre/lib/features/search/domain/fts_tokenizer.dart index 7891595e..fc63df9a 100644 --- a/apps/weblibre/lib/features/search/domain/fts_tokenizer.dart +++ b/apps/weblibre/lib/features/search/domain/fts_tokenizer.dart @@ -31,7 +31,9 @@ sealed class FtsQueryBuilder { ///a whitespace (group 2) static final _tokenizePattern = RegExp('"([^"]+)"|([^ ]+)'); - ///Reserved FTS5 baewords + ///Reserved FTS5 barewords. Compared case-insensitively — FTS5 treats them + ///as operators regardless of casing, so user-typed lowercase `and`/`or`/ + ///`not` would otherwise leak through as content trigrams and pollute hits. static const _reservedBarewords = {'AND', 'OR', 'NOT'}; ///Checks if string is an bareword accoring to: @@ -43,7 +45,7 @@ sealed class FtsQueryBuilder { ); static bool _isReservedBareword(Bareword input) => - _reservedBarewords.contains(input.word); + _reservedBarewords.contains(input.word.toUpperCase()); late final _Phrase _tokens; diff --git a/apps/weblibre/lib/features/search_credits/data/models/search_credits_status.dart b/apps/weblibre/lib/features/search_credits/data/models/search_credits_status.dart new file mode 100644 index 00000000..56653367 --- /dev/null +++ b/apps/weblibre/lib/features/search_credits/data/models/search_credits_status.dart @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'package:fast_equatable/fast_equatable.dart'; + +class SearchCreditsStatus with FastEquatable { + final int availableCredits; + final int monthlyAllowance; + final DateTime? lastResetAt; + final DateTime? lastIssuanceAt; + final DateTime? currentPeriodEnd; + + SearchCreditsStatus({ + required this.availableCredits, + this.monthlyAllowance = 0, + this.lastResetAt, + this.lastIssuanceAt, + this.currentPeriodEnd, + }); + + factory SearchCreditsStatus.fromJson(Map json) { + DateTime? parse(Object? v) => (v is String) ? DateTime.parse(v) : null; + return SearchCreditsStatus( + availableCredits: (json['available_credits'] as num?)?.toInt() ?? 0, + monthlyAllowance: (json['monthly_allowance'] as num?)?.toInt() ?? 0, + lastResetAt: parse(json['last_reset_at']), + lastIssuanceAt: parse(json['last_issuance_at']), + currentPeriodEnd: parse(json['current_period_end']), + ); + } + + // Not `const` because `FastEquatable` carries a cached-hash field that + // disallows const construction. Value-equality from the mixin means + // `SearchCreditsStatus(availableCredits: 0) == SearchCreditsStatus.empty` + // still holds, which is what Riverpod's `select` dedupe cares about. + static final empty = SearchCreditsStatus(availableCredits: 0); + + @override + List get hashParameters => [ + availableCredits, + monthlyAllowance, + lastResetAt, + lastIssuanceAt, + currentPeriodEnd, + ]; +} diff --git a/apps/weblibre/lib/features/search_credits/data/models/web_search_settings.dart b/apps/weblibre/lib/features/search_credits/data/models/web_search_settings.dart new file mode 100644 index 00000000..24b6785b --- /dev/null +++ b/apps/weblibre/lib/features/search_credits/data/models/web_search_settings.dart @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'package:copy_with_extension/copy_with_extension.dart'; +import 'package:fast_equatable/fast_equatable.dart'; +import 'package:json_annotation/json_annotation.dart'; +import 'package:search_backend/search_backend.dart'; + +part 'web_search_settings.g.dart'; + +@CopyWith() +@JsonSerializable(includeIfNull: true, constructor: 'withDefaults') +class WebSearchSettings with FastEquatable { + final bool routeThroughTor; + final SearchMode searchMode; + + /// ISO 639-1 language code (e.g. `en`, `de`). `null` means "use backend + /// default" (currently English). + final String? language; + + /// ISO 3166-1 alpha-2 region/country code (e.g. `US`, `DE`). `null` + /// means "no region preference" — engines won't apply region boosts. + final String? region; + + /// Safe-search level. `null` defers to the server default (moderate). + final SafeSearch? safeSearch; + + /// Freshness filter. `null` means "any time". + final TimeRange? timeRange; + + WebSearchSettings({ + required this.routeThroughTor, + required this.searchMode, + required this.language, + required this.region, + required this.safeSearch, + required this.timeRange, + }); + + WebSearchSettings.withDefaults({ + bool? routeThroughTor, + SearchMode? searchMode, + this.language, + this.region, + this.safeSearch, + this.timeRange, + }) : routeThroughTor = routeThroughTor ?? false, + searchMode = searchMode ?? SearchMode.general; + + factory WebSearchSettings.fromJson(Map json) => + _$WebSearchSettingsFromJson(json); + + Map toJson() => _$WebSearchSettingsToJson(this); + + @override + List get hashParameters => [ + routeThroughTor, + searchMode, + language, + region, + safeSearch, + timeRange, + ]; +} diff --git a/apps/weblibre/lib/features/search_credits/data/models/web_search_settings.g.dart b/apps/weblibre/lib/features/search_credits/data/models/web_search_settings.g.dart new file mode 100644 index 00000000..b1e64451 --- /dev/null +++ b/apps/weblibre/lib/features/search_credits/data/models/web_search_settings.g.dart @@ -0,0 +1,165 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'web_search_settings.dart'; + +// ************************************************************************** +// CopyWithGenerator +// ************************************************************************** + +abstract class _$WebSearchSettingsCWProxy { + WebSearchSettings routeThroughTor(bool routeThroughTor); + + WebSearchSettings searchMode(SearchMode searchMode); + + WebSearchSettings language(String? language); + + WebSearchSettings region(String? region); + + WebSearchSettings safeSearch(SafeSearch? safeSearch); + + WebSearchSettings timeRange(TimeRange? timeRange); + + /// Creates a new instance with the provided field values. + /// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `WebSearchSettings(...).copyWith.fieldName(value)`. + /// + /// Example: + /// ```dart + /// WebSearchSettings(...).copyWith(id: 12, name: "My name") + /// ``` + WebSearchSettings call({ + bool routeThroughTor, + SearchMode searchMode, + String? language, + String? region, + SafeSearch? safeSearch, + TimeRange? timeRange, + }); +} + +/// Callable proxy for `copyWith` functionality. +/// Use as `instanceOfWebSearchSettings.copyWith(...)` or call `instanceOfWebSearchSettings.copyWith.fieldName(value)` for a single field. +class _$WebSearchSettingsCWProxyImpl implements _$WebSearchSettingsCWProxy { + const _$WebSearchSettingsCWProxyImpl(this._value); + + final WebSearchSettings _value; + + @override + WebSearchSettings routeThroughTor(bool routeThroughTor) => + call(routeThroughTor: routeThroughTor); + + @override + WebSearchSettings searchMode(SearchMode searchMode) => + call(searchMode: searchMode); + + @override + WebSearchSettings language(String? language) => call(language: language); + + @override + WebSearchSettings region(String? region) => call(region: region); + + @override + WebSearchSettings safeSearch(SafeSearch? safeSearch) => + call(safeSearch: safeSearch); + + @override + WebSearchSettings timeRange(TimeRange? timeRange) => + call(timeRange: timeRange); + + @override + /// Creates a new instance with the provided field values. + /// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `WebSearchSettings(...).copyWith.fieldName(value)`. + /// + /// Example: + /// ```dart + /// WebSearchSettings(...).copyWith(id: 12, name: "My name") + /// ``` + WebSearchSettings call({ + Object? routeThroughTor = const $CopyWithPlaceholder(), + Object? searchMode = const $CopyWithPlaceholder(), + Object? language = const $CopyWithPlaceholder(), + Object? region = const $CopyWithPlaceholder(), + Object? safeSearch = const $CopyWithPlaceholder(), + Object? timeRange = const $CopyWithPlaceholder(), + }) { + return WebSearchSettings( + routeThroughTor: + routeThroughTor == const $CopyWithPlaceholder() || + routeThroughTor == null + ? _value.routeThroughTor + // ignore: cast_nullable_to_non_nullable + : routeThroughTor as bool, + searchMode: + searchMode == const $CopyWithPlaceholder() || searchMode == null + ? _value.searchMode + // ignore: cast_nullable_to_non_nullable + : searchMode as SearchMode, + language: language == const $CopyWithPlaceholder() + ? _value.language + // ignore: cast_nullable_to_non_nullable + : language as String?, + region: region == const $CopyWithPlaceholder() + ? _value.region + // ignore: cast_nullable_to_non_nullable + : region as String?, + safeSearch: safeSearch == const $CopyWithPlaceholder() + ? _value.safeSearch + // ignore: cast_nullable_to_non_nullable + : safeSearch as SafeSearch?, + timeRange: timeRange == const $CopyWithPlaceholder() + ? _value.timeRange + // ignore: cast_nullable_to_non_nullable + : timeRange as TimeRange?, + ); + } +} + +extension $WebSearchSettingsCopyWith on WebSearchSettings { + /// Returns a callable class used to build a new instance with modified fields. + /// Example: `instanceOfWebSearchSettings.copyWith(...)` or `instanceOfWebSearchSettings.copyWith.fieldName(...)`. + // ignore: library_private_types_in_public_api + _$WebSearchSettingsCWProxy get copyWith => + _$WebSearchSettingsCWProxyImpl(this); +} + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +WebSearchSettings _$WebSearchSettingsFromJson(Map json) => + WebSearchSettings.withDefaults( + routeThroughTor: json['routeThroughTor'] as bool?, + searchMode: $enumDecodeNullable(_$SearchModeEnumMap, json['searchMode']), + language: json['language'] as String?, + region: json['region'] as String?, + safeSearch: $enumDecodeNullable(_$SafeSearchEnumMap, json['safeSearch']), + timeRange: $enumDecodeNullable(_$TimeRangeEnumMap, json['timeRange']), + ); + +Map _$WebSearchSettingsToJson(WebSearchSettings instance) => + { + 'routeThroughTor': instance.routeThroughTor, + 'searchMode': _$SearchModeEnumMap[instance.searchMode]!, + 'language': instance.language, + 'region': instance.region, + 'safeSearch': _$SafeSearchEnumMap[instance.safeSearch], + 'timeRange': _$TimeRangeEnumMap[instance.timeRange], + }; + +const _$SearchModeEnumMap = { + SearchMode.general: 'general', + SearchMode.independentWeb: 'independentWeb', + SearchMode.smallWeb: 'smallWeb', +}; + +const _$SafeSearchEnumMap = { + SafeSearch.none: 'none', + SafeSearch.moderate: 'moderate', + SafeSearch.strict: 'strict', +}; + +const _$TimeRangeEnumMap = { + TimeRange.day: 'day', + TimeRange.week: 'week', + TimeRange.month: 'month', + TimeRange.year: 'year', +}; diff --git a/apps/weblibre/lib/features/search_credits/data/search_backend_config.dart b/apps/weblibre/lib/features/search_credits/data/search_backend_config.dart new file mode 100644 index 00000000..65893bfe --- /dev/null +++ b/apps/weblibre/lib/features/search_credits/data/search_backend_config.dart @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +abstract final class SearchBackendConfig { + /// Clearnet origin used when the user has not opted to route search through + /// Tor. Must be a normal HTTPS URL (or HTTP for dev). + static const searchBackendOrigin = String.fromEnvironment( + 'SEARCH_BACKEND_ORIGIN', + defaultValue: 'https://search.weblibre.eu', + ); + + /// Origin used when the user opts to route search through Tor. Should be + /// the WebLibre search service's onion address so the Tor circuit + /// terminates inside the Tor network instead of exiting back to the + /// clearnet. Falls back to the clearnet origin when no onion address is + /// configured at build time. + static const searchBackendOriginTor = String.fromEnvironment( + 'SEARCH_BACKEND_ORIGIN_TOR', + defaultValue: + 'http://eyipgwt32zaejr2xwblaswp2ur4qikapofunbqus5dklvf7jxkncirad.onion', + ); + + static Uri get originUri => Uri.parse(searchBackendOrigin); + static Uri get torOriginUri => Uri.parse(searchBackendOriginTor); +} diff --git a/apps/weblibre/lib/features/search_credits/data/token_stash.dart b/apps/weblibre/lib/features/search_credits/data/token_stash.dart new file mode 100644 index 00000000..730b17f0 --- /dev/null +++ b/apps/weblibre/lib/features/search_credits/data/token_stash.dart @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'dart:typed_data'; + +import 'package:search_client/search_client.dart'; +import 'package:weblibre/features/user/data/database/daos/search_tokens.dart'; + +/// Drift-backed [TokenStash]. The optional [issuerKeyVersion] is only used +/// by [add], and only the issuance controller is supposed to call that. +/// Read-only consumers (the search session: reserve / commit / release / +/// count / clear) can leave it null; calling [add] on a stash constructed +/// without a key version is a programmer error and throws. +class DriftTokenStash implements TokenStash { + final SearchTokensDao dao; + final String? issuerKeyVersion; + + DriftTokenStash(this.dao, {this.issuerKeyVersion}); + + @override + Future add(List tokens) { + final version = issuerKeyVersion; + if (version == null) { + throw StateError( + 'DriftTokenStash.add called without an issuerKeyVersion — ' + 'construct a stash with the version returned by ' + 'IssuanceClient.fetchPublicKey() before issuing tokens.', + ); + } + return dao.addTokens(tokens, issuerKeyVersion: version); + } + + @override + Future reserveOne() async { + final reserved = await dao.reserveOne(); + if (reserved == null) return null; + return ReservedStashToken(id: reserved.id, token: reserved.token); + } + + @override + Future commitReserved(int id) => dao.commitReserved(id); + + @override + Future releaseReserved(int id) => dao.releaseReserved(id); + + @override + Future takeOne() async { + final reserved = await reserveOne(); + if (reserved == null) return null; + await commitReserved(reserved.id); + return reserved.token; + } + + @override + Future count() => dao.count(); + + @override + Future clear() async { + await dao.clear(); + } +} diff --git a/apps/weblibre/lib/features/search_credits/domain/controllers/search_token_issuance_controller.dart b/apps/weblibre/lib/features/search_credits/domain/controllers/search_token_issuance_controller.dart new file mode 100644 index 00000000..e9879cf5 --- /dev/null +++ b/apps/weblibre/lib/features/search_credits/domain/controllers/search_token_issuance_controller.dart @@ -0,0 +1,238 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'dart:async'; +import 'dart:math'; + +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:search_client/search_client.dart'; +import 'package:uuid/uuid.dart'; +import 'package:weblibre/features/account/domain/repositories/account_auth.dart'; +import 'package:weblibre/features/search_credits/data/token_stash.dart'; +import 'package:weblibre/features/search_credits/domain/providers.dart'; +import 'package:weblibre/features/search_credits/domain/repositories/search_credits_repository.dart'; +import 'package:weblibre/features/search_credits/domain/repositories/search_token_stash_repository.dart'; +import 'package:weblibre/features/user/data/providers.dart'; + +part 'search_token_issuance_controller.g.dart'; + +sealed class SearchTokenIssuanceState { + const SearchTokenIssuanceState(); +} + +class SearchTokenIssuanceIdle extends SearchTokenIssuanceState { + const SearchTokenIssuanceIdle(); +} + +class SearchTokenIssuanceRequesting extends SearchTokenIssuanceState { + final int count; + final String idempotencyKey; + const SearchTokenIssuanceRequesting({ + required this.count, + required this.idempotencyKey, + }); +} + +class SearchTokenIssuanceNeedsPurchase extends SearchTokenIssuanceState { + final int remainingCredits; + const SearchTokenIssuanceNeedsPurchase({required this.remainingCredits}); +} + +class SearchTokenIssuanceNeedsReauth extends SearchTokenIssuanceState { + const SearchTokenIssuanceNeedsReauth(); +} + +class SearchTokenIssuanceFailed extends SearchTokenIssuanceState { + final Object error; + final StackTrace stackTrace; + const SearchTokenIssuanceFailed(this.error, this.stackTrace); +} + +@Riverpod(keepAlive: true) +class SearchTokenIssuanceController extends _$SearchTokenIssuanceController { + Future? _pending; + + @override + SearchTokenIssuanceState build() => const SearchTokenIssuanceIdle(); + + /// Request `count` tokens. Single-flight — a second call while `Requesting` + /// returns the same pending future. If a retry follows a failure, the same + /// idempotency key is reused so the server can replay the prior result. + Future issue({required int count}) async { + final pending = _pending; + if (pending != null) return pending; + + final authState = ref.read(accountAuthRepositoryProvider).value; + final client = authState?.client; + if (authState == null || !authState.isSignedIn || client == null) { + state = const SearchTokenIssuanceNeedsReauth(); + return null; + } + + final session = client.auth.currentSession; + final accessToken = session?.accessToken; + if (accessToken == null) { + state = const SearchTokenIssuanceNeedsReauth(); + return null; + } + + // Always generate a fresh idempotency key per attempt. The SDK + // re-blinds the token_request on every call — reusing a key with a + // new blinded request would trigger IdempotencyKeyReusedError on the + // backend rather than a safe replay. Retries after a failure must + // therefore start a new reservation from scratch. + final idempotencyKey = const Uuid().v4(); + + state = SearchTokenIssuanceRequesting( + count: count, + idempotencyKey: idempotencyKey, + ); + + final future = _runIssuance( + accessToken: accessToken, + idempotencyKey: idempotencyKey, + count: count, + ); + _pending = future; + + try { + final result = await future; + state = const SearchTokenIssuanceIdle(); + unawaited(ref.read(searchCreditsRepositoryProvider.notifier).refresh()); + ref.invalidate(searchTokenStashCountProvider); + return result; + } on InsufficientCreditsError catch (e) { + state = SearchTokenIssuanceNeedsPurchase( + remainingCredits: e.remainingCredits, + ); + return null; + } catch (e, s) { + state = SearchTokenIssuanceFailed(e, s); + return null; + } finally { + _pending = null; + } + } + + Future _runIssuance({ + required String accessToken, + required String idempotencyKey, + required int count, + }) async { + final issuance = ref.read(issuanceClientProvider); + // Fetch the public key explicitly so we can tag the stash rows that + // `issueAndStash` will write with the exact key version the issuer + // signed against — `DriftTokenStash.add` would otherwise have no way + // to know it. `IssuanceClient` caches the result, so this is free + // after the first call. + final key = await issuance.fetchPublicKey(); + final db = ref.read(userDatabaseProvider); + final stash = DriftTokenStash( + db.searchTokensDao, + issuerKeyVersion: key.version, + ); + return issuance.issueAndStash( + supabaseAccessToken: accessToken, + idempotencyKey: idempotencyKey, + count: count, + stash: stash, + ); + } + + void reset() { + if (_pending != null) return; + state = const SearchTokenIssuanceIdle(); + } +} + +/// Result of an opportunistic token top-up attempt. +enum TokenTopUpOutcome { + /// New tokens were issued and are now in the stash. + issued, + + /// User has zero available credits — needs to purchase more. + noCredits, + + /// Issuance failed (network, auth, server) — see controller state for + /// the surfaced error. + issuanceFailed, +} + +/// Result of ensuring at least one token is available for a search. +enum TokenAvailabilityOutcome { + /// A token is available in the local stash. + available, + + /// The account has no remaining credits to issue tokens from. + noCredits, + + /// Credits may exist, but token issuance failed and should be retried. + issuanceFailed, +} + +/// Helpers around the token stash + issuance flow. Lives as a plain class +/// rather than a Notifier because the operations are one-shot async calls +/// with no state to publish back to listeners — the relevant state is +/// already in `searchTokenIssuanceControllerProvider` / +/// `searchCreditsRepositoryProvider` / `searchTokenStashCountProvider`. +class SearchTokenAvailability { + final Ref ref; + + SearchTokenAvailability(this.ref); + + /// Ensure the stash has at least one token, topping up from the server when + /// possible. Distinguishes real zero-credit balances from retryable issuance + /// failures so callers can show the right recovery path. + Future ensureAvailable() async { + final stash = ref.read(searchTokenStashProvider); + if (await stash.count() > 0) return TokenAvailabilityOutcome.available; + + final outcome = await topUp(); + switch (outcome) { + case TokenTopUpOutcome.issued: + return (await stash.count()) > 0 + ? TokenAvailabilityOutcome.available + : TokenAvailabilityOutcome.issuanceFailed; + case TokenTopUpOutcome.noCredits: + return TokenAvailabilityOutcome.noCredits; + case TokenTopUpOutcome.issuanceFailed: + return TokenAvailabilityOutcome.issuanceFailed; + } + } + + /// Opportunistically request a fresh batch of tokens (capped at 10 per + /// the server's batch limit) when the stash runs dry. Used by both the + /// meta-search submit flow and the sandbox capture flow. + Future topUp({int desired = 10}) async { + final status = await ref.read(searchCreditsRepositoryProvider.future); + if (status.availableCredits <= 0) return TokenTopUpOutcome.noCredits; + + final requestCount = min(desired, status.availableCredits); + final result = await ref + .read(searchTokenIssuanceControllerProvider.notifier) + .issue(count: requestCount); + return result != null + ? TokenTopUpOutcome.issued + : TokenTopUpOutcome.issuanceFailed; + } +} + +@Riverpod(keepAlive: true) +SearchTokenAvailability searchTokenAvailability(Ref ref) => + SearchTokenAvailability(ref); diff --git a/apps/weblibre/lib/features/search_credits/domain/controllers/search_token_issuance_controller.g.dart b/apps/weblibre/lib/features/search_credits/domain/controllers/search_token_issuance_controller.g.dart new file mode 100644 index 00000000..856a9046 --- /dev/null +++ b/apps/weblibre/lib/features/search_credits/domain/controllers/search_token_issuance_controller.g.dart @@ -0,0 +1,118 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'search_token_issuance_controller.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(SearchTokenIssuanceController) +final searchTokenIssuanceControllerProvider = + SearchTokenIssuanceControllerProvider._(); + +final class SearchTokenIssuanceControllerProvider + extends + $NotifierProvider< + SearchTokenIssuanceController, + SearchTokenIssuanceState + > { + SearchTokenIssuanceControllerProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'searchTokenIssuanceControllerProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$searchTokenIssuanceControllerHash(); + + @$internal + @override + SearchTokenIssuanceController create() => SearchTokenIssuanceController(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(SearchTokenIssuanceState value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$searchTokenIssuanceControllerHash() => + r'fa821704ab80a12f4649b78bf50060acfd4b5544'; + +abstract class _$SearchTokenIssuanceController + extends $Notifier { + SearchTokenIssuanceState build(); + @$mustCallSuper + @override + void runBuild() { + final ref = + this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + SearchTokenIssuanceState, + Object?, + Object? + >; + element.handleCreate(ref, build); + } +} + +@ProviderFor(searchTokenAvailability) +final searchTokenAvailabilityProvider = SearchTokenAvailabilityProvider._(); + +final class SearchTokenAvailabilityProvider + extends + $FunctionalProvider< + SearchTokenAvailability, + SearchTokenAvailability, + SearchTokenAvailability + > + with $Provider { + SearchTokenAvailabilityProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'searchTokenAvailabilityProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$searchTokenAvailabilityHash(); + + @$internal + @override + $ProviderElement $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + SearchTokenAvailability create(Ref ref) { + return searchTokenAvailability(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(SearchTokenAvailability value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$searchTokenAvailabilityHash() => + r'4125c2f267684ee6adcd7a174a3d1570a01643a6'; diff --git a/apps/weblibre/lib/features/search_credits/domain/providers.dart b/apps/weblibre/lib/features/search_credits/domain/providers.dart new file mode 100644 index 00000000..ddd41152 --- /dev/null +++ b/apps/weblibre/lib/features/search_credits/domain/providers.dart @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'package:logger/logger.dart'; +import 'package:riverpod/riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:search_client/search_client.dart'; +import 'package:weblibre/features/search_credits/data/search_backend_config.dart'; +import 'package:weblibre/features/search_credits/domain/providers/proxy_client.dart'; +import 'package:weblibre/features/search_credits/domain/repositories/web_search_settings.dart'; + +part 'providers.g.dart'; + +@Riverpod(keepAlive: true) +BackendEndpoints searchBackendEndpoints(Ref ref) { + final routeThroughTor = ref.watch( + webSearchSettingsControllerProvider.select((s) => s.routeThroughTor), + ); + return BackendEndpoints.fromOrigin( + routeThroughTor + ? SearchBackendConfig.torOriginUri + : SearchBackendConfig.originUri, + ); +} + +@Riverpod(keepAlive: true) +Logger searchClientLogger(Ref ref) => Logger(); + +@Riverpod(keepAlive: true) +IssuanceClient issuanceClient(Ref ref) { + return IssuanceClient( + endpoints: ref.watch(searchBackendEndpointsProvider), + logger: ref.watch(searchClientLoggerProvider), + httpClient: ref.watch(searchProxyHttpClientProvider), + ); +} diff --git a/apps/weblibre/lib/features/search_credits/domain/providers.g.dart b/apps/weblibre/lib/features/search_credits/domain/providers.g.dart new file mode 100644 index 00000000..a741a0e7 --- /dev/null +++ b/apps/weblibre/lib/features/search_credits/domain/providers.g.dart @@ -0,0 +1,140 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'providers.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(searchBackendEndpoints) +final searchBackendEndpointsProvider = SearchBackendEndpointsProvider._(); + +final class SearchBackendEndpointsProvider + extends + $FunctionalProvider< + BackendEndpoints, + BackendEndpoints, + BackendEndpoints + > + with $Provider { + SearchBackendEndpointsProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'searchBackendEndpointsProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$searchBackendEndpointsHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + BackendEndpoints create(Ref ref) { + return searchBackendEndpoints(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(BackendEndpoints value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$searchBackendEndpointsHash() => + r'f7249075bdf7a83271f7f9030dd12a3a9d2bd483'; + +@ProviderFor(searchClientLogger) +final searchClientLoggerProvider = SearchClientLoggerProvider._(); + +final class SearchClientLoggerProvider + extends $FunctionalProvider + with $Provider { + SearchClientLoggerProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'searchClientLoggerProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$searchClientLoggerHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + Logger create(Ref ref) { + return searchClientLogger(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(Logger value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$searchClientLoggerHash() => + r'58d85a104431de5005a3fd7521afbbbd7a8672f2'; + +@ProviderFor(issuanceClient) +final issuanceClientProvider = IssuanceClientProvider._(); + +final class IssuanceClientProvider + extends $FunctionalProvider + with $Provider { + IssuanceClientProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'issuanceClientProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$issuanceClientHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + IssuanceClient create(Ref ref) { + return issuanceClient(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(IssuanceClient value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$issuanceClientHash() => r'4ef1f8f5a062ffa0f0f0fa3ebfa70c09efac778f'; diff --git a/apps/weblibre/lib/features/search_credits/domain/providers/proxy_client.dart b/apps/weblibre/lib/features/search_credits/domain/providers/proxy_client.dart new file mode 100644 index 00000000..e37fc7f1 --- /dev/null +++ b/apps/weblibre/lib/features/search_credits/domain/providers/proxy_client.dart @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'dart:io'; + +import 'package:http/http.dart' as http; +import 'package:http/io_client.dart'; +import 'package:riverpod/riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:socks5_proxy/socks_client.dart'; +import 'package:weblibre/features/search_credits/domain/repositories/web_search_settings.dart'; +import 'package:weblibre/features/tor/domain/services/tor_proxy.dart'; + +part 'proxy_client.g.dart'; + +/// Resolved Tor SOCKS5 port for search traffic, or `null` when search should +/// route directly. Returns non-null only when the user has enabled the +/// "route search through Tor" toggle AND Tor is currently running with a +/// known SOCKS port. +@Riverpod(keepAlive: true) +int? searchProxyPort(Ref ref) { + final route = ref.watch( + webSearchSettingsControllerProvider.select((s) => s.routeThroughTor), + ); + if (!route) return null; + final status = ref.watch(torProxyServiceProvider).value; + if (status == null || !status.isRunning || status.bootstrapProgress < 100) { + return null; + } + return status.socksPort; +} + +/// HttpClient used by both WebSocket (via `IOWebSocketChannel.customClient`) +/// and HTTP-based search clients. SOCKS5-routed when Tor toggle is on, plain +/// otherwise. Rebuilt when the proxy port changes. +@Riverpod(keepAlive: true) +HttpClient searchHttpClient(Ref ref) { + final port = ref.watch(searchProxyPortProvider); + final client = HttpClient() + // The default HttpClient has no connection timeout, so a search service + // that is unreachable can stall the UI for ~minute(s) before failing. + // Cap the TCP/TLS handshake at a few seconds so the user sees an error + // quickly and can retry; the WebSocket session itself imposes no + // ceiling on long-running streams once connected. + ..connectionTimeout = const Duration(seconds: 25); + if (port != null) { + SocksTCPClient.assignToHttpClient(client, [ + ProxySettings(InternetAddress.loopbackIPv4, port), + ]); + } + ref.onDispose(() => client.close(force: true)); + return client; +} + +/// `package:http` Client wrapping [searchHttpClientProvider]. Use for the +/// non-WebSocket parts of the search flow (token issuance, one-shot capture, +/// capture artifact downloads). +@Riverpod(keepAlive: true) +http.Client searchProxyHttpClient(Ref ref) { + final httpClient = ref.watch(searchHttpClientProvider); + // IOClient does not own the HttpClient lifetime here — the underlying + // HttpClient is closed by the searchHttpClientProvider's onDispose. Don't + // close the IOClient on dispose to avoid prematurely tearing down the + // shared HttpClient on rebuild. + return IOClient(httpClient); +} diff --git a/apps/weblibre/lib/features/search_credits/domain/providers/proxy_client.g.dart b/apps/weblibre/lib/features/search_credits/domain/providers/proxy_client.g.dart new file mode 100644 index 00000000..d051712a --- /dev/null +++ b/apps/weblibre/lib/features/search_credits/domain/providers/proxy_client.g.dart @@ -0,0 +1,169 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'proxy_client.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning +/// Resolved Tor SOCKS5 port for search traffic, or `null` when search should +/// route directly. Returns non-null only when the user has enabled the +/// "route search through Tor" toggle AND Tor is currently running with a +/// known SOCKS port. + +@ProviderFor(searchProxyPort) +final searchProxyPortProvider = SearchProxyPortProvider._(); + +/// Resolved Tor SOCKS5 port for search traffic, or `null` when search should +/// route directly. Returns non-null only when the user has enabled the +/// "route search through Tor" toggle AND Tor is currently running with a +/// known SOCKS port. + +final class SearchProxyPortProvider + extends $FunctionalProvider + with $Provider { + /// Resolved Tor SOCKS5 port for search traffic, or `null` when search should + /// route directly. Returns non-null only when the user has enabled the + /// "route search through Tor" toggle AND Tor is currently running with a + /// known SOCKS port. + SearchProxyPortProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'searchProxyPortProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$searchProxyPortHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + int? create(Ref ref) { + return searchProxyPort(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(int? value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$searchProxyPortHash() => r'759420a65b9a1375ff9595de2a0dcbf174ae3e79'; + +/// HttpClient used by both WebSocket (via `IOWebSocketChannel.customClient`) +/// and HTTP-based search clients. SOCKS5-routed when Tor toggle is on, plain +/// otherwise. Rebuilt when the proxy port changes. + +@ProviderFor(searchHttpClient) +final searchHttpClientProvider = SearchHttpClientProvider._(); + +/// HttpClient used by both WebSocket (via `IOWebSocketChannel.customClient`) +/// and HTTP-based search clients. SOCKS5-routed when Tor toggle is on, plain +/// otherwise. Rebuilt when the proxy port changes. + +final class SearchHttpClientProvider + extends $FunctionalProvider + with $Provider { + /// HttpClient used by both WebSocket (via `IOWebSocketChannel.customClient`) + /// and HTTP-based search clients. SOCKS5-routed when Tor toggle is on, plain + /// otherwise. Rebuilt when the proxy port changes. + SearchHttpClientProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'searchHttpClientProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$searchHttpClientHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + HttpClient create(Ref ref) { + return searchHttpClient(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(HttpClient value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$searchHttpClientHash() => r'b3aa4b658e99fb580031ac412fabfbeece5fe830'; + +/// `package:http` Client wrapping [searchHttpClientProvider]. Use for the +/// non-WebSocket parts of the search flow (token issuance, one-shot capture, +/// capture artifact downloads). + +@ProviderFor(searchProxyHttpClient) +final searchProxyHttpClientProvider = SearchProxyHttpClientProvider._(); + +/// `package:http` Client wrapping [searchHttpClientProvider]. Use for the +/// non-WebSocket parts of the search flow (token issuance, one-shot capture, +/// capture artifact downloads). + +final class SearchProxyHttpClientProvider + extends $FunctionalProvider + with $Provider { + /// `package:http` Client wrapping [searchHttpClientProvider]. Use for the + /// non-WebSocket parts of the search flow (token issuance, one-shot capture, + /// capture artifact downloads). + SearchProxyHttpClientProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'searchProxyHttpClientProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$searchProxyHttpClientHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + http.Client create(Ref ref) { + return searchProxyHttpClient(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(http.Client value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$searchProxyHttpClientHash() => + r'08acee399abc5c6960a82365219af6b5a44ba5bd'; diff --git a/apps/weblibre/lib/features/search_credits/domain/repositories/search_credits_repository.dart b/apps/weblibre/lib/features/search_credits/domain/repositories/search_credits_repository.dart new file mode 100644 index 00000000..fc7ff5c4 --- /dev/null +++ b/apps/weblibre/lib/features/search_credits/domain/repositories/search_credits_repository.dart @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/features/account/domain/repositories/account_auth.dart'; +import 'package:weblibre/features/search_credits/data/models/search_credits_status.dart'; + +part 'search_credits_repository.g.dart'; + +@Riverpod(keepAlive: true) +class SearchCreditsRepository extends _$SearchCreditsRepository { + @override + Future build() async { + final authState = ref.watch(accountAuthRepositoryProvider).value; + if (authState == null || !authState.isSignedIn) { + // Signed-out is not an error; it's a known zero-credit state. + return SearchCreditsStatus.empty; + } + + // Don't swallow RPC failures as `SearchCreditsStatus.empty` — that + // renders identically to a real zero balance and would prompt the + // user to "buy more" when the actual fix is to retry. Let errors + // propagate so the AsyncValue carries them and consumers can show an + // error state with a retry CTA. + final client = authState.client!; + final response = await client.rpc('get_my_search_balance').single(); + return SearchCreditsStatus.fromJson(response); + } + + Future refresh() async { + ref.invalidateSelf(); + // Swallow the propagated error — `refresh()` is fire-and-forget from + // pull-to-refresh / lifecycle hooks. The new state (success or error) + // is what consumers re-render from via `ref.watch`. + try { + await future; + } catch (_) { + // intentionally ignored — see comment above + } + } +} diff --git a/apps/weblibre/lib/features/search_credits/domain/repositories/search_credits_repository.g.dart b/apps/weblibre/lib/features/search_credits/domain/repositories/search_credits_repository.g.dart new file mode 100644 index 00000000..a7d1da3e --- /dev/null +++ b/apps/weblibre/lib/features/search_credits/domain/repositories/search_credits_repository.g.dart @@ -0,0 +1,58 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'search_credits_repository.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(SearchCreditsRepository) +final searchCreditsRepositoryProvider = SearchCreditsRepositoryProvider._(); + +final class SearchCreditsRepositoryProvider + extends + $AsyncNotifierProvider { + SearchCreditsRepositoryProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'searchCreditsRepositoryProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$searchCreditsRepositoryHash(); + + @$internal + @override + SearchCreditsRepository create() => SearchCreditsRepository(); +} + +String _$searchCreditsRepositoryHash() => + r'e351ac000fa2da31e513f5a9817ac81897199df5'; + +abstract class _$SearchCreditsRepository + extends $AsyncNotifier { + FutureOr build(); + @$mustCallSuper + @override + void runBuild() { + final ref = + this.ref as $Ref, SearchCreditsStatus>; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, SearchCreditsStatus>, + AsyncValue, + Object?, + Object? + >; + element.handleCreate(ref, build); + } +} diff --git a/apps/weblibre/lib/features/search_credits/domain/repositories/search_token_stash_repository.dart b/apps/weblibre/lib/features/search_credits/domain/repositories/search_token_stash_repository.dart new file mode 100644 index 00000000..2f194c57 --- /dev/null +++ b/apps/weblibre/lib/features/search_credits/domain/repositories/search_token_stash_repository.dart @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/features/search_credits/data/token_stash.dart'; +import 'package:weblibre/features/user/data/providers.dart'; + +part 'search_token_stash_repository.g.dart'; + +/// Read-only-shaped stash for the search session: only reserve / commit / +/// release / count / take / clear are safe to call. The issuance controller +/// constructs its own stash with the freshly-fetched `key.version` before +/// calling `stash.add` so token rows are tagged with the exact key the +/// issuer signed against — see [searchTokenIssuanceControllerProvider]. +@Riverpod(keepAlive: true) +DriftTokenStash searchTokenStash(Ref ref) { + final db = ref.watch(userDatabaseProvider); + return DriftTokenStash(db.searchTokensDao); +} + +@Riverpod(keepAlive: true) +Stream searchTokenStashCount(Ref ref) { + final db = ref.watch(userDatabaseProvider); + return db.searchTokensDao.watchCount(); +} diff --git a/apps/weblibre/lib/features/search_credits/domain/repositories/search_token_stash_repository.g.dart b/apps/weblibre/lib/features/search_credits/domain/repositories/search_token_stash_repository.g.dart new file mode 100644 index 00000000..79d147fb --- /dev/null +++ b/apps/weblibre/lib/features/search_credits/domain/repositories/search_token_stash_repository.g.dart @@ -0,0 +1,102 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'search_token_stash_repository.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning +/// Read-only-shaped stash for the search session: only reserve / commit / +/// release / count / take / clear are safe to call. The issuance controller +/// constructs its own stash with the freshly-fetched `key.version` before +/// calling `stash.add` so token rows are tagged with the exact key the +/// issuer signed against — see [searchTokenIssuanceControllerProvider]. + +@ProviderFor(searchTokenStash) +final searchTokenStashProvider = SearchTokenStashProvider._(); + +/// Read-only-shaped stash for the search session: only reserve / commit / +/// release / count / take / clear are safe to call. The issuance controller +/// constructs its own stash with the freshly-fetched `key.version` before +/// calling `stash.add` so token rows are tagged with the exact key the +/// issuer signed against — see [searchTokenIssuanceControllerProvider]. + +final class SearchTokenStashProvider + extends + $FunctionalProvider + with $Provider { + /// Read-only-shaped stash for the search session: only reserve / commit / + /// release / count / take / clear are safe to call. The issuance controller + /// constructs its own stash with the freshly-fetched `key.version` before + /// calling `stash.add` so token rows are tagged with the exact key the + /// issuer signed against — see [searchTokenIssuanceControllerProvider]. + SearchTokenStashProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'searchTokenStashProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$searchTokenStashHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + DriftTokenStash create(Ref ref) { + return searchTokenStash(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(DriftTokenStash value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$searchTokenStashHash() => r'e761b254b778f7c4cc7475e0034aa7bec8e126de'; + +@ProviderFor(searchTokenStashCount) +final searchTokenStashCountProvider = SearchTokenStashCountProvider._(); + +final class SearchTokenStashCountProvider + extends $FunctionalProvider, int, Stream> + with $FutureModifier, $StreamProvider { + SearchTokenStashCountProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'searchTokenStashCountProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$searchTokenStashCountHash(); + + @$internal + @override + $StreamProviderElement $createElement($ProviderPointer pointer) => + $StreamProviderElement(pointer); + + @override + Stream create(Ref ref) { + return searchTokenStashCount(ref); + } +} + +String _$searchTokenStashCountHash() => + r'0cef046aad0dc7e0a6e0f717fe1896f8f546d852'; diff --git a/apps/weblibre/lib/features/search_credits/domain/repositories/web_search_settings.dart b/apps/weblibre/lib/features/search_credits/domain/repositories/web_search_settings.dart new file mode 100644 index 00000000..f8f92f60 --- /dev/null +++ b/apps/weblibre/lib/features/search_credits/domain/repositories/web_search_settings.dart @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'package:riverpod/experimental/persist.dart'; +import 'package:riverpod_annotation/experimental/json_persist.dart'; +import 'package:riverpod_annotation/experimental/persist.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:search_backend/search_backend.dart'; +import 'package:weblibre/features/search_credits/data/models/web_search_settings.dart'; +import 'package:weblibre/features/user/data/providers.dart'; + +part 'web_search_settings.g.dart'; + +@Riverpod(keepAlive: true) +@JsonPersist() +class WebSearchSettingsController extends _$WebSearchSettingsController { + void setRouteThroughTor(bool value) { + state = state.copyWith(routeThroughTor: value); + } + + void setSearchMode(SearchMode mode) { + state = state.copyWith(searchMode: mode); + } + + void setLanguage(String? language) { + state = state.copyWith(language: language); + } + + void setRegion(String? region) { + state = state.copyWith(region: region); + } + + void setSafeSearch(SafeSearch? safeSearch) { + state = state.copyWith(safeSearch: safeSearch); + } + + void setTimeRange(TimeRange? timeRange) { + state = state.copyWith(timeRange: timeRange); + } + + @override + WebSearchSettings build() { + persist( + ref.watch(riverpodDatabaseStorageProvider), + key: 'WebSearchSettings', + ); + + return stateOrNull ?? WebSearchSettings.withDefaults(); + } +} diff --git a/apps/weblibre/lib/features/search_credits/domain/repositories/web_search_settings.g.dart b/apps/weblibre/lib/features/search_credits/domain/repositories/web_search_settings.g.dart new file mode 100644 index 00000000..439595c6 --- /dev/null +++ b/apps/weblibre/lib/features/search_credits/domain/repositories/web_search_settings.g.dart @@ -0,0 +1,106 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'web_search_settings.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(WebSearchSettingsController) +@JsonPersist() +final webSearchSettingsControllerProvider = + WebSearchSettingsControllerProvider._(); + +@JsonPersist() +final class WebSearchSettingsControllerProvider + extends $NotifierProvider { + WebSearchSettingsControllerProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'webSearchSettingsControllerProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$webSearchSettingsControllerHash(); + + @$internal + @override + WebSearchSettingsController create() => WebSearchSettingsController(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(WebSearchSettings value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$webSearchSettingsControllerHash() => + r'f21a6203fdb8b21c28b0ddd23f1b48768673138e'; + +@JsonPersist() +abstract class _$WebSearchSettingsControllerBase + extends $Notifier { + WebSearchSettings build(); + @$mustCallSuper + @override + void runBuild() { + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + WebSearchSettings, + Object?, + Object? + >; + element.handleCreate(ref, build); + } +} + +// ************************************************************************** +// JsonGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +abstract class _$WebSearchSettingsController + extends _$WebSearchSettingsControllerBase { + /// The default key used by [persist]. + String get key { + const resolvedKey = "WebSearchSettingsController"; + return resolvedKey; + } + + /// A variant of [persist], for JSON-specific encoding. + /// + /// You can override [key] to customize the key used for storage. + PersistResult persist( + FutureOr> storage, { + String? key, + String Function(WebSearchSettings state)? encode, + WebSearchSettings Function(String encoded)? decode, + StorageOptions options = const StorageOptions(), + }) { + return NotifierPersistX(this).persist( + storage, + key: key ?? this.key, + encode: encode ?? $jsonCodex.encode, + decode: + decode ?? + (encoded) { + final e = $jsonCodex.decode(encoded); + return WebSearchSettings.fromJson(e as Map); + }, + options: options, + ); + } +} diff --git a/apps/weblibre/lib/features/search_credits/presentation/widgets/search_credits_section.dart b/apps/weblibre/lib/features/search_credits/presentation/widgets/search_credits_section.dart new file mode 100644 index 00000000..3f4f4f01 --- /dev/null +++ b/apps/weblibre/lib/features/search_credits/presentation/widgets/search_credits_section.dart @@ -0,0 +1,202 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:intl/intl.dart'; +import 'package:timeago/timeago.dart' as timeago; +import 'package:url_launcher/url_launcher.dart'; +import 'package:weblibre/features/account/data/supabase_config.dart'; +import 'package:weblibre/features/search_credits/domain/controllers/search_token_issuance_controller.dart'; +import 'package:weblibre/features/search_credits/domain/repositories/search_credits_repository.dart'; +import 'package:weblibre/features/search_credits/domain/repositories/search_token_stash_repository.dart'; +import 'package:weblibre/features/settings/presentation/widgets/settings_content_card.dart'; + +class SearchCreditsSection extends HookConsumerWidget { + final bool embedded; + + const SearchCreditsSection({super.key, this.embedded = false}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final creditsAsync = ref.watch(searchCreditsRepositoryProvider); + final stashAsync = ref.watch(searchTokenStashCountProvider); + final issuance = ref.watch(searchTokenIssuanceControllerProvider); + + // Refresh the credits balance whenever the app returns to the + // foreground. `launchUrl` for the checkout flow returns immediately, + // long before the purchase completes, so refreshing right after the + // tap would just re-read a stale 0. The user coming back into the + // app is the signal that something may have changed. + useOnAppLifecycleStateChange((previous, current) async { + if (current == AppLifecycleState.resumed) { + await ref.read(searchCreditsRepositoryProvider.notifier).refresh(); + } + }); + + final creditsValue = creditsAsync.value; + final creditsError = creditsAsync.hasError && !creditsAsync.isLoading; + final credits = creditsValue?.availableCredits ?? 0; + final monthlyAllowance = creditsValue?.monthlyAllowance ?? 0; + final stash = stashAsync.value ?? 0; + final lastIssuanceAt = creditsValue?.lastIssuanceAt; + final currentPeriodEnd = creditsValue?.currentPeriodEnd; + + final isRequesting = issuance is SearchTokenIssuanceRequesting; + final canIssue = + credits > 0 && + (issuance is SearchTokenIssuanceIdle || + issuance is SearchTokenIssuanceFailed); + + Future openBuyMore() async { + final uri = Uri.parse('${SupabaseConfig.accountWebUrl}?view=search-pack'); + // No refresh here — launchUrl returns as soon as the browser opens, + // not when the user finishes checkout. The app-lifecycle listener + // above handles the refresh on return. + await launchUrl(uri, mode: LaunchMode.inAppBrowserView); + } + + Future onIssue() async { + final count = min(25, credits); + await ref + .read(searchTokenIssuanceControllerProvider.notifier) + .issue(count: count); + } + + final isEmpty = credits == 0 && stash == 0; + + final content = Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ListTile( + leading: Icon( + creditsError ? Icons.error_outline : Icons.search, + color: creditsError + ? theme.colorScheme.error + : theme.colorScheme.primary, + ), + title: Text( + creditsError ? 'Could not load credits' : 'Search credits', + ), + subtitle: creditsError + ? const Text('Check your connection and tap refresh to retry.') + : isEmpty + ? const Text('Buy a search pack to get started') + : Text( + monthlyAllowance > 0 + ? 'Credits: $credits / $monthlyAllowance · ' + 'Stashed tokens: $stash' + : 'Credits: $credits · Stashed tokens: $stash', + ), + trailing: IconButton( + icon: const Icon(Icons.refresh), + tooltip: 'Refresh', + onPressed: () async { + await ref + .read(searchCreditsRepositoryProvider.notifier) + .refresh(); + }, + ), + ), + if (currentPeriodEnd != null && monthlyAllowance > 0) + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 4), + child: Text( + 'Resets on ' + '${DateFormat.yMMMd().format(currentPeriodEnd.toLocal())}', + style: theme.textTheme.bodySmall, + ), + ), + if (lastIssuanceAt != null) + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + child: Text( + 'Last issuance: ${timeago.format(lastIssuanceAt)} ' + '(${DateFormat.yMMMd().add_Hm().format(lastIssuanceAt.toLocal())})', + style: theme.textTheme.bodySmall, + ), + ), + if (isRequesting) + const Padding( + padding: EdgeInsets.fromLTRB(16, 0, 16, 12), + child: Row( + children: [ + SizedBox( + height: 16, + width: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + SizedBox(width: 12), + Text('Requesting tokens...'), + ], + ), + ), + if (issuance is SearchTokenIssuanceFailed) + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: Text( + 'Token issuance failed: ${issuance.error}', + style: TextStyle(color: theme.colorScheme.error), + ), + ), + if (issuance is SearchTokenIssuanceNeedsReauth) + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: Text( + 'Please sign in again to request tokens.', + style: TextStyle(color: theme.colorScheme.error), + ), + ), + const Divider(height: 1), + if (isEmpty) + ListTile( + leading: const Icon(Icons.shopping_cart_outlined), + title: const Text('Buy a search pack'), + contentPadding: const EdgeInsets.symmetric(horizontal: 16.0), + onTap: openBuyMore, + ) + else ...[ + ListTile( + leading: const Icon(Icons.download_for_offline_outlined), + enabled: canIssue, + title: const Text('Get tokens'), + subtitle: credits > 0 + ? Text('Request ${min(25, credits)} tokens') + : const Text('No credits remaining'), + contentPadding: const EdgeInsets.symmetric(horizontal: 16.0), + onTap: canIssue ? onIssue : null, + ), + const Divider(height: 1), + ListTile( + leading: const Icon(Icons.shopping_cart_outlined), + title: const Text('Buy more'), + contentPadding: const EdgeInsets.symmetric(horizontal: 16.0), + onTap: openBuyMore, + ), + ], + ], + ); + + return SettingsContentCard(embedded: embedded, child: content); + } +} diff --git a/apps/weblibre/lib/features/settings/presentation/dialogs/log_details_dialog.dart b/apps/weblibre/lib/features/settings/presentation/dialogs/log_details_dialog.dart index 59c98eb5..b66646ed 100644 --- a/apps/weblibre/lib/features/settings/presentation/dialogs/log_details_dialog.dart +++ b/apps/weblibre/lib/features/settings/presentation/dialogs/log_details_dialog.dart @@ -20,6 +20,7 @@ // ignore_for_file: deprecated_member_use +import 'package:fading_scroll/fading_scroll.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:google_fonts/google_fonts.dart'; @@ -122,57 +123,63 @@ class LogDetailsDialog extends StatelessWidget { ), content: SizedBox( width: double.maxFinite, - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - if (message.isNotEmpty) ...[ - Text( - 'Message:', - style: TextStyle( - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.primary, - ), - ), - const SizedBox(height: 4), - SelectableText( - message, - style: GoogleFonts.robotoMono(fontSize: 12), - ), - const SizedBox(height: 16), - ], - if (error != null) ...[ - Text( - 'Error:', - style: TextStyle( - fontWeight: FontWeight.bold, - color: _levelColor(level), - ), - ), - const SizedBox(height: 4), - SelectableText( - error!, - style: GoogleFonts.robotoMono(fontSize: 11), - ), - const SizedBox(height: 16), - ], - if (stackTrace != null) ...[ - Text( - 'Stack Trace:', - style: TextStyle( - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.primary, - ), - ), - const SizedBox(height: 4), - SelectableText( - stackTrace!, - style: GoogleFonts.robotoMono(fontSize: 9), - ), - ], - ], - ), + child: FadingScroll( + fadingSize: 25, + builder: (context, controller) { + return SingleChildScrollView( + controller: controller, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (message.isNotEmpty) ...[ + Text( + 'Message:', + style: TextStyle( + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.primary, + ), + ), + const SizedBox(height: 4), + SelectableText( + message, + style: GoogleFonts.robotoMono(fontSize: 12), + ), + const SizedBox(height: 16), + ], + if (error != null) ...[ + Text( + 'Error:', + style: TextStyle( + fontWeight: FontWeight.bold, + color: _levelColor(level), + ), + ), + const SizedBox(height: 4), + SelectableText( + error!, + style: GoogleFonts.robotoMono(fontSize: 11), + ), + const SizedBox(height: 16), + ], + if (stackTrace != null) ...[ + Text( + 'Stack Trace:', + style: TextStyle( + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.primary, + ), + ), + const SizedBox(height: 4), + SelectableText( + stackTrace!, + style: GoogleFonts.robotoMono(fontSize: 9), + ), + ], + ], + ), + ); + }, ), ), actions: [ diff --git a/apps/weblibre/lib/features/settings/presentation/screens/addon_collection.dart b/apps/weblibre/lib/features/settings/presentation/screens/addon_collection.dart index 4a575b27..f0af38a4 100644 --- a/apps/weblibre/lib/features/settings/presentation/screens/addon_collection.dart +++ b/apps/weblibre/lib/features/settings/presentation/screens/addon_collection.dart @@ -22,6 +22,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart'; import 'package:weblibre/features/user/data/models/engine_settings.dart'; import 'package:weblibre/features/user/domain/repositories/engine_settings.dart'; import 'package:weblibre/utils/exit_app.dart'; @@ -57,95 +58,141 @@ class AddonCollectionScreen extends HookConsumerWidget { keys: [addonCollectionSetting], ); - return Scaffold( - appBar: AppBar( - title: const Text('Custom Extension Collection'), - actions: [ - if (addonCollectionSetting != null) - IconButton( - onPressed: () async { - await ref - .read(engineSettingsRepositoryProvider.notifier) - .updateSettings( - (currentSettings) => - currentSettings.copyWith.addonCollection(null), - ); + return SettingsCustomScrollScaffold( + title: 'Custom Extension Collection', + actions: [ + if (addonCollectionSetting != null) + IconButton( + onPressed: () async { + await ref + .read(engineSettingsRepositoryProvider.notifier) + .updateSettings( + (currentSettings) => + currentSettings.copyWith.addonCollection(null), + ); - await exitApp(ref.container); - }, - icon: const Icon(Icons.delete), - ), - ], - ), - body: Form( - key: formKey, - child: SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: ListView( - children: [ - TextFormField( - controller: serverURLController, - decoration: const InputDecoration( - label: Text('Server URL'), - hintText: _defaultServerUrl, - floatingLabelBehavior: FloatingLabelBehavior.always, - ), - keyboardType: TextInputType.url, - validator: (value) { - return validateUrl( - value, - onlyHttpProtocol: true, - eagerParsing: false, - ); - }, - ), - const SizedBox(height: 8), - TextFormField( - controller: collectionUserController, - decoration: const InputDecoration( - label: Text('Collection User'), - floatingLabelBehavior: FloatingLabelBehavior.always, - ), - validator: validateRequired, - ), - const SizedBox(height: 8), - TextFormField( - controller: collectionNameController, - decoration: const InputDecoration( - label: Text('Collection Name'), - floatingLabelBehavior: FloatingLabelBehavior.always, - ), - validator: validateRequired, - ), - const SizedBox(height: 32), - FilledButton( - onPressed: () async { - if (formKey.currentState?.validate() == true) { - await ref - .read(engineSettingsRepositoryProvider.notifier) - .updateSettings( - ( - currentSettings, - ) => currentSettings.copyWith.addonCollection( - AddonCollection( - serverURL: serverURLController.text, - collectionUser: collectionUserController.text, - collectionName: collectionNameController.text, + await exitApp(ref.container); + }, + icon: const Icon(Icons.delete), + ), + ], + slivers: [ + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 24, 16, 20), + sliver: SliverToBoxAdapter( + child: Form( + key: formKey, + child: SettingsSectionList( + sections: [ + SettingsSectionDefinition( + title: 'Collection Source', + entries: [ + SettingsEntryDefinition( + title: 'Collection configuration', + subtitle: + 'Mozilla server, collection owner, and collection name', + keywords: const ['addons', 'collection'], + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + TextFormField( + controller: serverURLController, + decoration: const InputDecoration( + label: Text('Server URL'), + hintText: _defaultServerUrl, + floatingLabelBehavior: + FloatingLabelBehavior.always, + ), + keyboardType: TextInputType.url, + validator: (value) { + return validateUrl( + value, + onlyHttpProtocol: true, + eagerParsing: false, + ); + }, ), - ), - ); + const SizedBox(height: 8), + TextFormField( + controller: collectionUserController, + decoration: const InputDecoration( + label: Text('Collection User'), + floatingLabelBehavior: + FloatingLabelBehavior.always, + ), + validator: validateRequired, + ), + const SizedBox(height: 8), + TextFormField( + controller: collectionNameController, + decoration: const InputDecoration( + label: Text('Collection Name'), + floatingLabelBehavior: + FloatingLabelBehavior.always, + ), + validator: validateRequired, + ), + ], + ), + ), + ), + ], + ), + SettingsSectionDefinition( + title: 'Actions', + entries: [ + SettingsEntryDefinition( + title: 'Save & Restart Browser', + subtitle: + 'Apply the custom collection and restart the browser', + keywords: const ['restart'], + child: Padding( + padding: const EdgeInsets.all(16.0), + child: SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: () async { + if (formKey.currentState?.validate() == true) { + await ref + .read( + engineSettingsRepositoryProvider + .notifier, + ) + .updateSettings( + (currentSettings) => currentSettings + .copyWith + .addonCollection( + AddonCollection( + serverURL: + serverURLController.text, + collectionUser: + collectionUserController + .text, + collectionName: + collectionNameController + .text, + ), + ), + ); - await exitApp(ref.container); - } - }, - child: const Text('Save & Restart Browser'), - ), - ], + await exitApp(ref.container); + } + }, + child: const Text('Save & Restart Browser'), + ), + ), + ), + ), + ], + ), + ], + query: '', + ), ), ), ), - ), + ], ); } } diff --git a/apps/weblibre/lib/features/settings/presentation/screens/advanced_settings.dart b/apps/weblibre/lib/features/settings/presentation/screens/advanced_settings.dart index ad9bcc68..2967c244 100644 --- a/apps/weblibre/lib/features/settings/presentation/screens/advanced_settings.dart +++ b/apps/weblibre/lib/features/settings/presentation/screens/advanced_settings.dart @@ -19,7 +19,6 @@ */ import 'dart:developer'; -import 'package:fading_scroll/fading_scroll.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -32,7 +31,7 @@ import 'package:weblibre/core/routing/routes.dart'; import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart'; import 'package:weblibre/features/settings/presentation/dialogs/user_agent_restart_dialog.dart'; import 'package:weblibre/features/settings/presentation/widgets/custom_list_tile.dart'; -import 'package:weblibre/features/settings/presentation/widgets/sections.dart'; +import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart'; import 'package:weblibre/features/user/data/models/engine_settings.dart'; import 'package:weblibre/features/user/domain/providers.dart'; import 'package:weblibre/features/user/domain/repositories/cache.dart'; @@ -40,76 +39,84 @@ import 'package:weblibre/features/user/domain/repositories/engine_settings.dart' import 'package:weblibre/utils/exit_app.dart'; import 'package:weblibre/utils/ui_helper.dart'; +const List advancedSettingsSections = [ + SettingsSectionDefinition( + title: 'Content & Identity', + keywords: ['engine'], + entries: [ + SettingsEntryDefinition( + title: 'Enable JavaScript', + subtitle: 'Turn website scripting on or off', + keywords: ['javascript'], + child: _JavaScriptTile(), + ), + SettingsEntryDefinition( + title: 'Custom User Agent', + subtitle: 'Override the browser user agent string', + keywords: ['ua'], + child: _UserAgentTile(), + ), + SettingsEntryDefinition( + title: 'Use third party CA certificates', + subtitle: 'Allow Android CA store certificates', + keywords: ['certificates', 'enterprise roots', 'ca'], + child: _EnterpriseRootsTile(), + ), + ], + ), + SettingsSectionDefinition( + title: 'Experimental', + entries: [ + SettingsEntryDefinition( + title: 'Experimental Features', + subtitle: 'Low-level runtime features and startup behavior', + keywords: ['runtime', 'startup'], + child: _ExperimentalSettingsTile(), + ), + ], + ), + SettingsSectionDefinition( + title: 'Developer Tools', + keywords: ['debug'], + entries: [ + SettingsEntryDefinition( + title: 'Icon Cache', + subtitle: 'Stored favicons', + keywords: ['favicons', 'cache'], + child: _IconCacheTile(), + ), + SettingsEntryDefinition( + title: 'Error Logs', + subtitle: 'View and copy logs for issue reporting', + keywords: ['logs'], + child: _ErrorLogsTile(), + ), + SettingsEntryDefinition( + title: 'Dart VM', + subtitle: 'Copy Dart VM service URL', + keywords: ['service url'], + child: _DartVmTile(), + ), + SettingsEntryDefinition( + title: 'Reset UI', + subtitle: 'Rebuild the entire browser UI', + keywords: ['refresh ui'], + child: _ResetUITile(), + ), + ], + ), +]; + class AdvancedSettingsScreen extends StatelessWidget { const AdvancedSettingsScreen({super.key}); @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: const Text('Advanced')), - body: SafeArea( - child: FadingScroll( - fadingSize: 25, - builder: (context, controller) { - return ListView( - controller: controller, - padding: const EdgeInsets.symmetric(horizontal: 12.0), - children: const [ - _ContentIdentitySection(), - _ExperimentalSection(), - _DeveloperToolsSection(), - ], - ); - }, - ), - ), - ); - } -} - -class _ContentIdentitySection extends StatelessWidget { - const _ContentIdentitySection(); - - @override - Widget build(BuildContext context) { - return const Column( - children: [ - SettingSection(name: 'Content & Identity'), - _JavaScriptTile(), - _UserAgentTile(), - _EnterpriseRootsTile(), - ], - ); - } -} - -class _ExperimentalSection extends StatelessWidget { - const _ExperimentalSection(); - - @override - Widget build(BuildContext context) { - return const Column( - children: [ - SettingSection(name: 'Experimental'), - _ExperimentalSettingsTile(), - ], - ); - } -} - -class _DeveloperToolsSection extends StatelessWidget { - const _DeveloperToolsSection(); - - @override - Widget build(BuildContext context) { - return const Column( - children: [ - SettingSection(name: 'Developer Tools'), - _IconCacheTile(), - _ErrorLogsTile(), - _DartVmTile(), - _ResetUITile(), - ], + return const SettingsDetailScaffold( + title: 'Advanced', + subtitle: 'Engine behavior, runtime overrides, and developer tools.', + icon: MdiIcons.tuneVertical, + sections: advancedSettingsSections, ); } } diff --git a/apps/weblibre/lib/features/settings/presentation/screens/bang_settings.dart b/apps/weblibre/lib/features/settings/presentation/screens/bang_settings.dart index a6025893..3ae51bca 100644 --- a/apps/weblibre/lib/features/settings/presentation/screens/bang_settings.dart +++ b/apps/weblibre/lib/features/settings/presentation/screens/bang_settings.dart @@ -17,57 +17,84 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -import 'package:fading_scroll/fading_scroll.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:weblibre/features/bangs/data/models/bang_group.dart'; import 'package:weblibre/features/bangs/domain/repositories/data.dart'; import 'package:weblibre/features/settings/presentation/widgets/bang_group_list_tile.dart'; import 'package:weblibre/features/settings/presentation/widgets/custom_list_tile.dart'; -import 'package:weblibre/features/settings/presentation/widgets/sections.dart'; +import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart'; + +const List bangSettingsSections = [ + SettingsSectionDefinition( + title: 'Usage Data', + entries: [ + SettingsEntryDefinition( + title: 'Bang Frequencies', + subtitle: 'Tracked usage for bang recommendations', + keywords: ['usage', 'recommendations'], + child: _BangFrequenciesTile(), + ), + ], + ), + SettingsSectionDefinition( + title: 'Repositories', + entries: [ + SettingsEntryDefinition( + title: 'General Bangs', + subtitle: 'Sync on demand from GitHub', + keywords: ['repository'], + child: BangGroupListTile( + group: BangGroup.general, + title: 'General Bangs', + subtitle: 'Sync on demand from GitHub', + ), + ), + SettingsEntryDefinition( + title: 'Kagi Bangs', + subtitle: 'Sync on demand from GitHub', + keywords: ['repository'], + child: BangGroupListTile( + group: BangGroup.kagi, + title: 'Kagi Bangs', + subtitle: 'Sync on-demand from GitHub', + ), + ), + ], + ), +]; class BangSettingsScreen extends HookConsumerWidget { const BangSettingsScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { - return Scaffold( - appBar: AppBar(title: const Text('Bang Settings')), - body: SafeArea( - child: FadingScroll( - fadingSize: 25, - builder: (context, controller) { - return ListView( - controller: controller, - children: [ - CustomListTile( - title: 'Bang Frequencies', - subtitle: 'Tracked usage for Bang recommendations', - suffix: FilledButton.icon( - onPressed: () async { - await ref - .read(bangDataRepositoryProvider.notifier) - .resetFrequencies(); - }, - icon: const Icon(Icons.delete), - label: const Text('Clear'), - ), - ), - const SettingSubSection(name: 'Repositories'), - const BangGroupListTile( - group: BangGroup.general, - title: 'General Bangs', - subtitle: 'Sync on demand from GitHub', - ), - const BangGroupListTile( - group: BangGroup.kagi, - title: 'Kagi Bangs', - subtitle: 'Sync on-demand from GitHub', - ), - ], - ); - }, - ), + return const SettingsDetailScaffold( + title: 'Bang Settings', + subtitle: 'Bang shortcuts usage, repositories, and on-demand sync.', + icon: MdiIcons.exclamationThick, + sections: bangSettingsSections, + ); + } +} + +class _BangFrequenciesTile extends HookConsumerWidget { + const _BangFrequenciesTile(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return CustomListTile( + title: 'Bang Frequencies', + subtitle: 'Tracked usage for bang recommendations', + suffix: FilledButton.icon( + onPressed: () async { + await ref + .read(bangDataRepositoryProvider.notifier) + .resetFrequencies(); + }, + icon: const Icon(Icons.delete), + label: const Text('Clear'), ), ); } diff --git a/apps/weblibre/lib/features/settings/presentation/screens/browsing_settings.dart b/apps/weblibre/lib/features/settings/presentation/screens/browsing_settings.dart index eb5552b4..12662dcb 100644 --- a/apps/weblibre/lib/features/settings/presentation/screens/browsing_settings.dart +++ b/apps/weblibre/lib/features/settings/presentation/screens/browsing_settings.dart @@ -17,7 +17,6 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -import 'package:fading_scroll/fading_scroll.dart'; import 'package:flutter/material.dart'; import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; @@ -27,87 +26,133 @@ import 'package:weblibre/core/routing/routes.dart'; import 'package:weblibre/features/geckoview/features/browser/domain/providers.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selected_container.dart'; import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart'; -import 'package:weblibre/features/settings/presentation/widgets/sections.dart'; +import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart'; import 'package:weblibre/features/user/data/models/general_settings.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; +const List browsingSettingsSections = [ + SettingsSectionDefinition( + title: 'Tabs', + entries: [ + SettingsEntryDefinition( + title: 'New Tab Default', + subtitle: 'Choose the default type for manually created tabs', + keywords: ['regular', 'private', 'isolated'], + child: _NewTabDefaultSection(), + ), + SettingsEntryDefinition( + title: 'Small Web Tab Default', + subtitle: 'Choose the tab type used when entering Small Web', + keywords: ['regular', 'private', 'isolated'], + child: _SmallWebTabDefaultSection(), + ), + SettingsEntryDefinition( + title: 'Tab List Direction', + subtitle: 'Choose how tabs are ordered in the list view', + keywords: ['sorting', 'order'], + child: _TabListDirectionSection(), + ), + SettingsEntryDefinition( + title: 'Tab Bar Direction', + subtitle: 'Choose how tabs are ordered in the tab bar', + keywords: ['sorting', 'order'], + child: _TabBarDirectionSection(), + ), + SettingsEntryDefinition( + title: 'Show Container UI', + subtitle: 'Show container selectors, menus, and management', + keywords: ['containers'], + child: _ShowContainerUiTile(), + ), + SettingsEntryDefinition( + title: 'Show Isolated Tab UI', + subtitle: 'Show isolated-tab creation options in the UI', + keywords: ['isolated tabs'], + child: _ShowIsolatedTabUiTile(), + ), + SettingsEntryDefinition( + title: 'Create Child Tabs', + subtitle: 'Open links from tabs in the same container context', + keywords: ['child tabs'], + child: _CreateChildTabsTile(), + ), + ], + ), + SettingsSectionDefinition( + title: 'Navigation', + entries: [ + SettingsEntryDefinition( + title: 'Pull to Refresh', + subtitle: 'Swipe down on pages to reload them', + keywords: ['reload'], + child: _PullToRefreshTile(), + ), + SettingsEntryDefinition( + title: 'Double Back to Close Tab', + subtitle: 'Require double back press before closing the current tab', + keywords: ['back button'], + child: _DoubleBackCloseTabTile(), + ), + SettingsEntryDefinition( + title: 'Tab Bar Swipe Behavior', + subtitle: 'Choose what horizontal swipes on the tab bar do', + keywords: ['gestures', 'swipe'], + child: _TabBarSwipeBehaviorSection(), + ), + SettingsEntryDefinition( + title: 'Open Links in Apps', + subtitle: 'Choose how external app links open', + keywords: ['app links', 'external apps'], + child: _AppLinksModeSection(), + ), + ], + ), + SettingsSectionDefinition( + title: 'Home Screen', + entries: [ + SettingsEntryDefinition( + title: 'Install Sites as Apps', + subtitle: 'Allow websites without a manifest to be installed as apps', + keywords: ['pwa', 'web apps'], + child: _AllowNonManifestPwaInstallTile(), + ), + ], + ), + SettingsSectionDefinition( + title: 'External Links', + entries: [ + SettingsEntryDefinition( + title: 'External Link Handling', + subtitle: 'Choose how external links open in WebLibre', + keywords: ['intents'], + child: _ExternalLinkHandlingSection(), + ), + SettingsEntryDefinition( + title: 'URL Cleaner', + subtitle: 'Tracking removal rules and catalog updates', + keywords: ['utm', 'tracking parameters'], + child: _UrlCleanerSettingsTile(), + ), + SettingsEntryDefinition( + title: 'Unshortener', + subtitle: 'Short link resolver and API token', + keywords: ['short links', 'redirects'], + child: _UnshortenerSettingsTile(), + ), + ], + ), +]; + class BrowsingSettingsScreen extends StatelessWidget { const BrowsingSettingsScreen({super.key}); @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: const Text('Browsing')), - body: SafeArea( - child: FadingScroll( - fadingSize: 25, - builder: (context, controller) { - return ListView( - controller: controller, - padding: const EdgeInsets.symmetric(horizontal: 12.0), - children: const [ - _TabsSection(), - _NavigationSection(), - _HomeScreenSection(), - _ExternalLinksSection(), - ], - ); - }, - ), - ), - ); - } -} - -class _TabsSection extends StatelessWidget { - const _TabsSection(); - - @override - Widget build(BuildContext context) { - return const Column( - children: [ - SettingSection(name: 'Tabs'), - _NewTabDefaultSection(), - _SmallWebTabDefaultSection(), - _TabListDirectionSection(), - _TabBarDirectionSection(), - _ShowContainerUiTile(), - _ShowIsolatedTabUiTile(), - _CreateChildTabsTile(), - ], - ); - } -} - -class _NavigationSection extends StatelessWidget { - const _NavigationSection(); - - @override - Widget build(BuildContext context) { - return const Column( - children: [ - SettingSection(name: 'Navigation'), - _PullToRefreshTile(), - _DoubleBackCloseTabTile(), - _TabBarSwipeBehaviorSection(), - _AppLinksModeSection(), - ], - ); - } -} - -class _ExternalLinksSection extends StatelessWidget { - const _ExternalLinksSection(); - - @override - Widget build(BuildContext context) { - return const Column( - children: [ - SettingSection(name: 'External Links'), - _ExternalLinkHandlingSection(), - _UrlCleanerSettingsTile(), - _UnshortenerSettingsTile(), - ], + return const SettingsDetailScaffold( + title: 'Browsing', + subtitle: 'Tabs, navigation, app links, and Small Web behavior.', + icon: MdiIcons.compassOutline, + sections: browsingSettingsSections, ); } } @@ -275,7 +320,6 @@ class _ExternalLinkHandlingSection extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final appColors = AppColors.of(context); final settings = ref.watch(generalSettingsWithDefaultsProvider); final tabIntentOpenSetting = settings.tabIntentOpenSetting; @@ -291,62 +335,54 @@ class _ExternalLinkHandlingSection extends HookConsumerWidget { leading: Icon(MdiIcons.tabPlus), contentPadding: EdgeInsets.zero, ), - Center( - child: SegmentedButton( - showSelectedIcon: false, - segments: [ - const ButtonSegment( - value: TabIntentOpenSetting.ask, - label: Text('Prompt'), - icon: Icon(MdiIcons.messageQuestion), - ), - const ButtonSegment( - value: TabIntentOpenSetting.regular, - label: Text('Regular'), - icon: Icon(MdiIcons.tab), - ), - ButtonSegment( - value: TabIntentOpenSetting.private, - label: const Text('Private'), - icon: Icon( - MdiIcons.dominoMask, - color: tabIntentOpenSetting == TabIntentOpenSetting.private - ? null - : appColors.privateTabPurple, - ), - ), - if (settings.showIsolatedTabUi) - ButtonSegment( - value: TabIntentOpenSetting.isolated, - label: const Text('Isolated'), - icon: Icon( - MdiIcons.snowflake, - color: - tabIntentOpenSetting == TabIntentOpenSetting.isolated - ? null - : appColors.isolatedTabTeal, - ), - ), - ], - selected: {tabIntentOpenSetting}, - onSelectionChanged: (value) async { + RadioGroup( + groupValue: tabIntentOpenSetting, + onChanged: (value) async { + if (value != null) { await ref .read(saveGeneralSettingsControllerProvider.notifier) .save( - (currentSettings) => currentSettings.copyWith - .tabIntentOpenSetting(value.first), + (currentSettings) => + currentSettings.copyWith.tabIntentOpenSetting(value), ); - }, - style: switch (tabIntentOpenSetting) { - TabIntentOpenSetting.regular => null, - TabIntentOpenSetting.private => SegmentedButton.styleFrom( - selectedBackgroundColor: appColors.privateSelectionOverlay, + } + }, + child: Column( + children: [ + const RadioListTile.adaptive( + value: TabIntentOpenSetting.ask, + title: Text('Prompt'), + subtitle: Text('Ask how external links should open'), + secondary: Icon(MdiIcons.messageQuestion), ), - TabIntentOpenSetting.isolated => SegmentedButton.styleFrom( - selectedBackgroundColor: appColors.isolatedSelectionOverlay, + const RadioListTile.adaptive( + value: TabIntentOpenSetting.regular, + title: Text('Regular'), + subtitle: Text('Open external links in a regular tab'), + secondary: Icon(MdiIcons.tab), ), - TabIntentOpenSetting.ask => null, - }, + RadioListTile.adaptive( + value: TabIntentOpenSetting.private, + title: const Text('Private'), + subtitle: const Text('Open external links in a private tab'), + secondary: Icon( + MdiIcons.dominoMask, + color: AppColors.of(context).privateTabPurple, + ), + ), + if (settings.showIsolatedTabUi) + RadioListTile.adaptive( + value: TabIntentOpenSetting.isolated, + title: const Text('Isolated'), + subtitle: const Text( + 'Open external links in an isolated tab', + ), + secondary: Icon( + MdiIcons.snowflake, + color: AppColors.of(context).isolatedTabTeal, + ), + ), + ], ), ), ], @@ -747,20 +783,6 @@ class _DoubleBackCloseTabTile extends HookConsumerWidget { } } -class _HomeScreenSection extends StatelessWidget { - const _HomeScreenSection(); - - @override - Widget build(BuildContext context) { - return const Column( - children: [ - SettingSection(name: 'Home Screen'), - _AllowNonManifestPwaInstallTile(), - ], - ); - } -} - class _AllowNonManifestPwaInstallTile extends HookConsumerWidget { const _AllowNonManifestPwaInstallTile(); diff --git a/apps/weblibre/lib/features/settings/presentation/screens/contextual_toolbar_settings.dart b/apps/weblibre/lib/features/settings/presentation/screens/contextual_toolbar_settings.dart index 23952ae6..c38996c0 100644 --- a/apps/weblibre/lib/features/settings/presentation/screens/contextual_toolbar_settings.dart +++ b/apps/weblibre/lib/features/settings/presentation/screens/contextual_toolbar_settings.dart @@ -29,6 +29,7 @@ import 'package:weblibre/features/geckoview/features/browser/features/contextual import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/presentation/toolbar_button_registry.dart'; import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/presentation/widgets/contextual_toolbar.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart'; +import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart'; import 'package:weblibre/features/user/data/database/definitions.drift.dart'; class ContextualToolbarSettingsScreen extends HookConsumerWidget { @@ -38,138 +39,144 @@ class ContextualToolbarSettingsScreen extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final configs = ref.watch(effectiveToolbarButtonConfigsProvider); final repository = ref.watch(contextualToolbarConfigRepositoryProvider); + final search = useSettingsSearch(); + final query = search.normalizedQuery; + + bool matchesQuery(ToolbarButtonConfig config) { + if (query.isEmpty) return true; + final def = toolbarButtonRegistryById[config.buttonId]; + if (def == null) return false; + return matchesSettingsSearch(query, [ + def.label, + ...def.longPressActions, + config.buttonId, + ]); + } final visibleConfigs = useMemoized( - () => configs.value.where((c) => c.isVisible).toList(growable: false), - [configs], + () => configs.value + .where((c) => c.isVisible) + .where(matchesQuery) + .toList(growable: false), + [configs, query], ); final hiddenConfigs = useMemoized( - () => configs.value.where((c) => !c.isVisible).toList(growable: false), - [configs], + () => configs.value + .where((c) => !c.isVisible) + .where(matchesQuery) + .toList(growable: false), + [configs, query], ); - return Scaffold( - appBar: AppBar( - title: const Text('Customize Toolbar'), - actions: [ - MenuAnchor( - builder: (context, controller, child) => IconButton( - onPressed: () { - if (controller.isOpen) { - controller.close(); - } else { - controller.open(); - } - }, - icon: const Icon(Icons.more_vert), - ), - menuChildren: [ - MenuItemButton( - leadingIcon: const Icon(Icons.restore), - onPressed: () => _resetToDefaults(ref), - child: const Text('Reset to Defaults'), - ), - ], + return SettingsCustomScrollScaffold( + title: 'Customize Toolbar', + searchController: search.controller, + searchHintText: 'Search toolbar buttons', + actions: [ + MenuAnchor( + builder: (context, controller, child) => IconButton( + onPressed: () { + if (controller.isOpen) { + controller.close(); + } else { + controller.open(); + } + }, + icon: const Icon(Icons.more_vert), ), - ], - ), - body: SafeArea( - child: CustomScrollView( - slivers: [ - SliverPersistentHeader( - pinned: true, - delegate: _ToolbarPreviewDelegate(configs: configs.value), + menuChildren: [ + MenuItemButton( + leadingIcon: const Icon(Icons.restore), + onPressed: () => _resetToDefaults(ref), + child: const Text('Reset to Defaults'), ), - SliverToBoxAdapter( - child: ListTile( - title: Text( - 'Enabled', - style: Theme.of(context).textTheme.labelLarge, - ), - ), - ), - if (visibleConfigs.isEmpty) - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 8, - ), - child: Text( - 'No enabled buttons. Toggle a button below to enable it.', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - ) - else - SliverReorderableList( - itemCount: visibleConfigs.length, - onReorder: (oldIndex, newIndex) => _onReorder( - visibleConfigs, - oldIndex, - newIndex, - repository, - isVisible: true, - ), - itemBuilder: (context, index) { - final config = visibleConfigs[index]; - return _ToolbarButtonConfigTile( - key: ValueKey(config.buttonId), - index: index, - config: config, - repository: repository, - ); - }, - ), - const SliverToBoxAdapter(child: Divider(height: 1)), - SliverToBoxAdapter( - child: ListTile( - title: Text( - 'Disabled', - style: Theme.of(context).textTheme.labelLarge, - ), - ), - ), - if (hiddenConfigs.isEmpty) - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 8, - ), - child: Text( - 'All buttons are enabled.', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - ) - else - SliverReorderableList( - itemCount: hiddenConfigs.length, - onReorder: (oldIndex, newIndex) => _onReorder( - hiddenConfigs, - oldIndex, - newIndex, - repository, - isVisible: false, - ), - itemBuilder: (context, index) { - final config = hiddenConfigs[index]; - return _ToolbarButtonConfigTile( - key: ValueKey(config.buttonId), - index: index, - config: config, - repository: repository, - ); - }, - ), ], ), - ), + ], + slivers: [ + const SliverToBoxAdapter(child: SizedBox(height: 16)), + SliverPersistentHeader( + pinned: true, + delegate: _ToolbarPreviewDelegate(configs: configs.value), + ), + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 20, 16, 0), + sliver: SliverToBoxAdapter(child: _SectionLabel(label: 'Enabled')), + ), + if (visibleConfigs.isEmpty) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Text( + query.isEmpty + ? 'No enabled buttons. Toggle a button below to enable it.' + : 'No enabled buttons match "${search.rawQuery}".', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + ) + else + SliverReorderableList( + itemCount: visibleConfigs.length, + onReorder: (oldIndex, newIndex) => _onReorder( + visibleConfigs, + oldIndex, + newIndex, + repository, + isVisible: true, + ), + itemBuilder: (context, index) { + final config = visibleConfigs[index]; + return _ToolbarButtonConfigTile( + key: ValueKey(config.buttonId), + index: index, + config: config, + repository: repository, + ); + }, + ), + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 20, 16, 0), + sliver: SliverToBoxAdapter(child: _SectionLabel(label: 'Disabled')), + ), + if (hiddenConfigs.isEmpty) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Text( + query.isEmpty + ? 'All buttons are enabled.' + : 'No disabled buttons match "${search.rawQuery}".', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + ) + else + SliverReorderableList( + itemCount: hiddenConfigs.length, + onReorder: (oldIndex, newIndex) => _onReorder( + hiddenConfigs, + oldIndex, + newIndex, + repository, + isVisible: false, + ), + itemBuilder: (context, index) { + final config = hiddenConfigs[index]; + return _ToolbarButtonConfigTile( + key: ValueKey(config.buttonId), + index: index, + config: config, + repository: repository, + ); + }, + ), + const SliverToBoxAdapter(child: SizedBox(height: 20)), + ], ); } @@ -251,6 +258,26 @@ class ContextualToolbarSettingsScreen extends HookConsumerWidget { } } +class _SectionLabel extends StatelessWidget { + const _SectionLabel({required this.label}); + + final String label; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Text( + label, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.w700, + ), + ), + ); + } +} + class _ToolbarButtonConfigTile extends HookConsumerWidget { const _ToolbarButtonConfigTile({ super.key, diff --git a/apps/weblibre/lib/features/settings/presentation/screens/custom_tracking_protection.dart b/apps/weblibre/lib/features/settings/presentation/screens/custom_tracking_protection.dart index 839e0736..428aa52c 100644 --- a/apps/weblibre/lib/features/settings/presentation/screens/custom_tracking_protection.dart +++ b/apps/weblibre/lib/features/settings/presentation/screens/custom_tracking_protection.dart @@ -17,41 +17,78 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -import 'package:fading_scroll/fading_scroll.dart'; import 'package:flutter/material.dart'; import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart'; -import 'package:weblibre/features/settings/presentation/widgets/sections.dart'; +import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart'; import 'package:weblibre/features/user/data/models/engine_settings.dart'; import 'package:weblibre/features/user/domain/repositories/engine_settings.dart'; +const List customTrackingProtectionSections = [ + SettingsSectionDefinition( + title: 'Allowlist Exceptions', + entries: [ + SettingsEntryDefinition( + title: 'Allowlist exceptions', + subtitle: 'Compatibility exceptions for major and minor website issues', + child: _AllowlistSection(), + ), + ], + ), + SettingsSectionDefinition( + title: 'Cookies', + entries: [ + SettingsEntryDefinition( + title: 'Cookies', + subtitle: 'Cookie blocking mode and policy selection', + child: _CookiesSection(), + ), + ], + ), + SettingsSectionDefinition( + title: 'Tracking Content', + entries: [ + SettingsEntryDefinition( + title: 'Tracking content', + subtitle: 'Tracking scripts and scope for blocking', + child: _TrackingContentSection(), + ), + ], + ), + SettingsSectionDefinition( + title: 'Trackers', + entries: [ + SettingsEntryDefinition( + title: 'Trackers', + subtitle: 'Cryptominers, known fingerprinters, and redirect trackers', + child: _TrackersSection(), + ), + ], + ), + SettingsSectionDefinition( + title: 'Advanced Fingerprinting Protection', + entries: [ + SettingsEntryDefinition( + title: 'Advanced fingerprinting protection', + subtitle: 'Suspected fingerprinters and tab scope', + child: _AdvancedFingerprintingSection(), + ), + ], + ), +]; + class CustomTrackingProtectionScreen extends StatelessWidget { const CustomTrackingProtectionScreen({super.key}); @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: const Text('Custom Tracking Protection')), - body: SafeArea( - child: FadingScroll( - fadingSize: 25, - builder: (context, controller) { - return ListView( - controller: controller, - padding: const EdgeInsets.symmetric(horizontal: 12.0), - children: const [ - _AllowlistSection(), - _CookiesSection(), - _TrackingContentSection(), - _TrackersSection(), - _AdvancedFingerprintingSection(), - ], - ); - }, - ), - ), + return const SettingsDetailScaffold( + title: 'Custom Tracking Protection', + subtitle: 'Custom cookie, content, tracker, and fingerprinting controls.', + icon: MdiIcons.shieldEditOutline, + sections: customTrackingProtectionSections, ); } } @@ -70,7 +107,6 @@ class _AllowlistSection extends HookConsumerWidget { return Column( children: [ - const SettingSection(name: 'Allowlist Exceptions'), SwitchListTile.adaptive( title: const Text('Fix website major issues'), subtitle: const Text( @@ -116,7 +152,6 @@ class _CookiesSection extends HookConsumerWidget { return Column( children: [ - const SettingSection(name: 'Cookies'), SwitchListTile.adaptive( title: const Text('Block Cookies'), subtitle: const Text('Block cookies based on the policy below'), @@ -198,7 +233,6 @@ class _TrackingContentSection extends HookConsumerWidget { return Column( children: [ - const SettingSection(name: 'Tracking Content'), SwitchListTile.adaptive( title: const Text('Block Tracking Content'), subtitle: const Text( @@ -270,7 +304,6 @@ class _TrackersSection extends HookConsumerWidget { return Column( children: [ - const SettingSection(name: 'Trackers'), const ListTile( title: Text('Always Blocked'), subtitle: Text( @@ -340,7 +373,6 @@ class _AdvancedFingerprintingSection extends HookConsumerWidget { return Column( children: [ - const SettingSection(name: 'Advanced Fingerprinting Protection'), SwitchListTile.adaptive( title: const Text('Suspected Fingerprinters'), subtitle: const Text( diff --git a/apps/weblibre/lib/features/settings/presentation/screens/doh_settings.dart b/apps/weblibre/lib/features/settings/presentation/screens/doh_settings.dart index 09e720d5..2aff0ee5 100644 --- a/apps/weblibre/lib/features/settings/presentation/screens/doh_settings.dart +++ b/apps/weblibre/lib/features/settings/presentation/screens/doh_settings.dart @@ -20,17 +20,32 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:weblibre/features/settings/presentation/widgets/doh_settings_content.dart'; +import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart'; + +const List dohSettingsSections = [ + SettingsSectionDefinition( + title: 'Resolver Settings', + entries: [ + SettingsEntryDefinition( + title: 'DNS over HTTPS', + subtitle: 'Protection level, provider choice, and custom resolver URL', + keywords: ['doh', 'resolver', 'dns provider'], + child: DohSettingsContent(), + ), + ], + ), +]; class DohSettingsScreen extends HookConsumerWidget { const DohSettingsScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { - return Scaffold( - appBar: AppBar(title: const Text('DNS over HTTPS')), - body: const SafeArea( - child: SingleChildScrollView(child: DohSettingsContent()), - ), + return const SettingsDetailScaffold( + title: 'DNS over HTTPS', + subtitle: 'Encrypted DNS protection level and resolver selection.', + icon: Icons.dns_outlined, + sections: dohSettingsSections, ); } } diff --git a/apps/weblibre/lib/features/settings/presentation/screens/error_logs_screen.dart b/apps/weblibre/lib/features/settings/presentation/screens/error_logs_screen.dart index 62c364c2..6e5127e0 100644 --- a/apps/weblibre/lib/features/settings/presentation/screens/error_logs_screen.dart +++ b/apps/weblibre/lib/features/settings/presentation/screens/error_logs_screen.dart @@ -30,6 +30,7 @@ import 'package:timeago/timeago.dart' as timeago; import 'package:weblibre/core/logger.dart'; import 'package:weblibre/features/settings/domain/providers/log_filter.dart'; import 'package:weblibre/features/settings/presentation/dialogs/log_details_dialog.dart'; +import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart'; import 'package:weblibre/utils/ui_helper.dart'; IconData _levelIcon(Level level) { @@ -98,62 +99,75 @@ class ErrorLogsScreen extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final minLogLevel = ref.watch(logFilterProvider); + final search = useSettingsSearch(); + final query = search.normalizedQuery; final allLogs = useMemoized(() => loggerMemory.buffer.toList()); final sortedLogs = useMemoized( () => allLogs.reversed .where((e) => e.level.value >= minLogLevel.value) + .where((e) { + if (query.isEmpty) return true; + return matchesSettingsSearch(query, [ + if (e.origin.message != null) e.origin.message.toString(), + if (e.origin.error != null) e.origin.error.toString(), + ]); + }) .toList(), - [allLogs, minLogLevel.value], + [allLogs, minLogLevel.value, query], ); - return Scaffold( - appBar: AppBar( - title: const Text('Error Logs'), - actions: [ - MenuAnchor( - builder: (context, controller, childAnchor) { - return TextButton.icon( - onPressed: controller.open, - icon: const Icon(Icons.filter_list), - label: Text(minLogLevel.name.toUpperCase()), - ); - }, - menuChildren: [ - const Divider(height: 0), - _buildLevelFilterMenuItem(context, ref, minLogLevel, Level.trace), - _buildLevelFilterMenuItem(context, ref, minLogLevel, Level.debug), - _buildLevelFilterMenuItem(context, ref, minLogLevel, Level.info), - _buildLevelFilterMenuItem( - context, - ref, - minLogLevel, - Level.warning, - ), - _buildLevelFilterMenuItem(context, ref, minLogLevel, Level.error), - _buildLevelFilterMenuItem(context, ref, minLogLevel, Level.fatal), - ], + return SettingsCustomScrollScaffold( + title: 'Error Logs', + searchController: search.controller, + searchHintText: 'Search log messages', + actions: [ + MenuAnchor( + builder: (context, controller, childAnchor) { + return TextButton.icon( + onPressed: controller.open, + icon: const Icon(Icons.filter_list), + label: Text(minLogLevel.name.toUpperCase()), + ); + }, + menuChildren: [ + const Divider(height: 0), + _buildLevelFilterMenuItem(context, ref, minLogLevel, Level.trace), + _buildLevelFilterMenuItem(context, ref, minLogLevel, Level.debug), + _buildLevelFilterMenuItem(context, ref, minLogLevel, Level.info), + _buildLevelFilterMenuItem(context, ref, minLogLevel, Level.warning), + _buildLevelFilterMenuItem(context, ref, minLogLevel, Level.error), + _buildLevelFilterMenuItem(context, ref, minLogLevel, Level.fatal), + ], + ), + IconButton( + onPressed: () => _copyToClipboard(context), + icon: const Icon(Icons.copy), + tooltip: 'Copy logs', + ), + ], + slivers: [ + if (sortedLogs.isEmpty) + const SliverFillRemaining( + hasScrollBody: false, + child: Center(child: Text('No logs available')), + ) + else + SliverPadding( + padding: const EdgeInsets.fromLTRB(8, 16, 8, 20), + // Log lines render as their own _LogEntryTile cards rather than + // settings-section entries because they have nothing to do with + // settings semantically — the SettingsSection abstraction was + // overkill here. We keep the SettingsSearchField + filtering for + // free via the shared scaffold. + sliver: SliverList.builder( + itemCount: sortedLogs.length, + itemBuilder: (context, index) => + _LogEntryTile(event: sortedLogs[index]), + ), ), - IconButton( - onPressed: () => _copyToClipboard(context), - icon: const Icon(Icons.copy), - tooltip: 'Copy logs', - ), - ], - ), - body: SafeArea( - child: sortedLogs.isEmpty - ? const Center(child: Text('No logs available')) - : ListView.builder( - padding: const EdgeInsets.all(8.0), - itemCount: sortedLogs.length, - itemBuilder: (context, index) { - final logEntry = sortedLogs[index]; - return _LogEntryTile(event: logEntry); - }, - ), - ), + ], ); } diff --git a/apps/weblibre/lib/features/settings/presentation/screens/experimental_settings.dart b/apps/weblibre/lib/features/settings/presentation/screens/experimental_settings.dart index 5b6cfe95..3cf07922 100644 --- a/apps/weblibre/lib/features/settings/presentation/screens/experimental_settings.dart +++ b/apps/weblibre/lib/features/settings/presentation/screens/experimental_settings.dart @@ -17,43 +17,58 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -import 'package:fading_scroll/fading_scroll.dart'; import 'package:flutter/material.dart'; import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart'; -import 'package:weblibre/features/settings/presentation/widgets/sections.dart'; +import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart'; import 'package:weblibre/features/user/data/models/engine_settings.dart'; import 'package:weblibre/features/user/domain/presentation/dialogs/quit_browser_dialog.dart'; import 'package:weblibre/features/user/domain/repositories/engine_settings.dart'; import 'package:weblibre/utils/exit_app.dart'; +const List experimentalSettingsSections = [ + SettingsSectionDefinition( + title: 'Web Push', + entries: [ + SettingsEntryDefinition( + title: 'Choose UnifiedPush Distributor', + subtitle: 'Select the app that delivers website push notifications', + keywords: ['notifications', 'push'], + child: _UnifiedPushDistributorTile(), + ), + ], + ), + SettingsSectionDefinition( + title: 'Runtime & Startup', + entries: [ + SettingsEntryDefinition( + title: 'Isolated Content Process', + subtitle: 'Run web content in an isolated process', + keywords: ['restart'], + child: _IsolatedProcessEnabledTile(), + ), + SettingsEntryDefinition( + title: 'App Zygote Process', + subtitle: 'Preload the content service for faster isolated startup', + keywords: ['restart', 'android 10'], + child: _AppZygoteProcessEnabledTile(), + ), + ], + ), +]; + class ExperimentalSettingsScreen extends StatelessWidget { const ExperimentalSettingsScreen({super.key}); @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: const Text('Experimental')), - body: SafeArea( - child: FadingScroll( - fadingSize: 25, - builder: (context, controller) { - return ListView( - controller: controller, - padding: const EdgeInsets.symmetric(horizontal: 12.0), - children: const [ - SettingSection(name: 'Web Push'), - _UnifiedPushDistributorTile(), - SettingSection(name: 'Runtime & Startup'), - _IsolatedProcessEnabledTile(), - _AppZygoteProcessEnabledTile(), - ], - ); - }, - ), - ), + return const SettingsDetailScaffold( + title: 'Experimental', + subtitle: 'Push delivery, runtime isolation, and startup behavior.', + icon: MdiIcons.flaskOutline, + sections: experimentalSettingsSections, ); } } diff --git a/apps/weblibre/lib/features/settings/presentation/screens/extensions_settings.dart b/apps/weblibre/lib/features/settings/presentation/screens/extensions_settings.dart index 6a7edb58..04a9148c 100644 --- a/apps/weblibre/lib/features/settings/presentation/screens/extensions_settings.dart +++ b/apps/weblibre/lib/features/settings/presentation/screens/extensions_settings.dart @@ -19,7 +19,6 @@ */ import 'dart:async'; -import 'package:fading_scroll/fading_scroll.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; @@ -27,35 +26,62 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:weblibre/core/routing/routes.dart'; import 'package:weblibre/features/geckoview/features/browser/domain/services/browser_addon.dart'; import 'package:weblibre/features/settings/presentation/widgets/custom_list_tile.dart'; -import 'package:weblibre/features/settings/presentation/widgets/sections.dart'; +import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart'; + +const List extensionsSettingsSections = [ + SettingsSectionDefinition( + title: 'Extensions', + entries: [ + SettingsEntryDefinition( + title: 'Manage Extensions', + subtitle: + 'Browse installed, disabled, available, and unsupported extensions', + keywords: ['addons', 'browser extensions'], + child: _ManageExtensionsTile(), + ), + SettingsEntryDefinition( + title: 'Custom Collection', + subtitle: 'Use a custom Mozilla addon collection', + keywords: ['addons'], + child: _AddonCollectionTile(), + ), + ], + ), + SettingsSectionDefinition( + title: 'Updates', + entries: [ + SettingsEntryDefinition( + title: 'Automatic updates', + subtitle: + 'Automatically check for and install extension updates every 12 hours', + keywords: ['addons'], + child: _AutoUpdateTile(), + ), + ], + ), + SettingsSectionDefinition( + title: 'Security', + entries: [ + SettingsEntryDefinition( + title: 'Allow unsigned extensions', + subtitle: 'Unsigned extensions have not been verified by Mozilla', + keywords: ['addons'], + child: _AllowUnsignedExtensionsTile(), + ), + ], + ), +]; class ExtensionsSettingsScreen extends StatelessWidget { const ExtensionsSettingsScreen({super.key}); @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: const Text('Extensions')), - body: SafeArea( - child: FadingScroll( - fadingSize: 25, - builder: (context, controller) { - return ListView( - controller: controller, - padding: const EdgeInsets.symmetric(horizontal: 12.0), - children: const [ - SettingSection(name: 'Extensions'), - _ManageExtensionsTile(), - _AddonCollectionTile(), - SettingSection(name: 'Updates'), - _AutoUpdateTile(), - SettingSection(name: 'Security'), - _AllowUnsignedExtensionsTile(), - ], - ); - }, - ), - ), + return const SettingsDetailScaffold( + title: 'Extensions', + subtitle: 'Manage add-ons, update behavior, and extension security.', + icon: MdiIcons.puzzleOutline, + sections: extensionsSettingsSections, ); } } diff --git a/apps/weblibre/lib/features/settings/presentation/screens/fingerprint_settings.dart b/apps/weblibre/lib/features/settings/presentation/screens/fingerprint_settings.dart index c8364d1c..6fb9de18 100644 --- a/apps/weblibre/lib/features/settings/presentation/screens/fingerprint_settings.dart +++ b/apps/weblibre/lib/features/settings/presentation/screens/fingerprint_settings.dart @@ -22,6 +22,7 @@ import 'package:flutter_material_design_icons/flutter_material_design_icons.dart import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nullability/nullability.dart'; import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart'; +import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart'; import 'package:weblibre/features/user/data/models/engine_settings.dart'; import 'package:weblibre/features/user/domain/entities/fingerprint_overrides.dart'; import 'package:weblibre/features/user/domain/providers.dart'; @@ -35,146 +36,172 @@ class FingerprintSettingsScreen extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final targetsAsync = ref.watch(fingerprintTargetsProvider); final settingsAsync = ref.watch(fingerprintOverrideSettingsProvider); + final search = useSettingsSearch(); - return Scaffold( - appBar: AppBar( - title: const Text('Fingerprint Protection'), - actions: [ - MenuAnchor( - builder: (context, controller, child) { - return IconButton( - onPressed: () { - if (controller.isOpen) { - controller.close(); - } else { - controller.open(); - } - }, - icon: const Icon(Icons.more_vert), - ); - }, - menuChildren: [ - MenuItemButton( - leadingIcon: const Icon(MdiIcons.restore), - child: const Text('Load Defaults'), - onPressed: () async { - await ref - .read(saveEngineSettingsControllerProvider.notifier) - .save( - (currentSettings) => currentSettings.copyWith - .fingerprintingProtectionOverrides( - FingerprintOverrides.defaults().toString(), - ), - ); - }, - ), - MenuItemButton( - leadingIcon: const Icon(MdiIcons.restore), - child: const Text('Load Hardened Defaults'), - onPressed: () async { - await ref - .read(saveEngineSettingsControllerProvider.notifier) - .save( - (currentSettings) => currentSettings.copyWith - .fingerprintingProtectionOverrides( - FingerprintOverrides.hardenedDefaults() - .toString(), - ), - ); - }, - ), - ], - ), - ], - ), - body: SafeArea( - child: settingsAsync.when( - skipLoadingOnReload: true, - data: (result) { - return result.fold( - (overrides) { - return targetsAsync.when( - skipLoadingOnReload: true, - data: (targets) { - return ListView.builder( - itemCount: targets.length, - itemBuilder: (context, index) { - final target = targets[index]; - final state = overrides.targets[target.name]; - - return CheckboxListTile.adaptive( - value: - (overrides.allTargets == true && - state != false) || - state == true, - onChanged: (value) async { - if (value != null) { - final newOverrides = overrides - .copyWithTarget(target.name, value) - .toString(); - - await ref - .read( - saveEngineSettingsControllerProvider - .notifier, - ) - .save( - (currentSettings) => currentSettings - .copyWith - .fingerprintingProtectionOverrides( - newOverrides, - ), - ); - } - }, - title: Text(target.name), - subtitle: target.description.mapNotNull( - (desc) => Text(desc), + return SettingsCustomScrollScaffold( + title: 'Fingerprint Protection', + searchController: search.controller, + searchHintText: 'Search fingerprint override targets', + actions: [ + MenuAnchor( + builder: (context, controller, child) { + return IconButton( + onPressed: () { + if (controller.isOpen) { + controller.close(); + } else { + controller.open(); + } + }, + icon: const Icon(Icons.more_vert), + ); + }, + menuChildren: [ + MenuItemButton( + leadingIcon: const Icon(MdiIcons.restore), + child: const Text('Load Defaults'), + onPressed: () async { + await ref + .read(saveEngineSettingsControllerProvider.notifier) + .save( + (currentSettings) => currentSettings.copyWith + .fingerprintingProtectionOverrides( + FingerprintOverrides.defaults().toString(), ), + ); + }, + ), + MenuItemButton( + leadingIcon: const Icon(MdiIcons.restore), + child: const Text('Load Hardened Defaults'), + onPressed: () async { + await ref + .read(saveEngineSettingsControllerProvider.notifier) + .save( + (currentSettings) => currentSettings.copyWith + .fingerprintingProtectionOverrides( + FingerprintOverrides.hardenedDefaults().toString(), + ), + ); + }, + ), + ], + ), + ], + slivers: [ + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 24, 16, 20), + sliver: SliverToBoxAdapter( + child: settingsAsync.when( + skipLoadingOnReload: true, + data: (result) { + return result.fold( + (overrides) { + return targetsAsync.when( + skipLoadingOnReload: true, + data: (targets) { + final filteredTargets = targets.where((target) { + if (search.normalizedQuery.isEmpty) return true; + return matchesSettingsSearch(search.normalizedQuery, [ + target.name, + if (target.description != null) target.description!, + ]); + }).toList(); + + return SettingsSectionList( + sections: filteredTargets.isEmpty + ? const [] + : [ + SettingsSectionDefinition( + title: 'Override Targets', + entries: [ + for (final target in filteredTargets) + SettingsEntryDefinition( + title: target.name, + subtitle: target.description, + child: CheckboxListTile.adaptive( + value: + (overrides.allTargets == true && + overrides.targets[target + .name] != + false) || + overrides.targets[target + .name] == + true, + onChanged: (value) async { + if (value != null) { + final newOverrides = overrides + .copyWithTarget( + target.name, + value, + ) + .toString(); + + await ref + .read( + saveEngineSettingsControllerProvider + .notifier, + ) + .save( + ( + currentSettings, + ) => currentSettings + .copyWith + .fingerprintingProtectionOverrides( + newOverrides, + ), + ); + } + }, + title: Text(target.name), + subtitle: target.description + .mapNotNull( + (desc) => Text(desc), + ), + ), + ), + ], + ), + ], + query: search.rawQuery, ); }, + error: (error, stackTrace) { + return FailureWidget( + exception: error, + onRetry: () { + ref.invalidate(fingerprintTargetsProvider); + }, + ); + }, + loading: () => + const Center(child: CircularProgressIndicator()), + ); + }, + onFailure: (errorMessage) { + return FailureWidget( + title: errorMessage.message, + exception: errorMessage.details, + onRetry: () { + ref.invalidate(fingerprintOverrideSettingsProvider); + }, ); }, - error: (error, stackTrace) { - return Center( - child: FailureWidget( - exception: error, - onRetry: () { - ref.invalidate(fingerprintTargetsProvider); - }, - ), - ); + ); + }, + error: (error, stackTrace) { + return FailureWidget( + exception: error, + onRetry: () { + ref.invalidate(fingerprintOverrideSettingsProvider); }, - loading: () => - const Center(child: CircularProgressIndicator()), ); }, - onFailure: (errorMessage) { - return Center( - child: FailureWidget( - title: errorMessage.message, - exception: errorMessage.details, - onRetry: () { - ref.invalidate(fingerprintOverrideSettingsProvider); - }, - ), - ); - }, - ); - }, - error: (error, stackTrace) { - return Center( - child: FailureWidget( - exception: error, - onRetry: () { - ref.invalidate(fingerprintOverrideSettingsProvider); - }, - ), - ); - }, - loading: () => const Center(child: CircularProgressIndicator()), + loading: () => const Center(child: CircularProgressIndicator()), + ), + ), ), - ), + ], ); } } diff --git a/apps/weblibre/lib/features/settings/presentation/screens/general_settings.dart b/apps/weblibre/lib/features/settings/presentation/screens/general_settings.dart index dbea9b67..f2320c40 100644 --- a/apps/weblibre/lib/features/settings/presentation/screens/general_settings.dart +++ b/apps/weblibre/lib/features/settings/presentation/screens/general_settings.dart @@ -25,48 +25,76 @@ import 'package:flutter_mozilla_components/flutter_mozilla_components.dart' import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart'; import 'package:weblibre/features/settings/presentation/widgets/custom_list_tile.dart'; -import 'package:weblibre/features/settings/presentation/widgets/sections.dart'; +import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart'; import 'package:weblibre/features/user/data/models/general_settings.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; import 'package:weblibre/presentation/hooks/cached_future.dart'; +const List generalSettingsSections = [ + SettingsSectionDefinition( + title: 'Default Browser', + keywords: ['browser defaults'], + entries: [ + SettingsEntryDefinition( + title: 'Default Browser', + subtitle: 'Set WebLibre as your default browser', + keywords: ['system browser'], + child: _DefaultBrowserTile(), + ), + ], + ), + SettingsSectionDefinition( + title: 'Appearance', + entries: [ + SettingsEntryDefinition( + title: 'Theme', + subtitle: 'Choose system, light, or dark mode', + keywords: ['light', 'dark', 'theme mode'], + child: _ThemeSection(), + ), + SettingsEntryDefinition( + title: 'User Interface Zoom', + subtitle: 'Make the user interface smaller or larger', + keywords: ['ui scale', 'zoom'], + child: _UiZoomSection(), + ), + SettingsEntryDefinition( + title: 'Disable Animations', + subtitle: 'Reduce motion and turn off app animations', + keywords: ['motion'], + child: _DisableAnimationsTile(), + ), + SettingsEntryDefinition( + title: 'Show Modal Barrier', + subtitle: 'Dim the background behind dialogs and bottom sheets', + keywords: ['dialogs', 'bottom sheets', 'overlay'], + child: _ShowModalBarrierTile(), + ), + ], + ), + SettingsSectionDefinition( + title: 'Downloads', + entries: [ + SettingsEntryDefinition( + title: 'Use external download manager', + subtitle: 'Manage downloads with another app', + keywords: ['downloads'], + child: _ExternalDownloadManagerTile(), + ), + ], + ), +]; + class GeneralSettingsScreen extends StatelessWidget { const GeneralSettingsScreen({super.key}); @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: const Text('General')), - body: SafeArea( - child: FadingScroll( - fadingSize: 25, - builder: (context, controller) { - return ListView( - controller: controller, - padding: const EdgeInsets.symmetric(horizontal: 12.0), - children: const [ - _DefaultBrowserSection(), - _AppearanceSection(), - _DownloadsSection(), - ], - ); - }, - ), - ), - ); - } -} - -class _DefaultBrowserSection extends StatelessWidget { - const _DefaultBrowserSection(); - - @override - Widget build(BuildContext context) { - return const Column( - children: [ - SettingSection(name: 'Default Browser'), - _DefaultBrowserTile(), - ], + return const SettingsDetailScaffold( + title: 'General', + subtitle: 'Appearance, language, downloads, and browser defaults.', + icon: Icons.tune, + sections: generalSettingsSections, ); } } @@ -118,37 +146,6 @@ class _DefaultBrowserTile extends HookConsumerWidget { } } -class _AppearanceSection extends StatelessWidget { - const _AppearanceSection(); - - @override - Widget build(BuildContext context) { - return const Column( - children: [ - SettingSection(name: 'Appearance'), - _ThemeSection(), - _UiZoomSection(), - _DisableAnimationsTile(), - _ShowModalBarrierTile(), - ], - ); - } -} - -class _DownloadsSection extends StatelessWidget { - const _DownloadsSection(); - - @override - Widget build(BuildContext context) { - return const Column( - children: [ - SettingSection(name: 'Downloads'), - _ExternalDownloadManagerTile(), - ], - ); - } -} - class _UiZoomSection extends HookConsumerWidget { const _UiZoomSection(); diff --git a/apps/weblibre/lib/features/settings/presentation/screens/locale_settings.dart b/apps/weblibre/lib/features/settings/presentation/screens/locale_settings.dart index 9733dac1..bb6ca70f 100644 --- a/apps/weblibre/lib/features/settings/presentation/screens/locale_settings.dart +++ b/apps/weblibre/lib/features/settings/presentation/screens/locale_settings.dart @@ -27,6 +27,7 @@ import 'package:nullability/nullability.dart'; import 'package:weblibre/domain/repositories/locale_resolver.dart'; import 'package:weblibre/extensions/locale.dart'; import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart'; +import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart'; import 'package:weblibre/features/user/data/models/engine_settings.dart'; import 'package:weblibre/features/user/domain/repositories/engine_settings.dart'; @@ -51,117 +52,174 @@ class LocaleSettingsScreen extends HookConsumerWidget { ), ); - final availableLocales = { - ...systemLocales, - ...userLocales.value, - Locale.fromSubtags(languageCode: 'en', countryCode: 'US'), - }; + final availableLocales = useMemoized( + () => + { + ...systemLocales, + ...userLocales.value, + Locale.fromSubtags(languageCode: 'en', countryCode: 'US'), + }.toList()..sort( + (a, b) => a.toLanguageTag().compareTo(b.toLanguageTag()), + ), + [systemLocales, userLocales], + ); final customLocaleController = useTextEditingController(); + final search = useSettingsSearch(); - return Scaffold( - appBar: AppBar(title: const Text('Browser Languages')), - body: SafeArea( - child: ListView( - children: [ - ...availableLocales.map((locale) { - return CheckboxListTile.adaptive( - value: userLocales.value.contains(locale), - onChanged: (value) async { - if (value != null) { - await ref - .read(saveEngineSettingsControllerProvider.notifier) - .save( - (currentSettings) => currentSettings.copyWith.locales( - value - ? { - ...currentSettings.locales, - locale.toLanguageTag(), - }.toList() - : ([ - ...currentSettings.locales, - ]..remove(locale.toLanguageTag())).toList(), - ), - ); - } - }, - title: Consumer( - builder: (context, ref, child) { - final resolvedAsync = ref.watch( - resolveLocaleProvider(locale), - ); + final filteredLocales = availableLocales.where((locale) { + if (search.normalizedQuery.isEmpty) return true; + return locale + .toLanguageTag() + .toLowerCase() + .contains(search.normalizedQuery); + }).toList(); - return Text( - resolvedAsync.maybeWhen( - data: (data) => - data.mapNotNull( - (data) => - '${data.languageName} ${data.countryName.mapNotNull((country) => '($country)') ?? ''}' - .trim(), - ) ?? - locale.toLanguageTag(), - orElse: () => locale.toLanguageTag(), - ), - ); - }, - ), - subtitle: Text(locale.toLanguageTag()), - secondary: CountryFlag.fromLanguageCode( - locale.languageCode, - theme: const ImageTheme(shape: RoundedRectangle(8.0)), - ), - ); - }), - const Divider(), - Padding( - padding: const EdgeInsets.only(left: 16.0, right: 20), - child: Form( - key: formKey, - child: TextFormField( - controller: customLocaleController, - decoration: InputDecoration( - label: const Text('Custom Locale'), - hint: const Text('en-US'), - floatingLabelBehavior: FloatingLabelBehavior.always, - suffixIcon: IconButton( - onPressed: () { - if (formKey.currentState?.validate() == true) { - formKey.currentState?.save(); - } - }, - icon: const Icon(Icons.add), - ), - ), - validator: (value) { - if (value != null && Locale.tryParse(value) == null) { - return 'Invalid locale identifier'; - } + return SettingsCustomScrollScaffold( + title: 'Browser Languages', + searchController: search.controller, + searchHintText: 'Search locales by tag', + slivers: [ + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 24, 16, 20), + sliver: SliverToBoxAdapter( + child: SettingsSectionList( + sections: [ + SettingsSectionDefinition( + title: 'Language & Region Settings', + entries: [ + for (final locale in filteredLocales) + SettingsEntryDefinition( + title: locale.toLanguageTag(), + subtitle: 'Browser language preference', + keywords: [locale.languageCode], + child: CheckboxListTile.adaptive( + value: userLocales.value.contains(locale), + onChanged: (value) async { + if (value != null) { + await ref + .read( + saveEngineSettingsControllerProvider + .notifier, + ) + .save( + (currentSettings) => + currentSettings.copyWith.locales( + value + ? { + ...currentSettings.locales, + locale.toLanguageTag(), + }.toList() + : ([...currentSettings.locales] + ..remove( + locale.toLanguageTag(), + )) + .toList(), + ), + ); + } + }, + title: Consumer( + builder: (context, ref, child) { + final resolvedAsync = ref.watch( + resolveLocaleProvider(locale), + ); - return null; - }, - onSaved: (newValue) async { - if (newValue != null) { - await ref - .read(saveEngineSettingsControllerProvider.notifier) - .save( - (currentSettings) => - currentSettings.copyWith.locales( - { - ...currentSettings.locales, - Locale.parse(newValue).toLanguageTag(), - }.toList(), + return Text( + resolvedAsync.maybeWhen( + data: (data) => + data.mapNotNull( + (data) => + '${data.languageName} ${data.countryName.mapNotNull((country) => '($country)') ?? ''}' + .trim(), + ) ?? + locale.toLanguageTag(), + orElse: () => locale.toLanguageTag(), ), - ); - - customLocaleController.clear(); - } - }, + ); + }, + ), + subtitle: Text(locale.toLanguageTag()), + secondary: CountryFlag.fromLanguageCode( + locale.languageCode, + theme: const ImageTheme( + shape: RoundedRectangle(8.0), + ), + ), + ), + ), + ], ), - ), + SettingsSectionDefinition( + title: 'Custom Locale', + entries: [ + SettingsEntryDefinition( + title: 'Add custom locale', + subtitle: 'Enter a locale tag such as en-US', + keywords: const ['locale tag'], + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 16), + child: Form( + key: formKey, + child: TextFormField( + controller: customLocaleController, + decoration: InputDecoration( + label: const Text('Custom Locale'), + hint: const Text('en-US'), + floatingLabelBehavior: + FloatingLabelBehavior.always, + suffixIcon: IconButton( + onPressed: () { + if (formKey.currentState?.validate() == + true) { + formKey.currentState?.save(); + } + }, + icon: const Icon(Icons.add), + ), + ), + validator: (value) { + if (value != null && + Locale.tryParse(value) == null) { + return 'Invalid locale identifier'; + } + + return null; + }, + onSaved: (newValue) async { + if (newValue != null) { + await ref + .read( + saveEngineSettingsControllerProvider + .notifier, + ) + .save( + (currentSettings) => + currentSettings.copyWith.locales( + { + ...currentSettings.locales, + Locale.parse( + newValue, + ).toLanguageTag(), + }.toList(), + ), + ); + + customLocaleController.clear(); + } + }, + ), + ), + ), + ), + ], + ), + ], + query: search.rawQuery, ), - ], + ), ), - ), + ], ); } } diff --git a/apps/weblibre/lib/features/settings/presentation/screens/privacy_security_settings.dart b/apps/weblibre/lib/features/settings/presentation/screens/privacy_security_settings.dart index d97815d0..5cce5157 100644 --- a/apps/weblibre/lib/features/settings/presentation/screens/privacy_security_settings.dart +++ b/apps/weblibre/lib/features/settings/presentation/screens/privacy_security_settings.dart @@ -17,7 +17,6 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -import 'package:fading_scroll/fading_scroll.dart'; import 'package:flutter/material.dart'; import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; @@ -29,6 +28,7 @@ import 'package:weblibre/features/intent_gatekeeper/domain/entities/intent_sourc import 'package:weblibre/features/intent_gatekeeper/domain/services/package_label_resolver.dart'; import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart'; import 'package:weblibre/features/settings/presentation/widgets/sections.dart'; +import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart'; import 'package:weblibre/features/user/data/models/engine_settings.dart'; import 'package:weblibre/features/user/data/models/general_settings.dart'; import 'package:weblibre/features/user/domain/presentation/dialogs/quit_browser_dialog.dart'; @@ -36,69 +36,216 @@ import 'package:weblibre/features/user/domain/repositories/engine_settings.dart' import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; import 'package:weblibre/utils/exit_app.dart'; +const List privacySecuritySettingsSections = [ + SettingsSectionDefinition( + title: 'Tracking Protection', + keywords: ['privacy'], + entries: [ + SettingsEntryDefinition( + title: 'Enhanced Tracking Protection', + subtitle: 'Choose how aggressively trackers are blocked', + keywords: ['etp', 'standard', 'strict', 'custom'], + child: _EnhancedTrackingProtectionSection(), + ), + SettingsEntryDefinition( + title: 'Bounce Tracking Protection', + subtitle: 'Remove tracking state left by redirect-based trackers', + keywords: ['redirect trackers'], + child: _BounceTrackingProtectionTile(), + ), + SettingsEntryDefinition( + title: 'Query Parameter Stripping', + subtitle: 'Remove tracking parameters from URLs', + keywords: ['utm'], + child: _QueryParameterStrippingSection(), + ), + SettingsEntryDefinition( + title: 'Tracking Protection Exceptions', + subtitle: 'Sites where tracking protection is disabled', + keywords: ['exceptions'], + child: _TrackingProtectionExceptionsTile(), + ), + SettingsEntryDefinition( + title: 'uBlock Filter Lists & Hardenings', + subtitle: 'Manage filter lists and apply WebLibre hardenings', + keywords: ['ublock', 'filters'], + child: _UBlockFilterListsTile(), + ), + ], + ), + SettingsSectionDefinition( + title: 'Fingerprinting', + entries: [ + SettingsEntryDefinition( + title: 'Browser Languages', + subtitle: 'Choose which languages websites can see', + keywords: ['locale'], + child: _BrowserLanguagesTile(), + ), + SettingsEntryDefinition( + title: 'Fingerprint Protection', + subtitle: 'Granular control over browser fingerprinting', + keywords: ['privacy'], + child: _FingerprintProtectionTile(), + ), + SettingsEntryDefinition( + title: 'Resist Fingerprinting', + subtitle: 'Advanced fingerprinting protection hardening', + keywords: ['rfp'], + child: _ResistFingerprintingTile(), + ), + ], + ), + SettingsSectionDefinition( + title: 'Connection Security', + entries: [ + SettingsEntryDefinition( + title: 'Block insecure HTTP connections', + subtitle: 'Prefer HTTPS and block insecure connections', + keywords: ['https only'], + child: _HttpsOnlyModeSection(), + ), + SettingsEntryDefinition( + title: 'DNS over HTTPS', + subtitle: 'Encrypt DNS lookups', + keywords: ['doh'], + child: _DnsTile(), + ), + ], + ), + SettingsSectionDefinition( + title: 'Network Protection', + entries: [ + SettingsEntryDefinition( + title: 'Local Network Access', + subtitle: 'Enable local network and device access blocking', + keywords: ['lan'], + child: _LnaEnabledTile(), + ), + SettingsEntryDefinition( + title: 'Block Local Network Requests', + subtitle: 'Block requests to local network devices and services', + keywords: ['lan'], + child: _LnaBlockingTile(), + ), + SettingsEntryDefinition( + title: 'Block Local Network Trackers', + subtitle: 'Block tracker-like local network requests', + keywords: ['lan'], + child: _LnaBlockTrackersTile(), + ), + ], + ), + SettingsSectionDefinition( + title: 'Privacy Signals & Modes', + entries: [ + SettingsEntryDefinition( + title: 'Incognito Mode', + subtitle: 'Delete selected browsing data on app restart', + keywords: ['private mode'], + child: _IncognitoModeSection(), + ), + SettingsEntryDefinition( + title: 'Screenshot protection', + subtitle: 'Prevent app content from appearing in screenshots', + keywords: ['screenshots'], + child: _ScreenshotProtectionTile(), + ), + SettingsEntryDefinition( + title: 'Global Privacy Control (GPC)', + subtitle: 'Send a privacy preference signal to websites', + keywords: ['gpc'], + child: _GlobalPrivacyControlTile(), + ), + ], + ), + SettingsSectionDefinition( + title: 'App-Opening Protection', + entries: [ + SettingsEntryDefinition( + title: 'Block apps from opening your browser', + subtitle: 'Control which apps may launch WebLibre directly', + keywords: ['intent gatekeeper', 'external apps'], + child: _AppOpeningProtectionSection(), + ), + ], + ), + SettingsSectionDefinition( + title: 'Data Management', + entries: [ + SettingsEntryDefinition( + title: 'Delete Browsing Data', + subtitle: 'Clear history, cookies, and other browsing data', + keywords: ['clear data'], + child: _DeleteBrowsingDataTile(), + ), + SettingsEntryDefinition( + title: 'Auto-Clear History', + subtitle: 'Automatically clear history after a chosen duration', + keywords: ['history retention'], + child: _AutoClearHistorySection(), + ), + SettingsEntryDefinition( + title: 'Auto-Clear Unassigned Tabs', + subtitle: 'Automatically close tabs not assigned to a container', + keywords: ['cleanup tabs'], + child: _AutoClearUnassignedTabsSection(), + ), + ], + ), + SettingsSectionDefinition( + title: 'Google Safe Browsing', + entries: [ + SettingsEntryDefinition( + title: 'Safe Browsing Malware Protection', + subtitle: 'Warn about malware and harmful downloads', + keywords: ['google safe browsing'], + child: _SafeBrowsingMalwareTile(), + ), + SettingsEntryDefinition( + title: 'Safe Browsing Phishing Protection', + subtitle: 'Warn about deceptive websites and login pages', + keywords: ['google safe browsing'], + child: _SafeBrowsingPhishingTile(), + ), + ], + ), + SettingsSectionDefinition( + title: 'Advanced Security', + entries: [ + SettingsEntryDefinition( + title: 'Web Engine Hardening', + subtitle: 'Harden browser engine behavior and defaults', + keywords: ['hardening'], + child: _WebEngineHardeningTile(), + ), + SettingsEntryDefinition( + title: 'Fission (Site Isolation)', + subtitle: 'Use stronger site isolation between origins', + keywords: ['site isolation'], + child: _FissionEnabledTile(), + ), + SettingsEntryDefinition( + title: 'Extensions Web API', + subtitle: 'Allow extensions to expose web APIs to pages', + keywords: ['extension api'], + child: _ExtensionsWebAPIEnabledTile(), + ), + ], + ), +]; + class PrivacySecuritySettingsScreen extends StatelessWidget { const PrivacySecuritySettingsScreen({super.key}); @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: const Text('Privacy & Security')), - body: SafeArea( - child: FadingScroll( - fadingSize: 25, - builder: (context, controller) { - return ListView( - controller: controller, - padding: const EdgeInsets.symmetric(horizontal: 12.0), - children: const [ - _TrackingProtectionSection(), - _FingerprintingSection(), - _ConnectionSecuritySection(), - _NetworkProtectionSection(), - _PrivacySignalsSection(), - _AppOpeningProtectionSection(), - _DataManagementSection(), - _SafeBrowsingSection(), - _AdvancedSecuritySection(), - ], - ); - }, - ), - ), - ); - } -} - -class _FingerprintingSection extends StatelessWidget { - const _FingerprintingSection(); - - @override - Widget build(BuildContext context) { - return const Column( - children: [ - SettingSection(name: 'Fingerprinting'), - _BrowserLanguagesTile(), - _FingerprintProtectionTile(), - _ResistFingerprintingTile(), - ], - ); - } -} - -class _TrackingProtectionSection extends StatelessWidget { - const _TrackingProtectionSection(); - - @override - Widget build(BuildContext context) { - return const Column( - children: [ - SettingSection(name: 'Tracking Protection'), - _EnhancedTrackingProtectionSection(), - _BounceTrackingProtectionTile(), - _QueryParameterStrippingSection(), - _TrackingProtectionExceptionsTile(), - _UBlockFilterListsTile(), - ], + return const SettingsDetailScaffold( + title: 'Privacy & Security', + subtitle: + 'Tracking protection, fingerprinting, browsing data, and network hardening.', + icon: MdiIcons.shieldLock, + sections: privacySecuritySettingsSections, ); } } @@ -120,84 +267,6 @@ class _TrackingProtectionExceptionsTile extends StatelessWidget { } } -class _ConnectionSecuritySection extends StatelessWidget { - const _ConnectionSecuritySection(); - - @override - Widget build(BuildContext context) { - return const Column( - children: [ - SettingSection(name: 'Connection Security'), - _HttpsOnlyModeSection(), - _DnsTile(), - ], - ); - } -} - -class _PrivacySignalsSection extends StatelessWidget { - const _PrivacySignalsSection(); - - @override - Widget build(BuildContext context) { - return const Column( - children: [ - SettingSection(name: 'Privacy Signals & Modes'), - _IncognitoModeSection(), - _ScreenshotProtectionTile(), - _GlobalPrivacyControlTile(), - ], - ); - } -} - -class _SafeBrowsingSection extends StatelessWidget { - const _SafeBrowsingSection(); - - @override - Widget build(BuildContext context) { - return const Column( - children: [ - SettingSection(name: 'Google Safe Browsing'), - _SafeBrowsingMalwareTile(), - _SafeBrowsingPhishingTile(), - ], - ); - } -} - -class _DataManagementSection extends StatelessWidget { - const _DataManagementSection(); - - @override - Widget build(BuildContext context) { - return const Column( - children: [ - SettingSection(name: 'Data Management'), - _DeleteBrowsingDataTile(), - _AutoClearHistorySection(), - _AutoClearUnassignedTabsSection(), - ], - ); - } -} - -class _AdvancedSecuritySection extends StatelessWidget { - const _AdvancedSecuritySection(); - - @override - Widget build(BuildContext context) { - return const Column( - children: [ - SettingSection(name: 'Advanced Security'), - _WebEngineHardeningTile(), - _FissionEnabledTile(), - _ExtensionsWebAPIEnabledTile(), - ], - ); - } -} - class _IncognitoModeSection extends HookConsumerWidget { const _IncognitoModeSection(); @@ -1052,22 +1121,6 @@ class _ManagedAppPolicyTile extends HookConsumerWidget { enum _PolicyAction { allow, block, remove } -class _NetworkProtectionSection extends StatelessWidget { - const _NetworkProtectionSection(); - - @override - Widget build(BuildContext context) { - return const Column( - children: [ - SettingSection(name: 'Network Protection'), - _LnaEnabledTile(), - _LnaBlockingTile(), - _LnaBlockTrackersTile(), - ], - ); - } -} - class _BrowserLanguagesTile extends StatelessWidget { const _BrowserLanguagesTile(); diff --git a/apps/weblibre/lib/features/settings/presentation/screens/search_settings.dart b/apps/weblibre/lib/features/settings/presentation/screens/search_settings.dart index 21e82dce..97e256bb 100644 --- a/apps/weblibre/lib/features/settings/presentation/screens/search_settings.dart +++ b/apps/weblibre/lib/features/settings/presentation/screens/search_settings.dart @@ -17,89 +17,117 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -import 'package:fading_scroll/fading_scroll.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nullability/nullability.dart'; import 'package:weblibre/core/routing/routes.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/providers.dart'; import 'package:weblibre/features/search/domain/entities/abstract/i_search_suggestion_provider.dart'; import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart'; import 'package:weblibre/features/settings/presentation/widgets/bang_icon.dart'; import 'package:weblibre/features/settings/presentation/widgets/default_search_selector.dart'; -import 'package:weblibre/features/settings/presentation/widgets/sections.dart'; +import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart'; import 'package:weblibre/features/user/data/models/general_settings.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; +const List searchSettingsSections = [ + SettingsSectionDefinition( + title: 'Providers', + keywords: ['engines'], + entries: [ + SettingsEntryDefinition( + title: 'Default Search Provider', + subtitle: 'Choose the default engine for searches', + keywords: ['search engine'], + child: _DefaultSearchProviderSection(), + ), + SettingsEntryDefinition( + title: 'Default Autocomplete Provider', + subtitle: 'Choose the provider for search suggestions', + keywords: ['suggestions'], + child: _AutocompleteProviderSection(), + ), + SettingsEntryDefinition( + title: 'Custom Search Engines', + subtitle: 'Add and manage your own search providers', + keywords: ['user bangs', 'providers'], + child: _CustomSearchEnginesTile(), + ), + ], + ), + SettingsSectionDefinition( + title: 'Bang Shortcuts', + keywords: ['bangs'], + entries: [ + SettingsEntryDefinition( + title: 'Bang Settings', + subtitle: 'Manage bang repositories and usage data', + keywords: ['shortcuts', 'bangs'], + child: _BangsTile(), + ), + ], + ), + SettingsSectionDefinition( + title: 'History & Suggestions', + entries: [ + SettingsEntryDefinition( + title: 'Search History Limit', + subtitle: 'Maximum number of recent searches to remember', + keywords: ['history', 'entries'], + child: _MaxSearchHistoryEntriesSection(), + ), + SettingsEntryDefinition( + title: 'Allow clipboard access for suggestions', + subtitle: 'Browser can read clipboard to suggest URLs', + keywords: ['clipboard'], + child: _AllowClipboardAccessTile(), + ), + SettingsEntryDefinition( + title: 'Autocomplete on enter', + subtitle: 'Accept the inline suggestion when pressing enter', + keywords: ['submit', 'keyboard', 'suggestions'], + child: _AcceptSuggestionOnSubmitTile(), + ), + ], + ), + SettingsSectionDefinition( + title: 'Local Search Index', + keywords: ['on device search', 'index'], + entries: [ + SettingsEntryDefinition( + title: 'Enable local search index', + subtitle: 'Index visited pages locally for content search', + keywords: ['page text', 'history'], + child: _LocalIndexEnabledTile(), + ), + SettingsEntryDefinition( + title: 'Index private tabs', + subtitle: 'Include private tabs in the local index', + keywords: ['incognito'], + child: _IndexPrivateTabsTile(), + ), + SettingsEntryDefinition( + title: 'Indexed pages', + subtitle: 'View and clear the local index', + keywords: ['clear index', 'stats'], + child: _LocalIndexStatsTile(), + ), + ], + ), +]; + class SearchSettingsScreen extends StatelessWidget { const SearchSettingsScreen({super.key}); @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: const Text('Search')), - body: SafeArea( - child: FadingScroll( - fadingSize: 25, - builder: (context, controller) { - return ListView( - controller: controller, - padding: const EdgeInsets.symmetric(horizontal: 12.0), - children: const [ - _ProvidersSection(), - _BangShortcutsSection(), - _HistorySuggestionsSection(), - ], - ); - }, - ), - ), - ); - } -} - -class _ProvidersSection extends StatelessWidget { - const _ProvidersSection(); - - @override - Widget build(BuildContext context) { - return const Column( - children: [ - SettingSection(name: 'Providers'), - _DefaultSearchProviderSection(), - _AutocompleteProviderSection(), - _CustomSearchEnginesTile(), - ], - ); - } -} - -class _BangShortcutsSection extends StatelessWidget { - const _BangShortcutsSection(); - - @override - Widget build(BuildContext context) { - return const Column( - children: [ - SettingSection(name: 'Bang Shortcuts'), - _BangsTile(), - ], - ); - } -} - -class _HistorySuggestionsSection extends StatelessWidget { - const _HistorySuggestionsSection(); - - @override - Widget build(BuildContext context) { - return const Column( - children: [ - SettingSection(name: 'History & Suggestions'), - _MaxSearchHistoryEntriesSection(), - _AllowClipboardAccessTile(), - ], + return const SettingsDetailScaffold( + title: 'Search', + subtitle: 'Providers, bangs, history suggestions, and on-device search.', + icon: MdiIcons.magnify, + sections: searchSettingsSections, ); } } @@ -328,3 +356,153 @@ class _AllowClipboardAccessTile extends HookConsumerWidget { ); } } + +class _AcceptSuggestionOnSubmitTile extends HookConsumerWidget { + const _AcceptSuggestionOnSubmitTile(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final acceptSuggestionOnSubmit = ref.watch( + generalSettingsWithDefaultsProvider.select( + (s) => s.acceptSuggestionOnSubmit, + ), + ); + + return SwitchListTile.adaptive( + title: const Text('Autocomplete on enter'), + subtitle: const Text( + 'Accept the inline suggestion when pressing enter on the keyboard', + ), + secondary: const Icon(Icons.keyboard_return), + value: acceptSuggestionOnSubmit, + onChanged: (value) async { + await ref + .read(saveGeneralSettingsControllerProvider.notifier) + .save( + (currentSettings) => + currentSettings.copyWith.acceptSuggestionOnSubmit(value), + ); + }, + ); + } +} + +class _LocalIndexEnabledTile extends HookConsumerWidget { + const _LocalIndexEnabledTile(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final enabled = ref.watch( + generalSettingsWithDefaultsProvider.select( + (s) => s.enableLocalSearchIndex, + ), + ); + + return SwitchListTile.adaptive( + title: const Text('Enable local search index'), + subtitle: const Text( + 'Index visited pages locally so the browser can search their ' + 'content. Visit metadata stays in the engine; only page text is ' + 'stored on-device.', + ), + secondary: const Icon(MdiIcons.bookSearchOutline), + value: enabled, + onChanged: (value) async { + await ref + .read(saveGeneralSettingsControllerProvider.notifier) + .save( + (currentSettings) => + currentSettings.copyWith.enableLocalSearchIndex(value), + ); + }, + ); + } +} + +class _IndexPrivateTabsTile extends HookConsumerWidget { + const _IndexPrivateTabsTile(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final settings = ref.watch(generalSettingsWithDefaultsProvider); + final enabled = settings.enableLocalSearchIndex; + final indexPrivate = settings.indexPrivateTabs; + + return SwitchListTile.adaptive( + title: const Text('Index private tabs'), + subtitle: const Text( + 'Include pages opened in private tabs in the local index. ' + 'Off by default.', + ), + secondary: const Icon(MdiIcons.incognito), + value: indexPrivate, + onChanged: enabled + ? (value) async { + await ref + .read(saveGeneralSettingsControllerProvider.notifier) + .save( + (currentSettings) => + currentSettings.copyWith.indexPrivateTabs(value), + ); + } + : null, + ); + } +} + +class _LocalIndexStatsTile extends HookConsumerWidget { + const _LocalIndexStatsTile(); + + Future _confirmClear(BuildContext context) { + return showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Clear local search index?'), + content: const Text( + 'This removes all locally indexed page content. Engine history ' + '(visit metadata) is not affected.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Clear'), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + // Bump on Clear to re-trigger the count query. + final refreshTick = useState(0); + final countSnapshot = useFuture( + useMemoized(() => ref.read(tabDatabaseProvider).historyDao.countRows(), [ + refreshTick.value, + ]), + ); + final count = countSnapshot.data; + + return ListTile( + leading: const Icon(MdiIcons.databaseOutline), + title: const Text('Indexed pages'), + subtitle: Text(count.mapNotNull((c) => '$c pages indexed') ?? 'Loading…'), + trailing: TextButton.icon( + icon: const Icon(MdiIcons.deleteOutline), + label: const Text('Clear'), + onPressed: count == null || count == 0 + ? null + : () async { + final confirmed = await _confirmClear(context); + if (confirmed != true) return; + await ref.read(tabDatabaseProvider).historyDao.clear(); + refreshTick.value++; + }, + ), + ); + } +} diff --git a/apps/weblibre/lib/features/settings/presentation/screens/settings.dart b/apps/weblibre/lib/features/settings/presentation/screens/settings.dart index 0427c1e8..e6b348c9 100644 --- a/apps/weblibre/lib/features/settings/presentation/screens/settings.dart +++ b/apps/weblibre/lib/features/settings/presentation/screens/settings.dart @@ -19,34 +19,61 @@ */ import 'package:fading_scroll/fading_scroll.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:weblibre/core/routing/routes.dart'; +import 'package:weblibre/features/settings/presentation/screens/advanced_settings.dart'; +import 'package:weblibre/features/settings/presentation/screens/browsing_settings.dart'; +import 'package:weblibre/features/settings/presentation/screens/extensions_settings.dart'; +import 'package:weblibre/features/settings/presentation/screens/general_settings.dart'; +import 'package:weblibre/features/settings/presentation/screens/privacy_security_settings.dart'; +import 'package:weblibre/features/settings/presentation/screens/search_settings.dart'; +import 'package:weblibre/features/settings/presentation/screens/web_content_settings.dart'; +import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart'; +import 'package:weblibre/features/settings/presentation/widgets/toolbar_layout_content.dart'; -class SettingsScreen extends HookConsumerWidget { +class SettingsScreen extends HookWidget { const SettingsScreen({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { + final search = useSettingsSearch(); + + final categories = _buildCategories(); + final sections = search.normalizedQuery.isEmpty + ? _buildCategorySections(categories) + : _buildSearchSections(categories, search.normalizedQuery); + return Scaffold( - appBar: AppBar(title: const Text('Settings')), body: SafeArea( child: FadingScroll( fadingSize: 25, builder: (context, controller) { - return ListView( + return CustomScrollView( controller: controller, - padding: const EdgeInsets.symmetric(horizontal: 12.0), - children: const [ - _GeneralTile(), - _BrowsingTile(), - _ToolbarLayoutTile(), - _WebContentTile(), - _SearchTile(), - _PrivacySecurityTile(), - _ExtensionsTile(), - _SyncTile(), - _AdvancedTile(), + slivers: [ + const SliverAppBar.large( + centerTitle: false, + title: Text('Settings'), + ), + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + sliver: SliverToBoxAdapter( + child: SettingsSearchField( + controller: search.controller, + hintText: 'Search all settings', + ), + ), + ), + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 24, 16, 20), + sliver: SliverToBoxAdapter( + child: SettingsSectionList( + sections: sections, + query: search.rawQuery, + ), + ), + ), ], ); }, @@ -56,227 +83,255 @@ class SettingsScreen extends HookConsumerWidget { } } -class _GeneralTile extends StatelessWidget { - const _GeneralTile(); +List<_SettingsCategoryDefinition> _buildCategories() { + return [ + _SettingsCategoryDefinition( + title: 'General', + subtitle: 'Appearance, language, downloads', + icon: Icons.tune, + keywords: const ['theme', 'ui zoom', 'default browser'], + sections: generalSettingsSections, + onTap: (context) => GeneralSettingsRoute().push(context), + ), + _SettingsCategoryDefinition( + title: 'Browsing', + subtitle: 'Tabs, navigation, external links', + icon: MdiIcons.compassOutline, + keywords: const ['tabs', 'small web', 'url cleaner', 'unshortener'], + sections: browsingSettingsSections, + onTap: (context) => BrowsingSettingsRoute().push(context), + ), + _SettingsCategoryDefinition( + title: 'Toolbar & Layout', + subtitle: 'Tab bar, toolbar, quick switcher, tab view', + icon: MdiIcons.viewDashboardOutline, + keywords: const ['contextual toolbar', 'quick tab switcher'], + sections: toolbarLayoutSettingsSections, + onTap: (context) => ToolbarLayoutSettingsRoute().push(context), + ), + _SettingsCategoryDefinition( + title: 'Web Content', + subtitle: 'Page display, PDF, reader mode, AI', + icon: MdiIcons.fileDocumentOutline, + keywords: const ['reader mode', 'pdf', 'fonts'], + sections: webContentSettingsSections, + onTap: (context) => WebContentSettingsRoute().push(context), + ), + _SettingsCategoryDefinition( + title: 'Search', + subtitle: 'Providers, bangs, search history', + icon: MdiIcons.magnify, + keywords: const ['bangs', 'suggestions', 'local search index'], + sections: searchSettingsSections, + onTap: (context) => SearchSettingsRoute().push(context), + ), + _SettingsCategoryDefinition( + title: 'Privacy & Security', + subtitle: 'Tracking protection, data clearing', + icon: MdiIcons.shieldLock, + keywords: const [ + 'fingerprinting', + 'https', + 'doh', + 'safe browsing', + 'network protection', + ], + sections: privacySecuritySettingsSections, + onTap: (context) => PrivacySecuritySettingsRoute().push(context), + ), + _SettingsCategoryDefinition( + title: 'Extensions', + subtitle: 'Install and manage extension sources', + icon: MdiIcons.puzzleOutline, + keywords: const ['addons', 'unsigned extensions'], + sections: extensionsSettingsSections, + onTap: (context) => ExtensionsSettingsRoute().push(context), + ), + _SettingsCategoryDefinition( + title: 'WebLibre Account', + subtitle: 'Sign in, sync settings', + icon: Icons.account_circle_outlined, + keywords: const ['account', 'subscription'], + onTap: (context) => AccountSettingsRoute().push(context), + ), + _SettingsCategoryDefinition( + title: 'Firefox Sync', + subtitle: 'Account, sync now, engine selection', + icon: Icons.sync, + keywords: const ['pair', 'device name', 'engines'], + onTap: (context) => SyncSettingsRoute().push(context), + ), + _SettingsCategoryDefinition( + title: 'Advanced', + subtitle: 'JavaScript, user agent, debugging', + icon: Icons.developer_mode, + keywords: const ['experimental', 'error logs', 'javascript'], + sections: advancedSettingsSections, + onTap: (context) => AdvancedSettingsRoute().push(context), + ), + ]; +} + +List _buildCategorySections( + List<_SettingsCategoryDefinition> categories, +) { + return [ + SettingsSectionDefinition( + title: 'Browser', + entries: [ + for (final category in categories.take(7)) + _buildCategoryEntry(category), + ], + ), + SettingsSectionDefinition( + title: 'Services & Advanced', + entries: [ + for (final category in categories.skip(7)) + _buildCategoryEntry(category), + ], + ), + ]; +} + +List _buildSearchSections( + List<_SettingsCategoryDefinition> categories, + String normalizedQuery, +) { + final results = >{}; + + for (final category in categories) { + final categoryEntries = []; + + if (matchesSettingsSearch(normalizedQuery, [ + category.title, + category.subtitle, + ...category.keywords, + ])) { + categoryEntries.add(_buildCategoryEntry(category)); + } + + for (final section in filterSettingsSections( + sections: category.sections, + query: normalizedQuery, + )) { + for (final entry in section.entries) { + categoryEntries.add( + SettingsEntryDefinition( + title: entry.title, + subtitle: '${section.title} • ${category.title}', + keywords: entry.keywords, + child: _SearchResultTile( + title: entry.title, + subtitle: entry.subtitle, + category: category.title, + section: section.title, + icon: category.icon, + onTap: category.onTap, + ), + ), + ); + } + } + + if (categoryEntries.isNotEmpty) { + results[category.title] = categoryEntries; + } + } + + return [ + for (final result in results.entries) + SettingsSectionDefinition(title: result.key, entries: result.value), + ]; +} + +SettingsEntryDefinition _buildCategoryEntry( + _SettingsCategoryDefinition category, +) { + return SettingsEntryDefinition( + title: category.title, + subtitle: category.subtitle, + keywords: category.keywords, + child: _CategoryTile(category: category), + ); +} + +class _SettingsCategoryDefinition { + final String title; + final String subtitle; + final IconData icon; + final List keywords; + final List sections; + final Future Function(BuildContext context) onTap; + + const _SettingsCategoryDefinition({ + required this.title, + required this.subtitle, + required this.icon, + required this.onTap, + this.keywords = const [], + this.sections = const [], + }); +} + +class _CategoryTile extends StatelessWidget { + final _SettingsCategoryDefinition category; + + const _CategoryTile({required this.category}); @override Widget build(BuildContext context) { - return Card( - color: Theme.of(context).colorScheme.surfaceContainerHigh, - clipBehavior: Clip.antiAlias, - child: ListTile( - title: const Text('General'), - subtitle: const Text('Appearance, language, downloads'), - contentPadding: const EdgeInsets.symmetric( - vertical: 8.0, - horizontal: 16.0, - ), - leading: const Icon(Icons.tune), - trailing: const Icon(Icons.chevron_right), - onTap: () async { - await GeneralSettingsRoute().push(context); - }, + return ListTile( + title: Text(category.title), + subtitle: Text(category.subtitle), + contentPadding: const EdgeInsets.symmetric( + vertical: 8.0, + horizontal: 16.0, ), + leading: Icon(category.icon), + trailing: const Icon(Icons.chevron_right), + onTap: () async { + await category.onTap(context); + }, ); } } -class _BrowsingTile extends StatelessWidget { - const _BrowsingTile(); +class _SearchResultTile extends StatelessWidget { + final String title; + final String? subtitle; + final String category; + final String section; + final IconData icon; + final Future Function(BuildContext context) onTap; + + const _SearchResultTile({ + required this.title, + required this.category, + required this.section, + required this.icon, + required this.onTap, + this.subtitle, + }); @override Widget build(BuildContext context) { - return Card( - color: Theme.of(context).colorScheme.surfaceContainerHigh, - clipBehavior: Clip.antiAlias, - child: ListTile( - title: const Text('Browsing'), - subtitle: const Text('Tabs, navigation, external links'), - contentPadding: const EdgeInsets.symmetric( - vertical: 8.0, - horizontal: 16.0, - ), - leading: const Icon(MdiIcons.compassOutline), - trailing: const Icon(Icons.chevron_right), - onTap: () async { - await BrowsingSettingsRoute().push(context); - }, - ), - ); - } -} - -class _ToolbarLayoutTile extends StatelessWidget { - const _ToolbarLayoutTile(); - - @override - Widget build(BuildContext context) { - return Card( - color: Theme.of(context).colorScheme.surfaceContainerHigh, - clipBehavior: Clip.antiAlias, - child: ListTile( - title: const Text('Toolbar & Layout'), - subtitle: const Text('Tab bar, toolbar, quick switcher, tab view'), - contentPadding: const EdgeInsets.symmetric( - vertical: 8.0, - horizontal: 16.0, - ), - leading: const Icon(MdiIcons.viewDashboardOutline), - trailing: const Icon(Icons.chevron_right), - onTap: () async { - await ToolbarLayoutSettingsRoute().push(context); - }, - ), - ); - } -} - -class _PrivacySecurityTile extends StatelessWidget { - const _PrivacySecurityTile(); - - @override - Widget build(BuildContext context) { - return Card( - color: Theme.of(context).colorScheme.surfaceContainerHigh, - clipBehavior: Clip.antiAlias, - child: ListTile( - title: const Text('Privacy & Security'), - subtitle: const Text('Tracking protection, data clearing'), - contentPadding: const EdgeInsets.symmetric( - vertical: 8.0, - horizontal: 16.0, - ), - leading: const Icon(MdiIcons.shieldLock), - trailing: const Icon(Icons.chevron_right), - onTap: () async { - await PrivacySecuritySettingsRoute().push(context); - }, - ), - ); - } -} - -class _WebContentTile extends StatelessWidget { - const _WebContentTile(); - - @override - Widget build(BuildContext context) { - return Card( - color: Theme.of(context).colorScheme.surfaceContainerHigh, - clipBehavior: Clip.antiAlias, - child: ListTile( - title: const Text('Web Content'), - subtitle: const Text('Page display, PDF, reader mode, AI'), - contentPadding: const EdgeInsets.symmetric( - vertical: 8.0, - horizontal: 16.0, - ), - leading: const Icon(MdiIcons.fileDocumentOutline), - trailing: const Icon(Icons.chevron_right), - onTap: () async { - await WebContentSettingsRoute().push(context); - }, - ), - ); - } -} - -class _SearchTile extends StatelessWidget { - const _SearchTile(); - - @override - Widget build(BuildContext context) { - return Card( - color: Theme.of(context).colorScheme.surfaceContainerHigh, - clipBehavior: Clip.antiAlias, - child: ListTile( - title: const Text('Search'), - subtitle: const Text('Providers, bangs, search history'), - contentPadding: const EdgeInsets.symmetric( - vertical: 8.0, - horizontal: 16.0, - ), - leading: const Icon(MdiIcons.magnify), - trailing: const Icon(Icons.chevron_right), - onTap: () async { - await SearchSettingsRoute().push(context); - }, - ), - ); - } -} - -class _ExtensionsTile extends StatelessWidget { - const _ExtensionsTile(); - - @override - Widget build(BuildContext context) { - return Card( - color: Theme.of(context).colorScheme.surfaceContainerHigh, - clipBehavior: Clip.antiAlias, - child: ListTile( - title: const Text('Extensions'), - subtitle: const Text('Install and manage extension sources'), - contentPadding: const EdgeInsets.symmetric( - vertical: 8.0, - horizontal: 16.0, - ), - leading: const Icon(MdiIcons.puzzleOutline), - trailing: const Icon(Icons.chevron_right), - onTap: () async { - await ExtensionsSettingsRoute().push(context); - }, - ), - ); - } -} - -class _SyncTile extends StatelessWidget { - const _SyncTile(); - - @override - Widget build(BuildContext context) { - return Card( - color: Theme.of(context).colorScheme.surfaceContainerHigh, - clipBehavior: Clip.antiAlias, - child: ListTile( - title: const Text('Firefox Sync'), - subtitle: const Text('Account, sync now, engine selection'), - contentPadding: const EdgeInsets.symmetric( - vertical: 8.0, - horizontal: 16.0, - ), - leading: const Icon(Icons.sync), - trailing: const Icon(Icons.chevron_right), - onTap: () async { - await SyncSettingsRoute().push(context); - }, - ), - ); - } -} - -class _AdvancedTile extends StatelessWidget { - const _AdvancedTile(); - - @override - Widget build(BuildContext context) { - return Card( - color: Theme.of(context).colorScheme.surfaceContainerHigh, - clipBehavior: Clip.antiAlias, - child: ListTile( - title: const Text('Advanced'), - subtitle: const Text('JavaScript, user agent, debugging'), - contentPadding: const EdgeInsets.symmetric( - vertical: 8.0, - horizontal: 16.0, - ), - leading: const Icon(Icons.developer_mode), - trailing: const Icon(Icons.chevron_right), - onTap: () async { - await AdvancedSettingsRoute().push(context); - }, + return ListTile( + leading: Icon(icon), + title: Text(title), + subtitle: Text( + subtitle == null || subtitle!.isEmpty + ? '$category • $section' + : '$category • $section\n$subtitle', ), + isThreeLine: subtitle != null && subtitle!.isNotEmpty, + contentPadding: const EdgeInsets.symmetric( + vertical: 8.0, + horizontal: 16.0, + ), + trailing: const Icon(Icons.chevron_right), + onTap: () async { + await onTap(context); + }, ); } } diff --git a/apps/weblibre/lib/features/settings/presentation/screens/toolbar_layout_settings.dart b/apps/weblibre/lib/features/settings/presentation/screens/toolbar_layout_settings.dart index 429741a4..1e3abc80 100644 --- a/apps/weblibre/lib/features/settings/presentation/screens/toolbar_layout_settings.dart +++ b/apps/weblibre/lib/features/settings/presentation/screens/toolbar_layout_settings.dart @@ -17,9 +17,9 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -import 'package:fading_scroll/fading_scroll.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart'; import 'package:weblibre/features/settings/presentation/widgets/toolbar_layout_content.dart'; import 'package:weblibre/features/settings/presentation/widgets/toolbar_preview.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; @@ -30,32 +30,28 @@ class ToolbarLayoutSettingsScreen extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final settings = ref.watch(generalSettingsWithDefaultsProvider); + final search = useSettingsSearch(); - return Scaffold( - appBar: AppBar(title: const Text('Toolbar & Layout')), - body: SafeArea( - child: FadingScroll( - fadingSize: 25, - builder: (context, controller) { - return CustomScrollView( - controller: controller, - slivers: [ - SliverPersistentHeader( - pinned: true, - delegate: TabBarPreviewHeaderDelegate( - settings: settings, - compact: true, - ), - ), - const SliverPadding( - padding: EdgeInsets.symmetric(horizontal: 12.0), - sliver: SliverToBoxAdapter(child: ToolbarLayoutContent()), - ), - ], - ); - }, + return SettingsCustomScrollScaffold( + title: 'Toolbar & Layout', + searchController: search.controller, + searchHintText: 'Search toolbar and layout settings', + slivers: [ + const SliverToBoxAdapter(child: SizedBox(height: 16)), + SliverPersistentHeader( + pinned: true, + delegate: TabBarPreviewHeaderDelegate( + settings: settings, + compact: true, + ), ), - ), + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 20, 16, 20), + sliver: SliverToBoxAdapter( + child: ToolbarLayoutContent(query: search.rawQuery), + ), + ), + ], ); } } diff --git a/apps/weblibre/lib/features/settings/presentation/screens/tracking_protection_exceptions.dart b/apps/weblibre/lib/features/settings/presentation/screens/tracking_protection_exceptions.dart index 66abeaf4..8bcad0a5 100644 --- a/apps/weblibre/lib/features/settings/presentation/screens/tracking_protection_exceptions.dart +++ b/apps/weblibre/lib/features/settings/presentation/screens/tracking_protection_exceptions.dart @@ -26,6 +26,7 @@ import 'package:weblibre/core/logger.dart'; import 'package:weblibre/extensions/uri.dart'; import 'package:weblibre/features/geckoview/features/browser/domain/repositories/tracking_protection.dart'; import 'package:weblibre/features/settings/presentation/dialogs/delete_all_exceptions_dialog.dart'; +import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart'; import 'package:weblibre/presentation/widgets/url_icon.dart'; import 'package:weblibre/utils/ui_helper.dart'; @@ -39,59 +40,89 @@ class TrackingProtectionExceptionsScreen extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final exceptionsAsync = ref.watch(trackingProtectionRepositoryProvider); + final search = useSettingsSearch(); - return Scaffold( - appBar: AppBar( - title: const Text('Tracking Protection Exceptions'), - actions: [ - exceptionsAsync.maybeWhen( - data: (exceptions) => exceptions.isNotEmpty - ? MenuAnchor( - builder: (context, controller, child) => IconButton( - icon: const Icon(Icons.more_vert), - onPressed: () { - if (controller.isOpen) { - controller.close(); - } else { - controller.open(); - } - }, + return SettingsCustomScrollScaffold( + title: 'Tracking Protection Exceptions', + searchController: search.controller, + searchHintText: 'Search exception URLs', + actions: [ + exceptionsAsync.maybeWhen( + data: (exceptions) => exceptions.isNotEmpty + ? MenuAnchor( + builder: (context, controller, child) => IconButton( + icon: const Icon(Icons.more_vert), + onPressed: () { + if (controller.isOpen) { + controller.close(); + } else { + controller.open(); + } + }, + ), + menuChildren: [ + MenuItemButton( + leadingIcon: const Icon(Icons.delete_sweep), + onPressed: () => _showDeleteAllDialog(context, ref), + child: const Text('Delete All'), ), - menuChildren: [ - MenuItemButton( - leadingIcon: const Icon(Icons.delete_sweep), - onPressed: () => _showDeleteAllDialog(context, ref), - child: const Text('Delete All'), - ), - ], - ) - : const SizedBox.shrink(), - orElse: () => const SizedBox.shrink(), - ), - ], - ), - body: SafeArea( - child: exceptionsAsync.when( - data: (exceptions) { - if (exceptions.isEmpty) { - return const _EmptyState(); - } + ], + ) + : const SizedBox.shrink(), + orElse: () => const SizedBox.shrink(), + ), + ], + slivers: [ + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 24, 16, 20), + sliver: SliverToBoxAdapter( + child: exceptionsAsync.when( + data: (exceptions) { + final filteredExceptions = exceptions.where((exception) { + if (search.normalizedQuery.isEmpty) return true; + return exception.url.toLowerCase().contains( + search.normalizedQuery, + ); + }).toList(); - return ListView.builder( - itemCount: exceptions.length, - itemBuilder: (context, index) { - final exception = exceptions[index]; - return _ExceptionTile( - exception: exception, - onDelete: () => _deleteException(context, ref, exception), + if (exceptions.isEmpty) { + return const _EmptyState(); + } + + if (filteredExceptions.isEmpty) { + return SettingsSectionList( + sections: const [], + query: search.rawQuery, + ); + } + + return SettingsSectionList( + sections: [ + SettingsSectionDefinition( + title: 'Exception List', + entries: [ + for (final exception in filteredExceptions) + SettingsEntryDefinition( + title: exception.url, + subtitle: 'Site with tracking protection disabled', + child: _ExceptionTile( + exception: exception, + onDelete: () => + _deleteException(context, ref, exception), + ), + ), + ], + ), + ], + query: search.rawQuery, ); }, - ); - }, - loading: () => const Center(child: CircularProgressIndicator()), - error: (error, stack) => _ErrorState(error: error.toString()), + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, stack) => _ErrorState(error: error.toString()), + ), + ), ), - ), + ], ); } diff --git a/apps/weblibre/lib/features/settings/presentation/screens/ublock_filter_lists.dart b/apps/weblibre/lib/features/settings/presentation/screens/ublock_filter_lists.dart index dec0c9ee..429af950 100644 --- a/apps/weblibre/lib/features/settings/presentation/screens/ublock_filter_lists.dart +++ b/apps/weblibre/lib/features/settings/presentation/screens/ublock_filter_lists.dart @@ -19,11 +19,11 @@ */ import 'dart:async'; -import 'package:fading_scroll/fading_scroll.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:url_launcher/url_launcher.dart'; +import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart'; import 'package:weblibre/features/user/data/models/engine_settings.dart'; import 'package:weblibre/features/user/data/models/ublock_asset.dart'; import 'package:weblibre/features/user/data/models/ublock_filter_list_settings.dart'; @@ -167,18 +167,17 @@ class UBlockFilterListsScreen extends HookConsumerWidget { ); } - final colorScheme = Theme.of(context).colorScheme; + final search = useSettingsSearch(); - return Scaffold( - backgroundColor: colorScheme.surface, - appBar: AppBar(title: const Text('uBlock Filter Lists')), - body: SafeArea( - child: FadingScroll( - fadingSize: 25, - builder: (context, controller) { - return ListView( - controller: controller, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + return SettingsCustomScrollScaffold( + title: 'uBlock Filter Lists', + searchController: search.controller, + searchHintText: 'Search lists, groups, and external URLs', + slivers: [ + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 24, 16, 20), + sliver: SliverToBoxAdapter( + child: Column( children: [ const _SectionHeader(label: 'Management'), _ManagementCard( @@ -245,6 +244,7 @@ class UBlockFilterListsScreen extends HookConsumerWidget { registry: registry, settings: settings, onUpdate: updateSettings, + query: search.normalizedQuery, ), loading: () => const Padding( padding: EdgeInsets.all(24), @@ -260,13 +260,14 @@ class UBlockFilterListsScreen extends HookConsumerWidget { _ExternalListsCard( settings: settings, onUpdate: updateSettings, + query: search.normalizedQuery, ), const SizedBox(height: 32), ], - ); - }, + ), + ), ), - ), + ], ); } } @@ -481,11 +482,13 @@ class _FilterListGroups extends StatelessWidget { final UBlockAssetsRegistry registry; final UBlockFilterListSettings settings; final _SettingsMutator onUpdate; + final String query; const _FilterListGroups({ required this.registry, required this.settings, required this.onUpdate, + required this.query, }); @override @@ -524,6 +527,7 @@ class _FilterListGroups extends StatelessWidget { autoTokens: autoTokens, managementEnabled: settings.enabled, onToggle: toggle, + query: query, ), ], ); @@ -573,6 +577,7 @@ class _GroupCard extends StatelessWidget { final Set enabledTokens; final Set autoTokens; final bool managementEnabled; + final String query; final Future Function(String token, bool value) onToggle; const _GroupCard({ @@ -582,13 +587,44 @@ class _GroupCard extends StatelessWidget { required this.enabledTokens, required this.autoTokens, required this.managementEnabled, + required this.query, required this.onToggle, }); @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - final allTokens = parentTree.values.expand((l) => l).toList(); + // Callers pass `search.normalizedQuery` so trimming/lowercasing already happened. + final groupMatches = + query.isEmpty || group.label.toLowerCase().contains(query); + final filteredParentTree = >{}; + + for (final entry in parentTree.entries) { + final parentMatches = + groupMatches || + (entry.key?.toLowerCase().contains(query) ?? false); + final filteredTokens = entry.value.where((tokenKey) { + if (query.isEmpty || parentMatches) return true; + final asset = registry[tokenKey]; + if (asset == null) return false; + return matchesSettingsSearch(query, [ + tokenKey, + if (asset.title != null) asset.title!, + if (asset.tags != null) asset.tags!, + if (asset.supportURL != null) asset.supportURL!, + ]); + }).toList(); + + if (filteredTokens.isNotEmpty) { + filteredParentTree[entry.key] = filteredTokens; + } + } + + if (filteredParentTree.isEmpty) { + return const SizedBox.shrink(); + } + + final allTokens = filteredParentTree.values.expand((l) => l).toList(); final totalCount = allTokens.length; final enabledCount = allTokens.where(enabledTokens.contains).length; @@ -616,7 +652,7 @@ class _GroupCard extends StatelessWidget { color: colorScheme.surfaceContainerLow, child: Column( children: [ - for (final entry in parentTree.entries) + for (final entry in filteredParentTree.entries) if (entry.key != null) _SubGroupTile( parentTitle: entry.key!, @@ -828,13 +864,26 @@ class _FilterListTile extends StatelessWidget { class _ExternalListsCard extends StatelessWidget { final UBlockFilterListSettings settings; final _SettingsMutator onUpdate; + final String query; - const _ExternalListsCard({required this.settings, required this.onUpdate}); + const _ExternalListsCard({ + required this.settings, + required this.onUpdate, + required this.query, + }); @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final lists = settings.externalFilterLists; + // Callers pass `search.normalizedQuery`. + final filteredLists = lists.where((entry) { + if (query.isEmpty) return true; + return matchesSettingsSearch(query, [ + entry.url, + if (entry.description != null) entry.description!, + ]); + }).toList(); final canAdd = settings.enabled && lists.length < kUBlockMaxExternalUrls; return Card( @@ -863,8 +912,16 @@ class _ExternalListsCard extends StatelessWidget { child: Text('No external lists configured.'), ), ) + else if (filteredLists.isEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 12), + child: Align( + alignment: Alignment.centerLeft, + child: Text('No external lists match "$query".'), + ), + ) else - for (var i = 0; i < lists.length; i++) ...[ + for (var i = 0; i < filteredLists.length; i++) ...[ if (i > 0) Padding( padding: const EdgeInsets.symmetric(horizontal: 16), @@ -874,8 +931,8 @@ class _ExternalListsCard extends StatelessWidget { ), ), _ExternalListRow( - index: i, - entry: lists[i], + index: lists.indexOf(filteredLists[i]), + entry: filteredLists[i], allEntries: lists, enabled: settings.enabled, onUpdate: onUpdate, diff --git a/apps/weblibre/lib/features/settings/presentation/screens/web_content_settings.dart b/apps/weblibre/lib/features/settings/presentation/screens/web_content_settings.dart index 0de57a9f..4f8261ba 100644 --- a/apps/weblibre/lib/features/settings/presentation/screens/web_content_settings.dart +++ b/apps/weblibre/lib/features/settings/presentation/screens/web_content_settings.dart @@ -17,72 +17,94 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -import 'package:fading_scroll/fading_scroll.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart'; -import 'package:weblibre/features/settings/presentation/widgets/sections.dart'; +import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart'; import 'package:weblibre/features/user/data/models/engine_settings.dart'; import 'package:weblibre/features/user/data/models/general_settings.dart'; import 'package:weblibre/features/user/domain/repositories/engine_settings.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; +const List webContentSettingsSections = [ + SettingsSectionDefinition( + title: 'Display', + entries: [ + SettingsEntryDefinition( + title: 'Web Fonts', + subtitle: 'Allow websites to use custom fonts', + keywords: ['fonts'], + child: _WebFontsEnabledTile(), + ), + SettingsEntryDefinition( + title: 'Automatic Font Size', + subtitle: 'Adjust font size based on system settings', + keywords: ['text size'], + child: _AutomaticFontSizeAdjustmentTile(), + ), + SettingsEntryDefinition( + title: 'Font Size Factor', + subtitle: 'Scale web page text size', + keywords: ['zoom', 'text'], + child: _FontSizeFactorSlider(), + ), + SettingsEntryDefinition( + title: 'Font Inflation', + subtitle: 'Enlarge text on pages without a mobile viewport', + keywords: ['readability'], + child: _FontInflationTile(), + ), + SettingsEntryDefinition( + title: 'Input Auto Zoom', + subtitle: 'Automatically zoom when focusing text inputs', + keywords: ['forms'], + child: _InputAutoZoomEnabledTile(), + ), + ], + ), + SettingsSectionDefinition( + title: 'Content Features', + entries: [ + SettingsEntryDefinition( + title: 'Built-in PDF Viewer', + subtitle: 'Open PDF files directly in the browser', + keywords: ['pdf'], + child: _PdfViewerTile(), + ), + SettingsEntryDefinition( + title: 'Enable Reader Mode', + subtitle: 'Extract and simplify pages for readability', + keywords: ['reader', 'readability'], + child: _EnableReaderModeTile(), + ), + SettingsEntryDefinition( + title: 'Enforce Reader Mode', + subtitle: 'Always show Reader Mode capabilities', + keywords: ['reader'], + child: _EnforceReaderModeTile(), + ), + SettingsEntryDefinition( + title: 'On Device AI', + subtitle: 'Local AI features including topic and tab suggestions', + keywords: ['local ai', 'suggestions'], + child: _OnDeviceAiTile(), + ), + ], + ), +]; + class WebContentSettingsScreen extends StatelessWidget { const WebContentSettingsScreen({super.key}); @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: const Text('Web Content')), - body: SafeArea( - child: FadingScroll( - fadingSize: 25, - builder: (context, controller) { - return ListView( - controller: controller, - padding: const EdgeInsets.symmetric(horizontal: 12.0), - children: const [_DisplaySection(), _ContentFeaturesSection()], - ); - }, - ), - ), - ); - } -} - -class _DisplaySection extends StatelessWidget { - const _DisplaySection(); - - @override - Widget build(BuildContext context) { - return const Column( - children: [ - SettingSection(name: 'Display'), - _WebFontsEnabledTile(), - _AutomaticFontSizeAdjustmentTile(), - _FontSizeFactorSlider(), - _FontInflationTile(), - _InputAutoZoomEnabledTile(), - ], - ); - } -} - -class _ContentFeaturesSection extends StatelessWidget { - const _ContentFeaturesSection(); - - @override - Widget build(BuildContext context) { - return const Column( - children: [ - SettingSection(name: 'Content Features'), - _PdfViewerTile(), - _EnableReaderModeTile(), - _EnforceReaderModeTile(), - _OnDeviceAiTile(), - ], + return const SettingsDetailScaffold( + title: 'Web Content', + subtitle: 'Text rendering, reader mode, PDFs, and local AI features.', + icon: MdiIcons.fileDocumentOutline, + sections: webContentSettingsSections, ); } } diff --git a/apps/weblibre/lib/features/settings/presentation/screens/web_engine_hardening.dart b/apps/weblibre/lib/features/settings/presentation/screens/web_engine_hardening.dart index 5bf2af44..e50b6450 100644 --- a/apps/weblibre/lib/features/settings/presentation/screens/web_engine_hardening.dart +++ b/apps/weblibre/lib/features/settings/presentation/screens/web_engine_hardening.dart @@ -26,6 +26,7 @@ import 'package:weblibre/core/routing/routes.dart'; import 'package:weblibre/features/geckoview/features/preferences/data/repositories/preference_settings.dart'; import 'package:weblibre/features/geckoview/features/tabs/utils/setting_groups_serializer.dart'; import 'package:weblibre/features/settings/presentation/widgets/hardening_group_icon.dart'; +import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart'; import 'package:weblibre/presentation/widgets/failure_widget.dart'; class WebEngineHardeningScreen extends HookConsumerWidget { @@ -47,104 +48,143 @@ class WebEngineHardeningScreen extends HookConsumerWidget { ); final theme = Theme.of(context); + final search = useSettingsSearch(); + final query = search.normalizedQuery; - return Scaffold( - appBar: AppBar( - title: const Text('Web Engine Hardening'), - actions: [ - MenuAnchor( - menuChildren: [ - MenuItemButton( - leadingIcon: const Icon(Icons.restore), - child: const Text('Reset all preferences'), - onPressed: () async { - final confirmed = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Reset all preferences?'), - content: const Text( - 'This will reset all user-defined web engine preferences to their defaults.', - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(false), - child: const Text('Cancel'), - ), - TextButton( - onPressed: () => Navigator.of(context).pop(true), - child: const Text('Reset'), - ), - ], + return SettingsCustomScrollScaffold( + title: 'Web Engine Hardening', + searchController: search.controller, + searchHintText: 'Search hardening groups', + actions: [ + MenuAnchor( + menuChildren: [ + MenuItemButton( + leadingIcon: const Icon(Icons.restore), + child: const Text('Reset all preferences'), + onPressed: () async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Reset all preferences?'), + content: const Text( + 'This will reset all user-defined web engine preferences to their defaults.', ), - ); - if (confirmed == true) { - await ref - .read( - unifiedPreferenceSettingsRepositoryProvider( - PreferencePartition.user, - ).notifier, - ) - .reset(); - } - }, - ), - ], - builder: (context, controller, child) => IconButton( - icon: const Icon(Icons.more_vert), - onPressed: () { - if (controller.isOpen) { - controller.close(); - } else { - controller.open(); + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Reset'), + ), + ], + ), + ); + if (confirmed == true) { + await ref + .read( + unifiedPreferenceSettingsRepositoryProvider( + PreferencePartition.user, + ).notifier, + ) + .reset(); } }, ), + ], + builder: (context, controller, child) => IconButton( + icon: const Icon(Icons.more_vert), + onPressed: () { + if (controller.isOpen) { + controller.close(); + } else { + controller.open(); + } + }, ), - ], - ), - body: SafeArea( - child: preferenceGroups.when( - skipLoadingOnReload: true, - data: (data) { - return Column( - children: [ - Padding( - padding: const EdgeInsets.all(8.0), - child: Card( - color: theme.colorScheme.primaryContainer, - child: Padding( - padding: const EdgeInsets.all(8.0), - child: SwitchListTile( - value: allGroupsActive, - title: Text( - 'Complete Hardening', - style: TextStyle( - color: theme.colorScheme.onPrimaryContainer, + ), + ], + slivers: [ + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 24, 16, 20), + sliver: SliverToBoxAdapter( + child: preferenceGroups.when( + skipLoadingOnReload: true, + data: (data) { + final overviewMatches = + query.isEmpty || + matchesSettingsSearch(query, const [ + 'overview', + 'complete hardening', + 'apply reset all grouped hardening preferences', + ]); + final filteredGroups = data.entries.where((group) { + if (query.isEmpty) return true; + return matchesSettingsSearch(query, [ + group.key, + if (group.value.description != null) + group.value.description!, + ]); + }).toList(); + + final sections = [ + if (overviewMatches) + SettingsSectionDefinition( + title: 'Overview', + entries: [ + SettingsEntryDefinition( + title: 'Complete Hardening', + subtitle: + 'Apply or reset all grouped hardening preferences', + child: Padding( + padding: const EdgeInsets.all(16.0), + child: DecoratedBox( + decoration: BoxDecoration( + color: theme.colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(16), + ), + child: SwitchListTile.adaptive( + value: allGroupsActive, + title: Text( + 'Complete Hardening', + style: TextStyle( + color: theme.colorScheme.onPrimaryContainer, + ), + ), + subtitle: Text( + 'Toggle all grouped hardening preferences at once.', + style: TextStyle( + color: theme.colorScheme.onPrimaryContainer, + ), + ), + onChanged: (value) async { + final notifier = ref.read( + unifiedPreferenceSettingsRepositoryProvider( + PreferencePartition.user, + ).notifier, + ); + + if (value) { + await notifier.apply(); + } else { + await notifier.reset(); + } + }, + ), + ), ), ), - onChanged: (value) async { - final notifier = ref.read( - unifiedPreferenceSettingsRepositoryProvider( - PreferencePartition.user, - ).notifier, - ); - - if (value) { - await notifier.apply(); - } else { - await notifier.reset(); - } - }, - ), + ], ), - ), - ), - Expanded( - child: ListView( - children: data.entries.map((group) { - return Row( - children: [ - Expanded( + if (filteredGroups.isNotEmpty) + SettingsSectionDefinition( + title: 'Hardening Groups', + entries: [ + for (final group in filteredGroups) + SettingsEntryDefinition( + title: group.key, + subtitle: group.value.description, child: ListTile( title: Text(group.key), subtitle: group.value.description.mapNotNull( @@ -165,26 +205,29 @@ class WebEngineHardeningScreen extends HookConsumerWidget { }, ), ), - ], - ); - }).toList(), + ], + ), + ]; + + return SettingsSectionList( + sections: sections, + query: search.rawQuery, + ); + }, + error: (error, stackTrace) => FailureWidget( + title: 'Could not load preference settings', + exception: error, + onRetry: () => ref.refresh( + unifiedPreferenceSettingsRepositoryProvider( + PreferencePartition.user, ), ), - ], - ); - }, - error: (error, stackTrace) => FailureWidget( - title: 'Could not load preference settings', - exception: error, - onRetry: () => ref.refresh( - unifiedPreferenceSettingsRepositoryProvider( - PreferencePartition.user, ), + loading: () => const Center(child: CircularProgressIndicator()), ), ), - loading: () => const Center(child: CircularProgressIndicator()), ), - ), + ], ); } } diff --git a/apps/weblibre/lib/features/settings/presentation/screens/web_engine_hardening_group.dart b/apps/weblibre/lib/features/settings/presentation/screens/web_engine_hardening_group.dart index e982a088..1836e55f 100644 --- a/apps/weblibre/lib/features/settings/presentation/screens/web_engine_hardening_group.dart +++ b/apps/weblibre/lib/features/settings/presentation/screens/web_engine_hardening_group.dart @@ -20,9 +20,11 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nullability/nullability.dart'; +import 'package:weblibre/features/geckoview/features/preferences/data/models/preference_setting.dart'; import 'package:weblibre/features/geckoview/features/preferences/data/repositories/preference_settings.dart'; import 'package:weblibre/features/geckoview/features/tabs/utils/setting_groups_serializer.dart'; import 'package:weblibre/features/settings/presentation/widgets/hardening_group_icon.dart'; +import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart'; import 'package:weblibre/presentation/widgets/failure_widget.dart'; class WebEngineHardeningGroupScreen extends HookConsumerWidget { @@ -40,178 +42,215 @@ class WebEngineHardeningGroupScreen extends HookConsumerWidget { ); final theme = Theme.of(context); + final search = useSettingsSearch(); + final query = search.normalizedQuery; - return Scaffold( - appBar: AppBar(title: Text(groupName)), - body: SafeArea( - child: settings.when( - skipLoadingOnReload: true, - data: (group) { - return Column( - children: [ - if (group.showMasterSwitch) - Padding( - padding: const EdgeInsets.all(8.0), - child: Card( - color: theme.colorScheme.primaryContainer, - child: Padding( - padding: const EdgeInsets.all(8.0), - child: SwitchListTile( - value: group.isActiveOrOptional, - title: Text( - groupName, - style: TextStyle( - color: theme.colorScheme.onPrimaryContainer, - ), - ), - subtitle: group.description.mapNotNull( - (description) => Text( - description, - style: TextStyle( - color: theme.colorScheme.onPrimaryContainer, + return SettingsCustomScrollScaffold( + title: groupName, + searchController: search.controller, + searchHintText: 'Search hardening settings', + slivers: [ + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 24, 16, 20), + sliver: SliverToBoxAdapter( + child: settings.when( + skipLoadingOnReload: true, + data: (group) { + final groupControlMatches = + query.isEmpty || + matchesSettingsSearch(query, [ + groupName, + if (group.description != null) group.description!, + 'group controls complete hardening', + ]); + final filteredSettings = group.settings.entries.where(( + setting, + ) { + if (query.isEmpty) return true; + return matchesSettingsSearch(query, [ + setting.key, + if (setting.value.title != null) setting.value.title!, + if (setting.value.description != null) + setting.value.description!, + ]); + }).toList(); + + final sections = [ + if (group.showMasterSwitch && groupControlMatches) + SettingsSectionDefinition( + title: 'Group Controls', + entries: [ + SettingsEntryDefinition( + title: groupName, + subtitle: group.description, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: DecoratedBox( + decoration: BoxDecoration( + color: theme.colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(16), ), - ), - ), - onChanged: (value) async { - final notifier = ref.read( - preferenceSettingsGroupRepositoryProvider( - PreferencePartition.user, - groupName, - ).notifier, - ); - - if (value) { - await notifier.apply(); - } else { - await notifier.reset(); - } - }, - ), - ), - ), - ), - Expanded( - child: ListView( - children: group.settings.entries.map((setting) { - var value = setting.value.value.toString(); - if (value.length > 160) { - value = '${value.substring(0, 160)}…'; - } - - return Tooltip( - message: '${setting.key}: $value', - child: Row( - children: [ - if (!setting.value.shouldBeDefault || - !setting.value.isActive) - Expanded( - child: SwitchListTile( - value: setting.value.isActive, - title: Text( - setting.value.title ?? setting.key, + child: SwitchListTile.adaptive( + value: group.isActiveOrOptional, + title: Text( + groupName, + style: TextStyle( + color: theme.colorScheme.onPrimaryContainer, ), - subtitle: Text.rich( - TextSpan( - children: [ - if (setting.value.requireUserOptIn) - WidgetSpan( - child: Container( - padding: - const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - margin: const EdgeInsets.only( - right: 8, - ), - decoration: BoxDecoration( - color: theme.colorScheme.error, - borderRadius: - BorderRadius.circular(4), - ), - child: Text( - 'Optional', - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w500, - color: - theme.colorScheme.onError, - ), - ), - ), - ), - if (setting.value.description != null) - TextSpan( - text: setting.value.description, - // style: theme.textTheme.bodyMedium, - ), - ], + ), + subtitle: group.description.mapNotNull( + (description) => Text( + description, + style: TextStyle( + color: + theme.colorScheme.onPrimaryContainer, ), ), - secondary: HardeningGroupIcon( - isActive: setting.value.isActive, - ), - onChanged: (value) async { - final notifier = ref.read( - preferenceSettingsGroupRepositoryProvider( - PreferencePartition.user, - groupName, - ).notifier, - ); + ), + onChanged: (value) async { + final notifier = ref.read( + preferenceSettingsGroupRepositoryProvider( + PreferencePartition.user, + groupName, + ).notifier, + ); - if (value) { - await notifier.apply( - filter: [setting.key], - ); - } else { - await notifier.reset( - filter: [setting.key], - ); - } - }, - ), - ) - else - Expanded( - child: ListTile( - title: Text( - setting.value.title ?? setting.key, - ), - subtitle: setting.value.description - .mapNotNull( - (description) => Text(description), - ), - leading: HardeningGroupIcon( - isActive: setting.value.isActive, - ), - trailing: const Padding( - padding: EdgeInsets.only(right: 18.0), - child: Icon(Icons.check), - ), - ), + if (value) { + await notifier.apply(); + } else { + await notifier.reset(); + } + }, ), - ], + ), + ), ), - ); - }).toList(), + ], + ), + if (filteredSettings.isNotEmpty) + SettingsSectionDefinition( + title: 'Preference Settings', + entries: [ + for (final setting in filteredSettings) + SettingsEntryDefinition( + title: setting.value.title ?? setting.key, + subtitle: setting.value.description, + child: _HardeningSettingTile( + groupName: groupName, + settingKey: setting.key, + settingValue: setting.value, + ), + ), + ], + ), + ]; + + return SettingsSectionList( + sections: sections, + query: search.rawQuery, + ); + }, + error: (error, stackTrace) => FailureWidget( + title: 'Could not load preference settings', + exception: error, + onRetry: () => ref.refresh( + preferenceSettingsGroupRepositoryProvider( + PreferencePartition.user, + groupName, ), ), - ], - ); - }, - error: (error, stackTrace) => FailureWidget( - title: 'Could not load preference settings', - exception: error, - onRetry: () => ref.refresh( - preferenceSettingsGroupRepositoryProvider( - PreferencePartition.user, - groupName, ), + loading: () => const Center(child: CircularProgressIndicator()), ), ), - loading: () => const Center(child: CircularProgressIndicator()), ), - ), + ], + ); + } +} + +class _HardeningSettingTile extends ConsumerWidget { + const _HardeningSettingTile({ + required this.groupName, + required this.settingKey, + required this.settingValue, + }); + + final String groupName; + final String settingKey; + final PreferenceSetting settingValue; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + var value = settingValue.value.toString(); + if (value.length > 160) { + value = '${value.substring(0, 160)}…'; + } + + return Tooltip( + message: '$settingKey: $value', + child: (!settingValue.shouldBeDefault || !settingValue.isActive) + ? SwitchListTile.adaptive( + value: settingValue.isActive, + title: Text(settingValue.title ?? settingKey), + subtitle: Text.rich( + TextSpan( + children: [ + if (settingValue.requireUserOptIn) + WidgetSpan( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + margin: const EdgeInsets.only(right: 8), + decoration: BoxDecoration( + color: theme.colorScheme.error, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + 'Optional', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w500, + color: theme.colorScheme.onError, + ), + ), + ), + ), + if (settingValue.description != null) + TextSpan(text: settingValue.description), + ], + ), + ), + secondary: HardeningGroupIcon(isActive: settingValue.isActive), + onChanged: (value) async { + final notifier = ref.read( + preferenceSettingsGroupRepositoryProvider( + PreferencePartition.user, + groupName, + ).notifier, + ); + + if (value) { + await notifier.apply(filter: [settingKey]); + } else { + await notifier.reset(filter: [settingKey]); + } + }, + ) + : ListTile( + title: Text(settingValue.title ?? settingKey), + subtitle: settingValue.description.mapNotNull( + (description) => Text(description), + ), + leading: HardeningGroupIcon(isActive: settingValue.isActive), + trailing: const Padding( + padding: EdgeInsets.only(right: 18.0), + child: Icon(Icons.check), + ), + ), ); } } diff --git a/apps/weblibre/lib/features/settings/presentation/widgets/default_search_selector.dart b/apps/weblibre/lib/features/settings/presentation/widgets/default_search_selector.dart index 96a4bcc0..ed1d45a3 100644 --- a/apps/weblibre/lib/features/settings/presentation/widgets/default_search_selector.dart +++ b/apps/weblibre/lib/features/settings/presentation/widgets/default_search_selector.dart @@ -24,7 +24,6 @@ import 'package:weblibre/features/bangs/data/models/bang_key.dart'; import 'package:weblibre/features/bangs/domain/providers/bangs.dart'; import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart'; import 'package:weblibre/features/user/data/models/general_settings.dart'; -import 'package:weblibre/presentation/widgets/selectable_chips.dart'; import 'package:weblibre/presentation/widgets/url_icon.dart'; class DefaultSearchSelector extends HookConsumerWidget { @@ -35,7 +34,6 @@ class DefaultSearchSelector extends HookConsumerWidget { final activeBang = ref.watch( defaultSearchBangDataProvider.select((value) => value.value), ); - final availableBangs = ref.watch(frequentBangListProvider); Future updateSearchProvider(BangKey key) async { await ref @@ -46,47 +44,37 @@ class DefaultSearchSelector extends HookConsumerWidget { ); } - return availableBangs.when( - skipLoadingOnReload: true, - data: (availableBangs) { - return SizedBox( - height: 48, - child: Row( - children: [ - Expanded( - child: SelectableChips( - itemId: (bang) => bang.trigger, - itemAvatar: (bang) => - UrlIcon([bang.getDefaultUrl()], iconSize: 20), - itemLabel: (bang) => Text(bang.websiteName), - itemTooltip: (bang) => bang.trigger, - availableItems: availableBangs, - selectedItem: activeBang, - onSelected: (bang) async { - await updateSearchProvider(bang.toKey()); - }, - ), - ), - IconButton( - onPressed: () async { - final trigger = await const BangSearchRoute().push( - context, - ); + Future pickProvider() async { + final trigger = await const BangSearchRoute().push(context); + if (trigger != null) { + await updateSearchProvider(trigger); + } + } - if (trigger != null) { - await updateSearchProvider(trigger); - } - }, - icon: const Icon(Icons.chevron_right), - ), - ], + return SizedBox( + height: 48, + child: Row( + children: [ + Expanded( + child: activeBang == null + ? OutlinedButton.icon( + onPressed: pickProvider, + icon: const Icon(Icons.search), + label: const Text('Choose a search provider'), + ) + : ActionChip( + avatar: UrlIcon([activeBang.getDefaultUrl()], iconSize: 20), + label: Text(activeBang.websiteName), + tooltip: activeBang.trigger, + onPressed: pickProvider, + ), ), - ); - }, - error: (error, stackTrace) => Center( - child: Icon(Icons.error, color: Theme.of(context).colorScheme.error), + IconButton( + onPressed: pickProvider, + icon: const Icon(Icons.chevron_right), + ), + ], ), - loading: () => const SizedBox(height: 48, width: double.infinity), ); } } diff --git a/apps/weblibre/lib/features/settings/presentation/widgets/settings_content_card.dart b/apps/weblibre/lib/features/settings/presentation/widgets/settings_content_card.dart new file mode 100644 index 00000000..64668ee9 --- /dev/null +++ b/apps/weblibre/lib/features/settings/presentation/widgets/settings_content_card.dart @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'package:flutter/material.dart'; + +/// Wraps [child] in the standard surface-container Card used by settings +/// content widgets — unless [embedded] is true, in which case the child is +/// returned as-is so the host container (typically a +/// [SettingsEntryDefinition] already inside a `Card.filled`) doesn't end +/// up with nested cards. +/// +/// Use this anywhere a widget is rendered both stand-alone (e.g. inside a +/// dedicated screen) and embedded as the `child` of a +/// [SettingsEntryDefinition]. +class SettingsContentCard extends StatelessWidget { + const SettingsContentCard({ + super.key, + required this.child, + this.embedded = false, + }); + + final Widget child; + final bool embedded; + + @override + Widget build(BuildContext context) { + if (embedded) return child; + + return Card( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + clipBehavior: Clip.antiAlias, + child: child, + ); + } +} diff --git a/apps/weblibre/lib/features/settings/presentation/widgets/settings_detail.dart b/apps/weblibre/lib/features/settings/presentation/widgets/settings_detail.dart new file mode 100644 index 00000000..efe02a12 --- /dev/null +++ b/apps/weblibre/lib/features/settings/presentation/widgets/settings_detail.dart @@ -0,0 +1,362 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'package:fading_scroll/fading_scroll.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +class SettingsEntryDefinition { + final String title; + final String? subtitle; + final List keywords; + final Widget child; + + const SettingsEntryDefinition({ + required this.title, + required this.child, + this.subtitle, + this.keywords = const [], + }); +} + +class SettingsSectionDefinition { + final String title; + final List keywords; + final List entries; + + const SettingsSectionDefinition({ + required this.title, + required this.entries, + this.keywords = const [], + }); +} + +/// Result of [useSettingsSearch]: the bound [TextEditingController] used by +/// [SettingsSearchField], plus the trimmed/lowercased query suitable for +/// substring matching with [matchesSettingsSearch]. +class SettingsSearchState { + final TextEditingController controller; + final String normalizedQuery; + + const SettingsSearchState({ + required this.controller, + required this.normalizedQuery, + }); + + String get rawQuery => controller.text; +} + +/// Standard search-field plumbing for a settings screen: returns a controller +/// that should be passed to [SettingsCustomScrollScaffold.searchController] / +/// [SettingsSearchField], plus the normalized query string callers use to +/// filter their own data. Rebuilds the surrounding widget on every keystroke. +SettingsSearchState useSettingsSearch() { + final controller = useTextEditingController(); + useListenable(controller); + return SettingsSearchState( + controller: controller, + normalizedQuery: controller.text.trim().toLowerCase(), + ); +} + +class SettingsDetailScaffold extends HookWidget { + final String title; + final String subtitle; + final IconData icon; + final List sections; + final List actions; + final String searchHintText; + + const SettingsDetailScaffold({ + super.key, + required this.title, + required this.subtitle, + required this.icon, + required this.sections, + this.actions = const [], + this.searchHintText = 'Search settings', + }); + + @override + Widget build(BuildContext context) { + final search = useSettingsSearch(); + + final filteredSections = filterSettingsSections( + sections: sections, + query: search.rawQuery, + ); + + return Scaffold( + body: SafeArea( + child: FadingScroll( + fadingSize: 25, + builder: (context, controller) { + return CustomScrollView( + controller: controller, + slivers: [ + SliverAppBar.large( + centerTitle: false, + title: Text(title), + actions: actions, + ), + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + sliver: SliverToBoxAdapter( + child: SettingsSearchField( + controller: search.controller, + hintText: searchHintText, + ), + ), + ), + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 24, 16, 20), + sliver: SliverToBoxAdapter( + child: SettingsSectionList( + sections: filteredSections, + query: search.rawQuery, + ), + ), + ), + ], + ); + }, + ), + ), + ); + } +} + +class SettingsCustomScrollScaffold extends StatelessWidget { + final String title; + final List actions; + final TextEditingController? searchController; + final String searchHintText; + final List slivers; + + const SettingsCustomScrollScaffold({ + super.key, + required this.title, + required this.slivers, + this.actions = const [], + this.searchController, + this.searchHintText = 'Search settings', + }); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: SafeArea( + child: FadingScroll( + fadingSize: 25, + builder: (context, controller) { + return CustomScrollView( + controller: controller, + slivers: [ + SliverAppBar.large( + centerTitle: false, + title: Text(title), + actions: actions, + ), + if (searchController != null) + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + sliver: SliverToBoxAdapter( + child: SettingsSearchField( + controller: searchController!, + hintText: searchHintText, + ), + ), + ), + ...slivers, + ], + ); + }, + ), + ), + ); + } +} + +class SettingsSearchField extends StatelessWidget { + final TextEditingController controller; + final String hintText; + + const SettingsSearchField({ + super.key, + required this.controller, + this.hintText = 'Search settings', + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return TextField( + controller: controller, + decoration: InputDecoration( + hintText: hintText, + prefixIcon: const Icon(Icons.search), + suffixIcon: controller.text.isEmpty + ? null + : IconButton( + onPressed: controller.clear, + icon: const Icon(Icons.close), + ), + filled: true, + fillColor: colorScheme.surfaceContainerHighest.withValues(alpha: 0.6), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide.none, + ), + ), + ); + } +} + +class SettingsSectionList extends StatelessWidget { + final List sections; + final String query; + + const SettingsSectionList({ + super.key, + required this.sections, + required this.query, + }); + + @override + Widget build(BuildContext context) { + if (sections.isEmpty) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 32.0, horizontal: 8.0), + child: Center( + child: Text( + query.trim().isEmpty + ? 'No settings available.' + : 'No settings match "$query".', + style: Theme.of(context).textTheme.bodyLarge, + textAlign: TextAlign.center, + ), + ), + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: buildSettingsSectionWidgets(context, sections), + ); + } +} + +List buildSettingsSectionWidgets( + BuildContext context, + List sections, +) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return [ + for ( + var sectionIndex = 0; + sectionIndex < sections.length; + sectionIndex++ + ) ...[ + if (sectionIndex > 0) const SizedBox(height: 24), + Text( + sections[sectionIndex].title, + style: theme.textTheme.titleSmall?.copyWith( + color: colorScheme.primary, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 12), + Card.filled( + margin: EdgeInsets.zero, + color: colorScheme.surfaceContainer, + clipBehavior: Clip.antiAlias, + child: Column( + children: [ + for ( + var entryIndex = 0; + entryIndex < sections[sectionIndex].entries.length; + entryIndex++ + ) ...[ + if (entryIndex > 0) const Divider(height: 1), + sections[sectionIndex].entries[entryIndex].child, + ], + ], + ), + ), + ], + ]; +} + +List filterSettingsSections({ + required List sections, + required String query, +}) { + final normalizedQuery = query.trim().toLowerCase(); + if (normalizedQuery.isEmpty) return sections; + + final filteredSections = []; + + for (final section in sections) { + if (matchesSettingsSearch(normalizedQuery, [ + section.title, + ...section.keywords, + ])) { + filteredSections.add(section); + continue; + } + + final filteredEntries = [ + for (final entry in section.entries) + if (matchesSettingsSearch(normalizedQuery, [ + section.title, + entry.title, + if (entry.subtitle != null) entry.subtitle!, + ...section.keywords, + ...entry.keywords, + ])) + entry, + ]; + + if (filteredEntries.isNotEmpty) { + filteredSections.add( + SettingsSectionDefinition( + title: section.title, + keywords: section.keywords, + entries: filteredEntries, + ), + ); + } + } + + return filteredSections; +} + +/// Token-AND substring match used by every settings search. The query is +/// expected to already be trimmed and lowercased (e.g. via +/// [useSettingsSearch] or [filterSettingsSections]). +bool matchesSettingsSearch(String normalizedQuery, List values) { + final tokens = normalizedQuery + .split(RegExp(r'\s+')) + .where((token) => token.isNotEmpty); + final haystack = values.join(' ').toLowerCase(); + return tokens.every(haystack.contains); +} diff --git a/apps/weblibre/lib/features/settings/presentation/widgets/toolbar_layout_content.dart b/apps/weblibre/lib/features/settings/presentation/widgets/toolbar_layout_content.dart index 32e0b217..ec4fdad3 100644 --- a/apps/weblibre/lib/features/settings/presentation/widgets/toolbar_layout_content.dart +++ b/apps/weblibre/lib/features/settings/presentation/widgets/toolbar_layout_content.dart @@ -22,35 +22,118 @@ import 'package:flutter_material_design_icons/flutter_material_design_icons.dart import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:weblibre/core/routing/routes.dart'; import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart'; -import 'package:weblibre/features/settings/presentation/widgets/sections.dart'; +import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart'; import 'package:weblibre/features/user/data/models/general_settings.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; +const List toolbarLayoutSettingsSections = [ + SettingsSectionDefinition( + title: 'Tab Bar', + entries: [ + SettingsEntryDefinition( + title: 'Tab Bar Position', + subtitle: 'Choose whether the tab bar stays at the top or bottom', + keywords: ['top', 'bottom'], + child: _TabBarPositionSection(), + ), + SettingsEntryDefinition( + title: 'Tab Bar Style', + subtitle: 'Choose between title and compact layouts', + keywords: ['layout', 'compact'], + child: _TabBarLayoutModeSection(), + ), + SettingsEntryDefinition( + title: 'Auto Hide Tab Bar', + subtitle: 'Hide the tab bar when scrolling', + keywords: ['scroll'], + child: _AutoHideTabBarTile(), + ), + SettingsEntryDefinition( + title: 'Long Press URL to Copy', + subtitle: 'Copy the current URL from the tab bar', + keywords: ['copy url'], + child: _TabBarLongPressUrlCopyTile(), + ), + ], + ), + SettingsSectionDefinition( + title: 'Contextual Toolbar', + entries: [ + SettingsEntryDefinition( + title: 'Show Contextual Toolbar', + subtitle: 'Show an additional toolbar for navigation and actions', + keywords: ['bottom toolbar'], + child: _ShowContextualTabBarTile(), + ), + SettingsEntryDefinition( + title: 'Customize Toolbar Buttons', + subtitle: 'Choose which actions appear in the contextual toolbar', + keywords: ['buttons'], + child: _CustomizeToolbarButtonsTile(), + ), + ], + ), + SettingsSectionDefinition( + title: 'Quick Tab Switcher', + entries: [ + SettingsEntryDefinition( + title: 'Show Quick Tab Switcher Bar', + subtitle: 'Show a bar for switching to recent tabs', + keywords: ['recent tabs'], + child: _ShowQuickTabSwitcherBarTile(), + ), + SettingsEntryDefinition( + title: 'Quick Tab Switcher Mode', + subtitle: 'Choose how the switcher orders and groups tabs', + keywords: ['recently used', 'container tabs'], + child: _QuickTabSwitcherModeSection(), + ), + SettingsEntryDefinition( + title: 'History Fallback in Quick Tab Switcher', + subtitle: 'Use history suggestions when there are no matching tabs', + keywords: ['suggestions'], + child: _QuickTabSwitcherHistorySuggestionsTile(), + ), + SettingsEntryDefinition( + title: 'Show Titles in Quick Tab Switcher', + subtitle: 'Display page titles in the switcher list', + keywords: ['page titles'], + child: _QuickTabSwitcherShowTitlesTile(), + ), + ], + ), + SettingsSectionDefinition( + title: 'Tab View', + entries: [ + SettingsEntryDefinition( + title: 'Bottom Sheet Tab View', + subtitle: 'Open the tab switcher as a bottom sheet', + keywords: ['sheet'], + child: _BottomSheetTabViewTile(), + ), + SettingsEntryDefinition( + title: 'Show Favicons in List View', + subtitle: 'Display site icons in the tab list', + keywords: ['icons'], + child: _TabListShowFaviconsTile(), + ), + ], + ), +]; + class ToolbarLayoutContent extends StatelessWidget { - const ToolbarLayoutContent({super.key}); + final String query; + + const ToolbarLayoutContent({super.key, this.query = ''}); @override Widget build(BuildContext context) { - return const Column( - children: [ - SettingSection(name: 'Tab Bar'), - _TabBarPositionSection(), - _TabBarLayoutModeSection(), - _AutoHideTabBarTile(), - _TabBarLongPressUrlCopyTile(), - SettingSection(name: 'Contextual Toolbar'), - _ShowContextualTabBarTile(), - _CustomizeToolbarButtonsTile(), - SettingSection(name: 'Quick Tab Switcher'), - _ShowQuickTabSwitcherBarTile(), - _QuickTabSwitcherModeSection(), - _QuickTabSwitcherHistorySuggestionsTile(), - _QuickTabSwitcherShowTitlesTile(), - SettingSection(name: 'Tab View'), - _BottomSheetTabViewTile(), - _TabListShowFaviconsTile(), - ], + final filteredSections = filterSettingsSections( + sections: toolbarLayoutSettingsSections, + query: query, ); + + return SettingsSectionList(sections: filteredSections, query: query); } } diff --git a/apps/weblibre/lib/features/settings/presentation/widgets/toolbar_preview.dart b/apps/weblibre/lib/features/settings/presentation/widgets/toolbar_preview.dart index f8068b33..3974d24a 100644 --- a/apps/weblibre/lib/features/settings/presentation/widgets/toolbar_preview.dart +++ b/apps/weblibre/lib/features/settings/presentation/widgets/toolbar_preview.dart @@ -30,6 +30,7 @@ import 'package:weblibre/features/geckoview/features/browser/presentation/widget import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/navigation_buttons.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tabs_action_button.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart'; +import 'package:weblibre/features/geckoview/features/tabs/utils/container_colors.dart'; import 'package:weblibre/features/user/data/models/general_settings.dart'; class TabBarPreviewHeaderDelegate extends SliverPersistentHeaderDelegate { @@ -136,9 +137,7 @@ class TabBarPreviewCard extends HookWidget { settings.effectiveUiQuickTabSwitcherMode() == QuickTabSwitcherMode.containerTabs, url: Uri.parse('https://example.com/news'), - color: settings.showContainerUi - ? colorScheme.primary.withValues(alpha: 0.18) - : null, + color: settings.showContainerUi ? colorScheme.primary : null, avatar: const Icon(MdiIcons.web, size: 20), ), QuickTabSwitcherItem( @@ -177,6 +176,9 @@ class TabBarPreviewCard extends HookWidget { avatar: const Icon(MdiIcons.web, size: 20), ), ]; + final previewContainerPalette = settings.showContainerUi + ? ContainerColors.palette(context, colorScheme.primary) + : null; final tabCountButton = TabsCountButtonView( isActive: false, @@ -236,9 +238,8 @@ class TabBarPreviewCard extends HookWidget { showQuickTabSwitcherBar: settings.tabBarShowQuickTabSwitcherBar, displayAppBar: true, displayQuickTabSwitcher: true, - backgroundColor: settings.showContainerUi - ? colorScheme.primaryContainer.withValues(alpha: 0.55) - : colorScheme.surfaceContainer, + backgroundColor: + previewContainerPalette?.surfaceColor ?? colorScheme.surfaceContainer, title: settings.tabBarLayout == TabBarLayout.compact ? _CompactPreviewTitle(tabState: previewTabState) : _RegularPreviewTitle(tabState: previewTabState), @@ -253,9 +254,8 @@ class TabBarPreviewCard extends HookWidget { showQuickTabSwitcherBar: false, displayAppBar: true, displayQuickTabSwitcher: false, - backgroundColor: settings.showContainerUi - ? colorScheme.primaryContainer.withValues(alpha: 0.55) - : colorScheme.surfaceContainer, + backgroundColor: + previewContainerPalette?.surfaceColor ?? colorScheme.surfaceContainer, title: settings.tabBarLayout == TabBarLayout.compact ? _CompactPreviewTitle(tabState: previewTabState) : _RegularPreviewTitle(tabState: previewTabState), diff --git a/apps/weblibre/lib/features/share_intent/domain/services/sharing_intent.dart b/apps/weblibre/lib/features/share_intent/domain/services/sharing_intent.dart index 771045a7..e641e5f0 100644 --- a/apps/weblibre/lib/features/share_intent/domain/services/sharing_intent.dart +++ b/apps/weblibre/lib/features/share_intent/domain/services/sharing_intent.dart @@ -27,6 +27,7 @@ import 'package:simple_intent_receiver/simple_intent_receiver.dart'; import 'package:uri_to_file/uri_to_file.dart' as uri_to_file; import 'package:weblibre/core/logger.dart'; import 'package:weblibre/data/models/received_intent_parameter.dart'; +import 'package:weblibre/features/account/domain/services/account_callback_handler.dart'; import 'package:weblibre/features/intent_gatekeeper/domain/entities/intent_source_policy.dart'; import 'package:weblibre/features/intent_gatekeeper/domain/services/intent_gatekeeper.dart'; import 'package:weblibre/features/share_intent/domain/entities/intent_container_mode.dart'; @@ -42,6 +43,13 @@ _buildSharingIntentTransformer( IntentGatekeeper gatekeeper, ) => StreamTransformer.fromHandlers( handleData: (intent, sink) async { + if (_extractAccountCallback(intent) != null) { + // Account callback intents are consumed by accountCallbackStreamProvider — + // suppress them from the regular share/sharing intent pipeline so they + // don't open a browser tab. + return; + } + final alwaysAllowPackage = intent.extra[_alwaysAllowPackageExtra] as String?; if (alwaysAllowPackage != null) { @@ -150,10 +158,76 @@ _buildSharingIntentTransformer( }, ); +/// Shared intent receiver instance. Both the sharing intent stream +/// and the account callback handler listen to its broadcast events. +@Riverpod(keepAlive: true) +Raw intentReceiver(Ref ref) { + final receiver = IntentReceiver.setUp(); + ref.onDispose(receiver.dispose); + return receiver; +} + +/// Runs the receiver event stream through [transformer] and forwards it into a +/// fresh single-subscription controller wired up to [ref]'s lifecycle. +/// +/// Centralising this in one helper means the two intent-consuming providers +/// below (sharing intents + account callbacks) agree on the receiver semantics: +/// `receiver.events` includes the recovered cold-start intent for each +/// listener, plus all live intents. +Raw> _consumeIntents( + Ref ref, + IntentReceiver receiver, + StreamTransformer transformer, +) { + final controller = StreamController(); + + final sub = receiver.events + .transform(transformer) + .listen(controller.add, onError: controller.addError); + + ref.onDispose(() { + unawaited(sub.cancel()); + unawaited(controller.close()); + }); + + return controller.stream; +} + @Riverpod(keepAlive: true) Raw> sharingIntentStream(Ref ref) { - final receiver = IntentReceiver.setUp(); + final receiver = ref.watch(intentReceiverProvider); final gatekeeper = ref.watch(intentGatekeeperProvider.notifier); - - return receiver.events.transform(_buildSharingIntentTransformer(gatekeeper)); + return _consumeIntents( + ref, + receiver, + _buildSharingIntentTransformer(gatekeeper), + ); +} + +/// Stream of account callback handoff codes extracted from deep link intents. +@Riverpod(keepAlive: true) +Raw> accountCallbackStream(Ref ref) { + final receiver = ref.watch(intentReceiverProvider); + return _consumeIntents(ref, receiver, _accountCallbackTransformer); +} + +/// Transformer that yields handoff codes for matching VIEW intents and +/// drops everything else. Shared with the sharing-intent suppression +/// branch via [_extractAccountCallback] so the two streams agree on which +/// intents are "account callbacks". +final _accountCallbackTransformer = + StreamTransformer.fromHandlers( + handleData: (intent, sink) { + final callback = _extractAccountCallback(intent); + if (callback != null) { + sink.add(callback.handoffCode); + } + }, + ); + +AccountCallback? _extractAccountCallback(Intent intent) { + if (intent.action != 'android.intent.action.VIEW' || intent.data == null) { + return null; + } + return tryParseAccountCallback(intent.data!); } diff --git a/apps/weblibre/lib/features/share_intent/domain/services/sharing_intent.g.dart b/apps/weblibre/lib/features/share_intent/domain/services/sharing_intent.g.dart index e6197409..f48864ed 100644 --- a/apps/weblibre/lib/features/share_intent/domain/services/sharing_intent.g.dart +++ b/apps/weblibre/lib/features/share_intent/domain/services/sharing_intent.g.dart @@ -8,6 +8,60 @@ part of 'sharing_intent.dart'; // GENERATED CODE - DO NOT MODIFY BY HAND // ignore_for_file: type=lint, type=warning +/// Shared intent receiver instance. Both the sharing intent stream +/// and the account callback handler listen to its broadcast events. + +@ProviderFor(intentReceiver) +final intentReceiverProvider = IntentReceiverProvider._(); + +/// Shared intent receiver instance. Both the sharing intent stream +/// and the account callback handler listen to its broadcast events. + +final class IntentReceiverProvider + extends + $FunctionalProvider< + Raw, + Raw, + Raw + > + with $Provider> { + /// Shared intent receiver instance. Both the sharing intent stream + /// and the account callback handler listen to its broadcast events. + IntentReceiverProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'intentReceiverProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$intentReceiverHash(); + + @$internal + @override + $ProviderElement> $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + Raw create(Ref ref) { + return intentReceiver(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(Raw value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider>(value), + ); + } +} + +String _$intentReceiverHash() => r'61527e0581f56e82a8eec830ad541c8593502df3'; @ProviderFor(sharingIntentStream) final sharingIntentStreamProvider = SharingIntentStreamProvider._(); @@ -56,4 +110,57 @@ final class SharingIntentStreamProvider } String _$sharingIntentStreamHash() => - r'21b189c5df56f81ed5ab88115c11ddce1189cfb7'; + r'02cef0c00bcb9416f8daf420977f58764c8efaca'; + +/// Stream of account callback handoff codes extracted from deep link intents. + +@ProviderFor(accountCallbackStream) +final accountCallbackStreamProvider = AccountCallbackStreamProvider._(); + +/// Stream of account callback handoff codes extracted from deep link intents. + +final class AccountCallbackStreamProvider + extends + $FunctionalProvider< + Raw>, + Raw>, + Raw> + > + with $Provider>> { + /// Stream of account callback handoff codes extracted from deep link intents. + AccountCallbackStreamProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'accountCallbackStreamProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$accountCallbackStreamHash(); + + @$internal + @override + $ProviderElement>> $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + Raw> create(Ref ref) { + return accountCallbackStream(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(Raw> value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider>>(value), + ); + } +} + +String _$accountCallbackStreamHash() => + r'669354c8000657bcaea046ab6318f89cb10dd7fc'; diff --git a/apps/weblibre/lib/features/small_web/data/database/database.drift.dart b/apps/weblibre/lib/features/small_web/data/database/database.drift.dart index 83635529..ce46025d 100644 --- a/apps/weblibre/lib/features/small_web/data/database/database.drift.dart +++ b/apps/weblibre/lib/features/small_web/data/database/database.drift.dart @@ -107,6 +107,11 @@ extension DefineFunctions on i7.CommonDatabase { required String Function(int, String?) lexoRankPrevious, required String Function(String?, String?) lexoRankReorderAfter, required String Function(String?, String?) lexoRankReorderBefore, + required int Function() generateContentHash, + required bool Function(String?) urlIndexable, + required String Function(String?) urlCanonical, + required String Function(String?) urlHost, + required String Function(String?) urlPath, }) { createFunction( functionName: 'lexo_rank_next', @@ -144,5 +149,44 @@ extension DefineFunctions on i7.CommonDatabase { return lexoRankReorderBefore(arg0, arg1); }, ); + createFunction( + functionName: 'generate_content_hash', + argumentCount: const i7.AllowedArgumentCount(0), + function: (args) { + return generateContentHash(); + }, + ); + createFunction( + functionName: 'url_indexable', + argumentCount: const i7.AllowedArgumentCount(1), + function: (args) { + final arg0 = args[0] as String?; + return urlIndexable(arg0); + }, + ); + createFunction( + functionName: 'url_canonical', + argumentCount: const i7.AllowedArgumentCount(1), + function: (args) { + final arg0 = args[0] as String?; + return urlCanonical(arg0); + }, + ); + createFunction( + functionName: 'url_host', + argumentCount: const i7.AllowedArgumentCount(1), + function: (args) { + final arg0 = args[0] as String?; + return urlHost(arg0); + }, + ); + createFunction( + functionName: 'url_path', + argumentCount: const i7.AllowedArgumentCount(1), + function: (args) { + final arg0 = args[0] as String?; + return urlPath(arg0); + }, + ); } } diff --git a/apps/weblibre/lib/features/small_web/presentation/widgets/small_web_history_list.dart b/apps/weblibre/lib/features/small_web/presentation/widgets/small_web_history_list.dart index 0d293a08..c75c0417 100644 --- a/apps/weblibre/lib/features/small_web/presentation/widgets/small_web_history_list.dart +++ b/apps/weblibre/lib/features/small_web/presentation/widgets/small_web_history_list.dart @@ -49,33 +49,43 @@ class SmallWebHistoryHeader extends ConsumerWidget { children: [ Text('Recent Discoveries', style: theme.textTheme.titleSmall), const Spacer(), - PopupMenuButton<_ClearAction>( - icon: Icon( - Icons.more_vert, - size: 20, - color: colorScheme.onSurfaceVariant, + MenuAnchor( + builder: (context, controller, _) => IconButton( + icon: Icon( + Icons.more_vert, + size: 20, + color: colorScheme.onSurfaceVariant, + ), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + style: const ButtonStyle( + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + onPressed: () => + controller.isOpen ? controller.close() : controller.open(), ), - iconSize: 20, - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - style: const ButtonStyle( - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - offset: const Offset(0, 36), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - onSelected: (action) async { - switch (action) { - case _ClearAction.clearMode: - await ref - .read(smallWebDatabaseProvider) - .smallWebVisitDao - .deleteVisitsBySourceAndMode( - sourceKind: sourceKind, - mode: mode, - ); - case _ClearAction.clearAll: + menuChildren: [ + if (modeLabel != null) + MenuItemButton( + leadingIcon: const Icon(Icons.delete_sweep, size: 20), + onPressed: () async { + await ref + .read(smallWebDatabaseProvider) + .smallWebVisitDao + .deleteVisitsBySourceAndMode( + sourceKind: sourceKind, + mode: mode, + ); + }, + child: Text('Clear $modeLabel'), + ), + MenuItemButton( + leadingIcon: Icon( + Icons.delete_forever, + size: 20, + color: colorScheme.error, + ), + onPressed: () async { final confirmed = await showDialog( context: context, builder: (context) => AlertDialog( @@ -101,33 +111,10 @@ class SmallWebHistoryHeader extends ConsumerWidget { .smallWebVisitDao .deleteAllVisits(); } - } - }, - itemBuilder: (context) => [ - if (modeLabel != null) - PopupMenuItem( - value: _ClearAction.clearMode, - child: ListTile( - dense: true, - contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.delete_sweep, size: 20), - title: Text('Clear $modeLabel'), - ), - ), - PopupMenuItem( - value: _ClearAction.clearAll, - child: ListTile( - dense: true, - contentPadding: EdgeInsets.zero, - leading: Icon( - Icons.delete_forever, - size: 20, - color: colorScheme.error, - ), - title: Text( - 'Clear all discoveries', - style: TextStyle(color: colorScheme.error), - ), + }, + child: Text( + 'Clear all discoveries', + style: TextStyle(color: colorScheme.error), ), ), ], @@ -137,8 +124,6 @@ class SmallWebHistoryHeader extends ConsumerWidget { } } -enum _ClearAction { clearMode, clearAll } - class SmallWebHistoryList extends HookConsumerWidget { static const _initialCount = 10; diff --git a/apps/weblibre/lib/features/small_web/presentation/widgets/small_web_menu_sheet.dart b/apps/weblibre/lib/features/small_web/presentation/widgets/small_web_menu_sheet.dart index 4cc0ee4f..294a41b5 100644 --- a/apps/weblibre/lib/features/small_web/presentation/widgets/small_web_menu_sheet.dart +++ b/apps/weblibre/lib/features/small_web/presentation/widgets/small_web_menu_sheet.dart @@ -17,6 +17,7 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ +import 'package:fading_scroll/fading_scroll.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:skeletonizer/skeletonizer.dart'; @@ -140,46 +141,45 @@ class _SourceKindChip extends StatelessWidget { Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - return PopupMenuButton( - initialValue: sourceKind, - onSelected: (kind) { - if (kind != sourceKind) onChanged(kind); - }, - offset: const Offset(0, 40), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - itemBuilder: (context) => [ + return MenuAnchor( + builder: (context, controller, _) => InkWell( + borderRadius: BorderRadius.circular(8), + onTap: () => controller.isOpen ? controller.close() : controller.open(), + child: Chip( + avatar: Icon(sourceKind.icon, size: 18), + label: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(sourceKind.label), + const SizedBox(width: 2), + const Icon(Icons.arrow_drop_down, size: 18), + ], + ), + visualDensity: VisualDensity.compact, + ), + ), + menuChildren: [ for (final kind in SmallWebSourceKind.values) - PopupMenuItem( - value: kind, - child: ListTile( - dense: true, - contentPadding: EdgeInsets.zero, - leading: Icon(kind.icon, size: 20), - title: Text(kind.label), - subtitle: Text( - kind.description, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, + MenuItemButton( + onPressed: () => onChanged(kind), + leadingIcon: Icon(kind.icon, size: 20), + trailingIcon: kind == sourceKind + ? Icon(Icons.check, size: 20, color: colorScheme.primary) + : null, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(kind.label), + Text( + kind.description, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), ), - ), - trailing: kind == sourceKind - ? Icon(Icons.check, size: 20, color: colorScheme.primary) - : null, + ], ), ), ], - child: Chip( - avatar: Icon(sourceKind.icon, size: 18), - label: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text(sourceKind.label), - const SizedBox(width: 2), - const Icon(Icons.arrow_drop_down, size: 18), - ], - ), - visualDensity: VisualDensity.compact, - ), ); } } @@ -282,74 +282,80 @@ class _WebCategoriesPanel extends ConsumerWidget { final categoriesAsync = ref.watch(kagiCategoriesProvider); return categoriesAsync.when( - data: (kagiCategories) => ListView( + data: (kagiCategories) => FadingScroll( controller: scrollController, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - children: [ - if (session.infoMessage != null) ...[ - _InfoMessageCard(message: session.infoMessage!), - const SizedBox(height: 8), - ], - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + fadingSize: 25, + builder: (context, controller) { + return ListView( + controller: controller, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), children: [ - Text('Refine Category', style: theme.textTheme.titleSmall), - FilterChip( - label: const Text('All'), - selected: session.currentCategory == null, - showCheckmark: false, - onSelected: (_) async { - final notifier = ref.read( - smallWebSessionControllerProvider.notifier, - ); - notifier.setCategory(null); - await notifier.discover(); - }, + if (session.infoMessage != null) ...[ + _InfoMessageCard(message: session.infoMessage!), + const SizedBox(height: 8), + ], + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('Refine Category', style: theme.textTheme.titleSmall), + FilterChip( + label: const Text('All'), + selected: session.currentCategory == null, + showCheckmark: false, + onSelected: (_) async { + final notifier = ref.read( + smallWebSessionControllerProvider.notifier, + ); + notifier.setCategory(null); + await notifier.discover(); + }, + ), + ], + ), + const SizedBox(height: 8), + for (final MapEntry(key: groupName, value: slugs) + in kagiCategories.groups.entries) ...[ + _SectionHeader(title: groupName), + const SizedBox(height: 6), + _CategoryGrid( + categories: kagiCategories.categories, + slugs: slugs, + currentCategory: session.currentCategory, + onCategorySelected: (slug) async { + final notifier = ref.read( + smallWebSessionControllerProvider.notifier, + ); + notifier.setCategory( + session.currentCategory == slug ? null : slug, + ); + await notifier.discover(); + }, + ), + const SizedBox(height: 10), + ], + SmallWebAttributionCard( + data: SmallWebAttributionData.forSelection( + sourceKind: session.sourceKind, + mode: session.mode, + ), + onOpenUri: (uri) => openSmallWebAttributionUri(context, uri), + compact: true, + ), + const SizedBox(height: 8), + const _DiscoverButton(), + const SizedBox(height: 12), + SmallWebHistoryHeader( + sourceKind: session.sourceKind, + mode: session.mode, + ), + const SizedBox(height: 4), + SmallWebHistoryList( + sourceKind: session.sourceKind, + mode: session.mode, ), ], - ), - const SizedBox(height: 8), - for (final MapEntry(key: groupName, value: slugs) - in kagiCategories.groups.entries) ...[ - _SectionHeader(title: groupName), - const SizedBox(height: 6), - _CategoryGrid( - categories: kagiCategories.categories, - slugs: slugs, - currentCategory: session.currentCategory, - onCategorySelected: (slug) async { - final notifier = ref.read( - smallWebSessionControllerProvider.notifier, - ); - notifier.setCategory( - session.currentCategory == slug ? null : slug, - ); - await notifier.discover(); - }, - ), - const SizedBox(height: 10), - ], - SmallWebAttributionCard( - data: SmallWebAttributionData.forSelection( - sourceKind: session.sourceKind, - mode: session.mode, - ), - onOpenUri: (uri) => openSmallWebAttributionUri(context, uri), - compact: true, - ), - const SizedBox(height: 8), - const _DiscoverButton(), - const SizedBox(height: 12), - SmallWebHistoryHeader( - sourceKind: session.sourceKind, - mode: session.mode, - ), - const SizedBox(height: 4), - SmallWebHistoryList( - sourceKind: session.sourceKind, - mode: session.mode, - ), - ], + ); + }, ), loading: () => const Center(child: CircularProgressIndicator()), error: (_, _) => const SizedBox.shrink(), @@ -394,58 +400,71 @@ class _ModeContextPanel extends ConsumerWidget { KagiSmallWebMode.web => (Icons.language, ''), }; - return ListView( + return FadingScroll( controller: scrollController, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - children: [ - if (session.infoMessage != null) ...[ - _InfoMessageCard(message: session.infoMessage!), - const SizedBox(height: 8), - ], - const SizedBox(height: 16), - Center( - child: Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: colorScheme.primaryContainer, - shape: BoxShape.circle, + fadingSize: 25, + builder: (context, controller) { + return ListView( + controller: controller, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + children: [ + if (session.infoMessage != null) ...[ + _InfoMessageCard(message: session.infoMessage!), + const SizedBox(height: 8), + ], + const SizedBox(height: 16), + Center( + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colorScheme.primaryContainer, + shape: BoxShape.circle, + ), + child: Icon( + icon, + size: 36, + color: colorScheme.onPrimaryContainer, + ), + ), ), - child: Icon(icon, size: 36, color: colorScheme.onPrimaryContainer), - ), - ), - const SizedBox(height: 12), - Text( - 'Searching ${mode.label}', - textAlign: TextAlign.center, - style: theme.textTheme.titleMedium, - ), - const SizedBox(height: 8), - Text( - description, - textAlign: TextAlign.center, - style: theme.textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 16), - SmallWebAttributionCard( - data: SmallWebAttributionData.forSelection( - sourceKind: session.sourceKind, - mode: session.mode, - ), - onOpenUri: (uri) => openSmallWebAttributionUri(context, uri), - compact: true, - ), - const SizedBox(height: 8), - const _DiscoverButton(), - const SizedBox(height: 12), - SmallWebHistoryHeader( - sourceKind: session.sourceKind, - mode: session.mode, - ), - const SizedBox(height: 4), - SmallWebHistoryList(sourceKind: session.sourceKind, mode: session.mode), - ], + const SizedBox(height: 12), + Text( + 'Searching ${mode.label}', + textAlign: TextAlign.center, + style: theme.textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + description, + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 16), + SmallWebAttributionCard( + data: SmallWebAttributionData.forSelection( + sourceKind: session.sourceKind, + mode: session.mode, + ), + onOpenUri: (uri) => openSmallWebAttributionUri(context, uri), + compact: true, + ), + const SizedBox(height: 8), + const _DiscoverButton(), + const SizedBox(height: 12), + SmallWebHistoryHeader( + sourceKind: session.sourceKind, + mode: session.mode, + ), + const SizedBox(height: 4), + SmallWebHistoryList( + sourceKind: session.sourceKind, + mode: session.mode, + ), + ], + ); + }, ); } } @@ -462,44 +481,53 @@ class _DefaultContentPanel extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - return ListView( + return FadingScroll( controller: scrollController, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - children: [ - if (session.infoMessage != null) ...[ - _InfoMessageCard(message: session.infoMessage!), - const SizedBox(height: 8), - ], - SmallWebAttributionCard( - data: SmallWebAttributionData.forSelection( - sourceKind: session.sourceKind, - mode: session.mode, - ), - onOpenUri: (uri) => openSmallWebAttributionUri(context, uri), - ), - const SizedBox(height: 12), - if (session.sourceKind == SmallWebSourceKind.wander) ...[ - _WanderConsoleCard(currentConsoleUrl: session.currentConsoleUrl), - const SizedBox(height: 8), - FilledButton.tonalIcon( - onPressed: () async { - Navigator.of(context).pop(); - await showWanderConsoleSheet(context); - }, - icon: const Icon(Icons.dns, size: 18), - label: const Text('Browse Consoles'), - ), - const SizedBox(height: 12), - ], - const _DiscoverButton(), - const SizedBox(height: 12), - SmallWebHistoryHeader( - sourceKind: session.sourceKind, - mode: session.mode, - ), - const SizedBox(height: 4), - SmallWebHistoryList(sourceKind: session.sourceKind, mode: session.mode), - ], + fadingSize: 25, + builder: (context, controller) { + return ListView( + controller: controller, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + children: [ + if (session.infoMessage != null) ...[ + _InfoMessageCard(message: session.infoMessage!), + const SizedBox(height: 8), + ], + SmallWebAttributionCard( + data: SmallWebAttributionData.forSelection( + sourceKind: session.sourceKind, + mode: session.mode, + ), + onOpenUri: (uri) => openSmallWebAttributionUri(context, uri), + ), + const SizedBox(height: 12), + if (session.sourceKind == SmallWebSourceKind.wander) ...[ + _WanderConsoleCard(currentConsoleUrl: session.currentConsoleUrl), + const SizedBox(height: 8), + FilledButton.tonalIcon( + onPressed: () async { + Navigator.of(context).pop(); + await showWanderConsoleSheet(context); + }, + icon: const Icon(Icons.dns, size: 18), + label: const Text('Browse Consoles'), + ), + const SizedBox(height: 12), + ], + const _DiscoverButton(), + const SizedBox(height: 12), + SmallWebHistoryHeader( + sourceKind: session.sourceKind, + mode: session.mode, + ), + const SizedBox(height: 4), + SmallWebHistoryList( + sourceKind: session.sourceKind, + mode: session.mode, + ), + ], + ); + }, ); } } @@ -736,16 +764,25 @@ class _SmallWebMenuLoading extends StatelessWidget { ), const Divider(height: 1), Expanded( - child: ListView( + child: FadingScroll( controller: scrollController, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - children: const [ - Bone.text(words: 2), - SizedBox(height: 8), - _SkeletonHistoryItem(), - _SkeletonHistoryItem(), - _SkeletonHistoryItem(), - ], + fadingSize: 25, + builder: (context, controller) { + return ListView( + controller: controller, + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + children: const [ + Bone.text(words: 2), + SizedBox(height: 8), + _SkeletonHistoryItem(), + _SkeletonHistoryItem(), + _SkeletonHistoryItem(), + ], + ); + }, ), ), ], @@ -798,19 +835,25 @@ class _SmallWebMenuError extends StatelessWidget { @override Widget build(BuildContext context) { - return ListView( + return FadingScroll( controller: scrollController, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - children: [ - SizedBox( - height: 240, - child: FailureWidget( - title: 'Small Web unavailable', - exception: error, - onRetry: onRetry, - ), - ), - ], + fadingSize: 25, + builder: (context, controller) { + return ListView( + controller: controller, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + children: [ + SizedBox( + height: 240, + child: FailureWidget( + title: 'Small Web unavailable', + exception: error, + onRetry: onRetry, + ), + ), + ], + ); + }, ); } } diff --git a/apps/weblibre/lib/features/small_web/presentation/widgets/small_web_mode_chips.dart b/apps/weblibre/lib/features/small_web/presentation/widgets/small_web_mode_chips.dart index 961a479c..797b6848 100644 --- a/apps/weblibre/lib/features/small_web/presentation/widgets/small_web_mode_chips.dart +++ b/apps/weblibre/lib/features/small_web/presentation/widgets/small_web_mode_chips.dart @@ -17,10 +17,12 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ +import 'package:fading_scroll/fading_scroll.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:weblibre/features/small_web/data/models/kagi_small_web_mode.dart'; import 'package:weblibre/features/small_web/domain/providers.dart'; +import 'package:weblibre/presentation/widgets/inline_count_badge.dart'; class SmallWebModeChips extends ConsumerWidget { final KagiSmallWebMode? currentMode; @@ -34,16 +36,6 @@ class SmallWebModeChips extends ConsumerWidget { required this.onModeSelected, }); - static String _formatCount(int count) { - if (count >= 1000) { - final k = count / 1000; - return k == k.roundToDouble() - ? '${k.round()}k' - : '${k.toStringAsFixed(1)}k'; - } - return count.toString(); - } - @override Widget build(BuildContext context, WidgetRef ref) { final countsAsync = ref.watch(smallWebAllModeItemCountsProvider); @@ -52,53 +44,46 @@ class SmallWebModeChips extends ConsumerWidget { return SizedBox( height: 48, - child: ListView.builder( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 16), - itemCount: KagiSmallWebMode.values.length, - itemBuilder: (context, index) { - final mode = KagiSmallWebMode.values[index]; - final isSelected = currentMode == mode; - final count = counts[mode]; - return Padding( - padding: const EdgeInsets.only(right: 8, top: 4), - child: ChoiceChip( - avatar: Icon(mode.icon, size: 18), - label: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text(mode.label), - if (count != null && count > 0) ...[ - const SizedBox(width: 6), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 1, - ), - decoration: BoxDecoration( - color: isSelected - ? colorScheme.primary.withValues(alpha: 0.15) - : colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(10), - ), - child: Text( - _formatCount(count), - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.bold, - color: isSelected + child: FadingScroll( + fadingSize: 15, + builder: (context, controller) { + return ListView.builder( + controller: controller, + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 16), + itemCount: KagiSmallWebMode.values.length, + itemBuilder: (context, index) { + final mode = KagiSmallWebMode.values[index]; + final isSelected = currentMode == mode; + final count = counts[mode]; + return Padding( + padding: const EdgeInsets.only(right: 8, top: 4), + child: ChoiceChip( + avatar: Icon(mode.icon, size: 18), + label: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(mode.label), + if (count != null && count > 0) ...[ + const SizedBox(width: 6), + InlineCountBadge( + count: count, + backgroundColor: isSelected + ? colorScheme.primary.withValues(alpha: 0.15) + : colorScheme.surfaceContainerHighest, + foregroundColor: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant, ), - ), - ), - ], - ], - ), - selected: isSelected, - showCheckmark: false, - onSelected: isLoading ? null : (_) => onModeSelected(mode), - ), + ], + ], + ), + selected: isSelected, + showCheckmark: false, + onSelected: isLoading ? null : (_) => onModeSelected(mode), + ), + ); + }, ); }, ), diff --git a/apps/weblibre/lib/features/small_web/presentation/widgets/wander_console_sheet.dart b/apps/weblibre/lib/features/small_web/presentation/widgets/wander_console_sheet.dart index 98178d43..94c4bacd 100644 --- a/apps/weblibre/lib/features/small_web/presentation/widgets/wander_console_sheet.dart +++ b/apps/weblibre/lib/features/small_web/presentation/widgets/wander_console_sheet.dart @@ -17,6 +17,7 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ +import 'package:fading_scroll/fading_scroll.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -263,18 +264,24 @@ class _WanderConsoleSheetLoading extends StatelessWidget { @override Widget build(BuildContext context) { return Skeletonizer( - child: ListView( + child: FadingScroll( controller: scrollController, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - children: const [ - TextField( - decoration: InputDecoration(hintText: 'Filter consoles...'), - ), - SizedBox(height: 12), - _SkeletonConsoleTile(), - _SkeletonConsoleTile(), - _SkeletonConsoleTile(), - ], + fadingSize: 25, + builder: (context, controller) { + return ListView( + controller: controller, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + children: const [ + TextField( + decoration: InputDecoration(hintText: 'Filter consoles...'), + ), + SizedBox(height: 12), + _SkeletonConsoleTile(), + _SkeletonConsoleTile(), + _SkeletonConsoleTile(), + ], + ); + }, ), ); } @@ -323,19 +330,25 @@ class _WanderConsoleSheetError extends StatelessWidget { @override Widget build(BuildContext context) { - return ListView( + return FadingScroll( controller: scrollController, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - children: [ - SizedBox( - height: 240, - child: FailureWidget( - title: 'Could not load Small Web session', - exception: error, - onRetry: onRetry, - ), - ), - ], + fadingSize: 25, + builder: (context, controller) { + return ListView( + controller: controller, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + children: [ + SizedBox( + height: 240, + child: FailureWidget( + title: 'Could not load Small Web session', + exception: error, + onRetry: onRetry, + ), + ), + ], + ); + }, ); } } @@ -381,17 +394,23 @@ class _LinkedConsoleList extends ConsumerWidget { ), ); } - return ListView.builder( + return FadingScroll( controller: scrollController, - padding: const EdgeInsets.only(top: 4, bottom: 56), - itemCount: filtered.length, - itemBuilder: (context, index) { - final console = filtered[index]; - return _ConsoleListTile( - url: console.url, - pageCount: console.pageCount, - selectedConsoleUrl: selectedConsoleUrl, - isLoading: isLoading, + fadingSize: 25, + builder: (context, controller) { + return ListView.builder( + controller: controller, + padding: const EdgeInsets.only(top: 4, bottom: 56), + itemCount: filtered.length, + itemBuilder: (context, index) { + final console = filtered[index]; + return _ConsoleListTile( + url: console.url, + pageCount: console.pageCount, + selectedConsoleUrl: selectedConsoleUrl, + isLoading: isLoading, + ); + }, ); }, ); @@ -430,17 +449,23 @@ class _AllConsoleList extends ConsumerWidget { ), ); } - return ListView.builder( + return FadingScroll( controller: scrollController, - padding: const EdgeInsets.only(top: 4, bottom: 56), - itemCount: consoles.length, - itemBuilder: (context, index) { - final console = consoles[index]; - return _ConsoleListTile( - url: console.url, - pageCount: console.pageCount, - selectedConsoleUrl: selectedConsoleUrl, - isLoading: isLoading, + fadingSize: 25, + builder: (context, controller) { + return ListView.builder( + controller: controller, + padding: const EdgeInsets.only(top: 4, bottom: 56), + itemCount: consoles.length, + itemBuilder: (context, index) { + final console = consoles[index]; + return _ConsoleListTile( + url: console.url, + pageCount: console.pageCount, + selectedConsoleUrl: selectedConsoleUrl, + isLoading: isLoading, + ); + }, ); }, ); diff --git a/apps/weblibre/lib/features/sync/presentation/screens/sync_settings.dart b/apps/weblibre/lib/features/sync/presentation/screens/sync_settings.dart index dbcdd70e..e05a6e1d 100644 --- a/apps/weblibre/lib/features/sync/presentation/screens/sync_settings.dart +++ b/apps/weblibre/lib/features/sync/presentation/screens/sync_settings.dart @@ -19,7 +19,6 @@ */ import 'dart:async'; -import 'package:fading_scroll/fading_scroll.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; @@ -29,7 +28,7 @@ import 'package:qr_code_scanner_plus/qr_code_scanner_plus.dart'; import 'package:weblibre/core/providers/format.dart'; import 'package:weblibre/features/qr_scanner/presentation/dialogs/qr_scanner_dialog.dart'; import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart'; -import 'package:weblibre/features/settings/presentation/widgets/sections.dart'; +import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart'; import 'package:weblibre/features/sync/domain/entities/sync_repository_state.dart'; import 'package:weblibre/features/sync/domain/repositories/sync.dart'; import 'package:weblibre/features/user/data/models/general_settings.dart'; @@ -77,6 +76,7 @@ class SyncSettingsScreen extends HookConsumerWidget { final syncController = useAnimationController( duration: disableAnimations ? Duration.zero : const Duration(seconds: 2), ); + final search = useSettingsSearch(); useEffect(() { if (isSyncing && !disableAnimations) { @@ -88,17 +88,18 @@ class SyncSettingsScreen extends HookConsumerWidget { return null; }, [isSyncing, disableAnimations]); - return Scaffold( - appBar: AppBar(title: const Text('Firefox Sync')), - body: SafeArea( - child: FadingScroll( - fadingSize: 25, - builder: (context, controller) { - return ListView( - controller: controller, - padding: const EdgeInsets.symmetric(horizontal: 12.0), + final sections = [ + SettingsSectionDefinition( + title: 'Account', + entries: [ + SettingsEntryDefinition( + title: syncInfo?.authenticated == true + ? 'Signed in account' + : 'Sign in', + subtitle: 'Account status, QR pairing, and device name', + keywords: const ['pairing', 'device name'], + child: Column( children: [ - const SettingSection(name: 'Account'), ListTile( leading: const Icon(Icons.account_circle_outlined), title: Text(syncInfo?.email ?? 'Not signed in'), @@ -134,7 +135,8 @@ class SyncSettingsScreen extends HookConsumerWidget { .signIn(); }, ), - if (syncInfo?.authenticated != true && !isSyncing) + if (syncInfo?.authenticated != true && !isSyncing) ...[ + const Divider(height: 1), ListTile( leading: const Icon(Icons.qr_code_scanner), title: const Text('Scan QR Code to pair'), @@ -168,7 +170,9 @@ class SyncSettingsScreen extends HookConsumerWidget { .signInWithPairing(code); }, ), - if (syncInfo?.authenticated == true) + ], + if (syncInfo?.authenticated == true) ...[ + const Divider(height: 1), ListTile( leading: const Icon(Icons.devices), title: const Text('Device Name'), @@ -201,7 +205,21 @@ class SyncSettingsScreen extends HookConsumerWidget { } }, ), - const SettingSection(name: 'Synchronization'), + ], + ], + ), + ), + ], + ), + SettingsSectionDefinition( + title: 'Synchronization', + entries: [ + SettingsEntryDefinition( + title: 'Sync Now', + subtitle: syncText, + keywords: const ['history', 'bookmarks', 'tabs'], + child: Column( + children: [ ListTile( leading: RotationTransition( turns: Tween( @@ -221,6 +239,7 @@ class SyncSettingsScreen extends HookConsumerWidget { .syncNow(); }, ), + const Divider(height: 1), SwitchListTile.adaptive( title: const Text('Sync History'), value: _engineEnabled(syncInfo, SyncEngineValue.history), @@ -232,6 +251,7 @@ class SyncSettingsScreen extends HookConsumerWidget { .setEngineEnabled(SyncEngineValue.history, value); }, ), + const Divider(height: 1), SwitchListTile.adaptive( title: const Text('Sync Bookmarks'), value: _engineEnabled(syncInfo, SyncEngineValue.bookmarks), @@ -246,6 +266,7 @@ class SyncSettingsScreen extends HookConsumerWidget { ); }, ), + const Divider(height: 1), SwitchListTile.adaptive( title: const Text('Sync Open Tabs'), value: _engineEnabled(syncInfo, SyncEngineValue.tabs), @@ -257,7 +278,20 @@ class SyncSettingsScreen extends HookConsumerWidget { .setEngineEnabled(SyncEngineValue.tabs, value); }, ), - const SettingSection(name: 'Server Overrides'), + ], + ), + ), + ], + ), + SettingsSectionDefinition( + title: 'Server Overrides', + entries: [ + SettingsEntryDefinition( + title: 'Server overrides', + subtitle: 'Custom Firefox Account and token server endpoints', + keywords: const ['fxa', 'token server'], + child: Column( + children: [ ListTile( title: const Text('FxA Server Override'), subtitle: Text( @@ -286,6 +320,7 @@ class SyncSettingsScreen extends HookConsumerWidget { }, ), ), + const Divider(height: 1), ListTile( title: const Text('Sync Token Server Override'), subtitle: Text( @@ -315,6 +350,7 @@ class SyncSettingsScreen extends HookConsumerWidget { }, ), ), + const Divider(height: 1), const ListTile( dense: true, title: Text( @@ -323,10 +359,32 @@ class SyncSettingsScreen extends HookConsumerWidget { ), ), ], - ); - }, - ), + ), + ), + ], ), + ]; + + final filteredSections = filterSettingsSections( + sections: sections, + query: search.rawQuery, + ); + + return SettingsCustomScrollScaffold( + title: 'Firefox Sync', + searchController: search.controller, + searchHintText: 'Search sync settings', + slivers: [ + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 24, 16, 20), + sliver: SliverToBoxAdapter( + child: SettingsSectionList( + sections: filteredSections, + query: search.rawQuery, + ), + ), + ), + ], ); } diff --git a/apps/weblibre/lib/features/tor/presentation/widgets/tor_notification.dart b/apps/weblibre/lib/features/tor/presentation/widgets/tor_notification.dart index fca35926..c64af3af 100644 --- a/apps/weblibre/lib/features/tor/presentation/widgets/tor_notification.dart +++ b/apps/weblibre/lib/features/tor/presentation/widgets/tor_notification.dart @@ -33,8 +33,10 @@ class TorNotification extends HookConsumerWidget { final appColors = AppColors.of(context); ref.listen(torProxyServiceProvider, (previous, next) { - if (next.hasValue & next.requireValue.isRunning && - next.requireValue.bootstrapProgress == 100) { + final status = next.value; + if (status != null && + status.isRunning && + status.bootstrapProgress >= 100) { ref.read(overlayControllerProvider.notifier).dismiss(); } }); diff --git a/apps/weblibre/lib/features/user/data/database/daos/cache.dart b/apps/weblibre/lib/features/user/data/database/daos/cache.dart index 16de9502..278032e1 100644 --- a/apps/weblibre/lib/features/user/data/database/daos/cache.dart +++ b/apps/weblibre/lib/features/user/data/database/daos/cache.dart @@ -19,6 +19,7 @@ */ import 'package:drift/drift.dart'; import 'package:weblibre/data/database/extensions/database_table_size.dart'; +import 'package:weblibre/features/user/data/icon_cache_marker.dart'; import 'package:weblibre/features/user/data/database/daos/cache.drift.dart'; import 'package:weblibre/features/user/data/database/database.dart'; import 'package:weblibre/features/user/data/database/definitions.drift.dart'; @@ -43,6 +44,14 @@ class CacheDao extends DatabaseAccessor with $CacheDaoMixin { return query.map((row) => row.read(db.iconCache.iconData)); } + SingleOrNullSelectable getCachedIconFetchDate(String origin) { + final query = selectOnly(db.iconCache) + ..addColumns([db.iconCache.fetchDate]) + ..where(db.iconCache.origin.equals(origin)); + + return query.map((row) => row.read(db.iconCache.fetchDate)); + } + Future cacheIcon(String origin, Uint8List bytes) { return db.iconCache.insertOne( IconCacheCompanion.insert( @@ -58,4 +67,15 @@ class CacheDao extends DatabaseAccessor with $CacheDaoMixin { ), ); } + + Future cacheIconIfAbsent(String origin, Uint8List bytes) async { + final existing = await getCachedIcon(origin).getSingleOrNull(); + if (existing == null || isMissingIconMarker(existing)) { + await cacheIcon(origin, bytes); + } + } + + Future cacheMissingIcon(String origin) { + return cacheIcon(origin, missingIconMarkerBytes); + } } diff --git a/apps/weblibre/lib/features/user/data/database/daos/search_tokens.dart b/apps/weblibre/lib/features/user/data/database/daos/search_tokens.dart new file mode 100644 index 00000000..25674115 --- /dev/null +++ b/apps/weblibre/lib/features/user/data/database/daos/search_tokens.dart @@ -0,0 +1,100 @@ +import 'package:drift/drift.dart'; +import 'package:weblibre/features/user/data/database/database.dart'; +import 'package:weblibre/features/user/data/database/definitions.drift.dart'; + +class ReservedToken { + final int id; + final Uint8List token; + const ReservedToken({required this.id, required this.token}); +} + +@DriftAccessor() +class SearchTokensDao extends DatabaseAccessor { + SearchTokensDao(super.attachedDatabase); + + Future addTokens( + List tokens, { + required String issuerKeyVersion, + }) { + return batch((b) { + b.insertAll( + db.searchTokens, + tokens + .map( + (t) => SearchTokensCompanion.insert( + token: t, + issuerKeyVersion: issuerKeyVersion, + insertedAt: DateTime.now(), + ), + ) + .toList(), + ); + }); + } + + /// Reserve the oldest unreserved token. Stamps `reserved_at` so the same + /// row won't be handed out twice. Caller MUST follow with either + /// [commitReserved] (server consumed it) or [releaseReserved] (we know the + /// server didn't see it). + Future reserveOne() { + return transaction(() async { + final row = + await (select(db.searchTokens) + ..where((t) => t.reservedAt.isNull()) + ..orderBy([(t) => OrderingTerm.asc(t.id)]) + ..limit(1)) + .getSingleOrNull(); + if (row == null) return null; + await (update(db.searchTokens)..where((t) => t.id.equals(row.id))).write( + SearchTokensCompanion(reservedAt: Value(DateTime.now())), + ); + return ReservedToken(id: row.id, token: row.token); + }); + } + + /// Permanently delete a reserved token after a successful redemption. + Future commitReserved(int id) async { + await (delete(db.searchTokens)..where((t) => t.id.equals(id))).go(); + } + + /// Clear the reservation so the token is handed out again. Use only when + /// the server is known not to have consumed it. + Future releaseReserved(int id) async { + await (update(db.searchTokens)..where((t) => t.id.equals(id))).write( + const SearchTokensCompanion(reservedAt: Value(null)), + ); + } + + /// Release any reservation older than [maxAge] — covers crashes mid-flight. + /// Returns the number of rows released. + Future releaseStaleReservations(Duration maxAge) async { + final cutoff = DateTime.now().subtract(maxAge); + return (update(db.searchTokens)..where( + (t) => + t.reservedAt.isNotNull() & + t.reservedAt.isSmallerThanValue(cutoff), + )) + .write(const SearchTokensCompanion(reservedAt: Value(null))); + } + + Future count() async { + final c = db.searchTokens.id.count(); + final query = selectOnly(db.searchTokens) + ..addColumns([c]) + ..where(db.searchTokens.reservedAt.isNull()); + final row = await query.getSingle(); + return row.read(c) ?? 0; + } + + Stream watchCount() { + final c = db.searchTokens.id.count(); + final query = selectOnly(db.searchTokens) + ..addColumns([c]) + ..where(db.searchTokens.reservedAt.isNull()); + return query.map((row) => row.read(c) ?? 0).watchSingle(); + } + + Future clear() { + return delete(db.searchTokens).go(); + } +} diff --git a/apps/weblibre/lib/features/user/data/database/daos/search_tokens.drift.dart b/apps/weblibre/lib/features/user/data/database/daos/search_tokens.drift.dart new file mode 100644 index 00000000..f361606b --- /dev/null +++ b/apps/weblibre/lib/features/user/data/database/daos/search_tokens.drift.dart @@ -0,0 +1,13 @@ +// dart format width=80 +// ignore_for_file: type=lint +import 'package:drift/drift.dart' as i0; +import 'package:weblibre/features/user/data/database/database.dart' as i1; + +mixin $SearchTokensDaoMixin on i0.DatabaseAccessor { + SearchTokensDaoManager get managers => SearchTokensDaoManager(this); +} + +class SearchTokensDaoManager { + final $SearchTokensDaoMixin _db; + SearchTokensDaoManager(this._db); +} diff --git a/apps/weblibre/lib/features/user/data/database/database.dart b/apps/weblibre/lib/features/user/data/database/database.dart index 16c1c4ec..85adb011 100644 --- a/apps/weblibre/lib/features/user/data/database/database.dart +++ b/apps/weblibre/lib/features/user/data/database/database.dart @@ -23,6 +23,7 @@ import 'package:drift_dev/api/migrations_native.dart'; import 'package:flutter/foundation.dart'; import 'package:weblibre/features/user/data/database/daos/cache.dart'; import 'package:weblibre/features/user/data/database/daos/onboarding.dart'; +import 'package:weblibre/features/user/data/database/daos/search_tokens.dart'; import 'package:weblibre/features/user/data/database/daos/setting.dart'; import 'package:weblibre/features/user/data/database/daos/toolbar_button_config.dart'; import 'package:weblibre/features/user/data/database/database.drift.dart'; @@ -30,11 +31,17 @@ import 'package:weblibre/features/user/data/database/database.steps.dart'; @DriftDatabase( include: {'definitions.drift'}, - daos: [SettingDao, CacheDao, OnboardingDao, ToolbarButtonConfigDao], + daos: [ + SettingDao, + CacheDao, + OnboardingDao, + ToolbarButtonConfigDao, + SearchTokensDao, + ], ) class UserDatabase extends $UserDatabase { @override - final int schemaVersion = 3; + final int schemaVersion = 5; @override MigrationStrategy get migration => MigrationStrategy( @@ -88,5 +95,13 @@ class UserDatabase extends $UserDatabase { await m.createTable(schema.toolbarButtonConfigs); await m.createIndex(schema.idxToolbarOrderKey); }, + from3To4: (m, schema) async { + await m.createTable(schema.searchTokens); + await m.createIndex(schema.idxSearchTokensInsertedAt); + }, + from4To5: (m, schema) async { + await m.addColumn(schema.searchTokens, schema.searchTokens.reservedAt); + await m.createIndex(schema.idxSearchTokensReservedAt); + }, ); } diff --git a/apps/weblibre/lib/features/user/data/database/database.drift.dart b/apps/weblibre/lib/features/user/data/database/database.drift.dart index f8e3cfd9..192d042d 100644 --- a/apps/weblibre/lib/features/user/data/database/database.drift.dart +++ b/apps/weblibre/lib/features/user/data/database/database.drift.dart @@ -10,8 +10,10 @@ import 'package:weblibre/features/user/data/database/daos/onboarding.dart' as i5; import 'package:weblibre/features/user/data/database/daos/toolbar_button_config.dart' as i6; -import 'package:drift/internal/modular.dart' as i7; -import 'package:sqlite3/common.dart' as i8; +import 'package:weblibre/features/user/data/database/daos/search_tokens.dart' + as i7; +import 'package:drift/internal/modular.dart' as i8; +import 'package:sqlite3/common.dart' as i9; abstract class $UserDatabase extends i0.GeneratedDatabase { $UserDatabase(i0.QueryExecutor e) : super(e); @@ -22,6 +24,7 @@ abstract class $UserDatabase extends i0.GeneratedDatabase { late final i1.Riverpod riverpod = i1.Riverpod(this); late final i1.ToolbarButtonConfigs toolbarButtonConfigs = i1.ToolbarButtonConfigs(this); + late final i1.SearchTokens searchTokens = i1.SearchTokens(this); late final i2.SettingDao settingDao = i2.SettingDao(this as i3.UserDatabase); late final i4.CacheDao cacheDao = i4.CacheDao(this as i3.UserDatabase); late final i5.OnboardingDao onboardingDao = i5.OnboardingDao( @@ -29,7 +32,10 @@ abstract class $UserDatabase extends i0.GeneratedDatabase { ); late final i6.ToolbarButtonConfigDao toolbarButtonConfigDao = i6.ToolbarButtonConfigDao(this as i3.UserDatabase); - i1.DefinitionsDrift get definitionsDrift => i7.ReadDatabaseContainer( + late final i7.SearchTokensDao searchTokensDao = i7.SearchTokensDao( + this as i3.UserDatabase, + ); + i1.DefinitionsDrift get definitionsDrift => i8.ReadDatabaseContainer( this, ).accessor(i1.DefinitionsDrift.new); @override @@ -43,6 +49,9 @@ abstract class $UserDatabase extends i0.GeneratedDatabase { riverpod, toolbarButtonConfigs, i1.idxToolbarOrderKey, + searchTokens, + i1.idxSearchTokensInsertedAt, + i1.idxSearchTokensReservedAt, ]; } @@ -59,18 +68,25 @@ class $UserDatabaseManager { i1.$RiverpodTableManager(_db, _db.riverpod); i1.$ToolbarButtonConfigsTableManager get toolbarButtonConfigs => i1.$ToolbarButtonConfigsTableManager(_db, _db.toolbarButtonConfigs); + i1.$SearchTokensTableManager get searchTokens => + i1.$SearchTokensTableManager(_db, _db.searchTokens); } -extension DefineFunctions on i8.CommonDatabase { +extension DefineFunctions on i9.CommonDatabase { void defineFunctions({ required String Function(int, String?) lexoRankNext, required String Function(int, String?) lexoRankPrevious, required String Function(String?, String?) lexoRankReorderAfter, required String Function(String?, String?) lexoRankReorderBefore, + required int Function() generateContentHash, + required bool Function(String?) urlIndexable, + required String Function(String?) urlCanonical, + required String Function(String?) urlHost, + required String Function(String?) urlPath, }) { createFunction( functionName: 'lexo_rank_next', - argumentCount: const i8.AllowedArgumentCount(2), + argumentCount: const i9.AllowedArgumentCount(2), function: (args) { final arg0 = args[0] as int; final arg1 = args[1] as String?; @@ -79,7 +95,7 @@ extension DefineFunctions on i8.CommonDatabase { ); createFunction( functionName: 'lexo_rank_previous', - argumentCount: const i8.AllowedArgumentCount(2), + argumentCount: const i9.AllowedArgumentCount(2), function: (args) { final arg0 = args[0] as int; final arg1 = args[1] as String?; @@ -88,7 +104,7 @@ extension DefineFunctions on i8.CommonDatabase { ); createFunction( functionName: 'lexo_rank_reorder_after', - argumentCount: const i8.AllowedArgumentCount(2), + argumentCount: const i9.AllowedArgumentCount(2), function: (args) { final arg0 = args[0] as String?; final arg1 = args[1] as String?; @@ -97,12 +113,51 @@ extension DefineFunctions on i8.CommonDatabase { ); createFunction( functionName: 'lexo_rank_reorder_before', - argumentCount: const i8.AllowedArgumentCount(2), + argumentCount: const i9.AllowedArgumentCount(2), function: (args) { final arg0 = args[0] as String?; final arg1 = args[1] as String?; return lexoRankReorderBefore(arg0, arg1); }, ); + createFunction( + functionName: 'generate_content_hash', + argumentCount: const i9.AllowedArgumentCount(0), + function: (args) { + return generateContentHash(); + }, + ); + createFunction( + functionName: 'url_indexable', + argumentCount: const i9.AllowedArgumentCount(1), + function: (args) { + final arg0 = args[0] as String?; + return urlIndexable(arg0); + }, + ); + createFunction( + functionName: 'url_canonical', + argumentCount: const i9.AllowedArgumentCount(1), + function: (args) { + final arg0 = args[0] as String?; + return urlCanonical(arg0); + }, + ); + createFunction( + functionName: 'url_host', + argumentCount: const i9.AllowedArgumentCount(1), + function: (args) { + final arg0 = args[0] as String?; + return urlHost(arg0); + }, + ); + createFunction( + functionName: 'url_path', + argumentCount: const i9.AllowedArgumentCount(1), + function: (args) { + final arg0 = args[0] as String?; + return urlPath(arg0); + }, + ); } } diff --git a/apps/weblibre/lib/features/user/data/database/database.steps.dart b/apps/weblibre/lib/features/user/data/database/database.steps.dart index ac0f72f8..81532bf5 100644 --- a/apps/weblibre/lib/features/user/data/database/database.steps.dart +++ b/apps/weblibre/lib/features/user/data/database/database.steps.dart @@ -311,9 +311,263 @@ i1.GeneratedColumn _column_14(String aliasedName) => $customConstraints: 'REFERENCES toolbar_button_configs(button_id)ON DELETE SET NULL', ); + +final class Schema4 extends i0.VersionedSchema { + Schema4({required super.database}) : super(version: 4); + @override + late final List entities = [ + setting, + iconCache, + onboarding, + riverpod, + toolbarButtonConfigs, + idxToolbarOrderKey, + searchTokens, + idxSearchTokensInsertedAt, + ]; + late final Shape0 setting = Shape0( + source: i0.VersionedTable( + entityName: 'setting', + withoutRowId: false, + isStrict: true, + tableConstraints: [], + columns: [_column_0, _column_1, _column_2], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape1 iconCache = Shape1( + source: i0.VersionedTable( + entityName: 'icon_cache', + withoutRowId: false, + isStrict: false, + tableConstraints: [], + columns: [_column_3, _column_4, _column_5], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape2 onboarding = Shape2( + source: i0.VersionedTable( + entityName: 'onboarding', + withoutRowId: false, + isStrict: false, + tableConstraints: [], + columns: [_column_6, _column_7], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape3 riverpod = Shape3( + source: i0.VersionedTable( + entityName: 'riverpod', + withoutRowId: true, + isStrict: false, + tableConstraints: [], + columns: [_column_0, _column_8, _column_9, _column_10], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape4 toolbarButtonConfigs = Shape4( + source: i0.VersionedTable( + entityName: 'toolbar_button_configs', + withoutRowId: false, + isStrict: false, + tableConstraints: [], + columns: [_column_11, _column_12, _column_13, _column_14], + attachedDatabase: database, + ), + alias: null, + ); + final i1.Index idxToolbarOrderKey = i1.Index( + 'idx_toolbar_order_key', + 'CREATE INDEX idx_toolbar_order_key ON toolbar_button_configs (order_key)', + ); + late final Shape5 searchTokens = Shape5( + source: i0.VersionedTable( + entityName: 'search_tokens', + withoutRowId: false, + isStrict: false, + tableConstraints: [], + columns: [_column_15, _column_16, _column_17, _column_18], + attachedDatabase: database, + ), + alias: null, + ); + final i1.Index idxSearchTokensInsertedAt = i1.Index( + 'idx_search_tokens_inserted_at', + 'CREATE INDEX idx_search_tokens_inserted_at ON search_tokens (inserted_at)', + ); +} + +class Shape5 extends i0.VersionedTable { + Shape5({required super.source, required super.alias}) : super.aliased(); + i1.GeneratedColumn get id => + columnsByName['id']! as i1.GeneratedColumn; + i1.GeneratedColumn get token => + columnsByName['token']! as i1.GeneratedColumn; + i1.GeneratedColumn get insertedAt => + columnsByName['inserted_at']! as i1.GeneratedColumn; + i1.GeneratedColumn get issuerKeyVersion => + columnsByName['issuer_key_version']! as i1.GeneratedColumn; +} + +i1.GeneratedColumn _column_15(String aliasedName) => + i1.GeneratedColumn( + 'id', + aliasedName, + false, + hasAutoIncrement: true, + type: i1.DriftSqlType.int, + $customConstraints: 'PRIMARY KEY AUTOINCREMENT', + ); +i1.GeneratedColumn _column_16(String aliasedName) => + i1.GeneratedColumn( + 'token', + aliasedName, + false, + type: i1.DriftSqlType.blob, + $customConstraints: 'NOT NULL', + ); +i1.GeneratedColumn _column_17(String aliasedName) => + i1.GeneratedColumn( + 'inserted_at', + aliasedName, + false, + type: i1.DriftSqlType.int, + $customConstraints: 'NOT NULL', + ); +i1.GeneratedColumn _column_18(String aliasedName) => + i1.GeneratedColumn( + 'issuer_key_version', + aliasedName, + false, + type: i1.DriftSqlType.string, + $customConstraints: 'NOT NULL', + ); + +final class Schema5 extends i0.VersionedSchema { + Schema5({required super.database}) : super(version: 5); + @override + late final List entities = [ + setting, + iconCache, + onboarding, + riverpod, + toolbarButtonConfigs, + idxToolbarOrderKey, + searchTokens, + idxSearchTokensInsertedAt, + idxSearchTokensReservedAt, + ]; + late final Shape0 setting = Shape0( + source: i0.VersionedTable( + entityName: 'setting', + withoutRowId: false, + isStrict: true, + tableConstraints: [], + columns: [_column_0, _column_1, _column_2], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape1 iconCache = Shape1( + source: i0.VersionedTable( + entityName: 'icon_cache', + withoutRowId: false, + isStrict: false, + tableConstraints: [], + columns: [_column_3, _column_4, _column_5], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape2 onboarding = Shape2( + source: i0.VersionedTable( + entityName: 'onboarding', + withoutRowId: false, + isStrict: false, + tableConstraints: [], + columns: [_column_6, _column_7], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape3 riverpod = Shape3( + source: i0.VersionedTable( + entityName: 'riverpod', + withoutRowId: true, + isStrict: false, + tableConstraints: [], + columns: [_column_0, _column_8, _column_9, _column_10], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape4 toolbarButtonConfigs = Shape4( + source: i0.VersionedTable( + entityName: 'toolbar_button_configs', + withoutRowId: false, + isStrict: false, + tableConstraints: [], + columns: [_column_11, _column_12, _column_13, _column_14], + attachedDatabase: database, + ), + alias: null, + ); + final i1.Index idxToolbarOrderKey = i1.Index( + 'idx_toolbar_order_key', + 'CREATE INDEX idx_toolbar_order_key ON toolbar_button_configs (order_key)', + ); + late final Shape6 searchTokens = Shape6( + source: i0.VersionedTable( + entityName: 'search_tokens', + withoutRowId: false, + isStrict: false, + tableConstraints: [], + columns: [_column_15, _column_16, _column_17, _column_18, _column_19], + attachedDatabase: database, + ), + alias: null, + ); + final i1.Index idxSearchTokensInsertedAt = i1.Index( + 'idx_search_tokens_inserted_at', + 'CREATE INDEX idx_search_tokens_inserted_at ON search_tokens (inserted_at)', + ); + final i1.Index idxSearchTokensReservedAt = i1.Index( + 'idx_search_tokens_reserved_at', + 'CREATE INDEX idx_search_tokens_reserved_at ON search_tokens (reserved_at)', + ); +} + +class Shape6 extends i0.VersionedTable { + Shape6({required super.source, required super.alias}) : super.aliased(); + i1.GeneratedColumn get id => + columnsByName['id']! as i1.GeneratedColumn; + i1.GeneratedColumn get token => + columnsByName['token']! as i1.GeneratedColumn; + i1.GeneratedColumn get insertedAt => + columnsByName['inserted_at']! as i1.GeneratedColumn; + i1.GeneratedColumn get issuerKeyVersion => + columnsByName['issuer_key_version']! as i1.GeneratedColumn; + i1.GeneratedColumn get reservedAt => + columnsByName['reserved_at']! as i1.GeneratedColumn; +} + +i1.GeneratedColumn _column_19(String aliasedName) => + i1.GeneratedColumn( + 'reserved_at', + aliasedName, + true, + type: i1.DriftSqlType.int, + $customConstraints: '', + ); i0.MigrationStepWithVersion migrationSteps({ required Future Function(i1.Migrator m, Schema2 schema) from1To2, required Future Function(i1.Migrator m, Schema3 schema) from2To3, + required Future Function(i1.Migrator m, Schema4 schema) from3To4, + required Future Function(i1.Migrator m, Schema5 schema) from4To5, }) { return (currentVersion, database) async { switch (currentVersion) { @@ -327,6 +581,16 @@ i0.MigrationStepWithVersion migrationSteps({ final migrator = i1.Migrator(database, schema); await from2To3(migrator, schema); return 3; + case 3: + final schema = Schema4(database: database); + final migrator = i1.Migrator(database, schema); + await from3To4(migrator, schema); + return 4; + case 4: + final schema = Schema5(database: database); + final migrator = i1.Migrator(database, schema); + await from4To5(migrator, schema); + return 5; default: throw ArgumentError.value('Unknown migration from $currentVersion'); } @@ -336,6 +600,13 @@ i0.MigrationStepWithVersion migrationSteps({ i1.OnUpgrade stepByStep({ required Future Function(i1.Migrator m, Schema2 schema) from1To2, required Future Function(i1.Migrator m, Schema3 schema) from2To3, + required Future Function(i1.Migrator m, Schema4 schema) from3To4, + required Future Function(i1.Migrator m, Schema5 schema) from4To5, }) => i0.VersionedSchema.stepByStepHelper( - step: migrationSteps(from1To2: from1To2, from2To3: from2To3), + step: migrationSteps( + from1To2: from1To2, + from2To3: from2To3, + from3To4: from3To4, + from4To5: from4To5, + ), ); diff --git a/apps/weblibre/lib/features/user/data/database/definitions.drift b/apps/weblibre/lib/features/user/data/database/definitions.drift index 2d5083ec..31ccefef 100644 --- a/apps/weblibre/lib/features/user/data/database/definitions.drift +++ b/apps/weblibre/lib/features/user/data/database/definitions.drift @@ -31,6 +31,17 @@ CREATE TABLE toolbar_button_configs ( CREATE INDEX idx_toolbar_order_key ON toolbar_button_configs(order_key); +CREATE TABLE search_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + token BLOB NOT NULL, + inserted_at DATETIME NOT NULL, + issuer_key_version TEXT NOT NULL, + reserved_at DATETIME +); + +CREATE INDEX idx_search_tokens_inserted_at ON search_tokens(inserted_at); +CREATE INDEX idx_search_tokens_reserved_at ON search_tokens(reserved_at); + -- All four queries are scoped to a visibility partition (`is_visible`) so the -- generated key falls strictly within the targeted section. The Customize -- Toolbar UI renders Enabled and Disabled as two independent reorderable diff --git a/apps/weblibre/lib/features/user/data/database/definitions.drift.dart b/apps/weblibre/lib/features/user/data/database/definitions.drift.dart index 31d8ad64..a379ec41 100644 --- a/apps/weblibre/lib/features/user/data/database/definitions.drift.dart +++ b/apps/weblibre/lib/features/user/data/database/definitions.drift.dart @@ -863,6 +863,214 @@ typedef $ToolbarButtonConfigsProcessedTableManager = i1.ToolbarButtonConfig, i0.PrefetchHooks Function() >; +typedef $SearchTokensCreateCompanionBuilder = + i1.SearchTokensCompanion Function({ + i0.Value id, + required i2.Uint8List token, + required DateTime insertedAt, + required String issuerKeyVersion, + i0.Value reservedAt, + }); +typedef $SearchTokensUpdateCompanionBuilder = + i1.SearchTokensCompanion Function({ + i0.Value id, + i0.Value token, + i0.Value insertedAt, + i0.Value issuerKeyVersion, + i0.Value reservedAt, + }); + +class $SearchTokensFilterComposer + extends i0.Composer { + $SearchTokensFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + i0.ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => i0.ColumnFilters(column), + ); + + i0.ColumnFilters get token => $composableBuilder( + column: $table.token, + builder: (column) => i0.ColumnFilters(column), + ); + + i0.ColumnFilters get insertedAt => $composableBuilder( + column: $table.insertedAt, + builder: (column) => i0.ColumnFilters(column), + ); + + i0.ColumnFilters get issuerKeyVersion => $composableBuilder( + column: $table.issuerKeyVersion, + builder: (column) => i0.ColumnFilters(column), + ); + + i0.ColumnFilters get reservedAt => $composableBuilder( + column: $table.reservedAt, + builder: (column) => i0.ColumnFilters(column), + ); +} + +class $SearchTokensOrderingComposer + extends i0.Composer { + $SearchTokensOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + i0.ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get token => $composableBuilder( + column: $table.token, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get insertedAt => $composableBuilder( + column: $table.insertedAt, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get issuerKeyVersion => $composableBuilder( + column: $table.issuerKeyVersion, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get reservedAt => $composableBuilder( + column: $table.reservedAt, + builder: (column) => i0.ColumnOrderings(column), + ); +} + +class $SearchTokensAnnotationComposer + extends i0.Composer { + $SearchTokensAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + i0.GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + i0.GeneratedColumn get token => + $composableBuilder(column: $table.token, builder: (column) => column); + + i0.GeneratedColumn get insertedAt => $composableBuilder( + column: $table.insertedAt, + builder: (column) => column, + ); + + i0.GeneratedColumn get issuerKeyVersion => $composableBuilder( + column: $table.issuerKeyVersion, + builder: (column) => column, + ); + + i0.GeneratedColumn get reservedAt => $composableBuilder( + column: $table.reservedAt, + builder: (column) => column, + ); +} + +class $SearchTokensTableManager + extends + i0.RootTableManager< + i0.GeneratedDatabase, + i1.SearchTokens, + i1.SearchToken, + i1.$SearchTokensFilterComposer, + i1.$SearchTokensOrderingComposer, + i1.$SearchTokensAnnotationComposer, + $SearchTokensCreateCompanionBuilder, + $SearchTokensUpdateCompanionBuilder, + ( + i1.SearchToken, + i0.BaseReferences< + i0.GeneratedDatabase, + i1.SearchTokens, + i1.SearchToken + >, + ), + i1.SearchToken, + i0.PrefetchHooks Function() + > { + $SearchTokensTableManager(i0.GeneratedDatabase db, i1.SearchTokens table) + : super( + i0.TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + i1.$SearchTokensFilterComposer($db: db, $table: table), + createOrderingComposer: () => + i1.$SearchTokensOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + i1.$SearchTokensAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + i0.Value id = const i0.Value.absent(), + i0.Value token = const i0.Value.absent(), + i0.Value insertedAt = const i0.Value.absent(), + i0.Value issuerKeyVersion = const i0.Value.absent(), + i0.Value reservedAt = const i0.Value.absent(), + }) => i1.SearchTokensCompanion( + id: id, + token: token, + insertedAt: insertedAt, + issuerKeyVersion: issuerKeyVersion, + reservedAt: reservedAt, + ), + createCompanionCallback: + ({ + i0.Value id = const i0.Value.absent(), + required i2.Uint8List token, + required DateTime insertedAt, + required String issuerKeyVersion, + i0.Value reservedAt = const i0.Value.absent(), + }) => i1.SearchTokensCompanion.insert( + id: id, + token: token, + insertedAt: insertedAt, + issuerKeyVersion: issuerKeyVersion, + reservedAt: reservedAt, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), i0.BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $SearchTokensProcessedTableManager = + i0.ProcessedTableManager< + i0.GeneratedDatabase, + i1.SearchTokens, + i1.SearchToken, + i1.$SearchTokensFilterComposer, + i1.$SearchTokensOrderingComposer, + i1.$SearchTokensAnnotationComposer, + $SearchTokensCreateCompanionBuilder, + $SearchTokensUpdateCompanionBuilder, + ( + i1.SearchToken, + i0.BaseReferences< + i0.GeneratedDatabase, + i1.SearchTokens, + i1.SearchToken + >, + ), + i1.SearchToken, + i0.PrefetchHooks Function() + >; class Setting extends i0.Table with i0.TableInfo { @override @@ -2022,6 +2230,317 @@ i0.Index get idxToolbarOrderKey => i0.Index( 'CREATE INDEX idx_toolbar_order_key ON toolbar_button_configs (order_key)', ); +class SearchTokens extends i0.Table + with i0.TableInfo { + @override + final i0.GeneratedDatabase attachedDatabase; + final String? _alias; + SearchTokens(this.attachedDatabase, [this._alias]); + late final i0.GeneratedColumn id = i0.GeneratedColumn( + 'id', + aliasedName, + false, + hasAutoIncrement: true, + type: i0.DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'PRIMARY KEY AUTOINCREMENT', + ); + late final i0.GeneratedColumn token = + i0.GeneratedColumn( + 'token', + aliasedName, + false, + type: i0.DriftSqlType.blob, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final i0.GeneratedColumn insertedAt = + i0.GeneratedColumn( + 'inserted_at', + aliasedName, + false, + type: i0.DriftSqlType.dateTime, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final i0.GeneratedColumn issuerKeyVersion = + i0.GeneratedColumn( + 'issuer_key_version', + aliasedName, + false, + type: i0.DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final i0.GeneratedColumn reservedAt = + i0.GeneratedColumn( + 'reserved_at', + aliasedName, + true, + type: i0.DriftSqlType.dateTime, + requiredDuringInsert: false, + $customConstraints: '', + ); + @override + List get $columns => [ + id, + token, + insertedAt, + issuerKeyVersion, + reservedAt, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'search_tokens'; + @override + Set get $primaryKey => {id}; + @override + i1.SearchToken map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return i1.SearchToken( + id: attachedDatabase.typeMapping.read( + i0.DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + token: attachedDatabase.typeMapping.read( + i0.DriftSqlType.blob, + data['${effectivePrefix}token'], + )!, + insertedAt: attachedDatabase.typeMapping.read( + i0.DriftSqlType.dateTime, + data['${effectivePrefix}inserted_at'], + )!, + issuerKeyVersion: attachedDatabase.typeMapping.read( + i0.DriftSqlType.string, + data['${effectivePrefix}issuer_key_version'], + )!, + reservedAt: attachedDatabase.typeMapping.read( + i0.DriftSqlType.dateTime, + data['${effectivePrefix}reserved_at'], + ), + ); + } + + @override + SearchTokens createAlias(String alias) { + return SearchTokens(attachedDatabase, alias); + } + + @override + bool get dontWriteConstraints => true; +} + +class SearchToken extends i0.DataClass + implements i0.Insertable { + final int id; + final i2.Uint8List token; + final DateTime insertedAt; + final String issuerKeyVersion; + final DateTime? reservedAt; + const SearchToken({ + required this.id, + required this.token, + required this.insertedAt, + required this.issuerKeyVersion, + this.reservedAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = i0.Variable(id); + map['token'] = i0.Variable(token); + map['inserted_at'] = i0.Variable(insertedAt); + map['issuer_key_version'] = i0.Variable(issuerKeyVersion); + if (!nullToAbsent || reservedAt != null) { + map['reserved_at'] = i0.Variable(reservedAt); + } + return map; + } + + factory SearchToken.fromJson( + Map json, { + i0.ValueSerializer? serializer, + }) { + serializer ??= i0.driftRuntimeOptions.defaultSerializer; + return SearchToken( + id: serializer.fromJson(json['id']), + token: serializer.fromJson(json['token']), + insertedAt: serializer.fromJson(json['inserted_at']), + issuerKeyVersion: serializer.fromJson(json['issuer_key_version']), + reservedAt: serializer.fromJson(json['reserved_at']), + ); + } + @override + Map toJson({i0.ValueSerializer? serializer}) { + serializer ??= i0.driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'token': serializer.toJson(token), + 'inserted_at': serializer.toJson(insertedAt), + 'issuer_key_version': serializer.toJson(issuerKeyVersion), + 'reserved_at': serializer.toJson(reservedAt), + }; + } + + i1.SearchToken copyWith({ + int? id, + i2.Uint8List? token, + DateTime? insertedAt, + String? issuerKeyVersion, + i0.Value reservedAt = const i0.Value.absent(), + }) => i1.SearchToken( + id: id ?? this.id, + token: token ?? this.token, + insertedAt: insertedAt ?? this.insertedAt, + issuerKeyVersion: issuerKeyVersion ?? this.issuerKeyVersion, + reservedAt: reservedAt.present ? reservedAt.value : this.reservedAt, + ); + SearchToken copyWithCompanion(i1.SearchTokensCompanion data) { + return SearchToken( + id: data.id.present ? data.id.value : this.id, + token: data.token.present ? data.token.value : this.token, + insertedAt: data.insertedAt.present + ? data.insertedAt.value + : this.insertedAt, + issuerKeyVersion: data.issuerKeyVersion.present + ? data.issuerKeyVersion.value + : this.issuerKeyVersion, + reservedAt: data.reservedAt.present + ? data.reservedAt.value + : this.reservedAt, + ); + } + + @override + String toString() { + return (StringBuffer('SearchToken(') + ..write('id: $id, ') + ..write('token: $token, ') + ..write('insertedAt: $insertedAt, ') + ..write('issuerKeyVersion: $issuerKeyVersion, ') + ..write('reservedAt: $reservedAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + i0.$driftBlobEquality.hash(token), + insertedAt, + issuerKeyVersion, + reservedAt, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is i1.SearchToken && + other.id == this.id && + i0.$driftBlobEquality.equals(other.token, this.token) && + other.insertedAt == this.insertedAt && + other.issuerKeyVersion == this.issuerKeyVersion && + other.reservedAt == this.reservedAt); +} + +class SearchTokensCompanion extends i0.UpdateCompanion { + final i0.Value id; + final i0.Value token; + final i0.Value insertedAt; + final i0.Value issuerKeyVersion; + final i0.Value reservedAt; + const SearchTokensCompanion({ + this.id = const i0.Value.absent(), + this.token = const i0.Value.absent(), + this.insertedAt = const i0.Value.absent(), + this.issuerKeyVersion = const i0.Value.absent(), + this.reservedAt = const i0.Value.absent(), + }); + SearchTokensCompanion.insert({ + this.id = const i0.Value.absent(), + required i2.Uint8List token, + required DateTime insertedAt, + required String issuerKeyVersion, + this.reservedAt = const i0.Value.absent(), + }) : token = i0.Value(token), + insertedAt = i0.Value(insertedAt), + issuerKeyVersion = i0.Value(issuerKeyVersion); + static i0.Insertable custom({ + i0.Expression? id, + i0.Expression? token, + i0.Expression? insertedAt, + i0.Expression? issuerKeyVersion, + i0.Expression? reservedAt, + }) { + return i0.RawValuesInsertable({ + if (id != null) 'id': id, + if (token != null) 'token': token, + if (insertedAt != null) 'inserted_at': insertedAt, + if (issuerKeyVersion != null) 'issuer_key_version': issuerKeyVersion, + if (reservedAt != null) 'reserved_at': reservedAt, + }); + } + + i1.SearchTokensCompanion copyWith({ + i0.Value? id, + i0.Value? token, + i0.Value? insertedAt, + i0.Value? issuerKeyVersion, + i0.Value? reservedAt, + }) { + return i1.SearchTokensCompanion( + id: id ?? this.id, + token: token ?? this.token, + insertedAt: insertedAt ?? this.insertedAt, + issuerKeyVersion: issuerKeyVersion ?? this.issuerKeyVersion, + reservedAt: reservedAt ?? this.reservedAt, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = i0.Variable(id.value); + } + if (token.present) { + map['token'] = i0.Variable(token.value); + } + if (insertedAt.present) { + map['inserted_at'] = i0.Variable(insertedAt.value); + } + if (issuerKeyVersion.present) { + map['issuer_key_version'] = i0.Variable(issuerKeyVersion.value); + } + if (reservedAt.present) { + map['reserved_at'] = i0.Variable(reservedAt.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('SearchTokensCompanion(') + ..write('id: $id, ') + ..write('token: $token, ') + ..write('insertedAt: $insertedAt, ') + ..write('issuerKeyVersion: $issuerKeyVersion, ') + ..write('reservedAt: $reservedAt') + ..write(')')) + .toString(); + } +} + +i0.Index get idxSearchTokensInsertedAt => i0.Index( + 'idx_search_tokens_inserted_at', + 'CREATE INDEX idx_search_tokens_inserted_at ON search_tokens (inserted_at)', +); +i0.Index get idxSearchTokensReservedAt => i0.Index( + 'idx_search_tokens_reserved_at', + 'CREATE INDEX idx_search_tokens_reserved_at ON search_tokens (reserved_at)', +); + class DefinitionsDrift extends i3.ModularAccessor { DefinitionsDrift(i0.GeneratedDatabase db) : super(db); i0.Selectable toolbarLeadingOrderKey({ diff --git a/apps/weblibre/lib/features/user/data/icon_cache_marker.dart b/apps/weblibre/lib/features/user/data/icon_cache_marker.dart new file mode 100644 index 00000000..e2607cc8 --- /dev/null +++ b/apps/weblibre/lib/features/user/data/icon_cache_marker.dart @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'dart:convert'; +import 'dart:typed_data'; + +/// Shared prefix for every "we know this URL has no favicon" cache entry. +/// +/// The recognised version is appended by [missingIconMarkerBytes]. The +/// [isMissingIconMarker] check matches by *prefix* so a future version +/// bump (`v2`, `v3` …) doesn't silently fail to recognise old entries — +/// those rows would otherwise be handed to consumers as opaque bytes +/// and confuse the image-decode fallback path. +const _missingIconMarkerPrefix = 'weblibre:missing-favicon:'; +const _currentMissingIconMarkerVersion = 'v1'; + +final Uint8List missingIconMarkerBytes = Uint8List.fromList( + utf8.encode('$_missingIconMarkerPrefix$_currentMissingIconMarkerVersion'), +); + +final Uint8List _missingIconMarkerPrefixBytes = Uint8List.fromList( + utf8.encode(_missingIconMarkerPrefix), +); + +bool isMissingIconMarker(Uint8List? bytes) { + if (bytes == null) return false; + if (bytes.length < _missingIconMarkerPrefixBytes.length) return false; + for (var i = 0; i < _missingIconMarkerPrefixBytes.length; i++) { + if (bytes[i] != _missingIconMarkerPrefixBytes[i]) return false; + } + return true; +} diff --git a/apps/weblibre/lib/features/user/data/models/general_settings.dart b/apps/weblibre/lib/features/user/data/models/general_settings.dart index 4a32cd67..86ac4b1e 100644 --- a/apps/weblibre/lib/features/user/data/models/general_settings.dart +++ b/apps/weblibre/lib/features/user/data/models/general_settings.dart @@ -125,6 +125,18 @@ class GeneralSettings with FastEquatable { final bool blockExternalAppsEnabled; final Map externalAppIntentPolicies; + /// Whether the local search index (`history` table populated via tab→ + /// history triggers) is active. When false, the SQL trigger guard returns + /// without writing; existing rows stay until the user clears them. + final bool enableLocalSearchIndex; + + /// Whether private tabs feed the local search index. Defaults to false. + final bool indexPrivateTabs; + + /// Whether pressing the keyboard submit/enter button should automatically + /// accept and complete an inline search suggestion. Defaults to false. + final bool acceptSuggestionOnSubmit; + GeneralSettings({ required this.themeMode, required this.uiScaleFactor, @@ -179,6 +191,9 @@ class GeneralSettings with FastEquatable { required this.allowNonManifestPwaInstall, required this.blockExternalAppsEnabled, required this.externalAppIntentPolicies, + required this.enableLocalSearchIndex, + required this.indexPrivateTabs, + required this.acceptSuggestionOnSubmit, }); GeneralSettings.withDefaults({ @@ -235,6 +250,9 @@ class GeneralSettings with FastEquatable { bool? allowNonManifestPwaInstall, bool? blockExternalAppsEnabled, Map? externalAppIntentPolicies, + bool? enableLocalSearchIndex, + bool? indexPrivateTabs, + bool? acceptSuggestionOnSubmit, }) : themeMode = themeMode ?? ThemeMode.dark, uiScaleFactor = uiScaleFactor ?? defaultUiScaleFactor, disableAnimations = disableAnimations ?? false, @@ -297,7 +315,10 @@ class GeneralSettings with FastEquatable { unshortenerToken = unshortenerToken ?? '', allowNonManifestPwaInstall = allowNonManifestPwaInstall ?? false, blockExternalAppsEnabled = blockExternalAppsEnabled ?? false, - externalAppIntentPolicies = externalAppIntentPolicies ?? const {}; + externalAppIntentPolicies = externalAppIntentPolicies ?? const {}, + enableLocalSearchIndex = enableLocalSearchIndex ?? true, + indexPrivateTabs = indexPrivateTabs ?? false, + acceptSuggestionOnSubmit = acceptSuggestionOnSubmit ?? false; factory GeneralSettings.fromJson(Map json) { // Migrate legacy `newTabPosition` setting to direction settings. @@ -404,5 +425,8 @@ class GeneralSettings with FastEquatable { allowNonManifestPwaInstall, blockExternalAppsEnabled, externalAppIntentPolicies, + enableLocalSearchIndex, + indexPrivateTabs, + acceptSuggestionOnSubmit, ]; } diff --git a/apps/weblibre/lib/features/user/data/models/general_settings.g.dart b/apps/weblibre/lib/features/user/data/models/general_settings.g.dart index e11a91cc..6725f044 100644 --- a/apps/weblibre/lib/features/user/data/models/general_settings.g.dart +++ b/apps/weblibre/lib/features/user/data/models/general_settings.g.dart @@ -133,6 +133,12 @@ abstract class _$GeneralSettingsCWProxy { Map externalAppIntentPolicies, ); + GeneralSettings enableLocalSearchIndex(bool enableLocalSearchIndex); + + GeneralSettings indexPrivateTabs(bool indexPrivateTabs); + + GeneralSettings acceptSuggestionOnSubmit(bool acceptSuggestionOnSubmit); + /// Creates a new instance with the provided field values. /// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `GeneralSettings(...).copyWith.fieldName(value)`. /// @@ -194,6 +200,9 @@ abstract class _$GeneralSettingsCWProxy { bool allowNonManifestPwaInstall, bool blockExternalAppsEnabled, Map externalAppIntentPolicies, + bool enableLocalSearchIndex, + bool indexPrivateTabs, + bool acceptSuggestionOnSubmit, }); } @@ -430,6 +439,18 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy { Map externalAppIntentPolicies, ) => call(externalAppIntentPolicies: externalAppIntentPolicies); + @override + GeneralSettings enableLocalSearchIndex(bool enableLocalSearchIndex) => + call(enableLocalSearchIndex: enableLocalSearchIndex); + + @override + GeneralSettings indexPrivateTabs(bool indexPrivateTabs) => + call(indexPrivateTabs: indexPrivateTabs); + + @override + GeneralSettings acceptSuggestionOnSubmit(bool acceptSuggestionOnSubmit) => + call(acceptSuggestionOnSubmit: acceptSuggestionOnSubmit); + @override /// Creates a new instance with the provided field values. /// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `GeneralSettings(...).copyWith.fieldName(value)`. @@ -493,6 +514,9 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy { Object? allowNonManifestPwaInstall = const $CopyWithPlaceholder(), Object? blockExternalAppsEnabled = const $CopyWithPlaceholder(), Object? externalAppIntentPolicies = const $CopyWithPlaceholder(), + Object? enableLocalSearchIndex = const $CopyWithPlaceholder(), + Object? indexPrivateTabs = const $CopyWithPlaceholder(), + Object? acceptSuggestionOnSubmit = const $CopyWithPlaceholder(), }) { return GeneralSettings( themeMode: themeMode == const $CopyWithPlaceholder() || themeMode == null @@ -807,6 +831,24 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy { ? _value.externalAppIntentPolicies // ignore: cast_nullable_to_non_nullable : externalAppIntentPolicies as Map, + enableLocalSearchIndex: + enableLocalSearchIndex == const $CopyWithPlaceholder() || + enableLocalSearchIndex == null + ? _value.enableLocalSearchIndex + // ignore: cast_nullable_to_non_nullable + : enableLocalSearchIndex as bool, + indexPrivateTabs: + indexPrivateTabs == const $CopyWithPlaceholder() || + indexPrivateTabs == null + ? _value.indexPrivateTabs + // ignore: cast_nullable_to_non_nullable + : indexPrivateTabs as bool, + acceptSuggestionOnSubmit: + acceptSuggestionOnSubmit == const $CopyWithPlaceholder() || + acceptSuggestionOnSubmit == null + ? _value.acceptSuggestionOnSubmit + // ignore: cast_nullable_to_non_nullable + : acceptSuggestionOnSubmit as bool, ); } } @@ -928,6 +970,9 @@ GeneralSettings _$GeneralSettingsFromJson( (json['externalAppIntentPolicies'] as Map?)?.map( (k, e) => MapEntry(k, $enumDecode(_$IntentSourcePolicyEnumMap, e)), ), + enableLocalSearchIndex: json['enableLocalSearchIndex'] as bool?, + indexPrivateTabs: json['indexPrivateTabs'] as bool?, + acceptSuggestionOnSubmit: json['acceptSuggestionOnSubmit'] as bool?, ); Map _$GeneralSettingsToJson( @@ -999,6 +1044,9 @@ Map _$GeneralSettingsToJson( 'externalAppIntentPolicies': instance.externalAppIntentPolicies.map( (k, e) => MapEntry(k, _$IntentSourcePolicyEnumMap[e]!), ), + 'enableLocalSearchIndex': instance.enableLocalSearchIndex, + 'indexPrivateTabs': instance.indexPrivateTabs, + 'acceptSuggestionOnSubmit': instance.acceptSuggestionOnSubmit, }; const _$ThemeModeEnumMap = { diff --git a/apps/weblibre/lib/features/user/data/providers.dart b/apps/weblibre/lib/features/user/data/providers.dart index a1821954..fc44505f 100644 --- a/apps/weblibre/lib/features/user/data/providers.dart +++ b/apps/weblibre/lib/features/user/data/providers.dart @@ -51,6 +51,14 @@ UserDatabase userDatabase(Ref ref) { }, ); }), + onAfterOpen: (db) async { + // Release any token reservations stranded by a prior crash so the + // tokens go back to the available pool. 10 minutes is generous — + // a real redemption round-trip is sub-second. + await db.searchTokensDao.releaseStaleReservations( + const Duration(minutes: 10), + ); + }, ); DatabaseRegistry.instance.register('user', db); diff --git a/apps/weblibre/lib/features/user/data/providers.g.dart b/apps/weblibre/lib/features/user/data/providers.g.dart index 7bfbd44e..a98e1aeb 100644 --- a/apps/weblibre/lib/features/user/data/providers.g.dart +++ b/apps/weblibre/lib/features/user/data/providers.g.dart @@ -48,7 +48,7 @@ final class UserDatabaseProvider } } -String _$userDatabaseHash() => r'097505438c098252a322e6d0e49f885d67ebe898'; +String _$userDatabaseHash() => r'05386a087a53dedd5f51859f45eb93ecf449c3c4'; @ProviderFor(riverpodDatabaseStorage) final riverpodDatabaseStorageProvider = RiverpodDatabaseStorageProvider._(); diff --git a/apps/weblibre/lib/features/user/domain/providers.dart b/apps/weblibre/lib/features/user/domain/providers.dart index 8ded41ca..5cbade81 100644 --- a/apps/weblibre/lib/features/user/domain/providers.dart +++ b/apps/weblibre/lib/features/user/domain/providers.dart @@ -17,6 +17,8 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ +import 'dart:typed_data'; + import 'package:exceptions/exceptions.dart'; import 'package:nullability/nullability.dart'; import 'package:riverpod/riverpod.dart'; @@ -27,6 +29,7 @@ import 'package:weblibre/domain/entities/profile.dart'; import 'package:weblibre/features/user/data/providers.dart'; import 'package:weblibre/features/user/domain/entities/fingerprint_overrides.dart'; import 'package:weblibre/features/user/domain/providers/backup_directory.dart'; +import 'package:weblibre/features/user/domain/repositories/cache.dart'; import 'package:weblibre/features/user/domain/repositories/engine_settings.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; import 'package:weblibre/features/user/domain/repositories/profile.dart'; @@ -41,6 +44,11 @@ Stream iconCacheSizeMegabytes(Ref ref) { return repository.cacheDao.getIconCacheSize().watchSingle(); } +@Riverpod() +Stream watchCachedIconBytes(Ref ref, String origin) { + return ref.watch(cacheRepositoryProvider.notifier).watchCachedIcon(origin); +} + @Riverpod() bool incognitoModeEnabled(Ref ref) { return ref.watch( diff --git a/apps/weblibre/lib/features/user/domain/providers.g.dart b/apps/weblibre/lib/features/user/domain/providers.g.dart index c14f4c22..74c5ba16 100644 --- a/apps/weblibre/lib/features/user/domain/providers.g.dart +++ b/apps/weblibre/lib/features/user/domain/providers.g.dart @@ -43,6 +43,81 @@ final class IconCacheSizeMegabytesProvider String _$iconCacheSizeMegabytesHash() => r'5d7f5f6485060b08ce4fd8fa634f07bf8bfdbd2d'; +@ProviderFor(watchCachedIconBytes) +final watchCachedIconBytesProvider = WatchCachedIconBytesFamily._(); + +final class WatchCachedIconBytesProvider + extends + $FunctionalProvider< + AsyncValue, + Uint8List?, + Stream + > + with $FutureModifier, $StreamProvider { + WatchCachedIconBytesProvider._({ + required WatchCachedIconBytesFamily super.from, + required String super.argument, + }) : super( + retry: null, + name: r'watchCachedIconBytesProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$watchCachedIconBytesHash(); + + @override + String toString() { + return r'watchCachedIconBytesProvider' + '' + '($argument)'; + } + + @$internal + @override + $StreamProviderElement $createElement($ProviderPointer pointer) => + $StreamProviderElement(pointer); + + @override + Stream create(Ref ref) { + final argument = this.argument as String; + return watchCachedIconBytes(ref, argument); + } + + @override + bool operator ==(Object other) { + return other is WatchCachedIconBytesProvider && other.argument == argument; + } + + @override + int get hashCode { + return argument.hashCode; + } +} + +String _$watchCachedIconBytesHash() => + r'65881670bd19f2d55e05e68118e1e23b28871c13'; + +final class WatchCachedIconBytesFamily extends $Family + with $FunctionalFamilyOverride, String> { + WatchCachedIconBytesFamily._() + : super( + retry: null, + name: r'watchCachedIconBytesProvider', + dependencies: null, + $allTransitiveDependencies: null, + isAutoDispose: true, + ); + + WatchCachedIconBytesProvider call(String origin) => + WatchCachedIconBytesProvider._(argument: origin, from: this); + + @override + String toString() => r'watchCachedIconBytesProvider'; +} + @ProviderFor(incognitoModeEnabled) final incognitoModeEnabledProvider = IncognitoModeEnabledProvider._(); diff --git a/apps/weblibre/lib/features/user/domain/repositories/cache.dart b/apps/weblibre/lib/features/user/domain/repositories/cache.dart index b6936ee6..8927ac91 100644 --- a/apps/weblibre/lib/features/user/domain/repositories/cache.dart +++ b/apps/weblibre/lib/features/user/domain/repositories/cache.dart @@ -22,6 +22,7 @@ import 'dart:typed_data'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:weblibre/core/logger.dart'; import 'package:weblibre/features/geckoview/domain/providers.dart'; +import 'package:weblibre/features/user/data/icon_cache_marker.dart'; import 'package:weblibre/features/user/data/providers.dart'; part 'cache.g.dart'; @@ -36,7 +37,26 @@ class CacheRepository extends _$CacheRepository { return ref.read(userDatabaseProvider).cacheDao.cacheIcon(url.origin, bytes); } - Future getCachedIcon(String origin) { + Future cacheIconIfAbsent(Uri url, Uint8List bytes) { + return ref + .read(userDatabaseProvider) + .cacheDao + .cacheIconIfAbsent(url.origin, bytes); + } + + Future cacheMissingIcon(Uri url) { + return ref.read(userDatabaseProvider).cacheDao.cacheMissingIcon(url.origin); + } + + Future getCachedIcon(String origin) async { + final bytes = await getCachedIconRaw(origin); + if (isMissingIconMarker(bytes)) { + return null; + } + return bytes; + } + + Future getCachedIconRaw(String origin) { return ref .read(userDatabaseProvider) .cacheDao @@ -44,6 +64,27 @@ class CacheRepository extends _$CacheRepository { .getSingleOrNull(); } + Stream watchCachedIcon(String origin) { + return ref + .read(userDatabaseProvider) + .cacheDao + .getCachedIcon(origin) + .watchSingleOrNull() + .map((bytes) => isMissingIconMarker(bytes) ? null : bytes); + } + + bool isMissingIconBytes(Uint8List? bytes) { + return isMissingIconMarker(bytes); + } + + Future getCachedIconFetchDate(String origin) { + return ref + .read(userDatabaseProvider) + .cacheDao + .getCachedIconFetchDate(origin) + .getSingleOrNull(); + } + @override void build() { final eventService = ref.watch(eventServiceProvider); diff --git a/apps/weblibre/lib/features/user/domain/repositories/cache.g.dart b/apps/weblibre/lib/features/user/domain/repositories/cache.g.dart index 625c6bc6..f33cefa5 100644 --- a/apps/weblibre/lib/features/user/domain/repositories/cache.g.dart +++ b/apps/weblibre/lib/features/user/domain/repositories/cache.g.dart @@ -41,7 +41,7 @@ final class CacheRepositoryProvider } } -String _$cacheRepositoryHash() => r'e3cd7461aefe9e034a663169cd81ab7f69c2640e'; +String _$cacheRepositoryHash() => r'60caff8364a3996502a6309925903190db71cd8f'; abstract class _$CacheRepository extends $Notifier { void build(); diff --git a/apps/weblibre/lib/features/user/domain/repositories/general_settings.dart b/apps/weblibre/lib/features/user/domain/repositories/general_settings.dart index 5cbe1ffd..fc49c759 100644 --- a/apps/weblibre/lib/features/user/domain/repositories/general_settings.dart +++ b/apps/weblibre/lib/features/user/domain/repositories/general_settings.dart @@ -248,6 +248,18 @@ class GeneralSettingsRepository extends _$GeneralSettingsRepository { 'externalAppIntentPolicies': settings['externalAppIntentPolicies'] ?.readAs(DriftSqlType.string, db.typeMapping) .mapNotNull(jsonDecode), + 'enableLocalSearchIndex': settings['enableLocalSearchIndex']?.readAs( + DriftSqlType.bool, + db.typeMapping, + ), + 'indexPrivateTabs': settings['indexPrivateTabs']?.readAs( + DriftSqlType.bool, + db.typeMapping, + ), + 'acceptSuggestionOnSubmit': settings['acceptSuggestionOnSubmit']?.readAs( + DriftSqlType.bool, + db.typeMapping, + ), }); } diff --git a/apps/weblibre/lib/features/user/domain/repositories/general_settings.g.dart b/apps/weblibre/lib/features/user/domain/repositories/general_settings.g.dart index f8243a3b..f1f0456e 100644 --- a/apps/weblibre/lib/features/user/domain/repositories/general_settings.g.dart +++ b/apps/weblibre/lib/features/user/domain/repositories/general_settings.g.dart @@ -35,7 +35,7 @@ final class GeneralSettingsRepositoryProvider } String _$generalSettingsRepositoryHash() => - r'5fe717f8bccad163fa0cb8ec3294c4b00847e141'; + r'b269ac847d1ae1547bdfe0b926d8d4ffc5ec8f7a'; abstract class _$GeneralSettingsRepository extends $StreamNotifier { diff --git a/apps/weblibre/lib/features/web_feed/data/database/database.drift.dart b/apps/weblibre/lib/features/web_feed/data/database/database.drift.dart index 1847c465..70974a8e 100644 --- a/apps/weblibre/lib/features/web_feed/data/database/database.drift.dart +++ b/apps/weblibre/lib/features/web_feed/data/database/database.drift.dart @@ -86,6 +86,11 @@ extension DefineFunctions on i6.CommonDatabase { required String Function(int, String?) lexoRankPrevious, required String Function(String?, String?) lexoRankReorderAfter, required String Function(String?, String?) lexoRankReorderBefore, + required int Function() generateContentHash, + required bool Function(String?) urlIndexable, + required String Function(String?) urlCanonical, + required String Function(String?) urlHost, + required String Function(String?) urlPath, }) { createFunction( functionName: 'lexo_rank_next', @@ -123,5 +128,44 @@ extension DefineFunctions on i6.CommonDatabase { return lexoRankReorderBefore(arg0, arg1); }, ); + createFunction( + functionName: 'generate_content_hash', + argumentCount: const i6.AllowedArgumentCount(0), + function: (args) { + return generateContentHash(); + }, + ); + createFunction( + functionName: 'url_indexable', + argumentCount: const i6.AllowedArgumentCount(1), + function: (args) { + final arg0 = args[0] as String?; + return urlIndexable(arg0); + }, + ); + createFunction( + functionName: 'url_canonical', + argumentCount: const i6.AllowedArgumentCount(1), + function: (args) { + final arg0 = args[0] as String?; + return urlCanonical(arg0); + }, + ); + createFunction( + functionName: 'url_host', + argumentCount: const i6.AllowedArgumentCount(1), + function: (args) { + final arg0 = args[0] as String?; + return urlHost(arg0); + }, + ); + createFunction( + functionName: 'url_path', + argumentCount: const i6.AllowedArgumentCount(1), + function: (args) { + final arg0 = args[0] as String?; + return urlPath(arg0); + }, + ); } } diff --git a/apps/weblibre/lib/features/web_search/data/locale_options.dart b/apps/weblibre/lib/features/web_search/data/locale_options.dart new file mode 100644 index 00000000..782418ef --- /dev/null +++ b/apps/weblibre/lib/features/web_search/data/locale_options.dart @@ -0,0 +1,151 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +library; + +/// Curated picker options for the web-search language and country selectors. +/// +/// Codes intersect what Brave (`search_lang`/`country`) and Mojeek +/// (`lb`/`rb`) accept; English display names so the menu doesn't need a +/// per-locale translation pass. The protocol carries just the primary +/// ISO 639-1 / ISO 3166-1 alpha-2 codes — region qualifiers (`en-gb`, +/// `pt-br`, etc.) are reconstructed server-side from the language+country +/// pair. + +class LanguageOption { + final String code; // ISO 639-1 + final String name; // English display name + const LanguageOption(this.code, this.name); +} + +class CountryOption { + final String code; // ISO 3166-1 alpha-2 (uppercase) + final String name; // English display name + const CountryOption(this.code, this.name); +} + +const supportedLanguages = [ + LanguageOption('ar', 'Arabic'), + LanguageOption('bg', 'Bulgarian'), + LanguageOption('bn', 'Bengali'), + LanguageOption('ca', 'Catalan'), + LanguageOption('cs', 'Czech'), + LanguageOption('da', 'Danish'), + LanguageOption('de', 'German'), + LanguageOption('el', 'Greek'), + LanguageOption('en', 'English'), + LanguageOption('es', 'Spanish'), + LanguageOption('et', 'Estonian'), + LanguageOption('eu', 'Basque'), + LanguageOption('fi', 'Finnish'), + LanguageOption('fr', 'French'), + LanguageOption('gl', 'Galician'), + LanguageOption('gu', 'Gujarati'), + LanguageOption('he', 'Hebrew'), + LanguageOption('hi', 'Hindi'), + LanguageOption('hr', 'Croatian'), + LanguageOption('hu', 'Hungarian'), + LanguageOption('id', 'Indonesian'), + LanguageOption('is', 'Icelandic'), + LanguageOption('it', 'Italian'), + LanguageOption('ja', 'Japanese'), + LanguageOption('kn', 'Kannada'), + LanguageOption('ko', 'Korean'), + LanguageOption('lt', 'Lithuanian'), + LanguageOption('lv', 'Latvian'), + LanguageOption('ml', 'Malayalam'), + LanguageOption('mr', 'Marathi'), + LanguageOption('ms', 'Malay'), + LanguageOption('nb', 'Norwegian'), + LanguageOption('nl', 'Dutch'), + LanguageOption('pa', 'Punjabi'), + LanguageOption('pl', 'Polish'), + LanguageOption('pt', 'Portuguese'), + LanguageOption('ro', 'Romanian'), + LanguageOption('ru', 'Russian'), + LanguageOption('sk', 'Slovak'), + LanguageOption('sl', 'Slovenian'), + LanguageOption('sr', 'Serbian'), + LanguageOption('sv', 'Swedish'), + LanguageOption('ta', 'Tamil'), + LanguageOption('te', 'Telugu'), + LanguageOption('th', 'Thai'), + LanguageOption('tr', 'Turkish'), + LanguageOption('uk', 'Ukrainian'), + LanguageOption('vi', 'Vietnamese'), + LanguageOption('zh', 'Chinese'), +]; + +const supportedCountries = [ + CountryOption('AR', 'Argentina'), + CountryOption('AT', 'Austria'), + CountryOption('AU', 'Australia'), + CountryOption('BE', 'Belgium'), + CountryOption('BR', 'Brazil'), + CountryOption('CA', 'Canada'), + CountryOption('CH', 'Switzerland'), + CountryOption('CL', 'Chile'), + CountryOption('CN', 'China'), + CountryOption('DE', 'Germany'), + CountryOption('DK', 'Denmark'), + CountryOption('ES', 'Spain'), + CountryOption('FI', 'Finland'), + CountryOption('FR', 'France'), + CountryOption('GB', 'United Kingdom'), + CountryOption('GR', 'Greece'), + CountryOption('HK', 'Hong Kong'), + CountryOption('ID', 'Indonesia'), + CountryOption('IN', 'India'), + CountryOption('IT', 'Italy'), + CountryOption('JP', 'Japan'), + CountryOption('KR', 'South Korea'), + CountryOption('MX', 'Mexico'), + CountryOption('MY', 'Malaysia'), + CountryOption('NL', 'Netherlands'), + CountryOption('NO', 'Norway'), + CountryOption('NZ', 'New Zealand'), + CountryOption('PH', 'Philippines'), + CountryOption('PL', 'Poland'), + CountryOption('PT', 'Portugal'), + CountryOption('RU', 'Russia'), + CountryOption('SA', 'Saudi Arabia'), + CountryOption('SE', 'Sweden'), + CountryOption('TR', 'Turkey'), + CountryOption('TW', 'Taiwan'), + CountryOption('US', 'United States'), + CountryOption('ZA', 'South Africa'), +]; + +LanguageOption? findLanguage(String? code) { + if (code == null) return null; + final lower = code.toLowerCase(); + for (final l in supportedLanguages) { + if (l.code == lower) return l; + } + return null; +} + +CountryOption? findCountry(String? code) { + if (code == null) return null; + final upper = code.toUpperCase(); + for (final c in supportedCountries) { + if (c.code == upper) return c; + } + return null; +} diff --git a/apps/weblibre/lib/features/web_search/domain/controllers/sandbox_capture_controller.dart b/apps/weblibre/lib/features/web_search/domain/controllers/sandbox_capture_controller.dart new file mode 100644 index 00000000..fb703653 --- /dev/null +++ b/apps/weblibre/lib/features/web_search/domain/controllers/sandbox_capture_controller.dart @@ -0,0 +1,690 @@ +import 'dart:async'; + +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart' + as fmc; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:search_backend/search_backend.dart'; +import 'package:search_client/search_client.dart'; +import 'package:weblibre/core/routing/routes.dart'; +import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart'; +import 'package:weblibre/features/geckoview/domain/entities/tab_container_selection.dart'; +import 'package:weblibre/features/geckoview/domain/providers/tab_session.dart'; +import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; +import 'package:weblibre/features/geckoview/domain/repositories/tab.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/database/daos/capture_tab.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/database/definitions.drift.dart' + show CaptureTabData; +import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/providers.dart'; +import 'package:weblibre/features/search_credits/domain/controllers/search_token_issuance_controller.dart'; +import 'package:weblibre/features/search_credits/domain/providers.dart'; +import 'package:weblibre/features/search_credits/domain/repositories/search_token_stash_repository.dart'; +import 'package:weblibre/features/user/domain/repositories/cache.dart'; +import 'package:weblibre/features/search_credits/domain/providers/proxy_client.dart'; +import 'package:weblibre/features/web_search/domain/services/capture_artifact_downloader.dart'; +import 'package:weblibre/features/web_search/domain/services/capture_server.dart'; +import 'package:weblibre/features/web_search/domain/services/sandbox_capture_store.dart'; + +part 'sandbox_capture_controller.g.dart'; + +enum SandboxCaptureErrorKind { + /// Stash is empty AND user has zero credits — needs to buy more. + insufficientCredits, + + /// Stash is empty, credits are available, but issuance failed + /// (network/auth/server). Retrying may succeed. + tokenIssuanceFailed, + + fetchPolicyRejected, + captureFailed, + downloadFailed, + unknown, +} + +class SandboxCaptureError { + final SandboxCaptureErrorKind kind; + final Uri targetUrl; + final String? detail; + const SandboxCaptureError({ + required this.kind, + required this.targetUrl, + this.detail, + }); +} + +@Riverpod(keepAlive: true) +OneShotCaptureClient oneShotCaptureClient(Ref ref) { + return OneShotCaptureClient( + endpoints: ref.watch(searchBackendEndpointsProvider), + logger: ref.watch(searchClientLoggerProvider), + httpClient: ref.watch(searchProxyHttpClientProvider), + ); +} + +/// Streams capture-tab rows keyed by tabId. Watched by the address bar so +/// the UI can show the canonical source URL (instead of the loopback +/// loader/capture URL) and a sandbox indicator. +@Riverpod(keepAlive: true) +Stream> sandboxCaptureMap(Ref ref) { + final dao = ref.watch(tabDatabaseProvider).captureTabDao; + return dao.watchAll().map((rows) => {for (final row in rows) row.tabId: row}); +} + +@Riverpod() +CaptureTabData? sandboxCaptureForTab(Ref ref, {required String? tabId}) { + if (tabId == null) return null; + final map = ref.watch(sandboxCaptureMapProvider).value; + return map?[tabId]; +} + +/// The canonical source URL of a sandbox-captured tab, or `null` when the +/// tab is not a sandbox capture (the regular `tabState.url` should be used in +/// that case). +@Riverpod() +Uri? sandboxSourceUriForTab(Ref ref, {required String? tabId}) { + final row = ref.watch(sandboxCaptureForTabProvider(tabId: tabId)); + return parseSandboxSource(row); +} + +Uri? parseSandboxSource(CaptureTabData? row) { + if (row == null) return null; + final raw = row.sourceUrl; + if (raw.isEmpty) return null; + return Uri.tryParse(raw); +} + +/// Search/edit text to pre-fill when the user taps the address bar of +/// [tabState]. Sandbox-captured tabs surface their source URL so editing +/// doesn't strip the user back to the loopback loader. +String searchTextForTab(TabState tabState, [Uri? sandboxSourceUri]) { + if (sandboxSourceUri != null) { + return sandboxSourceUri.toString(); + } + + final searchText = tabState.url.scheme == 'about' + ? '' + : tabState.url.toString(); + + return searchText.isEmpty ? SearchRoute.emptySearchText : searchText; +} + +@Riverpod(keepAlive: true) +SandboxCaptureStore sandboxCaptureStore(Ref ref) => SandboxCaptureStore(); + +@Riverpod(keepAlive: true) +Stream sandboxCaptureErrors(Ref ref) { + final ctrl = ref.watch(sandboxCaptureControllerProvider.notifier); + return ctrl.errors; +} + +/// Orchestrates sandbox capture browsing: +/// +/// 1. Listens for dispatch events from Kotlin (`onSandboxLinkClick`, +/// `onSandboxNewTab`) and runs the capture pipeline. +/// 2. Keeps the native `SandboxCaptureRegistry` and the on-disk JSON mirror +/// in sync with the `capture_tab` table via a DAO subscription. +/// 3. Listens on `CaptureServer.retryRequests` and re-runs failed captures. +/// +/// This provider is kept alive for the lifetime of the app; `build()` wires +/// up the subscriptions and returns nothing interesting. +@Riverpod(keepAlive: true) +class SandboxCaptureController extends _$SandboxCaptureController { + final _errors = StreamController.broadcast(); + // Keyed by (tabId, url) so two concurrent navigations in the same tab to + // different URLs don't dedupe each other, and the same URL across tabs + // doesn't either. Records compare structurally so this is safer than the + // earlier "tabId|url" string concat. + final _inFlight = <(String, Uri)>{}; + final _hostEvents = _SandboxHostEventsHandler(); + + /// In-memory record of the (method, variant) used to capture each sandbox + /// tab. Children opened via link-click inherit their parent's mode so a + /// PDF sandbox tab spawns more PDFs, a singlefile tab spawns more + /// singlefile, etc. Lost across app restarts (capture_tab schema doesn't + /// store these yet) — children fall back to singlefile/balanced in that + /// case. + final _tabCaptureMode = {}; + + static const _defaultMethod = 'singlefile'; + static const _defaultVariant = 'balanced'; + + StreamSubscription>? _captureTabSub; + StreamSubscription? _retrySub; + + Stream get errors => _errors.stream; + + @override + void build() { + _hostEvents.controller = this; + fmc.SandboxCaptureHostEvents.setUp(_hostEvents); + + final dao = ref.read(tabDatabaseProvider).captureTabDao; + _captureTabSub = dao.watchAll().listen(_onCaptureTabChange); + + _retrySub = ref + .read(captureServerProvider) + .retryRequests + .listen(_onRetryRequest); + + ref.onDispose(() { + // Cancel inbound subscriptions first so no more events can land on + // this controller. Only after that is it safe to detach the host + // event handler, drop the back-reference, and close the error + // stream — otherwise an in-flight Pigeon event or DAO emit could + // race with handler cleanup, or _markFailed could try to add to a + // closed _errors sink. + final captureTabSub = _captureTabSub; + final retrySub = _retrySub; + if (captureTabSub != null) unawaited(captureTabSub.cancel()); + if (retrySub != null) unawaited(retrySub.cancel()); + fmc.SandboxCaptureHostEvents.setUp(null); + _hostEvents.controller = null; + unawaited(_errors.close()); + }); + } + + // Entry point: search-results flow opened a root capture tab. + Future registerRootCapture({ + required String tabId, + required CapturedPageReceiptLike receipt, + String? method, + String? variant, + }) async { + final server = ref.read(captureServerProvider); + await server.publish(receipt.captureId); + final dao = ref.read(tabDatabaseProvider).captureTabDao; + await dao.upsert( + tabId: tabId, + captureId: receipt.captureId, + sourceUrl: receipt.sourceUrl.toString(), + status: CaptureTabStatus.ready, + ); + _tabCaptureMode[tabId] = ( + method: method ?? _defaultMethod, + variant: variant ?? _defaultVariant, + ); + await _pushRegistry(); + } + + // Called from Kotlin via pigeon when a sandbox tab tried to navigate to a + // non-loopback URL. We open a fresh private tab and run the capture + // pipeline into it. + Future captureIntoNewTab({ + required String parentTabId, + required Uri targetUrl, + }) async { + if (_inFlight.contains((parentTabId, targetUrl))) return; + + final parent = ref.read(tabStatesProvider)[parentTabId]; + final parentTabMode = + parent?.tabMode ?? + await ref + .read(tabDatabaseProvider) + .tabDao + .getTabMode(parentTabId) + .getSingleOrNull() ?? + TabMode.private; + final parentContainer = await ref + .read(tabDatabaseProvider) + .tabDao + .getTabContainerData(parentTabId) + .getSingleOrNull(); + final containerSelection = parentContainer == null + ? const TabContainerSelection.unassigned() + : TabContainerSelection.specific(parentContainer); + + // Create the tab without an initial URL — we'll load the loopback + // loader URL in _runCapture. Never seed the tab with the real + // targetUrl: Gecko's app-link resolution would fire on load/restore + // (e.g. github.com → "Open in GitHub app?" dialog) before the + // interceptor can redirect. + final newTabId = await ref + .read(tabRepositoryProvider.notifier) + .addTab( + tabMode: parentTabMode, + selectTab: true, + parentId: parentTabId, + containerSelection: containerSelection, + startLoading: false, + ); + + await _runCapture( + parentTabId: parentTabId, + newTabId: newTabId, + targetUrl: targetUrl, + triggerInitialLoad: true, + ); + } + + // Called from Kotlin middleware when GeckoView created a new tab inside a + // sandbox parent. The new tab is already at about:blank. + Future captureIntoExistingTab({ + required String parentTabId, + required String newTabId, + required Uri targetUrl, + }) async { + await _runCapture( + parentTabId: parentTabId, + newTabId: newTabId, + targetUrl: targetUrl, + triggerInitialLoad: true, + ); + } + + /// Resolve the (method, variant) for a child capture. Children inherit + /// the parent tab's capture mode so a PDF sandbox spawns more PDFs, etc. + /// Falls back to singlefile/balanced when the parent's mode is unknown + /// (e.g. after an app restart — `_tabCaptureMode` is in-memory only). + ({String method, String variant}) _resolveCaptureMode(String parentTabId) { + return _tabCaptureMode[parentTabId] ?? + (method: _defaultMethod, variant: _defaultVariant); + } + + Future _runCapture({ + required String parentTabId, + required String newTabId, + required Uri targetUrl, + required bool triggerInitialLoad, + String? existingCaptureId, + }) async { + final key = (newTabId, targetUrl); + if (!_inFlight.add(key)) return; + + final server = ref.read(captureServerProvider); + final dao = ref.read(tabDatabaseProvider).captureTabDao; + final logger = ref.read(searchClientLoggerProvider); + final mode = _resolveCaptureMode(parentTabId); + // For retries, reuse the existing placeholder id so the tab's loader + // URL stays valid and its in-flight long-poll picks up the new state. + final placeholderId = + existingCaptureId ?? + 'pending-${DateTime.now().microsecondsSinceEpoch}-$newTabId'; + + final stash = ref.read(searchTokenStashProvider); + ReservedStashToken? heldReservation; + + try { + server.clearFailed(placeholderId); + await dao.upsert( + tabId: newTabId, + captureId: placeholderId, + sourceUrl: targetUrl.toString(), + status: CaptureTabStatus.pending, + ); + _tabCaptureMode[newTabId] = mode; + await _pushRegistry(); + + if (triggerInitialLoad) { + final loaderUri = await server.loaderUrl( + tabId: newTabId, + captureId: placeholderId, + ); + await ref + .read(tabSessionProvider(tabId: newTabId).notifier) + .loadUrl(url: loaderUri); + } + + var reserved = await stash.reserveOne(); + if (reserved == null) { + // Stash empty — try to top up with the same 10-token batch we use + // on the search submit path, then retry the reservation once. + logger.i( + 'Sandbox capture: stash empty, attempting auto-issuance ' + '(tabId=$newTabId, host=${targetUrl.host})', + ); + final outcome = await ref.read(searchTokenAvailabilityProvider).topUp(); + if (outcome == TokenTopUpOutcome.issued) { + reserved = await stash.reserveOne(); + } + if (reserved == null) { + final kind = outcome == TokenTopUpOutcome.noCredits + ? SandboxCaptureErrorKind.insufficientCredits + : SandboxCaptureErrorKind.tokenIssuanceFailed; + final detail = outcome == TokenTopUpOutcome.noCredits + ? 'You have no search credits left. Purchase more to continue.' + : 'Could not issue new search tokens. Check your connection and try again.'; + logger.w( + 'Sandbox capture: auto-issuance did not yield a token ' + '(outcome=$outcome, host=${targetUrl.host})', + ); + await _markFailed( + tabId: newTabId, + captureId: placeholderId, + url: targetUrl, + kind: kind, + detail: detail, + ); + return; + } + logger.i( + 'Sandbox capture: auto-issuance succeeded, retrying capture ' + '(tabId=$newTabId)', + ); + } + + heldReservation = reserved; + final reservation = reserved; + logger.d( + 'Sandbox capture: reserved token #${reservation.id}, requesting ' + 'capture (tabId=$newTabId, host=${targetUrl.host}, ' + 'method=${mode.method}, variant=${mode.variant})', + ); + + final CaptureArtifactReceipt receipt; + try { + // Inherit the parent tab's capture mode: a PDF sandbox tab spawns + // more PDFs, a singlefile tab spawns more singlefile, etc. The + // capture server happily serves PDF/PNG artifacts on the loopback + // origin, and the loader/redirect machinery is content-type + // agnostic. + final result = await ref + .read(oneShotCaptureClientProvider) + .capture( + targetUrl, + method: mode.method, + variant: mode.variant, + token: reservation.token, + dimensions: currentDisplayCaptureDimensions(), + ); + receipt = result.receipt; + + // Server accepted and consumed the token — drop it permanently. + await stash.commitReserved(reservation.id); + heldReservation = null; + + if (result.faviconBytes != null) { + unawaited( + ref + .read(cacheRepositoryProvider.notifier) + .cacheIconIfAbsent(receipt.url, result.faviconBytes!), + ); + } + } on OneShotCaptureException catch (e) { + // Token-affecting failure modes: + // - fetch_not_allowed / invalid_url : server rejected before + // redeem → safe to release. + // - token_invalid / token_redeemed : server saw the token but + // did not honor it → must commit (do not double-spend). + // - any other 4xx/5xx after redemption attempt: ambiguous → commit. + final preRedemption = + e.code == 'fetch_not_allowed' || + e.code == 'invalid_url' || + e.code == 'invalid_request'; + if (preRedemption) { + await stash.releaseReserved(reservation.id); + logger.i( + 'Sandbox capture: released token #${reservation.id} ' + '(server rejected request before redemption: ${e.code})', + ); + } else { + await stash.commitReserved(reservation.id); + logger.w( + 'Sandbox capture: committed token #${reservation.id} after ' + 'ambiguous server failure (${e.code}) to avoid double-spend', + ); + } + heldReservation = null; + final kind = e.code == 'fetch_not_allowed' + ? SandboxCaptureErrorKind.fetchPolicyRejected + : SandboxCaptureErrorKind.captureFailed; + await _markFailed( + tabId: newTabId, + captureId: placeholderId, + url: targetUrl, + kind: kind, + detail: e.message, + ); + return; + } catch (e, s) { + // Network/transport failure — we don't know whether the server + // saw the token. Commit defensively to prevent double-spend on + // retry. Stale reservations also get cleaned up on app start. + await stash.commitReserved(reservation.id); + heldReservation = null; + logger.e( + 'Sandbox capture failed (committed token #${reservation.id} ' + 'defensively)', + error: e, + stackTrace: s, + ); + await _markFailed( + tabId: newTabId, + captureId: placeholderId, + url: targetUrl, + kind: SandboxCaptureErrorKind.unknown, + detail: e.toString(), + ); + return; + } + + try { + await ref.read(captureArtifactDownloaderProvider).download(receipt); + } catch (e, s) { + logger.e('Sandbox capture download failed', error: e, stackTrace: s); + await _markFailed( + tabId: newTabId, + captureId: placeholderId, + url: targetUrl, + kind: SandboxCaptureErrorKind.downloadFailed, + detail: e.toString(), + ); + return; + } + + final captureUri = await server.publish(receipt.captureId); + await dao.upsert( + tabId: newTabId, + captureId: receipt.captureId, + sourceUrl: targetUrl.toString(), + status: CaptureTabStatus.ready, + ); + _tabCaptureMode[newTabId] = mode; + await _pushRegistry(); + + // Navigate directly to the loopback capture URL. Never bounce + // through the real targetUrl — Gecko's app-link resolution fires + // before the interceptor can redirect. + await ref + .read(tabSessionProvider(tabId: newTabId).notifier) + .loadUrl(url: captureUri); + } catch (e, s) { + // Safety net: anything that escaped the inner blocks (e.g. DAO + // failure, navigation error, exception while we held a reservation + // outside the inner try) — make sure we don't strand the tab in + // pending and don't leak the reservation. Commit defensively if a + // reservation is still held: we may have already sent the token to + // the server. + logger.e('Sandbox capture: unexpected error', error: e, stackTrace: s); + if (heldReservation != null) { + try { + await stash.commitReserved(heldReservation.id); + } catch (commitError, commitStack) { + logger.w( + 'Sandbox capture: failed to commit reservation ' + '#${heldReservation.id} during error cleanup', + error: commitError, + stackTrace: commitStack, + ); + } + } + try { + await _markFailed( + tabId: newTabId, + captureId: placeholderId, + url: targetUrl, + kind: SandboxCaptureErrorKind.unknown, + detail: e.toString(), + ); + } catch (markError, markStack) { + logger.w( + 'Sandbox capture: failed to mark tab as failed during error ' + 'cleanup', + error: markError, + stackTrace: markStack, + ); + } + } finally { + _inFlight.remove(key); + } + } + + Future _markFailed({ + required String tabId, + required String captureId, + required Uri url, + required SandboxCaptureErrorKind kind, + required String detail, + }) async { + final dao = ref.read(tabDatabaseProvider).captureTabDao; + await dao.updateStatus(tabId, CaptureTabStatus.failed); + // Resolve any in-flight loader long-poll for this capture so the tab + // flips to the error UI immediately instead of waiting for the poll + // window to expire. + ref.read(captureServerProvider).markFailed(captureId); + await _pushRegistry(); + if (!_errors.isClosed) { + _errors.add( + SandboxCaptureError(kind: kind, targetUrl: url, detail: detail), + ); + } + } + + Future _onRetryRequest(RetryRequest request) async { + final dao = ref.read(tabDatabaseProvider).captureTabDao; + final row = await dao.findByTabId(request.tabId); + if (row == null) return; + final sourceUrl = Uri.tryParse(row.sourceUrl); + if (sourceUrl == null) return; + // Re-enter the capture pipeline as if the user clicked the link again. + // Reuse the existing capture id so the tab's loader URL (and its + // in-flight long-poll) stays addressable. + await _runCapture( + parentTabId: row.tabId, // we don't persist parent; use self as parent + newTabId: row.tabId, + targetUrl: sourceUrl, + triggerInitialLoad: false, + existingCaptureId: row.captureId, + ); + } + + Future _onCaptureTabChange(List rows) async { + final store = ref.read(sandboxCaptureStoreProvider); + await store.write(await _rowsForPersistentMirror(rows)); + // Drop in-memory mode entries for tabs the DAO no longer knows about so + // a recycled tabId doesn't accidentally inherit a stale mode. + final liveTabIds = rows.map((r) => r.tabId).toSet(); + _tabCaptureMode.removeWhere((tabId, _) => !liveTabIds.contains(tabId)); + await _pushRegistry(rows: rows); + } + + Future> _rowsForPersistentMirror( + List rows, + ) async { + final tabDao = ref.read(tabDatabaseProvider).tabDao; + final tabStates = ref.read(tabStatesProvider); + final persistedRows = []; + + for (final row in rows) { + final tabMode = + tabStates[row.tabId]?.tabMode ?? + await tabDao.getTabMode(row.tabId).getSingleOrNull(); + if (tabMode is PrivateTabMode) { + continue; + } + persistedRows.add(row); + } + + return persistedRows; + } + + Future _pushRegistry({List? rows}) async { + final dao = ref.read(tabDatabaseProvider).captureTabDao; + final server = ref.read(captureServerProvider); + final all = rows ?? await dao.findAll(); + // Resolve redirect URLs in parallel. Each `_redirectUrlFor` awaits a + // server publish/loaderUrl call; serializing was O(N) round-trips on + // every DAO emit, which got noticeable with many sandbox tabs open. + final redirectUrls = await Future.wait([ + for (final row in all) _redirectUrlFor(row, server), + ]); + final entries = [ + for (var i = 0; i < all.length; i++) + fmc.SandboxCaptureEntry( + tabId: all[i].tabId, + captureId: all[i].captureId, + sourceUrl: all[i].sourceUrl, + redirectUrl: redirectUrls[i], + status: all[i].status, + ), + ]; + await fmc.SandboxCaptureApi().resetAll(entries); + } + + Future _redirectUrlFor( + CaptureTabData row, + CaptureServer server, + ) async { + switch (row.status) { + case 'ready': + final uri = await server.publish(row.captureId); + return uri.toString(); + case 'failed': + final uri = await server.loaderUrl( + tabId: row.tabId, + captureId: row.captureId, + error: true, + ); + return uri.toString(); + case 'pending': + default: + final uri = await server.loaderUrl( + tabId: row.tabId, + captureId: row.captureId, + ); + return uri.toString(); + } + } +} + +class _SandboxHostEventsHandler implements fmc.SandboxCaptureHostEvents { + SandboxCaptureController? controller; + + @override + void onSandboxLinkClick(int sequence, String parentTabId, String targetUrl) { + final uri = Uri.tryParse(targetUrl); + final c = controller; + if (uri == null || c == null) return; + unawaited(c.captureIntoNewTab(parentTabId: parentTabId, targetUrl: uri)); + } + + @override + void onSandboxNewTab( + int sequence, + String parentTabId, + String newTabId, + String targetUrl, + ) { + final uri = Uri.tryParse(targetUrl); + final c = controller; + if (uri == null || c == null) return; + unawaited( + c.captureIntoExistingTab( + parentTabId: parentTabId, + newTabId: newTabId, + targetUrl: uri, + ), + ); + } +} + +/// Minimal receipt-like type for registerRootCapture — avoids a direct +/// dependency on the capture_artifact_receipt pigeon in this file when the +/// caller already has the right pieces to hand. +class CapturedPageReceiptLike { + final String captureId; + final Uri sourceUrl; + const CapturedPageReceiptLike({ + required this.captureId, + required this.sourceUrl, + }); +} diff --git a/apps/weblibre/lib/features/web_search/domain/controllers/sandbox_capture_controller.g.dart b/apps/weblibre/lib/features/web_search/domain/controllers/sandbox_capture_controller.g.dart new file mode 100644 index 00000000..9a2ce309 --- /dev/null +++ b/apps/weblibre/lib/features/web_search/domain/controllers/sandbox_capture_controller.g.dart @@ -0,0 +1,472 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'sandbox_capture_controller.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(oneShotCaptureClient) +final oneShotCaptureClientProvider = OneShotCaptureClientProvider._(); + +final class OneShotCaptureClientProvider + extends + $FunctionalProvider< + OneShotCaptureClient, + OneShotCaptureClient, + OneShotCaptureClient + > + with $Provider { + OneShotCaptureClientProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'oneShotCaptureClientProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$oneShotCaptureClientHash(); + + @$internal + @override + $ProviderElement $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + OneShotCaptureClient create(Ref ref) { + return oneShotCaptureClient(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(OneShotCaptureClient value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$oneShotCaptureClientHash() => + r'35c3dbeb91a5c9ca1663f43f4b48332bcde3ae3f'; + +/// Streams capture-tab rows keyed by tabId. Watched by the address bar so +/// the UI can show the canonical source URL (instead of the loopback +/// loader/capture URL) and a sandbox indicator. + +@ProviderFor(sandboxCaptureMap) +final sandboxCaptureMapProvider = SandboxCaptureMapProvider._(); + +/// Streams capture-tab rows keyed by tabId. Watched by the address bar so +/// the UI can show the canonical source URL (instead of the loopback +/// loader/capture URL) and a sandbox indicator. + +final class SandboxCaptureMapProvider + extends + $FunctionalProvider< + AsyncValue>, + Map, + Stream> + > + with + $FutureModifier>, + $StreamProvider> { + /// Streams capture-tab rows keyed by tabId. Watched by the address bar so + /// the UI can show the canonical source URL (instead of the loopback + /// loader/capture URL) and a sandbox indicator. + SandboxCaptureMapProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'sandboxCaptureMapProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$sandboxCaptureMapHash(); + + @$internal + @override + $StreamProviderElement> $createElement( + $ProviderPointer pointer, + ) => $StreamProviderElement(pointer); + + @override + Stream> create(Ref ref) { + return sandboxCaptureMap(ref); + } +} + +String _$sandboxCaptureMapHash() => r'1771bb1c618e34f24e718609d501c426ac49f192'; + +@ProviderFor(sandboxCaptureForTab) +final sandboxCaptureForTabProvider = SandboxCaptureForTabFamily._(); + +final class SandboxCaptureForTabProvider + extends + $FunctionalProvider + with $Provider { + SandboxCaptureForTabProvider._({ + required SandboxCaptureForTabFamily super.from, + required String? super.argument, + }) : super( + retry: null, + name: r'sandboxCaptureForTabProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$sandboxCaptureForTabHash(); + + @override + String toString() { + return r'sandboxCaptureForTabProvider' + '' + '($argument)'; + } + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + CaptureTabData? create(Ref ref) { + final argument = this.argument as String?; + return sandboxCaptureForTab(ref, tabId: argument); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(CaptureTabData? value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } + + @override + bool operator ==(Object other) { + return other is SandboxCaptureForTabProvider && other.argument == argument; + } + + @override + int get hashCode { + return argument.hashCode; + } +} + +String _$sandboxCaptureForTabHash() => + r'1d64f68c85d40dbd7d5b220ee54a5436f1c1eaeb'; + +final class SandboxCaptureForTabFamily extends $Family + with $FunctionalFamilyOverride { + SandboxCaptureForTabFamily._() + : super( + retry: null, + name: r'sandboxCaptureForTabProvider', + dependencies: null, + $allTransitiveDependencies: null, + isAutoDispose: true, + ); + + SandboxCaptureForTabProvider call({required String? tabId}) => + SandboxCaptureForTabProvider._(argument: tabId, from: this); + + @override + String toString() => r'sandboxCaptureForTabProvider'; +} + +/// The canonical source URL of a sandbox-captured tab, or `null` when the +/// tab is not a sandbox capture (the regular `tabState.url` should be used in +/// that case). + +@ProviderFor(sandboxSourceUriForTab) +final sandboxSourceUriForTabProvider = SandboxSourceUriForTabFamily._(); + +/// The canonical source URL of a sandbox-captured tab, or `null` when the +/// tab is not a sandbox capture (the regular `tabState.url` should be used in +/// that case). + +final class SandboxSourceUriForTabProvider + extends $FunctionalProvider + with $Provider { + /// The canonical source URL of a sandbox-captured tab, or `null` when the + /// tab is not a sandbox capture (the regular `tabState.url` should be used in + /// that case). + SandboxSourceUriForTabProvider._({ + required SandboxSourceUriForTabFamily super.from, + required String? super.argument, + }) : super( + retry: null, + name: r'sandboxSourceUriForTabProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$sandboxSourceUriForTabHash(); + + @override + String toString() { + return r'sandboxSourceUriForTabProvider' + '' + '($argument)'; + } + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + Uri? create(Ref ref) { + final argument = this.argument as String?; + return sandboxSourceUriForTab(ref, tabId: argument); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(Uri? value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } + + @override + bool operator ==(Object other) { + return other is SandboxSourceUriForTabProvider && + other.argument == argument; + } + + @override + int get hashCode { + return argument.hashCode; + } +} + +String _$sandboxSourceUriForTabHash() => + r'62f55eefd22377109080ea328c9ac0c00a0f7196'; + +/// The canonical source URL of a sandbox-captured tab, or `null` when the +/// tab is not a sandbox capture (the regular `tabState.url` should be used in +/// that case). + +final class SandboxSourceUriForTabFamily extends $Family + with $FunctionalFamilyOverride { + SandboxSourceUriForTabFamily._() + : super( + retry: null, + name: r'sandboxSourceUriForTabProvider', + dependencies: null, + $allTransitiveDependencies: null, + isAutoDispose: true, + ); + + /// The canonical source URL of a sandbox-captured tab, or `null` when the + /// tab is not a sandbox capture (the regular `tabState.url` should be used in + /// that case). + + SandboxSourceUriForTabProvider call({required String? tabId}) => + SandboxSourceUriForTabProvider._(argument: tabId, from: this); + + @override + String toString() => r'sandboxSourceUriForTabProvider'; +} + +@ProviderFor(sandboxCaptureStore) +final sandboxCaptureStoreProvider = SandboxCaptureStoreProvider._(); + +final class SandboxCaptureStoreProvider + extends + $FunctionalProvider< + SandboxCaptureStore, + SandboxCaptureStore, + SandboxCaptureStore + > + with $Provider { + SandboxCaptureStoreProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'sandboxCaptureStoreProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$sandboxCaptureStoreHash(); + + @$internal + @override + $ProviderElement $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + SandboxCaptureStore create(Ref ref) { + return sandboxCaptureStore(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(SandboxCaptureStore value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$sandboxCaptureStoreHash() => + r'59cf9cfe1e6727ea2c049ed60e7e69cfe6e55d03'; + +@ProviderFor(sandboxCaptureErrors) +final sandboxCaptureErrorsProvider = SandboxCaptureErrorsProvider._(); + +final class SandboxCaptureErrorsProvider + extends + $FunctionalProvider< + AsyncValue, + SandboxCaptureError, + Stream + > + with + $FutureModifier, + $StreamProvider { + SandboxCaptureErrorsProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'sandboxCaptureErrorsProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$sandboxCaptureErrorsHash(); + + @$internal + @override + $StreamProviderElement $createElement( + $ProviderPointer pointer, + ) => $StreamProviderElement(pointer); + + @override + Stream create(Ref ref) { + return sandboxCaptureErrors(ref); + } +} + +String _$sandboxCaptureErrorsHash() => + r'd6ef9ebba2515f8177a380b822f344128e4ea8c4'; + +/// Orchestrates sandbox capture browsing: +/// +/// 1. Listens for dispatch events from Kotlin (`onSandboxLinkClick`, +/// `onSandboxNewTab`) and runs the capture pipeline. +/// 2. Keeps the native `SandboxCaptureRegistry` and the on-disk JSON mirror +/// in sync with the `capture_tab` table via a DAO subscription. +/// 3. Listens on `CaptureServer.retryRequests` and re-runs failed captures. +/// +/// This provider is kept alive for the lifetime of the app; `build()` wires +/// up the subscriptions and returns nothing interesting. + +@ProviderFor(SandboxCaptureController) +final sandboxCaptureControllerProvider = SandboxCaptureControllerProvider._(); + +/// Orchestrates sandbox capture browsing: +/// +/// 1. Listens for dispatch events from Kotlin (`onSandboxLinkClick`, +/// `onSandboxNewTab`) and runs the capture pipeline. +/// 2. Keeps the native `SandboxCaptureRegistry` and the on-disk JSON mirror +/// in sync with the `capture_tab` table via a DAO subscription. +/// 3. Listens on `CaptureServer.retryRequests` and re-runs failed captures. +/// +/// This provider is kept alive for the lifetime of the app; `build()` wires +/// up the subscriptions and returns nothing interesting. +final class SandboxCaptureControllerProvider + extends $NotifierProvider { + /// Orchestrates sandbox capture browsing: + /// + /// 1. Listens for dispatch events from Kotlin (`onSandboxLinkClick`, + /// `onSandboxNewTab`) and runs the capture pipeline. + /// 2. Keeps the native `SandboxCaptureRegistry` and the on-disk JSON mirror + /// in sync with the `capture_tab` table via a DAO subscription. + /// 3. Listens on `CaptureServer.retryRequests` and re-runs failed captures. + /// + /// This provider is kept alive for the lifetime of the app; `build()` wires + /// up the subscriptions and returns nothing interesting. + SandboxCaptureControllerProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'sandboxCaptureControllerProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$sandboxCaptureControllerHash(); + + @$internal + @override + SandboxCaptureController create() => SandboxCaptureController(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(void value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$sandboxCaptureControllerHash() => + r'fb582cb09504dafd1b9addc1bfc0e6f58fabc9b1'; + +/// Orchestrates sandbox capture browsing: +/// +/// 1. Listens for dispatch events from Kotlin (`onSandboxLinkClick`, +/// `onSandboxNewTab`) and runs the capture pipeline. +/// 2. Keeps the native `SandboxCaptureRegistry` and the on-disk JSON mirror +/// in sync with the `capture_tab` table via a DAO subscription. +/// 3. Listens on `CaptureServer.retryRequests` and re-runs failed captures. +/// +/// This provider is kept alive for the lifetime of the app; `build()` wires +/// up the subscriptions and returns nothing interesting. + +abstract class _$SandboxCaptureController extends $Notifier { + void build(); + @$mustCallSuper + @override + void runBuild() { + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + void, + Object?, + Object? + >; + element.handleCreate(ref, build); + } +} diff --git a/apps/weblibre/lib/features/web_search/domain/controllers/search_controller.dart b/apps/weblibre/lib/features/web_search/domain/controllers/search_controller.dart new file mode 100644 index 00000000..9417fa53 --- /dev/null +++ b/apps/weblibre/lib/features/web_search/domain/controllers/search_controller.dart @@ -0,0 +1,1234 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:riverpod/riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:search_backend/search_backend.dart'; +import 'package:search_client/search_client.dart'; +import 'package:weblibre/domain/services/generic_website.dart'; +import 'package:weblibre/features/search_credits/domain/controllers/search_token_issuance_controller.dart'; +import 'package:weblibre/features/search_credits/domain/providers.dart'; +import 'package:weblibre/features/search_credits/domain/providers/proxy_client.dart'; +import 'package:weblibre/features/search_credits/domain/repositories/search_token_stash_repository.dart'; +import 'package:weblibre/features/search_credits/domain/repositories/web_search_settings.dart'; +import 'package:weblibre/features/tor/domain/services/tor_proxy.dart'; +import 'package:weblibre/features/tor/presentation/controllers/start_tor_proxy.dart'; +import 'package:weblibre/features/web_search/domain/entities/captured_page_state.dart'; +import 'package:weblibre/features/web_search/domain/entities/fetch_method.dart'; +import 'package:weblibre/features/web_search/domain/services/capture_artifact_downloader.dart'; + +part 'search_controller.g.dart'; + +enum WebSearchStatus { idle, needsCredits, submitting, ready, error } + +const _unset = Object(); + +class MetaSearchState { + final WebSearchStatus status; + final String query; + final List infos; + final List results; + final Map imagesByUrl; + final Map documentsByUrl; + final Set fetchingUrls; + final Map> capturingByUrl; + final Map> capturedPagesByUrl; + // Per-URL trafilatura fetch errors. Capture-method errors live on + // CapturedPageState.errorMessage (status == downloadFailed); this map + // only covers the trafilatura "Preview" path which has no per-method + // CapturedPageState entry. + final Map fetchErrorByUrl; + final String? errorMessage; + final bool hasOpenSession; + // Index of the last page delivered by the backend (0-based). Reset to 0 + // on every fresh submit; advanced as later pages stream in. + final int currentPage; + final bool hasMore; + // True while a loadPage command is in flight. Cleared when the next page + // arrives or the session closes/errors. + final bool isLoadingMore; + + const MetaSearchState({ + this.status = WebSearchStatus.idle, + this.query = '', + this.infos = const [], + this.results = const [], + this.imagesByUrl = const {}, + this.documentsByUrl = const {}, + this.fetchingUrls = const {}, + this.capturingByUrl = const {}, + this.capturedPagesByUrl = const {}, + this.fetchErrorByUrl = const {}, + this.errorMessage, + this.hasOpenSession = false, + this.currentPage = 0, + this.hasMore = false, + this.isLoadingMore = false, + }); + + bool get isSubmitting => status == WebSearchStatus.submitting; + + bool isFetched(Uri url) => documentsByUrl.containsKey(url); + + bool isFetching(Uri url) => fetchingUrls.contains(url); + + String? fetchError(Uri url) => fetchErrorByUrl[url]; + + bool isCapturing(Uri url, FetchMethodChoice method) => + capturingByUrl[url]?.contains(method) ?? false; + + bool isAnyCapturing(Uri url) => capturingByUrl[url]?.isNotEmpty ?? false; + + CapturedPageState? capturedPage(Uri url, FetchMethodChoice method) => + capturedPagesByUrl[url]?[method]; + + Map capturesFor(Uri url) => + capturedPagesByUrl[url] ?? const {}; + + bool isMethodBusy(Uri url, FetchMethodChoice method) { + if (method == FetchMethodChoice.trafilatura) { + return isFetching(url); + } + if (isCapturing(url, method)) { + return true; + } + final captured = capturedPage(url, method); + return captured != null && + (captured.status == CapturedPageStatus.capturing || + captured.status == CapturedPageStatus.downloading); + } + + bool isMethodReady(Uri url, FetchMethodChoice method) { + if (method == FetchMethodChoice.trafilatura) { + return isFetched(url); + } + return capturedPage(url, method)?.status == CapturedPageStatus.ready; + } + + MetaSearchState copyWith({ + WebSearchStatus? status, + String? query, + List? infos, + List? results, + Map? imagesByUrl, + Map? documentsByUrl, + Set? fetchingUrls, + Map>? capturingByUrl, + Map>? capturedPagesByUrl, + Map? fetchErrorByUrl, + Object? errorMessage = _unset, + bool? hasOpenSession, + int? currentPage, + bool? hasMore, + bool? isLoadingMore, + }) { + return MetaSearchState( + status: status ?? this.status, + query: query ?? this.query, + infos: infos ?? this.infos, + results: results ?? this.results, + imagesByUrl: imagesByUrl ?? this.imagesByUrl, + documentsByUrl: documentsByUrl ?? this.documentsByUrl, + fetchingUrls: fetchingUrls ?? this.fetchingUrls, + capturingByUrl: capturingByUrl ?? this.capturingByUrl, + capturedPagesByUrl: capturedPagesByUrl ?? this.capturedPagesByUrl, + fetchErrorByUrl: fetchErrorByUrl ?? this.fetchErrorByUrl, + errorMessage: errorMessage == _unset + ? this.errorMessage + : errorMessage as String?, + hasOpenSession: hasOpenSession ?? this.hasOpenSession, + currentPage: currentPage ?? this.currentPage, + hasMore: hasMore ?? this.hasMore, + isLoadingMore: isLoadingMore ?? this.isLoadingMore, + ); + } +} + +abstract interface class MetaSearchSession { + Stream get messages; + + bool get isClosed; + + void submitQuery( + String query, { + required SearchMode mode, + String? language, + String? region, + SafeSearch? safeSearch, + TimeRange? timeRange, + }); + + void fetchPage(Uri url); + + void capturePage( + Uri url, { + required String method, + required String variant, + CaptureDimensions? dimensions, + }); + + void loadPage(int page); + + Future close(); +} + +final class LiveMetaSearchSession implements MetaSearchSession { + final SearchSession _session; + + const LiveMetaSearchSession(this._session); + + @override + Stream get messages => _session.messages; + + @override + bool get isClosed => _session.isClosed; + + @override + void submitQuery( + String query, { + required SearchMode mode, + String? language, + String? region, + SafeSearch? safeSearch, + TimeRange? timeRange, + }) { + _session.submitQuery( + query, + mode: mode, + language: language, + region: region, + safeSearch: safeSearch, + timeRange: timeRange, + ); + } + + @override + void fetchPage(Uri url) { + _session.fetchPage(url); + } + + @override + void capturePage( + Uri url, { + required String method, + required String variant, + CaptureDimensions? dimensions, + }) { + _session.capturePage( + url, + method: method, + variant: variant, + dimensions: dimensions, + ); + } + + @override + void loadPage(int page) { + _session.loadPage(page); + } + + @override + Future close() => _session.close(); +} + +typedef MetaSearchEnsureTokenAvailable = + Future Function(); +typedef MetaSearchSessionFactory = Future Function(); + +final metaSearchEnsureTokenAvailableProvider = + Provider((ref) { + return () => ref.read(searchTokenAvailabilityProvider).ensureAvailable(); + }); + +final metaSearchSessionFactoryProvider = Provider(( + ref, +) { + return () async { + final session = await SearchSession.openFromStash( + endpoints: ref.read(searchBackendEndpointsProvider), + stash: ref.read(searchTokenStashProvider), + logger: ref.read(searchClientLoggerProvider), + customClient: ref.read(searchHttpClientProvider), + ); + return session == null ? null : LiveMetaSearchSession(session); + }; +}); + +@Riverpod(keepAlive: true) +class MetaSearchController extends _$MetaSearchController { + MetaSearchSession? _session; + StreamSubscription? _sub; + // Flipped by ref.onDispose so async tasks (notably HTTP downloads which + // have no cancellation handle of their own) can short-circuit before + // touching state. Cheaper and more deterministic than re-checking + // ref.mounted after every await. + bool _disposed = false; + + @override + MetaSearchState build() { + ref.onDispose(() { + _disposed = true; + unawaited(_closeActiveSession()); + }); + return const MetaSearchState(); + } + + bool isFetched(Uri url) => state.isFetched(url); + + bool isFetching(Uri url) => state.isFetching(url); + + Future submit( + String query, { + SearchMode mode = SearchMode.general, + String? language, + String? region, + SafeSearch? safeSearch, + TimeRange? timeRange, + }) async { + final normalizedQuery = query.trim(); + if (normalizedQuery.isEmpty || state.isSubmitting) { + return; + } + + await _closeActiveSession(); + ref.read(webSearchScrollOffsetProvider.notifier).reset(); + state = MetaSearchState( + status: WebSearchStatus.submitting, + query: normalizedQuery, + ); + + if (!await _ensureTorReadyIfRequested()) { + state = state.copyWith( + status: WebSearchStatus.error, + errorMessage: + 'Could not start Tor for the search. Disable the Tor toggle or try again.', + hasOpenSession: false, + ); + return; + } + + final TokenAvailabilityOutcome availability; + try { + availability = await ref.read(metaSearchEnsureTokenAvailableProvider)(); + } catch (error, stackTrace) { + ref + .read(searchClientLoggerProvider) + .e( + 'Failed to check search credit availability', + error: error, + stackTrace: stackTrace, + ); + state = state.copyWith( + status: WebSearchStatus.error, + errorMessage: 'Could not check search credits. Please try again.', + hasOpenSession: false, + ); + return; + } + switch (availability) { + case TokenAvailabilityOutcome.available: + break; + case TokenAvailabilityOutcome.noCredits: + state = state.copyWith( + status: WebSearchStatus.needsCredits, + hasOpenSession: false, + ); + return; + case TokenAvailabilityOutcome.issuanceFailed: + state = state.copyWith( + status: WebSearchStatus.error, + errorMessage: 'Could not issue search tokens. Please try again.', + hasOpenSession: false, + ); + return; + } + + final sessionFactory = ref.read(metaSearchSessionFactoryProvider); + final session = await sessionFactory(); + + if (session == null) { + state = state.copyWith( + status: WebSearchStatus.needsCredits, + hasOpenSession: false, + ); + return; + } + + _session = session; + final subscription = session.messages.listen( + _handleMessage, + onError: (Object error, StackTrace stackTrace) { + ref + .read(searchClientLoggerProvider) + .e('Search socket error', error: error, stackTrace: stackTrace); + final message = error is SearchSessionCloseException + ? _closeCodeUserMessage(error.closeCode) + : 'Search connection error. Please try again.'; + _applyError(message, sessionClosed: true); + }, + onDone: () { + if (!ref.mounted) { + return; + } + + _session = null; + _sub = null; + + // Guard against a silent close while still waiting for the first + // result (e.g. the server drops the TCP connection without a proper + // WebSocket close frame, so no error event was emitted above). + if (state.status == WebSearchStatus.submitting) { + state = state.copyWith( + status: WebSearchStatus.error, + errorMessage: + 'Search connection closed before results arrived. ' + 'Please try again.', + hasOpenSession: false, + isLoadingMore: false, + ); + } else { + state = state.copyWith(hasOpenSession: false, isLoadingMore: false); + } + }, + ); + _sub = subscription; + state = state.copyWith(hasOpenSession: true); + + try { + session.submitQuery( + normalizedQuery, + mode: mode, + language: language, + region: region, + safeSearch: safeSearch, + timeRange: timeRange, + ); + } catch (error, stackTrace) { + ref + .read(searchClientLoggerProvider) + .e('Failed to submit query', error: error, stackTrace: stackTrace); + // Guarantee the subscription we just attached is torn down even if + // _closeActiveSession races with the onDone callback (which clears + // _sub itself). Cancel the local handle directly and only fall back + // to the field-based close for the SearchSession itself. + unawaited(subscription.cancel()); + if (identical(_sub, subscription)) { + _sub = null; + } + _applyError('Failed to submit query: $error', sessionClosed: true); + unawaited(_closeActiveSession()); + } + } + + Future fetchPage(Uri url) async { + if (state.fetchingUrls.contains(url) || + state.documentsByUrl.containsKey(url)) { + return; + } + + final session = _session; + if (session == null || session.isClosed) { + _applyError( + 'Search session is no longer available. Run the search again.', + failedFetchUrl: url, + sessionClosed: true, + ); + return; + } + + final nextFetching = {...state.fetchingUrls, url}; + final nextFetchErrors = {...state.fetchErrorByUrl}..remove(url); + state = state.copyWith( + fetchingUrls: nextFetching, + fetchErrorByUrl: nextFetchErrors, + errorMessage: null, + ); + + try { + session.fetchPage(url); + } catch (error, stackTrace) { + ref + .read(searchClientLoggerProvider) + .e( + 'Failed to request page fetch', + error: error, + stackTrace: stackTrace, + ); + _applyError('Failed to fetch page: $error', failedFetchUrl: url); + } + } + + Future capturePage( + Uri url, { + required FetchMethodChoice choice, + CaptureDimensions? dimensions, + }) async { + final method = choice.method; + final variant = choice.variant; + if (method == null || variant == null) { + return; + } + + if (state.isCapturing(url, choice)) { + return; + } + final existing = state.capturedPage(url, choice); + if (existing != null && + existing.status != CapturedPageStatus.downloadFailed) { + return; + } + + final session = _session; + if (session == null || session.isClosed) { + _applyError( + 'Search session is no longer available. Run the search again.', + failedFetchUrl: url, + sessionClosed: true, + ); + return; + } + + final nextCapturing = _withMethodAdded(state.capturingByUrl, url, choice); + state = state.copyWith(capturingByUrl: nextCapturing, errorMessage: null); + + try { + session.capturePage( + url, + method: method, + variant: variant, + dimensions: dimensions, + ); + } catch (error, stackTrace) { + ref + .read(searchClientLoggerProvider) + .e( + 'Failed to request page capture', + error: error, + stackTrace: stackTrace, + ); + _applyError('Failed to capture page: $error', failedFetchUrl: url); + } + } + + static Map> _withMethodAdded( + Map> source, + Uri url, + FetchMethodChoice method, + ) { + final next = { + for (final entry in source.entries) entry.key: {...entry.value}, + }; + (next[url] ??= {}).add(method); + return next; + } + + static Map> _withMethodRemoved( + Map> source, + Uri url, + FetchMethodChoice method, + ) { + final next = { + for (final entry in source.entries) entry.key: {...entry.value}, + }; + final bucket = next[url]; + if (bucket != null) { + bucket.remove(method); + if (bucket.isEmpty) { + next.remove(url); + } + } + return next; + } + + static Map> _withCaptureSet( + Map> source, + Uri url, + FetchMethodChoice method, + CapturedPageState value, + ) { + final next = { + for (final entry in source.entries) entry.key: {...entry.value}, + }; + (next[url] ??= {})[method] = value; + return next; + } + + static Map> + _failInProgressCapturesForUrl( + Map> source, + Uri url, { + required Set capturingMethods, + String errorMessage = 'Capture was interrupted', + }) { + final urlCaptures = source[url] ?? const {}; + var changed = false; + final updated = {}; + for (final entry in urlCaptures.entries) { + if (entry.value.status == CapturedPageStatus.capturing || + entry.value.status == CapturedPageStatus.downloading) { + updated[entry.key] = entry.value.copyWith( + status: CapturedPageStatus.downloadFailed, + errorMessage: errorMessage, + ); + changed = true; + } else { + updated[entry.key] = entry.value; + } + } + // Synthesize downloadFailed entries for methods that were mid-capture + // (in capturingByUrl) but for which the backend never produced a + // capture frame — otherwise the busy chip vanishes with no replacement + // and the user is left wondering what happened to the PDF/Image they + // requested. + for (final method in capturingMethods) { + if (updated.containsKey(method)) continue; + updated[method] = CapturedPageState( + sourceUrl: url, + status: CapturedPageStatus.downloadFailed, + errorMessage: errorMessage, + ); + changed = true; + } + if (!changed) return source; + return { + for (final entry in source.entries) + if (entry.key != url) entry.key: entry.value, + url: updated, + }; + } + + static Map> + _failAllInProgressCaptures( + Map> source, { + required Map> capturingByUrl, + String errorMessage = 'Capture was interrupted', + }) { + var changed = false; + final result = >{}; + for (final urlEntry in source.entries) { + final updated = {}; + for (final methodEntry in urlEntry.value.entries) { + if (methodEntry.value.status == CapturedPageStatus.capturing || + methodEntry.value.status == CapturedPageStatus.downloading) { + updated[methodEntry.key] = methodEntry.value.copyWith( + status: CapturedPageStatus.downloadFailed, + errorMessage: errorMessage, + ); + changed = true; + } else { + updated[methodEntry.key] = methodEntry.value; + } + } + result[urlEntry.key] = updated; + } + // Synthesize entries for URLs/methods that were mid-capture but had no + // CapturedPageState yet, so the busy chip is replaced by a red error + // chip instead of vanishing silently. + for (final entry in capturingByUrl.entries) { + final url = entry.key; + final bucket = result[url] ?? {}; + var bucketChanged = false; + for (final method in entry.value) { + if (bucket.containsKey(method)) continue; + bucket[method] = CapturedPageState( + sourceUrl: url, + status: CapturedPageStatus.downloadFailed, + errorMessage: errorMessage, + ); + bucketChanged = true; + } + if (bucketChanged) { + result[url] = bucket; + changed = true; + } + } + if (!changed) return source; + return result; + } + + void reset() { + unawaited(_closeActiveSession()); + ref.read(webSearchScrollOffsetProvider.notifier).reset(); + state = const MetaSearchState(); + } + + /// When the user has opted to route web search through Tor, ensure the + /// Tor proxy is running and a SOCKS port is known before the search + /// session is opened. Returns false if Tor could not be brought up. + Future _ensureTorReadyIfRequested() async { + final route = ref.read(webSearchSettingsControllerProvider).routeThroughTor; + if (!route) return true; + + if (ref.read(searchProxyPortProvider) != null) return true; + + try { + await ref.read(startProxyControllerProvider.notifier).startProxy(); + } catch (error, stackTrace) { + ref + .read(searchClientLoggerProvider) + .e( + 'Failed to start Tor for search', + error: error, + stackTrace: stackTrace, + ); + return false; + } + + // startProxy() returns as soon as the Tor process has been launched, but + // the SOCKS port only becomes usable once bootstrap reaches 100%. + // Pushing the just-launched status into the stream synchronously is not + // enough — at that point bootstrap is still near 0, so + // searchProxyPortProvider stays null and the search fails even though + // Tor is coming up fine. Wait for the status stream to report a fully + // bootstrapped, running proxy before opening the search session. + await ref.read(torProxyServiceProvider.notifier).requestSync(); + if (ref.read(searchProxyPortProvider) != null) return true; + + final completer = Completer(); + final subscription = ref.listen(torProxyServiceProvider, (previous, next) { + final status = next.value; + if (status != null && + status.isRunning && + status.bootstrapProgress >= 100) { + if (!completer.isCompleted) completer.complete(true); + } + }); + + try { + return await completer.future.timeout( + const Duration(seconds: 90), + onTimeout: () => false, + ); + } finally { + subscription.close(); + } + } + + Future _closeActiveSession() async { + final sub = _sub; + final session = _session; + _sub = null; + _session = null; + await sub?.cancel(); + await session?.close(); + } + + void _handleMessage(StreamMessage message) { + switch (message.type) { + case MessageTypes.searchResults: + _safeHandle('search results', () => _handleSearchResults(message.data)); + case MessageTypes.favicon: + _safeHandle('favicon', () => _handleFavicon(message.data)); + case MessageTypes.image: + _safeHandle('image', () => _handleImage(message.data)); + case MessageTypes.document: + _safeHandle('document', () => _handleDocument(message.data)); + case MessageTypes.capture: + _safeHandle('capture', () => _handleCapture(message.data)); + case MessageTypes.checkpoint: + _handleCheckpoint(message.data); + case MessageTypes.error: + _handleStreamError(message.data); + case MessageTypes.documentSnippets: + case MessageTypes.answer: + case MessageTypes.references: + case MessageTypes.evidences: + break; + } + } + + /// Wraps a frame decoder so a malformed payload doesn't tear down the + /// whole socket — every frame handler used to duplicate this try/log/swallow + /// block. Search-results frames get special-cased: if decoding fails + /// mid-loadMore, the spinner needs to be cleared. + void _safeHandle(String frameKind, void Function() body) { + try { + body(); + } catch (error, stackTrace) { + if (frameKind == 'search results' && state.isLoadingMore) { + state = state.copyWith(isLoadingMore: false); + } + ref + .read(searchClientLoggerProvider) + .w( + 'Failed to decode $frameKind frame', + error: error, + stackTrace: stackTrace, + ); + } + } + + void _handleSearchResults(Object payload) { + final raw = payload as Map; + final response = PagedSearchResponse.fromJson(raw); + final isFirstPage = response.page == 0; + state = state.copyWith( + status: WebSearchStatus.ready, + // Page 0 is authoritative for infos; later pages omit them. + infos: isFirstPage ? response.infos : state.infos, + // Page 0 replaces; later pages append. The backend dedupes pages + // server-side (deliveredPages set), so duplicate appends are not + // expected — but if one slips through it surfaces as duplicate + // cards rather than crashing. + results: isFirstPage + ? response.results + : [...state.results, ...response.results], + currentPage: response.page, + hasMore: response.hasMore, + isLoadingMore: false, + errorMessage: null, + ); + } + + /// Request the next page of results from the active session. No-op if + /// already loading, no more pages exist, or the session has closed. + Future loadNextPage() async { + if (state.isLoadingMore || !state.hasMore) { + return; + } + final session = _session; + if (session == null || session.isClosed) { + return; + } + + final nextPage = state.currentPage + 1; + state = state.copyWith(isLoadingMore: true); + try { + session.loadPage(nextPage); + } catch (error, stackTrace) { + ref + .read(searchClientLoggerProvider) + .e( + 'Failed to request next search page', + error: error, + stackTrace: stackTrace, + ); + // Keep existing results visible; just let the UI try again. We do + // NOT mark hasMore=false here — the failure may be transient (e.g. + // a write to a closing socket) and clearing hasMore would + // permanently hide the load-more affordance. + state = state.copyWith(isLoadingMore: false); + } + } + + void _handleFavicon(Object payload) { + final raw = payload as Map; + final authority = raw['authority']?.toString(); + final bytes = raw['bytes']?.toString(); + if (authority == null || authority.isEmpty || bytes == null) { + return; + } + + final decodedBytes = base64Decode(bytes); + + final matchedResults = state.results.where( + (r) => r.url.authority == authority, + ); + + final origins = {}; + for (final result in matchedResults) { + final origin = result.url.origin; + if (origins.add(origin)) { + unawaited( + ref + .read(genericWebsiteServiceProvider.notifier) + .primeCachedIcon(result.url, decodedBytes), + ); + } + } + } + + void _handleImage(Object payload) { + final raw = payload as Map; + final url = raw['url']?.toString(); + final bytes = raw['bytes']?.toString(); + if (url == null || url.isEmpty || bytes == null) { + return; + } + + state = state.copyWith( + imagesByUrl: {...state.imagesByUrl, url: base64Decode(bytes)}, + ); + } + + void _handleDocument(Object payload) { + final raw = payload as Map; + final document = FetchedDocument.fromJson(raw); + final nextFetching = {...state.fetchingUrls}..remove(document.url); + final nextFetchErrors = {...state.fetchErrorByUrl}..remove(document.url); + state = state.copyWith( + documentsByUrl: {...state.documentsByUrl, document.url: document}, + fetchingUrls: nextFetching, + fetchErrorByUrl: nextFetchErrors, + errorMessage: null, + ); + } + + void _handleCapture(Object payload) { + final raw = payload as Map; + final receipt = CaptureArtifactReceipt.fromJson(raw); + final url = receipt.url; + final choice = FetchMethodChoice.forCapture( + method: receipt.method, + variant: receipt.variant, + ); + if (choice == null) { + ref + .read(searchClientLoggerProvider) + .w( + 'Received capture for unknown method/variant: ' + '${receipt.method}/${receipt.variant}', + ); + return; + } + + final captured = CapturedPageState( + sourceUrl: url, + status: CapturedPageStatus.downloading, + captureId: receipt.captureId, + finalUrl: receipt.finalUrl, + filename: receipt.filename, + method: receipt.method, + variant: receipt.variant, + contentType: receipt.contentType, + byteLength: receipt.byteLength, + downloadToken: receipt.downloadToken, + ); + state = state.copyWith( + capturingByUrl: _withMethodRemoved(state.capturingByUrl, url, choice), + capturedPagesByUrl: _withCaptureSet( + state.capturedPagesByUrl, + url, + choice, + captured, + ), + ); + unawaited(_downloadCapture(receipt, choice)); + } + + Future _downloadCapture( + CaptureArtifactReceipt receipt, + FetchMethodChoice choice, + ) async { + final downloader = ref.read(captureArtifactDownloaderProvider); + final url = receipt.url; + try { + final localPath = await downloader.download(receipt); + if (_disposed) { + return; + } + final current = state.capturedPage(url, choice); + if (current == null || current.captureId != receipt.captureId) { + return; + } + state = state.copyWith( + capturedPagesByUrl: _withCaptureSet( + state.capturedPagesByUrl, + url, + choice, + current.copyWith( + status: CapturedPageStatus.ready, + localPath: localPath, + errorMessage: null, + ), + ), + ); + } catch (error, stackTrace) { + if (_disposed) { + return; + } + ref + .read(searchClientLoggerProvider) + .w( + 'Failed to download capture artifact', + error: error, + stackTrace: stackTrace, + ); + final current = state.capturedPage(url, choice); + if (current == null || current.captureId != receipt.captureId) { + return; + } + state = state.copyWith( + capturedPagesByUrl: _withCaptureSet( + state.capturedPagesByUrl, + url, + choice, + current.copyWith( + status: CapturedPageStatus.downloadFailed, + errorMessage: error.toString(), + ), + ), + ); + } + } + + Future retryCaptureDownload(Uri url, FetchMethodChoice choice) async { + final current = state.capturedPage(url, choice); + if (current == null || + current.captureId == null || + current.downloadToken == null) { + return; + } + if (current.status != CapturedPageStatus.downloadFailed) { + return; + } + + state = state.copyWith( + capturedPagesByUrl: _withCaptureSet( + state.capturedPagesByUrl, + url, + choice, + current.copyWith( + status: CapturedPageStatus.downloading, + errorMessage: null, + ), + ), + ); + + await _downloadCapture( + CaptureArtifactReceipt( + captureId: current.captureId!, + url: url, + method: current.method!, + variant: current.variant!, + contentType: current.contentType!, + byteLength: current.byteLength ?? 0, + downloadToken: current.downloadToken!, + filename: current.filename, + finalUrl: current.finalUrl, + ), + choice, + ); + } + + void _handleCheckpoint(Object payload) { + if (payload is! Map) { + return; + } + + final urlString = payload['url']?.toString(); + final checkpointName = payload['checkpoint']?.toString(); + final url = urlString == null ? null : Uri.tryParse(urlString); + if (url == null || checkpointName == null) { + return; + } + + // `captureStarted`/`captureFinished` are intentionally ignored: the + // checkpoint payload does not carry method/variant info, so per-method + // capture state is driven instead by capturePage() / the capture frame. + switch (checkpointName) { + case 'documentFetchStarted': + state = state.copyWith(fetchingUrls: {...state.fetchingUrls, url}); + case 'documentFetchFinished': + state = state.copyWith( + fetchingUrls: {...state.fetchingUrls}..remove(url), + ); + } + } + + static String _closeCodeUserMessage(int code) { + return switch (code) { + 4001 => 'Search session timed out. Please try again.', + 4401 => + 'Your search credit could not be validated. ' + 'The credit may have been spent — please try again.', + 4403 => 'The requested page is not permitted by the search policy.', + 4500 => 'The search failed on the server. Please try again.', + _ => + 'Search connection closed unexpectedly (code $code). ' + 'Please try again.', + }; + } + + /// Translate a backend error `code` (see search_handler.dart on the server + /// for the full set: `protocol_error`, `search_failed`, `fetch_failed`, + /// `fetch_not_allowed`, `capture_failed`, `document_extract_failed`) into + /// a user-facing message. The raw server `detail` is appended so the + /// detail dialog has something specific to show. + static String _userMessageForStreamError(String? code, String detail) { + final fallbackDetail = detail.isEmpty ? 'unknown error' : detail; + return switch (code) { + 'protocol_error' => + 'Search protocol error. The session has ended — please try ' + 'again. ($fallbackDetail)', + 'search_failed' => + 'The search failed on the server. Please try again. ' + '($fallbackDetail)', + 'fetch_failed' => + 'Could not fetch this page from the source. ($fallbackDetail)', + 'document_extract_failed' => + 'Could not extract a readable preview from this page. ' + '($fallbackDetail)', + 'fetch_not_allowed' => + 'This page is not permitted by the search policy. ' + '($fallbackDetail)', + 'capture_failed' => 'Page capture failed. ($fallbackDetail)', + _ => 'Search error: $fallbackDetail', + }; + } + + /// True for codes that always tear down the WebSocket on the server side + /// (a close frame follows). These can never be per-URL recoverable, so we + /// fold them into a session-fatal error path even when a `url` is present + /// in the payload (which can happen for `fetch_not_allowed`). + static bool _isSessionFatalCode(String? code) { + return code == 'protocol_error' || code == 'search_failed'; + } + + void _handleStreamError(Object payload) { + final map = payload is Map ? payload : null; + final code = map?['code']?.toString(); + final detail = map?['message']?.toString() ?? payload.toString(); + final urlString = map?['url']?.toString(); + final explicitUrl = urlString != null ? Uri.tryParse(urlString) : null; + final message = _userMessageForStreamError(code, detail); + + ref + .read(searchClientLoggerProvider) + .w( + 'Search stream error frame: code=$code, url=$urlString, ' + 'detail=$detail', + ); + + if (_isSessionFatalCode(code)) { + _applyError(message, sessionClosed: true); + return; + } + + if (explicitUrl != null) { + _applyPerUrlError(explicitUrl, code, message); + return; + } + + if (state.fetchingUrls.isNotEmpty || + state.capturingByUrl.isNotEmpty || + state.results.isNotEmpty) { + final inferredUrl = _singleBusyUrlOrNull(); + if (inferredUrl != null) { + _applyPerUrlError(inferredUrl, code, message); + return; + } + _applyError(message, failedFetchUrl: null); + return; + } + + _applyError(message, sessionClosed: true); + } + + /// Route an error frame that names (or can be inferred to belong to) a + /// single URL into per-URL state: clear that URL's in-flight bookkeeping, + /// stamp `fetchErrorByUrl` for the trafilatura preview path, and convert + /// any in-progress capture for the same URL into a `downloadFailed` + /// CapturedPageState carrying the same message. The session itself is + /// preserved (search results stay visible) for non-fatal codes. + void _applyPerUrlError(Uri url, String? code, String message) { + final nextFetching = {...state.fetchingUrls}..remove(url); + final capturingForUrl = + state.capturingByUrl[url] ?? const {}; + final nextCapturing = { + for (final entry in state.capturingByUrl.entries) + entry.key: {...entry.value}, + }..remove(url); + + final nextCapturedPages = _failInProgressCapturesForUrl( + state.capturedPagesByUrl, + url, + capturingMethods: capturingForUrl, + errorMessage: message, + ); + + // Trafilatura ("Preview") doesn't have a CapturedPageState entry to hold + // an error; keep its failure on a side map so the chip can render red + // and a tap can pop the detail dialog. + final nextFetchErrors = {...state.fetchErrorByUrl, url: message}; + + final nextStatus = state.results.isEmpty + ? WebSearchStatus.error + : WebSearchStatus.ready; + + state = state.copyWith( + status: nextStatus, + fetchingUrls: nextFetching, + capturingByUrl: nextCapturing, + capturedPagesByUrl: nextCapturedPages, + fetchErrorByUrl: nextFetchErrors, + errorMessage: message, + ); + } + + Uri? _singleBusyUrlOrNull() { + final allBusy = {...state.fetchingUrls, ...state.capturingByUrl.keys}; + if (allBusy.length == 1) { + return allBusy.first; + } + return null; + } + + void _applyError( + String message, { + Uri? failedFetchUrl, + bool sessionClosed = false, + }) { + final nextFetching = {...state.fetchingUrls}; + var nextCapturing = { + for (final entry in state.capturingByUrl.entries) + entry.key: {...entry.value}, + }; + Map> nextCapturedPages = + state.capturedPagesByUrl; + if (failedFetchUrl != null) { + final capturingForUrl = + state.capturingByUrl[failedFetchUrl] ?? const {}; + nextFetching.remove(failedFetchUrl); + nextCapturing.remove(failedFetchUrl); + nextCapturedPages = _failInProgressCapturesForUrl( + nextCapturedPages, + failedFetchUrl, + capturingMethods: capturingForUrl, + errorMessage: message, + ); + } else if (state.results.isNotEmpty || + nextFetching.isNotEmpty || + nextCapturing.isNotEmpty) { + nextFetching.clear(); + final priorCapturing = nextCapturing; + nextCapturing = {}; + nextCapturedPages = _failAllInProgressCaptures( + nextCapturedPages, + capturingByUrl: priorCapturing, + errorMessage: message, + ); + } + + final nextStatus = state.results.isEmpty + ? WebSearchStatus.error + : WebSearchStatus.ready; + + state = state.copyWith( + status: nextStatus, + fetchingUrls: nextFetching, + capturingByUrl: nextCapturing, + capturedPagesByUrl: nextCapturedPages, + errorMessage: message, + hasOpenSession: !sessionClosed && state.hasOpenSession, + // A non-URL session error during loadPage (e.g. page_out_of_range, + // protocol_error) leaves the loadMore spinner stuck otherwise. + isLoadingMore: false, + ); + } +} + +/// Persisted vertical scroll offset of the web-search results list. Kept +/// alive (like [MetaSearchController]) so returning to the search screen +/// after opening a result restores the user's place instead of jumping +/// back to the top. Reset to 0 on every fresh submit and on reset(). +@Riverpod(keepAlive: true) +class WebSearchScrollOffset extends _$WebSearchScrollOffset { + @override + double build() => 0; + + void update(double offset) => state = offset; + + void reset() => state = 0; +} diff --git a/apps/weblibre/lib/features/web_search/domain/controllers/search_controller.g.dart b/apps/weblibre/lib/features/web_search/domain/controllers/search_controller.g.dart new file mode 100644 index 00000000..ec51c859 --- /dev/null +++ b/apps/weblibre/lib/features/web_search/domain/controllers/search_controller.g.dart @@ -0,0 +1,134 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'search_controller.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(MetaSearchController) +final metaSearchControllerProvider = MetaSearchControllerProvider._(); + +final class MetaSearchControllerProvider + extends $NotifierProvider { + MetaSearchControllerProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'metaSearchControllerProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$metaSearchControllerHash(); + + @$internal + @override + MetaSearchController create() => MetaSearchController(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(MetaSearchState value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$metaSearchControllerHash() => + r'b040527dfb49d9407fe0ea22169f5961d1faef13'; + +abstract class _$MetaSearchController extends $Notifier { + MetaSearchState build(); + @$mustCallSuper + @override + void runBuild() { + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + MetaSearchState, + Object?, + Object? + >; + element.handleCreate(ref, build); + } +} + +/// Persisted vertical scroll offset of the web-search results list. Kept +/// alive (like [MetaSearchController]) so returning to the search screen +/// after opening a result restores the user's place instead of jumping +/// back to the top. Reset to 0 on every fresh submit and on reset(). + +@ProviderFor(WebSearchScrollOffset) +final webSearchScrollOffsetProvider = WebSearchScrollOffsetProvider._(); + +/// Persisted vertical scroll offset of the web-search results list. Kept +/// alive (like [MetaSearchController]) so returning to the search screen +/// after opening a result restores the user's place instead of jumping +/// back to the top. Reset to 0 on every fresh submit and on reset(). +final class WebSearchScrollOffsetProvider + extends $NotifierProvider { + /// Persisted vertical scroll offset of the web-search results list. Kept + /// alive (like [MetaSearchController]) so returning to the search screen + /// after opening a result restores the user's place instead of jumping + /// back to the top. Reset to 0 on every fresh submit and on reset(). + WebSearchScrollOffsetProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'webSearchScrollOffsetProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$webSearchScrollOffsetHash(); + + @$internal + @override + WebSearchScrollOffset create() => WebSearchScrollOffset(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(double value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$webSearchScrollOffsetHash() => + r'8d622d93aba7712b2bf0e6da7cbde6c90ee00c07'; + +/// Persisted vertical scroll offset of the web-search results list. Kept +/// alive (like [MetaSearchController]) so returning to the search screen +/// after opening a result restores the user's place instead of jumping +/// back to the top. Reset to 0 on every fresh submit and on reset(). + +abstract class _$WebSearchScrollOffset extends $Notifier { + double build(); + @$mustCallSuper + @override + void runBuild() { + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + double, + Object?, + Object? + >; + element.handleCreate(ref, build); + } +} diff --git a/apps/weblibre/lib/features/web_search/domain/entities/captured_page_state.dart b/apps/weblibre/lib/features/web_search/domain/entities/captured_page_state.dart new file mode 100644 index 00000000..29ba43bb --- /dev/null +++ b/apps/weblibre/lib/features/web_search/domain/entities/captured_page_state.dart @@ -0,0 +1,62 @@ +import 'package:copy_with_extension/copy_with_extension.dart'; +import 'package:fast_equatable/fast_equatable.dart'; + +part 'captured_page_state.g.dart'; + +enum CapturedPageStatus { capturing, downloading, ready, downloadFailed } + +@CopyWith() +class CapturedPageState with FastEquatable { + final Uri sourceUrl; + final CapturedPageStatus status; + final String? captureId; + final Uri? finalUrl; + final String? filename; + + /// Capture engine used: `singlefile` or `shot-scraper`. + final String? method; + + /// Engine-specific selector — preset (singlefile) or mode (shot-scraper). + final String? variant; + + /// MIME type returned by the backend (e.g. `text/html`, `application/pdf`, + /// `image/png`). Used to choose the right file extension and to pick the + /// right viewer when opening the artifact locally. + final String? contentType; + + final int? byteLength; + final String? localPath; + final String? downloadToken; + final String? errorMessage; + + CapturedPageState({ + required this.sourceUrl, + required this.status, + this.captureId, + this.finalUrl, + this.filename, + this.method, + this.variant, + this.contentType, + this.byteLength, + this.localPath, + this.downloadToken, + this.errorMessage, + }); + + @override + List get hashParameters => [ + sourceUrl, + status, + captureId, + finalUrl, + filename, + method, + variant, + contentType, + byteLength, + localPath, + downloadToken, + errorMessage, + ]; +} diff --git a/apps/weblibre/lib/features/web_search/domain/entities/captured_page_state.g.dart b/apps/weblibre/lib/features/web_search/domain/entities/captured_page_state.g.dart new file mode 100644 index 00000000..ea918702 --- /dev/null +++ b/apps/weblibre/lib/features/web_search/domain/entities/captured_page_state.g.dart @@ -0,0 +1,184 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'captured_page_state.dart'; + +// ************************************************************************** +// CopyWithGenerator +// ************************************************************************** + +abstract class _$CapturedPageStateCWProxy { + CapturedPageState sourceUrl(Uri sourceUrl); + + CapturedPageState status(CapturedPageStatus status); + + CapturedPageState captureId(String? captureId); + + CapturedPageState finalUrl(Uri? finalUrl); + + CapturedPageState filename(String? filename); + + CapturedPageState method(String? method); + + CapturedPageState variant(String? variant); + + CapturedPageState contentType(String? contentType); + + CapturedPageState byteLength(int? byteLength); + + CapturedPageState localPath(String? localPath); + + CapturedPageState downloadToken(String? downloadToken); + + CapturedPageState errorMessage(String? errorMessage); + + /// Creates a new instance with the provided field values. + /// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `CapturedPageState(...).copyWith.fieldName(value)`. + /// + /// Example: + /// ```dart + /// CapturedPageState(...).copyWith(id: 12, name: "My name") + /// ``` + CapturedPageState call({ + Uri sourceUrl, + CapturedPageStatus status, + String? captureId, + Uri? finalUrl, + String? filename, + String? method, + String? variant, + String? contentType, + int? byteLength, + String? localPath, + String? downloadToken, + String? errorMessage, + }); +} + +/// Callable proxy for `copyWith` functionality. +/// Use as `instanceOfCapturedPageState.copyWith(...)` or call `instanceOfCapturedPageState.copyWith.fieldName(value)` for a single field. +class _$CapturedPageStateCWProxyImpl implements _$CapturedPageStateCWProxy { + const _$CapturedPageStateCWProxyImpl(this._value); + + final CapturedPageState _value; + + @override + CapturedPageState sourceUrl(Uri sourceUrl) => call(sourceUrl: sourceUrl); + + @override + CapturedPageState status(CapturedPageStatus status) => call(status: status); + + @override + CapturedPageState captureId(String? captureId) => call(captureId: captureId); + + @override + CapturedPageState finalUrl(Uri? finalUrl) => call(finalUrl: finalUrl); + + @override + CapturedPageState filename(String? filename) => call(filename: filename); + + @override + CapturedPageState method(String? method) => call(method: method); + + @override + CapturedPageState variant(String? variant) => call(variant: variant); + + @override + CapturedPageState contentType(String? contentType) => + call(contentType: contentType); + + @override + CapturedPageState byteLength(int? byteLength) => call(byteLength: byteLength); + + @override + CapturedPageState localPath(String? localPath) => call(localPath: localPath); + + @override + CapturedPageState downloadToken(String? downloadToken) => + call(downloadToken: downloadToken); + + @override + CapturedPageState errorMessage(String? errorMessage) => + call(errorMessage: errorMessage); + + @override + /// Creates a new instance with the provided field values. + /// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `CapturedPageState(...).copyWith.fieldName(value)`. + /// + /// Example: + /// ```dart + /// CapturedPageState(...).copyWith(id: 12, name: "My name") + /// ``` + CapturedPageState call({ + Object? sourceUrl = const $CopyWithPlaceholder(), + Object? status = const $CopyWithPlaceholder(), + Object? captureId = const $CopyWithPlaceholder(), + Object? finalUrl = const $CopyWithPlaceholder(), + Object? filename = const $CopyWithPlaceholder(), + Object? method = const $CopyWithPlaceholder(), + Object? variant = const $CopyWithPlaceholder(), + Object? contentType = const $CopyWithPlaceholder(), + Object? byteLength = const $CopyWithPlaceholder(), + Object? localPath = const $CopyWithPlaceholder(), + Object? downloadToken = const $CopyWithPlaceholder(), + Object? errorMessage = const $CopyWithPlaceholder(), + }) { + return CapturedPageState( + sourceUrl: sourceUrl == const $CopyWithPlaceholder() || sourceUrl == null + ? _value.sourceUrl + // ignore: cast_nullable_to_non_nullable + : sourceUrl as Uri, + status: status == const $CopyWithPlaceholder() || status == null + ? _value.status + // ignore: cast_nullable_to_non_nullable + : status as CapturedPageStatus, + captureId: captureId == const $CopyWithPlaceholder() + ? _value.captureId + // ignore: cast_nullable_to_non_nullable + : captureId as String?, + finalUrl: finalUrl == const $CopyWithPlaceholder() + ? _value.finalUrl + // ignore: cast_nullable_to_non_nullable + : finalUrl as Uri?, + filename: filename == const $CopyWithPlaceholder() + ? _value.filename + // ignore: cast_nullable_to_non_nullable + : filename as String?, + method: method == const $CopyWithPlaceholder() + ? _value.method + // ignore: cast_nullable_to_non_nullable + : method as String?, + variant: variant == const $CopyWithPlaceholder() + ? _value.variant + // ignore: cast_nullable_to_non_nullable + : variant as String?, + contentType: contentType == const $CopyWithPlaceholder() + ? _value.contentType + // ignore: cast_nullable_to_non_nullable + : contentType as String?, + byteLength: byteLength == const $CopyWithPlaceholder() + ? _value.byteLength + // ignore: cast_nullable_to_non_nullable + : byteLength as int?, + localPath: localPath == const $CopyWithPlaceholder() + ? _value.localPath + // ignore: cast_nullable_to_non_nullable + : localPath as String?, + downloadToken: downloadToken == const $CopyWithPlaceholder() + ? _value.downloadToken + // ignore: cast_nullable_to_non_nullable + : downloadToken as String?, + errorMessage: errorMessage == const $CopyWithPlaceholder() + ? _value.errorMessage + // ignore: cast_nullable_to_non_nullable + : errorMessage as String?, + ); + } +} + +extension $CapturedPageStateCopyWith on CapturedPageState { + /// Returns a callable class used to build a new instance with modified fields. + /// Example: `instanceOfCapturedPageState.copyWith(...)` or `instanceOfCapturedPageState.copyWith.fieldName(...)`. + // ignore: library_private_types_in_public_api + _$CapturedPageStateCWProxy get copyWith => + _$CapturedPageStateCWProxyImpl(this); +} diff --git a/apps/weblibre/lib/features/web_search/domain/entities/fetch_method.dart b/apps/weblibre/lib/features/web_search/domain/entities/fetch_method.dart new file mode 100644 index 00000000..cb91e39e --- /dev/null +++ b/apps/weblibre/lib/features/web_search/domain/entities/fetch_method.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; + +/// Capture pipeline selector. Each choice maps to a (method, variant) pair +/// understood by the search backend's capture clients. +enum FetchMethodChoice { + /// Trafilatura — extracted reader-mode text + metadata. Not a "capture" + /// per se; goes through the `fetchPage` command. + trafilatura(method: null, variant: null), + + /// SingleFile — self-contained HTML archive. + singlefileHtml(method: 'singlefile', variant: 'balanced'), + + /// shot-scraper — PDF rendering. + shotScraperPdf(method: 'shot-scraper', variant: 'pdf'), + + /// shot-scraper — PNG screenshot. + shotScraperPng(method: 'shot-scraper', variant: 'png'); + + const FetchMethodChoice({required this.method, required this.variant}); + + final String? method; + final String? variant; + + static FetchMethodChoice? forCapture({ + required String method, + required String variant, + }) { + for (final choice in FetchMethodChoice.values) { + if (choice.method == method && choice.variant == variant) { + return choice; + } + } + return null; + } + + String get title => switch (this) { + FetchMethodChoice.trafilatura => 'Extracted Preview', + FetchMethodChoice.singlefileHtml => 'Full Page Capture', + FetchMethodChoice.shotScraperPdf => 'PDF Snapshot', + FetchMethodChoice.shotScraperPng => 'Image Snapshot', + }; + + String get shortLabel => switch (this) { + FetchMethodChoice.trafilatura => 'Preview', + FetchMethodChoice.singlefileHtml => 'Archive', + FetchMethodChoice.shotScraperPdf => 'PDF', + FetchMethodChoice.shotScraperPng => 'Image', + }; + + String get subtitle => switch (this) { + FetchMethodChoice.trafilatura => + 'Reader-optimized text and metadata for the in-app preview', + FetchMethodChoice.singlefileHtml => + 'Archive the full page with layout and assets for later use', + FetchMethodChoice.shotScraperPdf => + 'Render the page to a PDF for offline reading and sharing', + FetchMethodChoice.shotScraperPng => + 'Capture a full-page PNG screenshot of the rendered page', + }; + + IconData get icon => switch (this) { + FetchMethodChoice.trafilatura => Icons.description_outlined, + FetchMethodChoice.singlefileHtml => Icons.archive_outlined, + FetchMethodChoice.shotScraperPdf => Icons.picture_as_pdf_outlined, + FetchMethodChoice.shotScraperPng => Icons.image_outlined, + }; +} diff --git a/apps/weblibre/lib/features/web_search/domain/services/capture_artifact_downloader.dart b/apps/weblibre/lib/features/web_search/domain/services/capture_artifact_downloader.dart new file mode 100644 index 00000000..b9b4d68e --- /dev/null +++ b/apps/weblibre/lib/features/web_search/domain/services/capture_artifact_downloader.dart @@ -0,0 +1,213 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:ui'; + +import 'package:http/http.dart' as http; +import 'package:path/path.dart' as p; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:search_backend/search_backend.dart'; +import 'package:search_client/search_client.dart'; +import 'package:weblibre/core/filesystem.dart'; +import 'package:weblibre/features/search_credits/domain/providers.dart'; +import 'package:weblibre/features/search_credits/domain/providers/proxy_client.dart'; + +part 'capture_artifact_downloader.g.dart'; + +class CaptureArtifactDownloadException implements Exception { + final String message; + final int? statusCode; + + CaptureArtifactDownloadException(this.message, {this.statusCode}); + + @override + String toString() => + 'CaptureArtifactDownloadException: $message' + '${statusCode != null ? ' (status $statusCode)' : ''}'; +} + +class CaptureArtifactDownloader { + CaptureArtifactDownloader({ + required BackendEndpoints endpoints, + required Directory storageDir, + http.Client? client, + }) : _endpoints = endpoints, + _storageDir = storageDir, + _client = client ?? http.Client(); + + final BackendEndpoints _endpoints; + final Directory _storageDir; + final http.Client _client; + + /// Bound on the artifact download — covers connect + read combined. The + /// download path runs through the proxy client (which may be Tor), so a + /// stuck connection would otherwise leave the UI in `downloading` forever. + static const _downloadTimeout = Duration(seconds: 60); + + Future download(CaptureArtifactReceipt receipt) async { + await _storageDir.create(recursive: true); + + final extension = captureFileExtension(receipt.contentType); + if (extension == null) { + // No on-disk extension we know how to serve back to Gecko later — the + // loopback capture server only probes a fixed set of extensions and an + // `.bin` artifact would be orphaned on disk. Surface the failure + // upstream instead of silently writing a file that can never load. + throw CaptureArtifactDownloadException( + 'unsupported capture content type: ${receipt.contentType}', + ); + } + final targetPath = p.join( + _storageDir.path, + '${receipt.captureId}$extension', + ); + final tmpPath = '$targetPath.part'; + + final target = File(targetPath); + if (await target.exists()) { + return targetPath; + } + + final uri = _endpoints.captureDownload(receipt.captureId); + + final request = http.Request('GET', uri); + request.headers[HttpHeaders.authorizationHeader] = + 'Bearer ${receipt.downloadToken}'; + final streamed = await _client.send(request).timeout(_downloadTimeout); + + if (streamed.statusCode != HttpStatus.ok) { + throw CaptureArtifactDownloadException( + 'capture artifact download failed', + statusCode: streamed.statusCode, + ); + } + + final tmp = File(tmpPath); + final sink = tmp.openWrite(); + try { + await streamed.stream.pipe(sink).timeout(_downloadTimeout); + } catch (e) { + await sink.close(); + if (await tmp.exists()) { + await tmp.delete(); + } + rethrow; + } + + await tmp.rename(targetPath); + return targetPath; + } +} + +/// Directory under the app temp area where capture artifacts are stored. +Directory captureStorageDirectory() { + return Directory( + p.join( + filesystem.tempDir.path, + 'web_search_captures', + filesystem.selectedProfile.uuid, + ), + ); +} + +/// Single source of truth for the capture artifact MIME ↔ extension mapping. +/// +/// The capture server's loopback handler probes for exactly these extensions +/// on disk (see [resolveCaptureExtensions]) and looks up the response +/// `Content-Type` against this list (see [captureContentTypeForExtension]), +/// so adding a new artifact format only requires editing this one table. +const _captureArtifactTypes = <({String mime, String extension})>[ + (mime: 'text/html', extension: '.html'), + (mime: 'application/pdf', extension: '.pdf'), + (mime: 'image/png', extension: '.png'), + (mime: 'image/jpeg', extension: '.jpg'), +]; + +/// File extensions the capture server should probe when resolving a stored +/// artifact (HTML + `.jpeg` alias for `image/jpeg`). +const captureSupportedExtensions = [ + '.html', + '.pdf', + '.png', + '.jpg', + '.jpeg', +]; + +/// Maps a capture receipt's MIME [contentType] to the on-disk extension we +/// store the artifact under. The extension is also what the loopback capture +/// server uses to pick the right `Content-Type` when serving back to Gecko. +/// +/// Returns `null` for an unknown content type — the caller (downloader) +/// surfaces this as a download error rather than writing an orphan `.bin` +/// the loopback server can't serve. +String? captureFileExtension(String? contentType) { + if (contentType == null) return null; + final mime = contentType.split(';').first.trim().toLowerCase(); + for (final type in _captureArtifactTypes) { + if (type.mime == mime) return type.extension; + } + return null; +} + +/// True if [contentType] designates an HTML artifact. The sandbox-capture +/// flow (loopback redirects, retry loader, link-click interception) only +/// applies to HTML — PDF / PNG artifacts are loaded directly in a regular +/// tab so Gecko's built-in viewer renders them. +bool isHtmlCaptureContentType(String? contentType) { + if (contentType == null) return false; + return contentType.split(';').first.trim().toLowerCase() == 'text/html'; +} + +/// Snapshot of the host display, fed to the capture engines as +/// `CaptureDimensions` so PDF/PNG renders match the user's viewport. +/// +/// `mobile` is always `true` so captures emulate a mobile viewport. +/// `colorScheme` follows the platform brightness so dark-mode sites render +/// correctly. +CaptureDimensions currentDisplayCaptureDimensions() { + final view = + PlatformDispatcher.instance.implicitView ?? + PlatformDispatcher.instance.views.first; + final dpr = view.devicePixelRatio; + final physical = view.physicalSize; + final colorScheme = + PlatformDispatcher.instance.platformBrightness == Brightness.dark + ? 'dark' + : 'light'; + if (dpr <= 0 || physical.width <= 0 || physical.height <= 0) { + return CaptureDimensions(mobile: true, colorScheme: colorScheme); + } + final logicalWidth = (physical.width / dpr).round(); + final logicalHeight = (physical.height / dpr).round(); + return CaptureDimensions( + width: logicalWidth, + height: logicalHeight, + screenWidth: logicalWidth, + screenHeight: logicalHeight, + dpi: dpr, + mobile: true, + colorScheme: colorScheme, + ); +} + +/// Inverse of [captureFileExtension] — used by the loopback capture server +/// to choose a `Content-Type` header from a stored artifact's extension. +/// `.html` carries a charset; binary types don't. +String? captureContentTypeForExtension(String extension) { + final ext = extension.toLowerCase(); + if (ext == '.jpeg') return 'image/jpeg'; + for (final type in _captureArtifactTypes) { + if (type.extension == ext) { + return type.mime == 'text/html' ? 'text/html; charset=utf-8' : type.mime; + } + } + return null; +} + +@Riverpod(keepAlive: true) +CaptureArtifactDownloader captureArtifactDownloader(Ref ref) { + return CaptureArtifactDownloader( + endpoints: ref.watch(searchBackendEndpointsProvider), + storageDir: captureStorageDirectory(), + client: ref.watch(searchProxyHttpClientProvider), + ); +} diff --git a/apps/weblibre/lib/features/web_search/domain/services/capture_artifact_downloader.g.dart b/apps/weblibre/lib/features/web_search/domain/services/capture_artifact_downloader.g.dart new file mode 100644 index 00000000..324612a3 --- /dev/null +++ b/apps/weblibre/lib/features/web_search/domain/services/capture_artifact_downloader.g.dart @@ -0,0 +1,58 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'capture_artifact_downloader.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(captureArtifactDownloader) +final captureArtifactDownloaderProvider = CaptureArtifactDownloaderProvider._(); + +final class CaptureArtifactDownloaderProvider + extends + $FunctionalProvider< + CaptureArtifactDownloader, + CaptureArtifactDownloader, + CaptureArtifactDownloader + > + with $Provider { + CaptureArtifactDownloaderProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'captureArtifactDownloaderProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$captureArtifactDownloaderHash(); + + @$internal + @override + $ProviderElement $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + CaptureArtifactDownloader create(Ref ref) { + return captureArtifactDownloader(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(CaptureArtifactDownloader value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$captureArtifactDownloaderHash() => + r'f746015082a68afc71e2af2e37ac83e3cc856891'; diff --git a/apps/weblibre/lib/features/web_search/domain/services/capture_server.dart b/apps/weblibre/lib/features/web_search/domain/services/capture_server.dart new file mode 100644 index 00000000..a4033840 --- /dev/null +++ b/apps/weblibre/lib/features/web_search/domain/services/capture_server.dart @@ -0,0 +1,631 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; + +import 'package:path/path.dart' as p; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/features/web_search/domain/services/capture_artifact_downloader.dart'; + +part 'capture_server.g.dart'; + +// Extensions kept in lock-step with [captureFileExtension] in +// capture_artifact_downloader.dart — the downloader writes the artifact under +// one of these and this server probes for the same set. + +class RetryRequest { + final String tabId; + final String captureId; + const RetryRequest({required this.tabId, required this.captureId}); +} + +/// Loopback HTTP server that exposes locally-stored capture HTML to the +/// in-app GeckoView. +/// +/// Gecko rejects `content://` (PDF-only) and `file://` (blocked scheme), so +/// captures are served over `http://127.0.0.1:/captures/.html?t=`. +/// Every published capture gets a fresh random token — other apps binding +/// to the same loopback cannot enumerate captures without guessing it. +/// +/// The server also serves a zero-JS "Capturing…" loader page at +/// `/loader?tab=&capture=[&err=1]`. Sandbox tab navigation +/// is redirected to this loader while the capture is in flight; a +/// `meta http-equiv="refresh"` tag loops until the capture is ready, at which +/// point the loader responds with a `Refresh: 0; url=` header so +/// GeckoView transitions to the real capture without JavaScript. +class _CaptureEntry { + _CaptureEntry({required this.token}); + + final String token; + // Cached path to the resolved artifact, populated by the first probe so + // hot paths (loader long-poll wakeups) don't keep stat'ing the FS for + // five extensions per check. + File? cachedFile; +} + +class CaptureServer { + CaptureServer({required Directory storageDir}) : _storageDir = storageDir; + + final Directory _storageDir; + final Map _entriesById = {}; + final Set _failedIds = {}; + // Completer per captureId, completed by publish()/markFailed() so loader + // long-polls wake up immediately instead of busy-looping a 200ms poll. + // Recreated after each completion so a subsequent retry can wait again. + final Map> _waiters = {}; + final _random = Random.secure(); + final _retryController = StreamController.broadcast(); + + HttpServer? _server; + Future? _starting; + + Completer _waiterFor(String captureId) { + return _waiters.putIfAbsent(captureId, Completer.new); + } + + void _signalWaiter(String captureId) { + final completer = _waiters.remove(captureId); + if (completer != null && !completer.isCompleted) { + completer.complete(); + } + } + + Stream get retryRequests => _retryController.stream; + + Future _ensureStarted() async { + final existing = _server; + if (existing != null) return existing; + final pending = _starting; + if (pending != null) return pending; + + // Track success and failure: a rejected bind() must clear `_starting`, + // otherwise the stale rejected future is handed back to every subsequent + // caller and the server can never recover (e.g. transient EADDRINUSE). + final future = HttpServer.bind(InternetAddress.loopbackIPv4, 0); + _starting = future + .then((server) { + _server = server; + unawaited(_serve(server)); + return server; + }) + .whenComplete(() => _starting = null); + return _starting!; + } + + Future _serve(HttpServer server) async { + await for (final request in server) { + unawaited(_handle(request)); + } + } + + Future _handle(HttpRequest request) async { + try { + final segments = request.uri.pathSegments; + if (segments.isEmpty) { + request.response.statusCode = HttpStatus.notFound; + await request.response.close(); + return; + } + + switch (segments[0]) { + case 'captures': + if (request.method != 'GET' || segments.length != 2) { + await _emptyResponse(request, HttpStatus.methodNotAllowed); + return; + } + await _handleCapture(request, segments[1]); + return; + case 'loader': + if (segments.length == 1) { + if (request.method != 'GET') { + await _emptyResponse(request, HttpStatus.methodNotAllowed); + return; + } + await _handleLoader(request); + return; + } + if (segments.length == 2 && segments[1] == 'wait') { + if (request.method != 'GET') { + await _emptyResponse(request, HttpStatus.methodNotAllowed); + return; + } + await _handleLoaderWait(request); + return; + } + if (segments.length == 2 && segments[1] == 'retry') { + if (request.method != 'POST') { + await _emptyResponse(request, HttpStatus.methodNotAllowed); + return; + } + await _handleRetry(request); + return; + } + await _emptyResponse(request, HttpStatus.notFound); + return; + default: + await _emptyResponse(request, HttpStatus.notFound); + return; + } + } catch (_) { + try { + await request.response.close(); + } catch (_) {} + } + } + + Future _handleCapture(HttpRequest request, String lastSegment) async { + final extIndex = lastSegment.lastIndexOf('.'); + final captureId = extIndex >= 0 + ? lastSegment.substring(0, extIndex) + : lastSegment; + if (!_captureIdPattern.hasMatch(captureId)) { + await _emptyResponse(request, HttpStatus.notFound); + return; + } + + final entry = _entriesById[captureId]; + final token = request.uri.queryParameters['t']; + if (entry == null || token == null || token != entry.token) { + await _emptyResponse(request, HttpStatus.forbidden); + return; + } + + final file = await _resolveCaptureFile(captureId); + if (file == null) { + await _emptyResponse(request, HttpStatus.notFound); + return; + } + + final extension = p.extension(file.path).toLowerCase(); + final mime = + captureContentTypeForExtension(extension) ?? 'application/octet-stream'; + final isHtml = extension == '.html'; + final length = await file.length(); + + request.response.statusCode = HttpStatus.ok; + request.response.headers + ..set(HttpHeaders.contentTypeHeader, mime) + ..contentLength = length + ..set(HttpHeaders.cacheControlHeader, 'no-store') + ..set('X-Content-Type-Options', 'nosniff'); + if (isHtml) { + // HTML is the only artifact type that can execute script or load + // subresources — clamp it down. PDF/PNG bytes are inert; the browser's + // built-in viewer handles them and CSP would just confuse it. + request.response.headers.set('Content-Security-Policy', _captureCsp); + } + await request.response.addStream(file.openRead()); + await request.response.close(); + } + + /// Resolves the on-disk artifact for [captureId] and caches the result on + /// the entry so subsequent lookups skip the five-extension stat loop. The + /// cache is re-validated on every lookup via a cheap `exists()` so a file + /// removed out-of-band (cache cleanup, manual delete) is detected and the + /// stat loop is re-run; without this, the server would happily hand + /// `request.response.addStream(file.openRead())` a missing file and + /// surface as a mid-stream error rather than the expected 404. + Future _resolveCaptureFile(String captureId) async { + final entry = _entriesById[captureId]; + if (entry == null) return null; + final cached = entry.cachedFile; + if (cached != null) { + if (await cached.exists()) return cached; + entry.cachedFile = null; + } + for (final ext in captureSupportedExtensions) { + final file = File(p.join(_storageDir.path, '$captureId$ext')); + if (await file.exists()) { + entry.cachedFile = file; + return file; + } + } + return null; + } + + File? _resolveCaptureFileSync(String captureId) { + final entry = _entriesById[captureId]; + if (entry == null) return null; + final cached = entry.cachedFile; + if (cached != null) { + if (cached.existsSync()) return cached; + entry.cachedFile = null; + } + for (final ext in captureSupportedExtensions) { + final file = File(p.join(_storageDir.path, '$captureId$ext')); + if (file.existsSync()) { + entry.cachedFile = file; + return file; + } + } + return null; + } + + Future _handleLoader(HttpRequest request) async { + final tabId = request.uri.queryParameters['tab']; + final captureId = request.uri.queryParameters['capture']; + final err = request.uri.queryParameters['err'] == '1'; + + if (tabId == null || + captureId == null || + !_captureIdPattern.hasMatch(captureId)) { + await _emptyResponse(request, HttpStatus.notFound); + return; + } + + // Render the loader shell immediately. Status transitions are driven + // by an inline JS long-poll against /loader/wait — no meta-refresh, + // no full-page reloads while the user is staring at the spinner. + await _writeLoaderHtml( + request, + body: _loaderShellBody(initialError: err), + tabId: tabId, + captureId: captureId, + ); + } + + /// JSON long-poll endpoint consumed by the loader shell. Holds the + /// connection open up to ~25s, returning as soon as the capture + /// transitions to `ready` or `failed`. Idle clients reconnect on close, + /// so we keep timeouts well under typical proxy buffering windows. + /// + /// Wakes up event-driven via a per-id [Completer] signaled by + /// [publish]/[markFailed]; the older revision busy-polled `isReady` every + /// 200ms which burned CPU on long captures. + Future _handleLoaderWait(HttpRequest request) async { + final captureId = request.uri.queryParameters['capture']; + if (captureId == null || !_captureIdPattern.hasMatch(captureId)) { + await _emptyResponse(request, HttpStatus.badRequest); + return; + } + + String status; + String? captureUrl; + + if (_failedIds.contains(captureId)) { + status = 'failed'; + } else if (isReady(captureId)) { + status = 'ready'; + } else { + // Race-free: register the waiter before re-checking ready/failed. + // If publish() fires between the early returns above and here, the + // waiter has already been completed via _signalWaiter. + final completer = _waiterFor(captureId); + try { + await completer.future.timeout(const Duration(seconds: 25)); + } on TimeoutException { + // Fall through — loader script reconnects on close. + } + if (_failedIds.contains(captureId)) { + status = 'failed'; + } else if (isReady(captureId)) { + status = 'ready'; + } else { + status = 'pending'; + } + } + + if (status == 'ready') { + final server = _server; + final entry = _entriesById[captureId]; + if (server != null && entry != null) { + captureUrl = _buildCaptureUrl( + server.port, + captureId, + entry.token, + ).toString(); + } + } + + final body = jsonEncode({ + 'status': status, + if (captureUrl != null) 'url': captureUrl, + }); + request.response.statusCode = HttpStatus.ok; + request.response.headers + ..contentType = ContentType('application', 'json', charset: 'utf-8') + ..set(HttpHeaders.cacheControlHeader, 'no-store'); + request.response.write(body); + await request.response.close(); + } + + Future _handleRetry(HttpRequest request) async { + final tabId = request.uri.queryParameters['tab']; + final captureId = request.uri.queryParameters['capture']; + if (tabId == null || + captureId == null || + !_captureIdPattern.hasMatch(captureId)) { + await _emptyResponse(request, HttpStatus.badRequest); + return; + } + _retryController.add(RetryRequest(tabId: tabId, captureId: captureId)); + // The loader script in the *current* page picks up the new state via its + // long-poll, so a Refresh header (or full reload) would just discard the + // tab's in-flight fetch. Reply with an empty 204 — the client doesn't + // need the body. + await _emptyResponse(request, HttpStatus.noContent); + } + + Future _writeLoaderHtml( + HttpRequest request, { + required String body, + required String tabId, + required String captureId, + }) async { + request.response.statusCode = HttpStatus.ok; + request.response.headers + ..contentType = ContentType('text', 'html', charset: 'utf-8') + ..set(HttpHeaders.cacheControlHeader, 'no-store') + ..set('X-Content-Type-Options', 'nosniff') + ..set('Content-Security-Policy', _loaderCsp); + final tabIdJson = jsonEncode(tabId); + final captureIdJson = jsonEncode(captureId); + final html = + '' + '' + 'Capturing…' + '$body' + '' + '' + ''; + request.response.write(html); + await request.response.close(); + } + + Future _emptyResponse(HttpRequest request, int status) async { + request.response.statusCode = status; + await request.response.close(); + } + + /// Registers a capture and returns a `http://127.0.0.1:/...` URL + /// that can be loaded in a GeckoView tab. + Future publish(String captureId) async { + final server = await _ensureStarted(); + _failedIds.remove(captureId); + final entry = _entriesById.putIfAbsent( + captureId, + () => _CaptureEntry(token: _generateToken()), + ); + // Wake any in-flight long-poll for this id — the artifact is now + // resolvable, so the poll can return `ready` immediately. + _signalWaiter(captureId); + return _buildCaptureUrl(server.port, captureId, entry.token); + } + + /// Marks a capture as failed so any in-flight loader long-polls return + /// immediately with `status: 'failed'`. + void markFailed(String captureId) { + _failedIds.add(captureId); + _signalWaiter(captureId); + } + + /// Clears any prior failed flag for the given capture id (e.g. before a + /// retry). + void clearFailed(String captureId) { + _failedIds.remove(captureId); + } + + /// Ensures the server is running and returns its port. Useful when callers + /// need to build a loader URL before any capture has been published. + Future ensureStarted() async { + final server = await _ensureStarted(); + return server.port; + } + + /// Builds the loader URL for the given tab + capture. Also ensures the + /// server is running so callers can use the returned port stably. + Future loaderUrl({ + required String tabId, + required String captureId, + bool error = false, + }) async { + final port = await ensureStarted(); + return Uri( + scheme: 'http', + host: InternetAddress.loopbackIPv4.address, + port: port, + pathSegments: const ['loader'], + queryParameters: { + 'tab': tabId, + 'capture': captureId, + if (error) 'err': '1', + }, + ); + } + + /// True if [publish] has been called for [captureId] and the artifact file + /// is present on disk. Used by the loader to decide between meta-refresh + /// and redirect. + bool isReady(String captureId) { + final entry = _entriesById[captureId]; + if (entry == null) return false; + return _resolveCaptureFileSync(captureId) != null; + } + + void revoke(String captureId) { + _entriesById.remove(captureId); + _failedIds.remove(captureId); + _signalWaiter(captureId); + } + + Future stop() async { + final server = _server; + _server = null; + _starting = null; + _entriesById.clear(); + _failedIds.clear(); + // Release every pending long-poll so the request handler can finish. + for (final completer in _waiters.values) { + if (!completer.isCompleted) completer.complete(); + } + _waiters.clear(); + await server?.close(force: true); + await _retryController.close(); + } + + Uri _buildCaptureUrl(int port, String captureId, String token) { + // If the artifact is on disk, use its real extension so Gecko picks the + // right viewer (e.g. PDF.js for `.pdf`). Pre-publish (no file yet) we + // fall back to `.html`; the loader long-poll re-resolves the URL once + // the artifact lands. + final file = _resolveCaptureFileSync(captureId); + final ext = file != null ? p.extension(file.path).toLowerCase() : '.html'; + return Uri( + scheme: 'http', + host: InternetAddress.loopbackIPv4.address, + port: port, + pathSegments: ['captures', '$captureId$ext'], + queryParameters: {'t': token}, + ); + } + + String _generateToken() { + final bytes = List.generate(24, (_) => _random.nextInt(256)); + return base64Url.encode(bytes).replaceAll('=', ''); + } + + static final _captureIdPattern = RegExp(r'^[A-Za-z0-9_-]+$'); + + static const _captureCsp = + "default-src 'none'; " + "script-src 'none'; " + "style-src 'unsafe-inline' data:; " + "img-src data: blob:; " + "font-src data:; " + "frame-ancestors 'none'; " + "base-uri 'none'; " + "form-action 'none'"; + + // The loader is server-controlled HTML on the loopback origin only — it + // never loads remote subresources. Inline script/style are required for + // the long-poll JS and the spinner CSS. + static const _loaderCsp = + "default-src 'none'; " + "script-src 'unsafe-inline'; " + "style-src 'unsafe-inline'; " + "connect-src 'self'; " + "img-src data:; " + "frame-ancestors 'none'; " + "base-uri 'none'; " + "form-action 'self'"; + + static const _loaderStyles = + 'html,body{height:100%}' + 'body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,' + 'sans-serif;display:flex;align-items:center;justify-content:center;' + 'min-height:100vh;margin:0;background:#0b0d10;color:#e6e8eb}' + 'main{text-align:center;max-width:480px;padding:32px;opacity:0;' + 'animation:fade .35s ease-out forwards}' + '@keyframes fade{to{opacity:1}}' + '.spinner{width:56px;height:56px;margin:0 auto 20px;border-radius:50%;' + 'border:3px solid rgba(255,255,255,.08);border-top-color:#7aa2f7;' + 'animation:spin 1s linear infinite}' + '@keyframes spin{to{transform:rotate(360deg)}}' + 'h1{font-size:18px;font-weight:600;margin:0 0 8px;letter-spacing:.2px}' + 'p{margin:0;color:#9aa1a8;font-size:14px;line-height:1.5}' + '.dots::after{display:inline-block;width:1.2em;text-align:left;' + 'animation:dots 1.4s steps(4,end) infinite;content:""}' + '@keyframes dots{0%{content:""}25%{content:"."}50%{content:".."}' + '75%{content:"..."}100%{content:""}}' + '.error h1{color:#f7768e}' + '.actions{margin-top:20px;display:flex;gap:8px;justify-content:center}' + '.btn{padding:10px 16px;border:0;border-radius:10px;cursor:pointer;' + 'font:inherit;font-weight:600;background:#7aa2f7;color:#0b0d10}' + '.btn.secondary{background:transparent;color:#9aa1a8;' + 'border:1px solid rgba(255,255,255,.12)}' + '.hidden{display:none}' + '@media(prefers-color-scheme:light){body{background:#f5f6f8;' + 'color:#1a1d22}.spinner{border-color:rgba(0,0,0,.08);' + 'border-top-color:#3b82f6}.btn{background:#3b82f6;color:white}' + 'p{color:#52606b}}'; + + // Inline script for the loader shell. Reads window.__TAB_ID__ and + // window.__CAPTURE_ID__, long-polls /loader/wait, then either redirects + // to the ready capture or flips to the error pane. + // + // Network-error retries are capped (MAX_NET_ERRORS) so a tab whose capture + // service has gone away doesn't busy-loop forever — after the cap the + // loader gives up and shows the error pane with a Retry button (which + // resets the counter via showPending → poll()). + static const _loaderScript = r''' +(function(){ + var pending=document.getElementById('pending'); + var error=document.getElementById('error'); + var retryBtn=document.getElementById('retry'); + var tabId=window.__TAB_ID__; + var captureId=window.__CAPTURE_ID__; + var MAX_NET_ERRORS=15; + var netErrors=0; + function showError(){ + if(pending)pending.classList.add('hidden'); + if(error)error.classList.remove('hidden'); + } + function showPending(){ + netErrors=0; + if(error)error.classList.add('hidden'); + if(pending)pending.classList.remove('hidden'); + } + async function poll(){ + try{ + var r=await fetch('/loader/wait?tab='+encodeURIComponent(tabId)+ + '&capture='+encodeURIComponent(captureId),{cache:'no-store'}); + if(!r.ok){ + if(++netErrors>=MAX_NET_ERRORS){showError();return} + await new Promise(function(res){setTimeout(res,2000)});return poll(); + } + netErrors=0; + var data=await r.json(); + if(data.status==='ready'&&data.url){location.replace(data.url);return} + if(data.status==='failed'){showError();return} + // pending timeout — reconnect immediately. + return poll(); + }catch(e){ + if(++netErrors>=MAX_NET_ERRORS){showError();return} + await new Promise(function(res){setTimeout(res,2000)}); + return poll(); + } + } + if(retryBtn){ + retryBtn.addEventListener('click',function(){ + showPending(); + fetch('/loader/retry?tab='+encodeURIComponent(tabId)+ + '&capture='+encodeURIComponent(captureId), + {method:'POST',cache:'no-store'}).catch(function(){}); + poll(); + }); + } + if(error&&!error.classList.contains('hidden')){ + // Started in error mode — wait for user retry. + return; + } + poll(); +})(); +'''; + + String _loaderShellBody({required bool initialError}) { + final pendingClass = initialError ? 'hidden' : ''; + final errorClass = initialError ? 'error' : 'error hidden'; + return '

' + '
' + '

Capturing page

' + '

Saving an offline copy. This usually takes a few seconds.

' + '
' + '
' + '

Capture failed

' + '

The page could not be saved. Check the notification for details.

' + '
' + '' + '
' + '
'; + } +} + +@Riverpod(keepAlive: true) +CaptureServer captureServer(Ref ref) { + final server = CaptureServer(storageDir: captureStorageDirectory()); + ref.onDispose(() { + unawaited(server.stop()); + }); + return server; +} diff --git a/apps/weblibre/lib/features/web_search/domain/services/capture_server.g.dart b/apps/weblibre/lib/features/web_search/domain/services/capture_server.g.dart new file mode 100644 index 00000000..d7907fc8 --- /dev/null +++ b/apps/weblibre/lib/features/web_search/domain/services/capture_server.g.dart @@ -0,0 +1,51 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'capture_server.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(captureServer) +final captureServerProvider = CaptureServerProvider._(); + +final class CaptureServerProvider + extends $FunctionalProvider + with $Provider { + CaptureServerProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'captureServerProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$captureServerHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + CaptureServer create(Ref ref) { + return captureServer(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(CaptureServer value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$captureServerHash() => r'ddbb9eff1b3d622a44a5ae9523e2f96a37b075fb'; diff --git a/apps/weblibre/lib/features/web_search/domain/services/sandbox_capture_store.dart b/apps/weblibre/lib/features/web_search/domain/services/sandbox_capture_store.dart new file mode 100644 index 00000000..94f4e008 --- /dev/null +++ b/apps/weblibre/lib/features/web_search/domain/services/sandbox_capture_store.dart @@ -0,0 +1,57 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:weblibre/core/filesystem.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/database/definitions.drift.dart' + show CaptureTabData; + +/// Mirror of the capture_tab rows on disk under +/// `/files/sandbox_captures.json`. Kotlin reads this file during +/// pre-Gecko bootstrap, before tab restore can dispatch any load request. +/// +/// The schema is intentionally tiny: only the fields Kotlin needs to build a +/// placeholder [SandboxEntry] with `redirectUrl = "about:blank"`. Dart +/// replaces those placeholders with real loopback URLs via +/// [SandboxCaptureApi.resetAll] once [CaptureServer] is running. +/// +/// The [_version] field is written at the top of the JSON envelope. Kotlin +/// reads it and refuses to rehydrate any version higher than the one it +/// supports — a forward-incompatible write from a newer Dart side leaves +/// the registry empty for that cold start, which is the safe outcome +/// (sandbox tabs reopen as about:blank rather than loading live URLs). +/// Bump this in lockstep with the Kotlin `SUPPORTED_VERSION` constant. +const _version = 1; + +class SandboxCaptureStore { + File _file() { + return File( + p.join( + filesystem.selectedProfileDir.path, + 'files', + 'sandbox_captures.json', + ), + ); + } + + Future write(List rows) async { + final file = _file(); + await file.parent.create(recursive: true); + final payload = jsonEncode({ + 'version': _version, + 'entries': rows + .map((r) { + return { + 'tabId': r.tabId, + 'captureId': r.captureId, + 'sourceUrl': r.sourceUrl, + 'status': r.status, + }; + }) + .toList(growable: false), + }); + final tmp = File('${file.path}.part'); + await tmp.writeAsString(payload, flush: true); + await tmp.rename(file.path); + } +} diff --git a/apps/weblibre/lib/features/web_search/presentation/dialogs/fetch_method_dialog.dart b/apps/weblibre/lib/features/web_search/presentation/dialogs/fetch_method_dialog.dart new file mode 100644 index 00000000..eaccc325 --- /dev/null +++ b/apps/weblibre/lib/features/web_search/presentation/dialogs/fetch_method_dialog.dart @@ -0,0 +1,208 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:weblibre/features/web_search/domain/controllers/search_controller.dart'; +import 'package:weblibre/features/web_search/domain/entities/captured_page_state.dart'; +import 'package:weblibre/features/web_search/domain/entities/fetch_method.dart'; +import 'package:weblibre/features/web_search/domain/services/capture_artifact_downloader.dart'; + +Future showFetchMethodSheet( + BuildContext context, { + required Uri url, + required Future Function(Uri url) onPreview, + required Future Function(CapturedPageState captured) onOpenCapture, +}) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + showDragHandle: true, + builder: (context) => _FetchMethodSheet( + url: url, + onPreview: onPreview, + onOpenCapture: onOpenCapture, + ), + ); +} + +class _FetchMethodSheet extends ConsumerWidget { + final Uri url; + final Future Function(Uri url) onPreview; + final Future Function(CapturedPageState captured) onOpenCapture; + + const _FetchMethodSheet({ + required this.url, + required this.onPreview, + required this.onOpenCapture, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch(metaSearchControllerProvider); + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + + return SafeArea( + child: Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Fetch Page Data', style: textTheme.titleLarge), + const SizedBox(height: 4), + Text( + url.toString(), + style: textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + const SizedBox(height: 12), + for (final choice in FetchMethodChoice.values) + _MethodTile( + url: url, + choice: choice, + state: state, + onPreview: onPreview, + onOpenCapture: onOpenCapture, + ), + ], + ), + ), + ); + } +} + +class _MethodTile extends ConsumerWidget { + final Uri url; + final FetchMethodChoice choice; + final MetaSearchState state; + final Future Function(Uri url) onPreview; + final Future Function(CapturedPageState captured) onOpenCapture; + + const _MethodTile({ + required this.url, + required this.choice, + required this.state, + required this.onPreview, + required this.onOpenCapture, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + + final busy = state.isMethodBusy(url, choice); + final ready = state.isMethodReady(url, choice); + final captured = state.capturedPage(url, choice); + final failed = captured?.status == CapturedPageStatus.downloadFailed; + + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4), + leading: Icon(choice.icon, color: colorScheme.onSurfaceVariant), + title: Text(choice.title), + subtitle: Text( + failed + ? (captured?.errorMessage ?? 'Download failed — tap to retry') + : choice.subtitle, + style: textTheme.bodySmall?.copyWith( + color: failed ? colorScheme.error : colorScheme.onSurfaceVariant, + ), + ), + trailing: _StatusIndicator( + busy: busy, + ready: ready, + failed: failed, + colorScheme: colorScheme, + ), + onTap: busy ? null : () => _handleTap(context, ref, ready, failed, captured), + ); + } + + Future _handleTap( + BuildContext context, + WidgetRef ref, + bool ready, + bool failed, + CapturedPageState? captured, + ) async { + if (ready) { + Navigator.of(context).pop(); + if (choice == FetchMethodChoice.trafilatura) { + await onPreview(url); + } else if (captured != null) { + await onOpenCapture(captured); + } + return; + } + + Navigator.of(context).pop(); + + if (failed && captured != null) { + await ref + .read(metaSearchControllerProvider.notifier) + .retryCaptureDownload(url, choice); + return; + } + + if (choice == FetchMethodChoice.trafilatura) { + await ref.read(metaSearchControllerProvider.notifier).fetchPage(url); + return; + } + + await ref + .read(metaSearchControllerProvider.notifier) + .capturePage( + url, + choice: choice, + // Render at the device's actual viewport so PDF/PNG snapshots + // match what the user sees; singlefile ignores these fields. + dimensions: currentDisplayCaptureDimensions(), + ); + } +} + +class _StatusIndicator extends StatelessWidget { + final bool busy; + final bool ready; + final bool failed; + final ColorScheme colorScheme; + + const _StatusIndicator({ + required this.busy, + required this.ready, + required this.failed, + required this.colorScheme, + }); + + @override + Widget build(BuildContext context) { + if (busy) { + return const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ); + } + if (ready) { + return Icon(Icons.check_circle, color: colorScheme.primary); + } + if (failed) { + return Icon(Icons.refresh, color: colorScheme.error); + } + return Icon( + Icons.download_rounded, + color: colorScheme.onSurfaceVariant, + ); + } +} diff --git a/apps/weblibre/lib/features/web_search/presentation/open_in_new_tab.dart b/apps/weblibre/lib/features/web_search/presentation/open_in_new_tab.dart new file mode 100644 index 00000000..8d9e8313 --- /dev/null +++ b/apps/weblibre/lib/features/web_search/presentation/open_in_new_tab.dart @@ -0,0 +1,142 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:weblibre/core/routing/routes.dart'; +import 'package:weblibre/features/geckoview/domain/entities/tab_container_selection.dart'; +import 'package:weblibre/features/geckoview/domain/providers/tab_session.dart'; +import 'package:weblibre/features/geckoview/domain/repositories/tab.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart'; +import 'package:weblibre/features/web_search/domain/controllers/sandbox_capture_controller.dart'; +import 'package:weblibre/features/web_search/domain/services/capture_server.dart'; + +/// Parameters describing where a web-search-opened tab should land. +/// +/// The search screen owns the user-visible selectors (tab type, container, +/// parent for child tabs) and threads them through here so the result-card +/// open path honours them — historically this path inherited the *currently +/// selected* tab's mode/container instead, ignoring the search UI. +class WebSearchOpenTarget { + final TabMode tabMode; + final TabContainerSelection containerSelection; + final String? parentId; + + const WebSearchOpenTarget({ + required this.tabMode, + required this.containerSelection, + this.parentId, + }); +} + +abstract interface class WebSearchTabOpener { + Future open( + BuildContext context, + WidgetRef ref, + Uri uri, { + required WebSearchOpenTarget target, + }); + + Future openCapture( + BuildContext context, + WidgetRef ref, { + required String captureId, + required Uri sourceUrl, + required WebSearchOpenTarget target, + String? contentType, + String? method, + String? variant, + }); +} + +final webSearchTabOpenerProvider = Provider( + (ref) => const _DefaultWebSearchTabOpener(), +); + +final class _DefaultWebSearchTabOpener implements WebSearchTabOpener { + const _DefaultWebSearchTabOpener(); + + @override + Future open( + BuildContext context, + WidgetRef ref, + Uri uri, { + required WebSearchOpenTarget target, + }) async { + await ref + .read(tabRepositoryProvider.notifier) + .addTab( + url: uri, + tabMode: target.tabMode, + parentId: target.parentId, + promptOnBackBehavior: ReturnToSearchTabBackPromptBehavior( + tabType: target.tabMode.toTabType(), + ), + selectTab: true, + containerSelection: target.containerSelection, + ); + + // Push (not go/replace) so the SearchRoute stays underneath. Pressing + // back from the freshly opened tab pops back to the search results + // instead of closing the tab. metaSearchControllerProvider is + // keepAlive, so results survive the navigation either way. + if (context.mounted) { + const BrowserRoute().go(context); + } + } + + @override + Future openCapture( + BuildContext context, + WidgetRef ref, { + required String captureId, + required Uri sourceUrl, + required WebSearchOpenTarget target, + String? contentType, + String? method, + String? variant, + }) async { + // Never navigate to the real sourceUrl — Gecko's app-link resolution + // (e.g. github.com triggers the "Open in GitHub app?" dialog) fires + // before our interceptor can redirect, and a cancel falls back to + // loading the live site. Load the loopback capture URL directly. + final captureUrl = await ref.read(captureServerProvider).publish(captureId); + + final tabId = await ref + .read(tabRepositoryProvider.notifier) + .addTab( + url: captureUrl, + tabMode: target.tabMode, + parentId: target.parentId, + containerSelection: target.containerSelection, + promptOnBackBehavior: ReturnToSearchTabBackPromptBehavior( + tabType: target.tabMode.toTabType(), + ), + selectTab: true, + startLoading: false, + ); + + // Always register the capture root — even for PDF / PNG. The capture_tab + // DAO row is what (a) tells Kotlin's middleware to treat this tab as a + // sandbox tab so out-of-origin link navigations get intercepted, and + // (b) backs the address bar's source-URL display so the loopback URL + // doesn't leak. PDF and PNG viewers don't follow links in practice, so + // the extra interception is harmless. + await ref + .read(sandboxCaptureControllerProvider.notifier) + .registerRootCapture( + tabId: tabId, + receipt: CapturedPageReceiptLike( + captureId: captureId, + sourceUrl: sourceUrl, + ), + method: method, + variant: variant, + ); + + await ref + .read(tabSessionProvider(tabId: tabId).notifier) + .loadUrl(url: captureUrl); + + if (context.mounted) { + const BrowserRoute().go(context); + } + } +} diff --git a/apps/weblibre/lib/features/web_search/presentation/screens/page_preview.dart b/apps/weblibre/lib/features/web_search/presentation/screens/page_preview.dart new file mode 100644 index 00000000..3bf86571 --- /dev/null +++ b/apps/weblibre/lib/features/web_search/presentation/screens/page_preview.dart @@ -0,0 +1,94 @@ +import 'package:fading_scroll/fading_scroll.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:weblibre/features/web_search/domain/controllers/search_controller.dart'; +import 'package:weblibre/features/web_search/presentation/open_in_new_tab.dart'; +import 'package:weblibre/features/web_search/presentation/widgets/search_result_metadata_chips.dart'; +import 'package:weblibre/presentation/widgets/failure_widget.dart'; +import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart'; +import 'package:weblibre/presentation/widgets/url_icon.dart'; + +class PagePreviewScreen extends ConsumerWidget { + final Uri uri; + final WebSearchOpenTarget Function() resolveOpenTarget; + + const PagePreviewScreen({ + super.key, + required this.uri, + required this.resolveOpenTarget, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + // The preview screen only depends on the document for this one URL and + // the matching result row. Selecting both via a record means unrelated + // controller updates (favicons, image streams, other URLs' fetches) + // don't rebuild the (potentially-large) Markdown body below. + final (document, result) = ref.watch( + metaSearchControllerProvider.select( + (s) => ( + s.documentsByUrl[uri], + s.results.where((item) => item.url == uri).firstOrNull, + ), + ), + ); + final title = result?.title ?? document?.metadata.title ?? uri.authority; + + if (document == null) { + return Scaffold( + appBar: AppBar(title: Text(title)), + body: const FailureWidget( + title: 'Preview unavailable', + exception: + 'Fetch the page from the result list before opening a preview.', + ), + ); + } + + return Scaffold( + appBar: AppBar( + title: Text(title), + actions: [ + IconButton( + tooltip: 'Open in browser', + onPressed: () async { + await ref + .read(webSearchTabOpenerProvider) + .open(context, ref, uri, target: resolveOpenTarget()); + }, + icon: const Icon(Icons.open_in_new), + ), + ], + ), + body: FadingScroll( + fadingSize: 25, + builder: (context, controller) { + return SingleChildScrollView( + controller: controller, + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UriBreadcrumb( + uri: uri, + icon: UrlIcon([uri], iconSize: 16, cacheOnly: true), + ), + const SizedBox(height: 16), + SearchResultMetadataChips( + metadata: result?.metadata ?? const [], + pageMetadata: document.metadata, + ), + if (result?.metadata case final metadata? + when metadata.isNotEmpty) + SearchResultMetadataExpandable(metadata: metadata), + const SizedBox(height: 16), + MarkdownBody(selectable: true, data: document.content), + ], + ), + ); + }, + ), + ); + } +} diff --git a/apps/weblibre/lib/features/web_search/presentation/widgets/page_metadata_chips.dart b/apps/weblibre/lib/features/web_search/presentation/widgets/page_metadata_chips.dart new file mode 100644 index 00000000..faeb5e1c --- /dev/null +++ b/apps/weblibre/lib/features/web_search/presentation/widgets/page_metadata_chips.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; +import 'package:intl/intl.dart'; +import 'package:nullability/nullability.dart'; +import 'package:search_backend/search_backend.dart'; + +class PageMetadataChips extends HookWidget { + final PageMetadata metadata; + + const PageMetadataChips({super.key, required this.metadata}); + + @override + Widget build(BuildContext context) { + final chips = useMemoized(() { + final chips = []; + + final parsedDate = metadata.date.mapNotNull(DateTime.tryParse); + + if (parsedDate != null) { + chips.add( + Chip( + avatar: const Icon(Icons.calendar_month), + label: Text(DateFormat.yMMMd().format(parsedDate)), + ), + ); + } + + if (metadata.sitename case final String sitename + when sitename.isNotEmpty) { + chips.add( + Chip(avatar: const Icon(MdiIcons.domain), label: Text(sitename)), + ); + } + + if (metadata.author case final String author when author.isNotEmpty) { + chips.add(Chip(avatar: const Icon(Icons.person), label: Text(author))); + } + + if (metadata.license case final String license when license.isNotEmpty) { + chips.add( + Chip(avatar: const Icon(MdiIcons.license), label: Text(license)), + ); + } + + return chips; + }); + + if (chips.isEmpty) { + return const SizedBox.shrink(); + } + + return Wrap(spacing: 8, runSpacing: 8, children: chips); + } +} diff --git a/apps/weblibre/lib/features/web_search/presentation/widgets/route_through_tor_toggle.dart b/apps/weblibre/lib/features/web_search/presentation/widgets/route_through_tor_toggle.dart new file mode 100644 index 00000000..dccb8a55 --- /dev/null +++ b/apps/weblibre/lib/features/web_search/presentation/widgets/route_through_tor_toggle.dart @@ -0,0 +1,187 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:weblibre/core/design/app_colors.dart'; +import 'package:weblibre/features/search_credits/domain/providers/proxy_client.dart'; +import 'package:weblibre/features/search_credits/domain/repositories/web_search_settings.dart'; +import 'package:weblibre/features/tor/domain/services/tor_proxy.dart'; +import 'package:weblibre/features/tor/presentation/controllers/start_tor_proxy.dart'; +import 'package:weblibre/presentation/hooks/on_initialization.dart'; + +class RouteThroughTorToggle extends HookConsumerWidget { + const RouteThroughTorToggle({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + Future ensureTorBootstrap() async { + // Sync the latest native status into the stream first so subsequent + // listeners (toggle spinner, progress bar, search submit) don't see a + // stale `null`/`AsyncLoading` state on first build. + final status = await ref + .read(torProxyServiceProvider.notifier) + .requestSync(); + + if (status.isRunning) return false; + + await ref.read(startProxyControllerProvider.notifier).startProxy(); + return true; + } + + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + + final routeThroughTor = ref.watch( + webSearchSettingsControllerProvider.select((s) => s.routeThroughTor), + ); + + final torStatus = ref.watch(torProxyServiceProvider).value; + final activePort = ref.watch(searchProxyPortProvider); + + final bootstrapProgress = torStatus?.bootstrapProgress ?? 0; + + final showSpinner = + routeThroughTor && (activePort == null || bootstrapProgress < 100); + + // Push the latest native status into the stream on first build so the + // bar reflects real progress even when the user lands on the search + // screen with Tor already mid-bootstrap (otherwise the AsyncLoading + // state lingers and the bar appears to spin forever at zero). + useOnInitialization(() async { + await ref.read(torProxyServiceProvider.notifier).requestSync(); + }); + + return InkWell( + borderRadius: BorderRadius.circular(20), + onTap: () async { + final wasTorEnabled = ref + .read(webSearchSettingsControllerProvider) + .routeThroughTor; + + ref + .read(webSearchSettingsControllerProvider.notifier) + .setRouteThroughTor(!wasTorEnabled); + + if (!wasTorEnabled) { + await ensureTorBootstrap(); + } + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: routeThroughTor + ? colorScheme.primaryContainer + : colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (showSpinner) + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: routeThroughTor + ? colorScheme.onPrimaryContainer + : colorScheme.primary, + ), + ) + else + Badge( + isLabelVisible: torStatus?.isRunning == true, + backgroundColor: AppColors.of(context).torActiveGreen, + child: Icon( + routeThroughTor + ? Icons.shield_rounded + : Icons.shield_outlined, + color: routeThroughTor + ? colorScheme.onPrimaryContainer + : colorScheme.primary, + size: 18, + ), + ), + const SizedBox(width: 6), + Text( + routeThroughTor ? 'Tor on' : 'Tor off', + style: textTheme.labelLarge?.copyWith( + color: routeThroughTor + ? colorScheme.onPrimaryContainer + : colorScheme.onSurfaceVariant, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ); + } +} + +/// Slim linear progress bar shown directly underneath the search-screen Tor +/// toggle while Tor is bootstrapping. Mirrors the bar on the Tor settings +/// screen so users get the same visual feedback regardless of where they +/// turned Tor on. Returns a zero-height widget when not relevant. +class WebSearchTorBootstrapProgress extends HookConsumerWidget { + const WebSearchTorBootstrapProgress({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final routeThroughTor = ref.watch( + webSearchSettingsControllerProvider.select((s) => s.routeThroughTor), + ); + + if (!routeThroughTor) return const SizedBox.shrink(); + + // Push the latest native status into the stream on first build so the + // bar reflects real progress even when the user lands on the search + // screen with Tor already mid-bootstrap (otherwise the AsyncLoading + // state lingers and the bar appears to spin forever at zero). + useOnInitialization(() async { + await ref.read(torProxyServiceProvider.notifier).requestSync(); + }); + + final torAsync = ref.watch(torProxyServiceProvider); + final status = torAsync.value; + final isRunning = status?.isRunning ?? false; + final bootstrapProgress = status?.bootstrapProgress ?? 0; + + // Hide once Tor is fully running and bootstrapped — otherwise, mirror + // the Tor settings screen and always show a determinate bar (value + // anchored to the live bootstrap progress, never indeterminate, so the + // user can actually track the percentage). + if (isRunning && bootstrapProgress >= 100) return const SizedBox.shrink(); + + final appColors = AppColors.of(context); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: ClipRRect( + borderRadius: BorderRadius.circular(2), + child: LinearProgressIndicator( + minHeight: 3, + backgroundColor: appColors.torBackgroundGrey, + color: appColors.torActiveGreen, + value: bootstrapProgress / 100, + ), + ), + ); + } +} diff --git a/apps/weblibre/lib/features/web_search/presentation/widgets/search_field.dart b/apps/weblibre/lib/features/web_search/presentation/widgets/search_field.dart new file mode 100644 index 00000000..1307ebd1 --- /dev/null +++ b/apps/weblibre/lib/features/web_search/presentation/widgets/search_field.dart @@ -0,0 +1,80 @@ +import 'package:flutter/material.dart'; + +class WebSearchField extends StatelessWidget { + final TextEditingController controller; + final bool enabled; + final Future Function(String query) onSubmitted; + final VoidCallback onClear; + + const WebSearchField({ + super.key, + required this.controller, + required this.enabled, + required this.onSubmitted, + required this.onClear, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Container( + height: 60, + padding: const EdgeInsets.only(left: 20, right: 6), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(30), + border: Border.all(color: colorScheme.outlineVariant), + ), + child: Row( + children: [ + Icon(Icons.search, color: colorScheme.onSurfaceVariant), + const SizedBox(width: 16), + Expanded( + child: TextField( + controller: controller, + enabled: enabled, + minLines: 1, + maxLines: 5, + keyboardType: TextInputType.multiline, + textInputAction: TextInputAction.search, + style: Theme.of( + context, + ).textTheme.bodyLarge?.copyWith(color: colorScheme.onSurface), + decoration: InputDecoration( + border: InputBorder.none, + hintText: 'Search the web...', + hintStyle: TextStyle(color: colorScheme.onSurfaceVariant), + isDense: true, + ), + onSubmitted: enabled ? onSubmitted : null, + ), + ), + ValueListenableBuilder( + valueListenable: controller, + builder: (context, value, child) { + if (value.text.isEmpty) { + return const SizedBox.shrink(); + } + + return IconButton( + tooltip: 'Clear', + onPressed: enabled ? onClear : null, + icon: Icon(Icons.close, color: colorScheme.onSurfaceVariant), + ); + }, + ), + const SizedBox(width: 4), + FilledButton( + onPressed: enabled ? () => onSubmitted(controller.text) : null, + style: FilledButton.styleFrom( + elevation: 0, + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + ), + child: const Text('Search'), + ), + ], + ), + ); + } +} diff --git a/apps/weblibre/lib/features/web_search/presentation/widgets/search_filter_chips.dart b/apps/weblibre/lib/features/web_search/presentation/widgets/search_filter_chips.dart new file mode 100644 index 00000000..e8585a4d --- /dev/null +++ b/apps/weblibre/lib/features/web_search/presentation/widgets/search_filter_chips.dart @@ -0,0 +1,416 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:search_backend/search_backend.dart'; +import 'package:weblibre/features/search_credits/domain/repositories/web_search_settings.dart'; +import 'package:weblibre/features/web_search/data/locale_options.dart'; + +class _FilterPill extends StatelessWidget { + final IconData icon; + final String label; + final bool isHighlighted; + final VoidCallback onTap; + + const _FilterPill({ + required this.icon, + required this.label, + required this.isHighlighted, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + + final fg = isHighlighted + ? colorScheme.onPrimaryContainer + : colorScheme.onSurfaceVariant; + + return Material( + color: isHighlighted + ? colorScheme.primaryContainer + : colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(20), + child: InkWell( + borderRadius: BorderRadius.circular(20), + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + color: isHighlighted ? fg : colorScheme.primary, + size: 18, + ), + const SizedBox(width: 6), + Text( + label, + style: textTheme.labelLarge?.copyWith( + color: fg, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(width: 2), + Icon(Icons.arrow_drop_down_rounded, size: 18, color: fg), + ], + ), + ), + ), + ); + } +} + +class _MenuRow extends StatelessWidget { + final String label; + final String? subtitle; + final bool isSelected; + final bool isHighlighted; + + const _MenuRow({ + required this.label, + this.subtitle, + required this.isSelected, + this.isHighlighted = false, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + final labelColor = isHighlighted ? colorScheme.primary : null; + return Row( + children: [ + Expanded( + child: subtitle != null + ? RichText( + text: TextSpan( + children: [ + TextSpan( + text: label, + style: textTheme.bodyMedium?.copyWith( + color: labelColor, + fontWeight: isSelected || isHighlighted + ? FontWeight.bold + : null, + ), + ), + TextSpan( + text: ' $subtitle', + style: textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ) + : Text( + label, + style: textTheme.bodyMedium?.copyWith( + color: labelColor, + fontWeight: isSelected || isHighlighted + ? FontWeight.bold + : null, + ), + ), + ), + if (isSelected) + Padding( + padding: const EdgeInsets.only(left: 8), + child: Icon( + Icons.check_rounded, + size: 18, + color: colorScheme.primary, + ), + ), + ], + ); + } +} + +class LanguageSelector extends ConsumerWidget { + const LanguageSelector({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final locale = Localizations.localeOf(context); + + final selected = ref.watch( + webSearchSettingsControllerProvider.select((s) => s.language), + ); + + final isHighlighted = selected != null && selected != locale.languageCode; + + final selectedOption = findLanguage(selected); + final label = selected == null + ? 'Auto' + : (selectedOption?.name ?? selected); + + final defaultOption = findLanguage(locale.languageCode); + final others = [ + for (final l in supportedLanguages) + if (l.code != defaultOption?.code) l, + ]..sort((a, b) => a.name.compareTo(b.name)); + + return MenuAnchor( + builder: (context, controller, _) => _FilterPill( + icon: Icons.translate_rounded, + label: label, + isHighlighted: isHighlighted, + onTap: () => controller.isOpen ? controller.close() : controller.open(), + ), + menuChildren: [ + MenuItemButton( + onPressed: () { + ref + .read(webSearchSettingsControllerProvider.notifier) + .setLanguage(null); + }, + child: _MenuRow( + label: 'Auto (device default)', + subtitle: defaultOption?.code ?? locale.languageCode, + isSelected: selected == null, + ), + ), + const Divider(), + for (final option in others) + MenuItemButton( + onPressed: () { + ref + .read(webSearchSettingsControllerProvider.notifier) + .setLanguage(option.code); + }, + child: _MenuRow( + label: option.name, + subtitle: option.code, + isSelected: selected == option.code, + ), + ), + ], + ); + } +} + +class CountrySelector extends ConsumerWidget { + const CountrySelector({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final locale = Localizations.localeOf(context); + + final selected = ref.watch( + webSearchSettingsControllerProvider.select((s) => s.region), + ); + + final isHighlighted = selected != null && selected != locale.countryCode; + + final selectedOption = findCountry(selected); + final label = selected == null ? 'Any' : (selectedOption?.name ?? selected); + + final defaultOption = findCountry(locale.countryCode); + + final others = [ + for (final c in supportedCountries) + if (c.code != defaultOption?.code) c, + ]..sort((a, b) => a.name.compareTo(b.name)); + + return MenuAnchor( + builder: (context, controller, _) => _FilterPill( + icon: Icons.public_rounded, + label: label, + isHighlighted: isHighlighted, + onTap: () => controller.isOpen ? controller.close() : controller.open(), + ), + menuChildren: [ + MenuItemButton( + onPressed: () { + ref + .read(webSearchSettingsControllerProvider.notifier) + .setRegion(null); + }, + child: _MenuRow(label: 'Any region', isSelected: selected == null), + ), + if (defaultOption != null) ...[ + const Divider(), + MenuItemButton( + onPressed: () { + ref + .read(webSearchSettingsControllerProvider.notifier) + .setRegion(defaultOption.code); + }, + child: _MenuRow( + label: '${defaultOption.name} (device)', + subtitle: defaultOption.code, + isSelected: selected == defaultOption.code, + ), + ), + ], + const Divider(), + for (final option in others) + MenuItemButton( + onPressed: () { + ref + .read(webSearchSettingsControllerProvider.notifier) + .setRegion(option.code); + }, + child: _MenuRow( + label: option.name, + subtitle: option.code, + isSelected: selected == option.code, + ), + ), + ], + ); + } +} + +class SafeSearchSelector extends ConsumerWidget { + const SafeSearchSelector({super.key}); + + String _label(SafeSearch? value) => switch (value) { + null => 'Safe: default', + SafeSearch.none => 'Safe: off', + SafeSearch.moderate => 'Safe: moderate', + SafeSearch.strict => 'Safe: strict', + }; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final selected = ref.watch( + webSearchSettingsControllerProvider.select((s) => s.safeSearch), + ); + + final isHighlighted = selected != null; + + return MenuAnchor( + builder: (context, controller, _) => _FilterPill( + icon: Icons.shield_moon_outlined, + label: _label(selected), + isHighlighted: isHighlighted, + onTap: () => controller.isOpen ? controller.close() : controller.open(), + ), + menuChildren: [ + MenuItemButton( + onPressed: () { + ref + .read(webSearchSettingsControllerProvider.notifier) + .setSafeSearch(null); + }, + child: _MenuRow( + label: 'Default (moderate)', + isSelected: selected == null, + ), + ), + const Divider(), + MenuItemButton( + onPressed: () { + ref + .read(webSearchSettingsControllerProvider.notifier) + .setSafeSearch(SafeSearch.none); + }, + child: _MenuRow( + label: 'Off', + isSelected: selected == SafeSearch.none, + isHighlighted: true, + ), + ), + MenuItemButton( + onPressed: () { + ref + .read(webSearchSettingsControllerProvider.notifier) + .setSafeSearch(SafeSearch.moderate); + }, + child: _MenuRow( + label: 'Moderate', + isSelected: selected == SafeSearch.moderate, + ), + ), + MenuItemButton( + onPressed: () { + ref + .read(webSearchSettingsControllerProvider.notifier) + .setSafeSearch(SafeSearch.strict); + }, + child: _MenuRow( + label: 'Strict', + isSelected: selected == SafeSearch.strict, + isHighlighted: true, + ), + ), + ], + ); + } +} + +class FreshnessSelector extends ConsumerWidget { + const FreshnessSelector({super.key}); + + String _label(TimeRange? value) => switch (value) { + null => 'Any time', + TimeRange.day => 'Past day', + TimeRange.week => 'Past week', + TimeRange.month => 'Past month', + TimeRange.year => 'Past year', + }; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final selected = ref.watch( + webSearchSettingsControllerProvider.select((s) => s.timeRange), + ); + + final isHighlighted = selected != null; + + return MenuAnchor( + builder: (context, controller, _) => _FilterPill( + icon: Icons.schedule_rounded, + label: _label(selected), + isHighlighted: isHighlighted, + onTap: () => controller.isOpen ? controller.close() : controller.open(), + ), + menuChildren: [ + MenuItemButton( + onPressed: () { + ref + .read(webSearchSettingsControllerProvider.notifier) + .setTimeRange(null); + }, + child: _MenuRow(label: 'Any time', isSelected: selected == null), + ), + const Divider(), + for (final value in TimeRange.values) + MenuItemButton( + onPressed: () { + ref + .read(webSearchSettingsControllerProvider.notifier) + .setTimeRange(value); + }, + child: _MenuRow( + label: _label(value), + isSelected: selected == value, + ), + ), + ], + ); + } +} diff --git a/apps/weblibre/lib/features/web_search/presentation/widgets/search_mode_selector.dart b/apps/weblibre/lib/features/web_search/presentation/widgets/search_mode_selector.dart new file mode 100644 index 00000000..4a3e7e6b --- /dev/null +++ b/apps/weblibre/lib/features/web_search/presentation/widgets/search_mode_selector.dart @@ -0,0 +1,113 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:search_backend/search_backend.dart'; +import 'package:weblibre/features/search_credits/domain/repositories/web_search_settings.dart'; + +class SearchModeSelector extends ConsumerWidget { + const SearchModeSelector({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + + final searchMode = ref.watch( + webSearchSettingsControllerProvider.select((s) => s.searchMode), + ); + + return MenuAnchor( + builder: (context, controller, _) => InkWell( + borderRadius: BorderRadius.circular(20), + onTap: () => controller.isOpen ? controller.close() : controller.open(), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(_iconFor(searchMode), color: colorScheme.primary, size: 18), + const SizedBox(width: 6), + Text( + _labelFor(searchMode), + style: textTheme.labelLarge?.copyWith( + color: colorScheme.onSurfaceVariant, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(width: 2), + Icon( + Icons.arrow_drop_down_rounded, + size: 18, + color: colorScheme.onSurfaceVariant, + ), + ], + ), + ), + ), + menuChildren: [ + for (final mode in SearchMode.values) + MenuItemButton( + onPressed: () { + ref + .read(webSearchSettingsControllerProvider.notifier) + .setSearchMode(mode); + }, + leadingIcon: Icon( + _iconFor(mode), + size: 20, + color: mode == searchMode + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + ), + trailingIcon: mode == searchMode + ? Icon( + Icons.check_rounded, + size: 18, + color: colorScheme.primary, + ) + : null, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _labelFor(mode), + style: textTheme.bodyMedium?.copyWith( + fontWeight: mode == searchMode + ? FontWeight.bold + : FontWeight.normal, + ), + ), + Text( + _descriptionFor(mode), + style: textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ); + } + + static IconData _iconFor(SearchMode mode) => switch (mode) { + SearchMode.general => Icons.public, + SearchMode.independentWeb => Icons.volunteer_activism, + SearchMode.smallWeb => Icons.explore, + }; + + static String _labelFor(SearchMode mode) => switch (mode) { + SearchMode.general => 'General', + SearchMode.independentWeb => 'Independent Web', + SearchMode.smallWeb => 'Small Web', + }; + + static String _descriptionFor(SearchMode mode) => switch (mode) { + SearchMode.general => 'Balanced results across the open web', + SearchMode.independentWeb => 'Favor smaller and less corporate sources', + SearchMode.smallWeb => 'Independent, personal & niche sites', + }; +} diff --git a/apps/weblibre/lib/features/web_search/presentation/widgets/search_result_card.dart b/apps/weblibre/lib/features/web_search/presentation/widgets/search_result_card.dart new file mode 100644 index 00000000..22bc4863 --- /dev/null +++ b/apps/weblibre/lib/features/web_search/presentation/widgets/search_result_card.dart @@ -0,0 +1,527 @@ +import 'dart:typed_data'; + +import 'package:fast_equatable/fast_equatable.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:intl/intl.dart'; +import 'package:nullability/nullability.dart'; +import 'package:search_backend/search_backend.dart'; +import 'package:weblibre/features/search_credits/domain/repositories/web_search_settings.dart'; +import 'package:weblibre/features/web_search/domain/controllers/search_controller.dart'; +import 'package:weblibre/features/web_search/domain/entities/captured_page_state.dart'; +import 'package:weblibre/features/web_search/domain/entities/fetch_method.dart'; +import 'package:weblibre/features/web_search/presentation/widgets/search_result_metadata_chips.dart'; +import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart'; +import 'package:weblibre/presentation/widgets/url_icon.dart'; + +class WebSearchResultCard extends ConsumerWidget { + final CompactSearchResult result; + + final Future Function(Uri url) onOpen; + final Future Function(Uri url) onFetch; + final Future Function(Uri url) onPreview; + final Future Function(CapturedPageState captured) onOpenCapture; + + const WebSearchResultCard({ + super.key, + required this.result, + required this.onOpen, + required this.onFetch, + required this.onPreview, + required this.onOpenCapture, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + + final imageUrl = (result.imgSrc?.isNotEmpty ?? false) + ? result.imgSrc + : (result.thumbnail?.isNotEmpty ?? false) + ? result.thumbnail + : null; + + final imageBytes = imageUrl.mapNotNull( + (imageUrl) => ref.watch( + metaSearchControllerProvider.select((s) => s.imagesByUrl[imageUrl]), + ), + ); + + final document = ref.watch( + metaSearchControllerProvider.select((s) => s.documentsByUrl[result.url]), + ); + + final queryLanguage = ref.watch( + webSearchSettingsControllerProvider.select((s) => s.language), + ); + + return Card( + color: colorScheme.surfaceContainerHigh, + clipBehavior: Clip.antiAlias, + margin: EdgeInsets.zero, + child: InkWell( + onTap: () => onOpen(result.url), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _UrlRow(result: result), + const SizedBox(height: 16), + _ContentRow( + result: result, + imageBytes: imageBytes, + colorScheme: colorScheme, + textTheme: textTheme, + ), + if ((result.metadata ?? const []).isNotEmpty || + document != null) ...[ + const SizedBox(height: 12), + SearchResultMetadataChips( + metadata: result.metadata ?? const [], + pageMetadata: document?.metadata, + queryLanguage: queryLanguage, + ), + ], + if (result.metadata case final metadata? when metadata.isNotEmpty) + SearchResultMetadataExpandable(metadata: metadata), + const SizedBox(height: 16), + _FetchFooter( + url: result.url, + onFetch: onFetch, + onPreview: onPreview, + onOpenCapture: onOpenCapture, + ), + ], + ), + ), + ), + ); + } +} + +class _UrlRow extends StatelessWidget { + final CompactSearchResult result; + + const _UrlRow({required this.result}); + + @override + Widget build(BuildContext context) { + return UriBreadcrumb( + uri: result.url, + icon: UrlIcon([result.url], iconSize: 16, cacheOnly: true), + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ); + } +} + +class _ContentRow extends StatelessWidget { + final CompactSearchResult result; + final Uint8List? imageBytes; + final ColorScheme colorScheme; + final TextTheme textTheme; + + const _ContentRow({ + required this.result, + required this.imageBytes, + required this.colorScheme, + required this.textTheme, + }); + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (imageBytes != null) ...[ + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Image.memory( + imageBytes!, + width: 88, + height: 88, + fit: BoxFit.cover, + gaplessPlayback: true, + errorBuilder: (_, _, _) => const SizedBox.shrink(), + ), + ), + const SizedBox(width: 16), + ], + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + result.title, + style: textTheme.titleMedium?.copyWith( + color: colorScheme.primary, + fontWeight: FontWeight.w600, + height: 1.3, + ), + ), + if (result.publishedDate case final String date + when date.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + _formatDate(date), + style: textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + if (result.content case final String content + when content.trim().isNotEmpty) ...[ + const SizedBox(height: 6), + _ExpandableDescription( + text: content.trim(), + style: textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + height: 1.5, + ), + ), + ], + ], + ), + ), + ], + ); + } + + String _formatDate(String date) { + final parsed = DateTime.tryParse(date); + + if (parsed != null) { + return DateFormat.yMMMd().format(parsed); + } + + return date; + } +} + +/// Per-URL footer state, batched into a single select() so the footer only +/// rebuilds when something relevant to *this* URL changes. +/// +/// `capturing`/`captures` are exposed for rendering, but they are NOT used in +/// [hashParameters] — Dart's built-in `Set`/`Map` compare by identity, so +/// including them would defeat the dedupe (the controller produces fresh +/// instances on every state mutation). Equality is driven instead by scalar +/// signatures derived from those collections, which is enough to detect any +/// change a single result card cares about. +class _FooterState with FastEquatable { + final bool hasOpenSession; + final bool isFetching; + final bool isFetched; + final String? fetchError; + final Set capturing; + final Map captures; + final int _capturingSignature; + final int _capturesSignature; + + _FooterState({ + required this.hasOpenSession, + required this.isFetching, + required this.isFetched, + required this.fetchError, + required this.capturing, + required this.captures, + }) : _capturingSignature = _hashCapturing(capturing), + _capturesSignature = _hashCaptures(captures); + + static int _hashCapturing(Set set) { + // Order-independent hash so we don't depend on Set iteration order. + var h = 0; + for (final choice in set) { + h ^= choice.index; + } + return h; + } + + static int _hashCaptures(Map map) { + var h = 0; + for (final entry in map.entries) { + // status + errorMessage + localPath cover everything the footer renders + // about a capture; any change that affects the rendered chip flips one + // of these fields. + final v = Object.hash( + entry.key.index, + entry.value.status.index, + entry.value.errorMessage, + entry.value.localPath, + entry.value.captureId, + entry.value.downloadToken, + ); + h ^= v; + } + return h; + } + + @override + List get hashParameters => [ + hasOpenSession, + isFetching, + isFetched, + fetchError, + _capturingSignature, + _capturesSignature, + ]; +} + +class _FetchFooter extends ConsumerWidget { + final Uri url; + + final Future Function(Uri url) onFetch; + final Future Function(Uri url) onPreview; + final Future Function(CapturedPageState captured) onOpenCapture; + + const _FetchFooter({ + required this.url, + required this.onFetch, + required this.onPreview, + required this.onOpenCapture, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final footerState = ref.watch( + metaSearchControllerProvider.select( + (s) => _FooterState( + hasOpenSession: s.hasOpenSession, + isFetching: s.fetchingUrls.contains(url), + isFetched: s.documentsByUrl.containsKey(url), + fetchError: s.fetchErrorByUrl[url], + capturing: s.capturingByUrl[url] ?? const {}, + captures: s.capturedPagesByUrl[url] ?? const {}, + ), + ), + ); + + final readyChips = []; + final busyChips = []; + final errorChips = []; + + for (final choice in FetchMethodChoice.values) { + if (choice == FetchMethodChoice.trafilatura) { + if (footerState.isFetched) { + readyChips.add( + _CaptureChip(choice: choice, onTap: () => onPreview(url)), + ); + } else if (footerState.fetchError case final String err) { + errorChips.add( + _ErrorChip( + choice: choice, + errorMessage: err, + canRetry: footerState.hasOpenSession, + onRetry: () => ref + .read(metaSearchControllerProvider.notifier) + .fetchPage(url), + ), + ); + } + continue; + } + + final captured = footerState.captures[choice]; + + if (captured != null && captured.status == CapturedPageStatus.ready) { + readyChips.add( + _CaptureChip(choice: choice, onTap: () => onOpenCapture(captured)), + ); + } else if (captured != null && + captured.status == CapturedPageStatus.downloadFailed) { + // Distinguish "download failed" (we have a captureId/downloadToken + // and can retry just the artifact download — no new server work) + // from "capture failed on server" (no captureId; would need a fresh + // capture command, which costs another upstream render). + final canRetryDownload = + captured.captureId != null && captured.downloadToken != null; + + errorChips.add( + _ErrorChip( + choice: choice, + errorMessage: + captured.errorMessage ?? 'Capture failed for unknown reason.', + canRetry: canRetryDownload && footerState.hasOpenSession, + onRetry: () => ref + .read(metaSearchControllerProvider.notifier) + .retryCaptureDownload(url, choice), + ), + ); + } else if (_isMethodBusy(footerState, choice)) { + busyChips.add(_BusyChip(choice: choice)); + } + } + + final chips = [...readyChips, ...busyChips, ...errorChips]; + + return Row( + children: [ + Expanded( + child: chips.isEmpty + ? const SizedBox.shrink() + : Wrap(spacing: 6, runSpacing: 6, children: chips), + ), + // Once the WebSocket session has closed, no further fetch/capture + // commands can be issued — hide the button rather than have it + // surface a generic "session is no longer available" error on every + // tap. The chips above remain interactive (read-only). + if (footerState.hasOpenSession) ...[ + const SizedBox(width: 8), + FilledButton.tonalIcon( + onPressed: () => onFetch(url), + icon: const Icon(Icons.download_rounded, size: 18), + label: const Text('Fetch'), + style: FilledButton.styleFrom(elevation: 0), + ), + ], + ], + ); + } + + bool _isMethodBusy(_FooterState s, FetchMethodChoice method) { + if (method == FetchMethodChoice.trafilatura) { + return s.isFetching; + } + if (s.capturing.contains(method)) { + return true; + } + final captured = s.captures[method]; + return captured != null && + (captured.status == CapturedPageStatus.capturing || + captured.status == CapturedPageStatus.downloading); + } +} + +class _CaptureChip extends StatelessWidget { + final FetchMethodChoice choice; + final Future Function() onTap; + + const _CaptureChip({required this.choice, required this.onTap}); + + @override + Widget build(BuildContext context) { + return ActionChip( + avatar: Icon(choice.icon, size: 16), + label: Text(choice.shortLabel), + visualDensity: VisualDensity.compact, + onPressed: () => onTap(), + ); + } +} + +/// Failed-fetch / failed-capture chip. Always rendered (red, with an error +/// outline icon) so the user can see *which* method failed; tapping pops a +/// dialog with the verbatim server message and an optional retry action. +class _ErrorChip extends StatelessWidget { + final FetchMethodChoice choice; + final String errorMessage; + final bool canRetry; + final Future Function() onRetry; + + const _ErrorChip({ + required this.choice, + required this.errorMessage, + required this.canRetry, + required this.onRetry, + }); + + Future _showDetail(BuildContext context) async { + final retry = await showDialog( + context: context, + builder: (dialogContext) { + return AlertDialog( + title: Row( + children: [ + const Icon(Icons.error_outline, color: Colors.red), + const SizedBox(width: 8), + Expanded(child: Text('${choice.title} failed')), + ], + ), + content: SingleChildScrollView(child: Text(errorMessage)), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: const Text('Close'), + ), + if (canRetry) + FilledButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + child: const Text('Retry'), + ), + ], + ); + }, + ); + + if (retry == true) { + await onRetry(); + } + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return ActionChip( + avatar: Icon(Icons.error_outline, size: 16, color: colorScheme.error), + label: Text(choice.shortLabel), + visualDensity: VisualDensity.compact, + side: BorderSide(color: colorScheme.error), + labelStyle: TextStyle(color: colorScheme.error), + onPressed: () => _showDetail(context), + ); + } +} + +class _BusyChip extends StatelessWidget { + final FetchMethodChoice choice; + + const _BusyChip({required this.choice}); + + @override + Widget build(BuildContext context) { + return Chip( + avatar: const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ), + label: Text(choice.shortLabel), + visualDensity: VisualDensity.compact, + ); + } +} + +class _ExpandableDescription extends HookWidget { + static const _collapsedMaxLines = 4; + + final String text; + final TextStyle? style; + + const _ExpandableDescription({required this.text, required this.style}); + + @override + Widget build(BuildContext context) { + final expanded = useState(false); + + return GestureDetector( + behavior: HitTestBehavior.opaque, + onLongPress: () { + expanded.value = !expanded.value; + }, + child: AnimatedSize( + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + alignment: Alignment.topCenter, + child: Text( + text, + maxLines: expanded.value ? null : _collapsedMaxLines, + overflow: expanded.value ? TextOverflow.clip : TextOverflow.ellipsis, + style: style, + ), + ), + ); + } +} diff --git a/apps/weblibre/lib/features/web_search/presentation/widgets/search_result_metadata_chips.dart b/apps/weblibre/lib/features/web_search/presentation/widgets/search_result_metadata_chips.dart new file mode 100644 index 00000000..4a46f15d --- /dev/null +++ b/apps/weblibre/lib/features/web_search/presentation/widgets/search_result_metadata_chips.dart @@ -0,0 +1,407 @@ +import 'package:fading_scroll/fading_scroll.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:intl/intl.dart'; +import 'package:intl/locale.dart' as intl; +import 'package:search_backend/search_backend.dart'; +import 'package:weblibre/domain/repositories/locale_resolver.dart'; + +const _expandableKeys = {'snippet', 'review', 'question'}; + +const _keyOrder = [ + // Badges + 'type', + 'subtype', + 'access', + // Time / freshness + 'released', + 'duration', + 'time', + 'hours', + // Quality / popularity + 'rating', + 'answers', + 'stars', + 'forks', + // Identity + 'author', + 'publisher', + 'organization', + 'sitename', + 'forum', + // Commerce + 'price', + 'price_range', + // Content descriptors + 'genre', + 'cuisine', + 'categories', + 'pages', + 'servings', + 'calories', + 'version', + 'distance', + // Meta + 'language', + 'license', + 'pagetype', +]; + +const _hiddenKeys = { + // The publisher-declared article date duplicates the inline `publishedDate` + // shown under the title. Keep only the inline version. + 'date', + 'score', + 'gravity', + 'quality', + 'phrases', + 'size', + 'format', + 'results_from_domain', + 'more_from_domain', + 'content_type', + 'tags', +}; + +// Built once at import time so the sort comparator doesn't rebuild it on +// every widget rebuild. +final Map _keyOrderIndex = { + for (var i = 0; i < _keyOrder.length; i++) _keyOrder[i]: i, +}; + +IconData _iconFor(String key) { + switch (key) { + case 'type': + return Icons.label_outline; + case 'subtype': + return Icons.category_outlined; + case 'rating': + return Icons.star_outline; + case 'language': + return Icons.language; + case 'author': + return Icons.person_outline; + case 'publisher': + case 'organization': + case 'sitename': + return MdiIcons.domain; + case 'forum': + return MdiIcons.forum; + case 'answers': + return Icons.question_answer_outlined; + case 'price': + case 'price_range': + return MdiIcons.currencyUsd; + case 'access': + return Icons.lock_outline; + case 'duration': + case 'time': + case 'hours': + return Icons.schedule; + case 'pages': + return MdiIcons.bookOpenPageVariantOutline; + // `date` is in `_hiddenKeys`, so only `released` reaches this branch in + // practice — keep the case for safety should `date` ever be un-hidden. + case 'released': + case 'date': + return Icons.calendar_month; + case 'genre': + case 'cuisine': + case 'categories': + return Icons.local_offer_outlined; + case 'servings': + return MdiIcons.silverwareForkKnife; + case 'calories': + return MdiIcons.fire; + case 'stars': + return Icons.star_border; + case 'forks': + return MdiIcons.sourceFork; + case 'version': + return MdiIcons.tagOutline; + case 'distance': + return Icons.place_outlined; + case 'license': + return MdiIcons.license; + case 'pagetype': + return Icons.article_outlined; + default: + return Icons.info_outline; + } +} + +String? _formatValue(String key, String value) { + switch (key) { + case 'released': + final parsed = DateTime.tryParse(value); + if (parsed != null) return DateFormat.yMMMd().format(parsed); + return value; + default: + return value; + } +} + +/// Compare the primary subtag of two BCP 47-ish language strings, ignoring +/// region and case. `null`/empty `queryTag` means "no preference set", so +/// the result language is always shown. +bool _languageMatchesQuery(String resultLanguage, String? queryTag) { + if (queryTag == null || queryTag.isEmpty) return false; + + final result = resultLanguage + .trim() + .toLowerCase() + .split(RegExp(r'[-_]')) + .first; + + final query = queryTag.trim().toLowerCase().split(RegExp(r'[-_]')).first; + + if (result.isEmpty || query.isEmpty) return false; + + return result == query; +} + +List _metadataFromPage(PageMetadata? pageMetadata) { + if (pageMetadata == null) return const []; + + final items = []; + + if (pageMetadata.date case final String date when date.isNotEmpty) { + items.add(MetadataItem(key: 'released', value: date)); + } + if (pageMetadata.sitename case final String sitename + when sitename.isNotEmpty) { + items.add(MetadataItem(key: 'sitename', value: sitename)); + } + if (pageMetadata.author case final String author when author.isNotEmpty) { + items.add(MetadataItem(key: 'author', value: author)); + } + if (pageMetadata.language case final String language + when language.isNotEmpty) { + items.add(MetadataItem(key: 'language', value: language)); + } + if (pageMetadata.license case final String license when license.isNotEmpty) { + items.add(MetadataItem(key: 'license', value: license)); + } + if (pageMetadata.pagetype case final String pagetype + when pagetype.isNotEmpty) { + items.add(MetadataItem(key: 'pagetype', value: pagetype)); + } + + return items; +} + +class SearchResultMetadataChips extends StatelessWidget { + final List metadata; + final PageMetadata? pageMetadata; + + /// ISO 639-1 language code of the query (e.g. `en`, `de`). When the + /// result's `language` metadata matches by primary subtag, the language + /// chip is suppressed as redundant. + final String? queryLanguage; + + const SearchResultMetadataChips({ + super.key, + required this.metadata, + this.pageMetadata, + this.queryLanguage, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + final seen = {}; + final merged = <_ResolvedMetadataItem>[]; + + // Page metadata items first so they take priority on deduplication. + for (final item in _metadataFromPage(pageMetadata)) { + if (_expandableKeys.contains(item.key)) continue; + if (_hiddenKeys.contains(item.key)) continue; + if (item.value.trim().isEmpty) continue; + if (item.key == 'language' && + _languageMatchesQuery(item.value, queryLanguage)) { + continue; + } + if (!seen.add(item.key)) continue; + merged.add( + _ResolvedMetadataItem(item: item, isLanguage: item.key == 'language'), + ); + } + + for (final item in metadata) { + if (_expandableKeys.contains(item.key)) continue; + if (_hiddenKeys.contains(item.key)) continue; + if (item.value.trim().isEmpty) continue; + if (item.key == 'language' && + _languageMatchesQuery(item.value, queryLanguage)) { + continue; + } + // Skip if page metadata already provided this key (preview takes priority). + if (!seen.add(item.key)) continue; + merged.add( + _ResolvedMetadataItem(item: item, isLanguage: item.key == 'language'), + ); + } + + if (merged.isEmpty) return const SizedBox.shrink(); + + merged.sort((a, b) { + final ai = _keyOrderIndex[a.item.key] ?? _keyOrder.length; + final bi = _keyOrderIndex[b.item.key] ?? _keyOrder.length; + return ai.compareTo(bi); + }); + + return FadingScroll( + fadingSize: 15, + builder: (context, controller) { + return SingleChildScrollView( + controller: controller, + scrollDirection: Axis.horizontal, + child: Row( + children: [ + for (var i = 0; i < merged.length; i++) ...[ + if (i > 0) const SizedBox(width: 8), + // Decorative — these chips don't filter or navigate. Using a + // plain Chip (instead of an OutlinedButton with an empty + // onPressed) avoids the misleading tap ripple. + Chip( + avatar: Icon( + _iconFor(merged[i].item.key), + size: 16, + color: colorScheme.onSurfaceVariant, + ), + label: merged[i].isLanguage + ? _ResolvedLanguageLabel( + languageTag: merged[i].item.value, + ) + : Text( + _formatValue( + merged[i].item.key, + merged[i].item.value, + ) ?? + merged[i].item.value, + ), + side: BorderSide(color: colorScheme.outlineVariant), + labelStyle: TextStyle(color: colorScheme.onSurfaceVariant), + backgroundColor: Colors.transparent, + visualDensity: VisualDensity.compact, + ), + ], + ], + ), + ); + }, + ); + } +} + +class _ResolvedMetadataItem { + final MetadataItem item; + final bool isLanguage; + const _ResolvedMetadataItem({required this.item, required this.isLanguage}); +} + +class _ResolvedLanguageLabel extends ConsumerWidget { + final String languageTag; + + const _ResolvedLanguageLabel({required this.languageTag}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final locale = intl.Locale.tryParse(languageTag); + + if (locale == null) return Text(languageTag); + + final resolved = ref.watch(resolveLocaleProvider(locale)); + + return Text( + resolved.maybeWhen( + data: (data) => data.languageName, + orElse: () => languageTag, + ), + ); + } +} + +class SearchResultMetadataExpandable extends StatelessWidget { + final List metadata; + + const SearchResultMetadataExpandable({super.key, required this.metadata}); + + @override + Widget build(BuildContext context) { + final entries = metadata + .where((item) => _expandableKeys.contains(item.key)) + .where((item) => item.value.trim().isNotEmpty) + .toList(); + + if (entries.isEmpty) return const SizedBox.shrink(); + + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + final hasSnippets = entries.any((e) => e.key == 'snippet'); + final hasReview = entries.any((e) => e.key == 'review'); + final hasQuestion = entries.any((e) => e.key == 'question'); + + final parts = [ + if (hasQuestion) 'question', + if (hasSnippets) 'snippets', + if (hasReview) 'review', + ]; + + final label = parts.isEmpty ? 'More' : 'Show ${parts.join(' & ')}'; + + return Theme( + data: Theme.of(context).copyWith( + dividerColor: Colors.transparent, + splashColor: Colors.transparent, + ), + child: ExpansionTile( + tilePadding: EdgeInsets.zero, + childrenPadding: const EdgeInsets.only(bottom: 8), + dense: true, + visualDensity: VisualDensity.compact, + title: Text( + label, + style: textTheme.labelLarge?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + children: [ + for (final item in entries) ...[ + if (item != entries.first) const SizedBox(height: 8), + Align( + alignment: Alignment.centerLeft, + child: Text.rich( + TextSpan( + children: [ + if (item.key == 'question') + TextSpan( + text: 'Q: ', + style: textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurface, + fontWeight: FontWeight.w600, + height: 1.5, + ), + ), + TextSpan( + text: item.value, + style: textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + height: 1.5, + fontStyle: item.key == 'question' + ? FontStyle.italic + : null, + ), + ), + ], + ), + ), + ), + ], + ], + ), + ); + } +} diff --git a/apps/weblibre/lib/features/web_search/presentation/widgets/web_search_infobox_card.dart b/apps/weblibre/lib/features/web_search/presentation/widgets/web_search_infobox_card.dart new file mode 100644 index 00000000..9d024933 --- /dev/null +++ b/apps/weblibre/lib/features/web_search/presentation/widgets/web_search_infobox_card.dart @@ -0,0 +1,398 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:search_backend/search_backend.dart'; +import 'package:weblibre/features/web_search/domain/controllers/search_controller.dart'; +import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart'; +import 'package:weblibre/presentation/widgets/url_icon.dart'; + +class WebSearchInfoboxCard extends HookConsumerWidget { + final CompactInfobox info; + final Future Function(Uri url) onOpen; + + /// When non-null the card uses this externally-controlled expansion state + /// (e.g. shared across a carousel). When null, the card manages its own + /// state with a default of expanded. + final bool? expandedOverride; + final VoidCallback? onToggleExpanded; + + const WebSearchInfoboxCard({ + super.key, + required this.info, + required this.onOpen, + this.expandedOverride, + this.onToggleExpanded, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + + final imageUrl = info.imgSrc; + final imageBytes = (imageUrl == null || imageUrl.isEmpty) + ? null + : ref.watch( + metaSearchControllerProvider.select((s) => s.imagesByUrl[imageUrl]), + ); + + final attributes = info.attributes ?? const []; + final urls = info.urls ?? const []; + final heading = _heading(info); + + final localExpanded = useState(true); + final isExpanded = expandedOverride ?? localExpanded.value; + + void toggle() { + final external = onToggleExpanded; + if (external != null) { + external(); + } else { + localExpanded.value = !localExpanded.value; + } + } + + return Card( + color: colorScheme.surfaceContainerHigh, + clipBehavior: Clip.antiAlias, + margin: EdgeInsets.zero, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + InkWell( + onTap: toggle, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 8, 12), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (heading.isNotEmpty) + Text( + heading, + style: textTheme.headlineSmall?.copyWith( + color: colorScheme.onSurface, + fontWeight: FontWeight.w600, + ), + ), + if (info.url case final Uri sourceUrl) ...[ + const SizedBox(height: 6), + UriBreadcrumb( + uri: sourceUrl, + icon: UrlIcon( + [sourceUrl], + iconSize: 16, + cacheOnly: true, + ), + style: textTheme.labelMedium?.copyWith( + color: colorScheme.primary, + fontWeight: FontWeight.w500, + ), + ), + ], + ], + ), + ), + AnimatedRotation( + turns: isExpanded ? 0.5 : 0, + duration: const Duration(milliseconds: 200), + child: Icon( + Icons.expand_more, + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + if (isExpanded) + _Body( + info: info, + imageBytes: imageBytes, + attributes: attributes, + urls: urls, + onOpen: onOpen, + colorScheme: colorScheme, + textTheme: textTheme, + ), + ], + ), + ); + } + + String _heading(CompactInfobox info) { + final title = info.title?.trim(); + if (title != null && title.isNotEmpty) return title; + return info.infobox.trim(); + } +} + +class _Body extends StatelessWidget { + final CompactInfobox info; + final Uint8List? imageBytes; + final List attributes; + final List urls; + final Future Function(Uri url) onOpen; + final ColorScheme colorScheme; + final TextTheme textTheme; + + const _Body({ + required this.info, + required this.imageBytes, + required this.attributes, + required this.urls, + required this.onOpen, + required this.colorScheme, + required this.textTheme, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (imageBytes != null) ...[ + Align( + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 260), + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Image.memory( + imageBytes!, + fit: BoxFit.contain, + gaplessPlayback: true, + errorBuilder: (_, _, _) => const SizedBox.shrink(), + ), + ), + ), + ), + const SizedBox(height: 16), + ], + if (info.content case final String content + when content.trim().isNotEmpty) ...[ + Text( + content.trim(), + style: textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + height: 1.5, + ), + ), + const SizedBox(height: 12), + ], + if (info.source.isNotEmpty) + Text( + 'Source: ${info.source}', + style: textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + if (attributes.isNotEmpty) + _Factsheet( + attributes: attributes, + colorScheme: colorScheme, + textTheme: textTheme, + ), + if (urls.isNotEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + for (final urlObj in urls) ...[ + OutlinedButton.icon( + onPressed: () => onOpen(urlObj.url), + icon: Icon(_iconForLink(urlObj.title), size: 16), + label: Text(urlObj.title), + style: OutlinedButton.styleFrom( + foregroundColor: colorScheme.onSurfaceVariant, + side: BorderSide(color: colorScheme.outlineVariant), + padding: const EdgeInsets.symmetric(horizontal: 12), + ), + ), + const SizedBox(width: 8), + ], + ], + ), + ), + ) + else + const SizedBox(height: 16), + ], + ); + } + + IconData _iconForLink(String title) { + final lower = title.toLowerCase(); + + if (lower.contains('wikipedia') || lower.contains('wiki')) { + return Icons.article_outlined; + } + if (lower.contains('reddit')) return Icons.forum_outlined; + if (lower.contains('facebook')) return Icons.facebook_outlined; + if (lower.contains('youtube') || lower.contains('video')) { + return Icons.play_circle_outline; + } + if (lower.contains('twitter') || lower.contains('x.com')) { + return Icons.alternate_email; + } + if (lower.contains('instagram')) return Icons.photo_camera_outlined; + if (lower.contains('github')) return Icons.code; + if (lower.contains('mastodon')) return Icons.public; + + return Icons.link; + } +} + +class _Factsheet extends StatelessWidget { + final List attributes; + final ColorScheme colorScheme; + final TextTheme textTheme; + + const _Factsheet({ + required this.attributes, + required this.colorScheme, + required this.textTheme, + }); + + @override + Widget build(BuildContext context) { + return Theme( + data: Theme.of(context).copyWith(dividerColor: Colors.transparent), + child: ExpansionTile( + title: Text( + 'Factsheet', + style: textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600), + ), + tilePadding: const EdgeInsets.symmetric(horizontal: 16), + childrenPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + expandedCrossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final attr in attributes) + if (attr.value case final String value when value.trim().isNotEmpty) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: RichText( + text: TextSpan( + style: textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + height: 1.5, + ), + children: [ + TextSpan( + text: + '${attr.label.replaceAll(RegExp(r':+\s*$'), '')}: ', + style: TextStyle( + fontWeight: FontWeight.w600, + color: colorScheme.onSurface, + ), + ), + TextSpan(text: value), + ], + ), + ), + ), + ], + ), + ); + } +} + +class WebSearchInfoboxCarousel extends HookConsumerWidget { + final List infos; + final Future Function(Uri url) onOpen; + + /// Upper bound for the carousel viewport. The PageView gets exactly this + /// height; if a card is taller, the inner [SingleChildScrollView] handles + /// the overflow. Picked large enough to fit a typical Wikipedia-style + /// infobox without scrolling, small enough not to dominate the screen. + static const _maxCardHeight = 520.0; + + const WebSearchInfoboxCarousel({ + super.key, + required this.infos, + required this.onOpen, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final colorScheme = Theme.of(context).colorScheme; + + final controller = usePageController(); + final currentPage = useState(0); + final expanded = useState(true); + + useEffect(() { + void listener() { + final page = controller.page?.round() ?? 0; + if (page != currentPage.value) { + currentPage.value = page; + } + } + + controller.addListener(listener); + return () => controller.removeListener(listener); + }, [controller]); + + // Earlier revisions measured each page's real height in a post-frame + // callback and animated the carousel to match. With SizeChangedLayout + // notifications + post-frame setState, this created a measurement + // feedback loop that was vulnerable to floating-point jitter and + // sometimes spent the whole expand/collapse animation re-measuring. + // Using a fixed maximum + per-page scrolling sidesteps the loop and is + // measurably cheaper. + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox( + height: _maxCardHeight, + child: PageView.builder( + controller: controller, + itemCount: infos.length, + itemBuilder: (context, index) { + return SingleChildScrollView( + child: WebSearchInfoboxCard( + info: infos[index], + onOpen: onOpen, + expandedOverride: expanded.value, + onToggleExpanded: () => expanded.value = !expanded.value, + ), + ); + }, + ), + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + for (var i = 0; i < infos.length; i++) + AnimatedContainer( + duration: const Duration(milliseconds: 200), + margin: const EdgeInsets.symmetric(horizontal: 3), + width: currentPage.value == i ? 18 : 6, + height: 6, + decoration: BoxDecoration( + color: currentPage.value == i + ? colorScheme.primary + : colorScheme.outlineVariant, + borderRadius: BorderRadius.circular(3), + ), + ), + ], + ), + ], + ); + } +} diff --git a/apps/weblibre/lib/features/web_search/presentation/widgets/web_search_results_section.dart b/apps/weblibre/lib/features/web_search/presentation/widgets/web_search_results_section.dart new file mode 100644 index 00000000..1a57792a --- /dev/null +++ b/apps/weblibre/lib/features/web_search/presentation/widgets/web_search_results_section.dart @@ -0,0 +1,305 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:weblibre/core/routing/routes.dart'; +import 'package:weblibre/features/account/data/supabase_config.dart'; +import 'package:weblibre/features/search_credits/domain/repositories/search_credits_repository.dart'; +import 'package:weblibre/features/search_credits/domain/repositories/search_token_stash_repository.dart'; +import 'package:weblibre/features/web_search/domain/controllers/search_controller.dart'; +import 'package:weblibre/features/web_search/domain/entities/captured_page_state.dart'; +import 'package:weblibre/features/web_search/presentation/dialogs/fetch_method_dialog.dart'; +import 'package:weblibre/features/web_search/presentation/open_in_new_tab.dart'; +import 'package:weblibre/features/web_search/presentation/screens/page_preview.dart'; +import 'package:weblibre/features/web_search/presentation/widgets/search_result_card.dart'; +import 'package:weblibre/features/web_search/presentation/widgets/web_search_infobox_card.dart'; +import 'package:weblibre/presentation/widgets/failure_widget.dart'; + +/// Number of result cards from the bottom at which to prefetch the next +/// page. With a backend page size of 10, four cards of look-ahead means +/// the next page is requested when the user reaches result 6/10 — enough +/// runway for the round-trip on a typical mobile connection without +/// firing the request when it's still ambiguous whether the user will +/// actually scroll further. +const _loadMoreThreshold = 4; + +/// Combined credits+tokens balance below which the small "low credits" +/// chip surfaces in the app bar. Picked so the user gets ample warning +/// (~one full page worth of token spend) before running out mid-session. +const _lowCreditWarningThreshold = 25; + +/// Resolves [WebSearchOpenTarget] for the *next* result tap. +/// +/// We resolve lazily (per-tap) instead of capturing once at section build +/// time so the user can change the tab-type or container selectors in the +/// search header *after* a search completes and have those choices honoured +/// on subsequent taps. +typedef WebSearchOpenTargetResolver = WebSearchOpenTarget Function(); + +class WebSearchResultsSection extends HookConsumerWidget { + final WebSearchOpenTargetResolver resolveOpenTarget; + + const WebSearchResultsSection({super.key, required this.resolveOpenTarget}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + useOnAppLifecycleStateChange((previous, current) async { + if (current == AppLifecycleState.resumed) { + await ref.read(searchCreditsRepositoryProvider.notifier).refresh(); + } + }); + + Future openUri(Uri uri) { + return ref + .read(webSearchTabOpenerProvider) + .open(context, ref, uri, target: resolveOpenTarget()); + } + + Future showPreview(Uri uri) { + return Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => + PagePreviewScreen(uri: uri, resolveOpenTarget: resolveOpenTarget), + ), + ); + } + + Future openCapture(CapturedPageState captured) async { + final captureId = captured.captureId; + if (captureId == null || captured.localPath == null) { + return; + } + await ref + .read(webSearchTabOpenerProvider) + .openCapture( + context, + ref, + captureId: captureId, + sourceUrl: captured.sourceUrl, + target: resolveOpenTarget(), + contentType: captured.contentType, + method: captured.method, + variant: captured.variant, + ); + } + + Future onFetch(Uri uri) { + return showFetchMethodSheet( + context, + url: uri, + onPreview: showPreview, + onOpenCapture: openCapture, + ); + } + + // We re-render the whole results sliver on any controller state change + // because we need the full results/infos lists below. The `select` for + // the empty-error message is therefore subsumed by this watch. + final state = ref.watch(metaSearchControllerProvider); + + if (state.status == WebSearchStatus.needsCredits) { + return const SliverToBoxAdapter(child: _NeedsCredits()); + } + + if (state.status == WebSearchStatus.error && state.results.isEmpty) { + return SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(24), + child: FailureWidget( + title: 'Search failed', + exception: state.errorMessage, + ), + ), + ); + } + + if (state.status == WebSearchStatus.submitting && state.results.isEmpty) { + return const SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text('Searching the web...'), + ], + ), + ), + ); + } + + if (state.results.isNotEmpty || state.infos.isNotEmpty) { + return SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + sliver: SliverMainAxisGroup( + slivers: [ + if (state.infos.length == 1) + SliverToBoxAdapter( + child: WebSearchInfoboxCard( + info: state.infos.first, + onOpen: openUri, + ), + ) + else if (state.infos.length > 1) + SliverToBoxAdapter( + child: WebSearchInfoboxCarousel( + infos: state.infos, + onOpen: openUri, + ), + ), + if (state.infos.isNotEmpty && state.results.isNotEmpty) + const SliverToBoxAdapter(child: SizedBox(height: 12)), + SliverList.separated( + itemCount: state.results.length, + separatorBuilder: (_, _) => const SizedBox(height: 12), + itemBuilder: (context, index) { + // Prefetch the next page once the user is within + // _loadMoreThreshold cards of the end. Scheduled in a + // post-frame callback because triggering state writes + // during build is not allowed; the controller no-ops if + // a load is already in flight, so re-scheduling on + // rebuilds is safe. + if (state.hasMore && + !state.isLoadingMore && + index >= state.results.length - _loadMoreThreshold) { + WidgetsBinding.instance.addPostFrameCallback((_) { + unawaited( + ref + .read(metaSearchControllerProvider.notifier) + .loadNextPage(), + ); + }); + } + final result = state.results[index]; + return WebSearchResultCard( + key: ValueKey(result.url), + result: result, + onOpen: openUri, + onFetch: onFetch, + onPreview: showPreview, + onOpenCapture: openCapture, + ); + }, + ), + if (state.hasMore || state.isLoadingMore) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Center( + child: state.isLoadingMore + ? const SizedBox( + height: 24, + width: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const SizedBox.shrink(), + ), + ), + ), + ], + ), + ); + } + + if (state.query.isNotEmpty) { + return SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text( + 'No results found for "${state.query}".', + textAlign: TextAlign.center, + ), + ), + ); + } + + return const SliverToBoxAdapter(child: SizedBox.shrink()); + } +} + +class WebSearchStatusChip extends ConsumerWidget { + const WebSearchStatusChip({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final creditsAsync = ref.watch(searchCreditsRepositoryProvider); + final stashAsync = ref.watch(searchTokenStashCountProvider); + + if (!creditsAsync.hasValue || !stashAsync.hasValue) { + return const SizedBox.shrink(); + } + + final credits = creditsAsync.value!.availableCredits; + final stash = stashAsync.value!; + + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + + if (credits + stash >= _lowCreditWarningThreshold) { + return const SizedBox.shrink(); + } + + return Padding( + padding: const EdgeInsets.only(right: 8.0), + child: InkWell( + borderRadius: BorderRadius.circular(20), + onTap: () => AccountSettingsRoute().push(context), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.stars_rounded, color: colorScheme.primary, size: 18), + const SizedBox(width: 6), + Text( + '$credits credits | $stash tokens', + style: textTheme.labelLarge?.copyWith( + color: colorScheme.onSurfaceVariant, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _NeedsCredits extends StatelessWidget { + const _NeedsCredits(); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'No search credits or tokens are available for a new web search.', + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + FilledButton.icon( + icon: const Icon(Icons.shopping_cart_outlined), + label: const Text('Buy a search pack'), + onPressed: () async { + await launchUrl( + Uri.parse('${SupabaseConfig.accountWebUrl}?view=search-pack'), + mode: LaunchMode.inAppBrowserView, + ); + }, + ), + ], + ), + ); + } +} diff --git a/apps/weblibre/lib/main.dart b/apps/weblibre/lib/main.dart index 08cf0638..285334bf 100644 --- a/apps/weblibre/lib/main.dart +++ b/apps/weblibre/lib/main.dart @@ -35,6 +35,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:logger/logger.dart'; import 'package:material_color_utilities/material_color_utilities.dart'; import 'package:nullability/nullability.dart'; +import 'package:privacypass_client/privacypass_client.dart'; import 'package:weblibre/core/design/app_colors.dart'; import 'package:weblibre/core/error_observer.dart'; import 'package:weblibre/core/filesystem.dart'; @@ -44,12 +45,16 @@ import 'package:weblibre/core/providers/defaults.dart'; import 'package:weblibre/core/providers/router.dart'; import 'package:weblibre/domain/services/app_initialization.dart'; import 'package:weblibre/features/geckoview/features/browser/domain/services/engine_settings_replication.dart'; +import 'package:weblibre/features/account/domain/services/account_callback_handler.dart'; import 'package:weblibre/features/geckoview/features/open_link_tools/domain/services/url_cleaner_catalog_service.dart'; import 'package:weblibre/features/geckoview/features/preferences/data/repositories/preference_observer.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/services/local_index_pruner.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/services/local_index_settings_sync.dart'; import 'package:weblibre/features/user/domain/repositories/engine_settings.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; import 'package:weblibre/features/web_feed/presentation/controllers/fetch_articles.dart'; import 'package:weblibre/features/web_feed/utils/fetch_entrypoint.dart'; +import 'package:weblibre/features/web_search/domain/controllers/sandbox_capture_controller.dart'; import 'package:weblibre/presentation/hooks/on_initialization.dart'; import 'package:weblibre/presentation/main_app.dart'; @@ -115,6 +120,10 @@ class _MainWidget extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + // Keep the sandbox capture controller alive for the lifetime of the app + // so it can react to pigeon events even when no UI subscribes to it. + ref.watch(sandboxCaptureControllerProvider); + final rootKey = ref.watch(appStateKeyProvider); final pauseTime = useRef(null); @@ -254,6 +263,17 @@ class _MainWidget extends HookConsumerWidget { unawaited(preloadUrlCleanerCatalog()); + // Wire settings → local_index_setting (tab.db) so the trigger gate + // is in sync from the moment tabs start writing. + ref.read(localIndexSettingsSyncProvider); + + // Cold-start prune of the local search index — drops rows the engine + // has forgotten (Places retention, user-initiated clears). Cheap and + // background; failures are logged and ignored. + unawaited(ref.read(localIndexPrunerProvider.notifier).prune()); + + // Activate account callback deep link handler + ref.read(accountCallbackHandlerProvider); if (!kDebugMode) { await BackgroundFetch.configure( BackgroundFetchConfig( @@ -383,6 +403,8 @@ void main() async { await filesystem.init(); + await RustLib.init(); + if (!kDebugMode) { await BackgroundFetch.registerHeadlessTask(backgroundFetch); } diff --git a/apps/weblibre/lib/presentation/main_app.dart b/apps/weblibre/lib/presentation/main_app.dart index c83b06e2..78b1fa56 100644 --- a/apps/weblibre/lib/presentation/main_app.dart +++ b/apps/weblibre/lib/presentation/main_app.dart @@ -23,6 +23,7 @@ import 'package:weblibre/core/providers/router.dart'; import 'package:weblibre/domain/services/app_initialization.dart'; import 'package:weblibre/features/sync/domain/entities/sync_repository_state.dart'; import 'package:weblibre/features/sync/domain/repositories/sync.dart'; +import 'package:weblibre/features/web_search/domain/controllers/sandbox_capture_controller.dart'; import 'package:weblibre/presentation/widgets/failure_widget.dart'; import 'package:weblibre/utils/ui_helper.dart' as ui_helper; @@ -97,7 +98,9 @@ class MainApp extends HookConsumerWidget { uiScaleFactor: uiScaleFactor, disableAnimations: disableAnimations, child: _SyncEventListener( - child: child ?? const SizedBox.shrink(), + child: _SandboxCaptureErrorListener( + child: child ?? const SizedBox.shrink(), + ), ), ); }, @@ -221,6 +224,39 @@ class _AppTextScaler extends TextScaler { int get hashCode => Object.hash(baseTextScaler, uiScaleFactor); } +class _SandboxCaptureErrorListener extends ConsumerWidget { + final Widget child; + + const _SandboxCaptureErrorListener({required this.child}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + ref.listen(sandboxCaptureErrorsProvider, (previous, next) { + final error = next.value; + if (error == null) return; + final message = switch (error.kind) { + SandboxCaptureErrorKind.insufficientCredits => + error.detail ?? + 'You have no search credits left. Purchase more to continue.', + SandboxCaptureErrorKind.tokenIssuanceFailed => + error.detail ?? + 'Could not issue new search tokens. Check your connection and try again.', + SandboxCaptureErrorKind.fetchPolicyRejected => + 'Capture blocked by fetch policy: ${error.detail ?? 'not allowed'}', + SandboxCaptureErrorKind.captureFailed => + 'Capture failed: ${error.detail ?? 'unknown error'}', + SandboxCaptureErrorKind.downloadFailed => + 'Capture artifact download failed.', + SandboxCaptureErrorKind.unknown => + 'Sandbox capture error: ${error.detail ?? 'unknown error'}', + }; + ui_helper.showErrorMessage(context, message); + }); + + return child; + } +} + class _SyncEventListener extends ConsumerWidget { final Widget child; diff --git a/apps/weblibre/lib/presentation/widgets/auto_suggest_text_field.dart b/apps/weblibre/lib/presentation/widgets/auto_suggest_text_field.dart index 21fbcabf..dff8a224 100644 --- a/apps/weblibre/lib/presentation/widgets/auto_suggest_text_field.dart +++ b/apps/weblibre/lib/presentation/widgets/auto_suggest_text_field.dart @@ -54,6 +54,10 @@ class AutoSuggestTextField extends HookWidget { final bool autocorrect; final GlobalKey? textFieldKey; + /// Whether pressing the keyboard submit/enter button should automatically + /// accept and complete an inline search suggestion. Defaults to false. + final bool acceptSuggestionOnSubmit; + const AutoSuggestTextField({ super.key, required this.controller, @@ -84,6 +88,7 @@ class AutoSuggestTextField extends HookWidget { this.onSuggestionDismiss, this.autocorrect = false, this.textFieldKey, + this.acceptSuggestionOnSubmit = false, }); bool _suggestionHasMatch() => @@ -215,7 +220,7 @@ class AutoSuggestTextField extends HookWidget { autocorrect: autocorrect, onFieldSubmitted: onSubmitted.mapNotNull( (onSubmitted) => (value) { - if (_suggestionHasMatch()) { + if (acceptSuggestionOnSubmit && _suggestionHasMatch()) { onSubmitted(suggestion!); } else { onSubmitted(value); diff --git a/apps/weblibre/lib/presentation/widgets/inline_count_badge.dart b/apps/weblibre/lib/presentation/widgets/inline_count_badge.dart new file mode 100644 index 00000000..1d34e5c3 --- /dev/null +++ b/apps/weblibre/lib/presentation/widgets/inline_count_badge.dart @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'package:flutter/material.dart'; + +class InlineCountBadge extends StatelessWidget { + const InlineCountBadge({ + required this.count, + required this.backgroundColor, + required this.foregroundColor, + super.key, + }); + + final int count; + final Color backgroundColor; + final Color foregroundColor; + + static String formatCount(int count) { + if (count >= 1000) { + final k = count / 1000; + return k == k.roundToDouble() + ? '${k.round()}k' + : '${k.toStringAsFixed(1)}k'; + } + return count.toString(); + } + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(10), + ), + child: Text( + formatCount(count), + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + color: foregroundColor, + ), + ), + ); + } +} diff --git a/apps/weblibre/lib/presentation/widgets/selectable_chips.dart b/apps/weblibre/lib/presentation/widgets/selectable_chips.dart index 3955c665..71c61078 100644 --- a/apps/weblibre/lib/presentation/widgets/selectable_chips.dart +++ b/apps/weblibre/lib/presentation/widgets/selectable_chips.dart @@ -54,6 +54,36 @@ class _GestureWrapper extends StatelessWidget { } } +/// Per-item visual / delete-button overrides for [SelectableChips]. +/// +/// Grouped into a single value so the [SelectableChips] constructor +/// doesn't grow another knob every time a new variant adds a per-item +/// override. Pass via [SelectableChips.decoration]; any callback left +/// null falls back to the [FilterChip] default for that property. +/// +/// All callbacks receive the item and (where applicable) its current +/// `isSelected` flag so the decoration can branch on selection state. +class SelectableChipDecoration { + final Color? Function(T item, bool isSelected)? color; + final BorderSide? Function(T item, bool isSelected)? side; + final Widget? Function(T item)? deleteIcon; + final EdgeInsetsGeometry? Function(T item)? labelPadding; + + /// Per-item override for the global [SelectableChips.enableDelete] toggle. + /// When [SelectableChips.enableDelete] is false, no item shows a delete + /// button regardless of this callback. When it's true, this callback + /// can hide the delete button for individual items (returning false). + final bool Function(T item)? canDelete; + + const SelectableChipDecoration({ + this.color, + this.side, + this.deleteIcon, + this.labelPadding, + this.canDelete, + }); +} + class SelectableChips extends StatelessWidget { final Iterable availableItems; final List prefixListItems; @@ -71,9 +101,8 @@ class SelectableChips extends StatelessWidget { final Widget? Function(T item)? itemAvatar; final String? Function(T item)? itemTooltip; final int? Function(T item)? itemBadgeCount; - final Color? Function(T item)? itemBackgroundColor; final Color? selectedBorderColor; - final EdgeInsetsGeometry? Function(T item)? labelPadding; + final SelectableChipDecoration? decoration; final Widget Function(Widget child, S item)? itemWrap; @@ -88,8 +117,8 @@ class SelectableChips extends StatelessWidget { this.itemBadgeCount, this.itemWrap, this.itemTooltip, - this.itemBackgroundColor, this.selectedBorderColor, + this.decoration, this.prefixListItems = const [], required this.availableItems, this.selectedItem, @@ -102,7 +131,6 @@ class SelectableChips extends StatelessWidget { this.scrollController, this.activeItemKey, this.cacheExtent = 0, - this.labelPadding, super.key, }); @@ -137,7 +165,7 @@ class SelectableChips extends StatelessWidget { itemBuilder: (context, index) { if (index < prefixListItems.length) { return Padding( - padding: const EdgeInsets.only(right: 8.0, top: 4.0), + padding: const EdgeInsets.only(top: 4.0, right: 4), child: prefixListItems[index], ); } @@ -146,6 +174,10 @@ class SelectableChips extends StatelessWidget { final isSelected = selectedItem != null && itemId(item) == itemId(selectedItem as S); + final deco = decoration; + final itemColorValue = deco?.color?.call(item, isSelected); + final canDeleteItem = + enableDelete && (deco?.canDelete?.call(item) ?? true); final child = Padding( padding: const EdgeInsets.only(right: 8.0, top: 4.0), child: _BadgeWrapper( @@ -156,9 +188,13 @@ class SelectableChips extends StatelessWidget { () => callback(item), ), child: FilterChip( + color: itemColorValue != null + ? WidgetStatePropertyAll(itemColorValue) + : null, selected: selectedBorderColor == null && isSelected, showCheckmark: false, - labelPadding: labelPadding?.call(item), + labelPadding: deco?.labelPadding?.call(item), + deleteIcon: deco?.deleteIcon?.call(item), onSelected: (value) { if (value) { onSelected?.call(item); @@ -166,7 +202,7 @@ class SelectableChips extends StatelessWidget { onDeleted?.call(item); } }, - onDeleted: enableDelete + onDeleted: canDeleteItem ? () { onDeleted?.call(item); } @@ -174,10 +210,14 @@ class SelectableChips extends StatelessWidget { label: itemLabel.call(item), avatar: itemAvatar?.call(item), tooltip: itemTooltip?.call(item), - backgroundColor: itemBackgroundColor?.call(item), - side: isSelected && selectedBorderColor != null - ? BorderSide(color: selectedBorderColor!, width: 2.0) - : null, + side: + deco?.side?.call(item, isSelected) ?? + (isSelected && selectedBorderColor != null + ? BorderSide( + color: selectedBorderColor!, + width: 2.0, + ) + : null), ), ), ), diff --git a/apps/weblibre/lib/presentation/widgets/url_icon.dart b/apps/weblibre/lib/presentation/widgets/url_icon.dart index aa6d0559..6d8df6fc 100644 --- a/apps/weblibre/lib/presentation/widgets/url_icon.dart +++ b/apps/weblibre/lib/presentation/widgets/url_icon.dart @@ -17,32 +17,73 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ +import 'dart:async'; +import 'dart:typed_data'; + import 'package:fast_equatable/fast_equatable.dart'; import 'package:flutter/material.dart'; import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:skeletonizer/skeletonizer.dart'; import 'package:weblibre/domain/services/generic_website.dart'; +import 'package:weblibre/features/geckoview/domain/entities/browser_icon.dart'; +import 'package:weblibre/features/user/domain/providers.dart'; import 'package:weblibre/presentation/hooks/cached_future.dart'; import 'package:weblibre/presentation/widgets/safe_raw_image.dart'; +Uint8List? selectFirstCachedIconBytes(Iterable cachedBytesByUrl) { + for (final cachedBytes in cachedBytesByUrl) { + if (cachedBytes != null) { + return cachedBytes; + } + } + + return null; +} + class UrlIcon extends HookConsumerWidget { final double iconSize; final List urlList; + final bool cacheOnly; - const UrlIcon(this.urlList, {required this.iconSize, super.key}); + const UrlIcon( + this.urlList, { + required this.iconSize, + this.cacheOnly = false, + super.key, + }); @override Widget build(BuildContext context, WidgetRef ref) { + final eligibleUrls = urlList + .where((u) => u.isScheme('http') || u.isScheme('https')) + .toList(); + + final cachedBytesByUrl = [ + for (final url in eligibleUrls) + ref.watch(watchCachedIconBytesProvider(url.origin)).value, + ]; + + final cacheHashes = [ + for (final bytes in cachedBytesByUrl) + bytes == null ? null : identityHashCode(bytes), + ]; + final icon = useCachedFuture( - () => - // ignore: discarded_futures - ref.read(genericWebsiteServiceProvider.notifier).getUrlIcon(urlList), - [EquatableValue(urlList)], + () => _resolveIcon(ref, eligibleUrls, cachedBytesByUrl), + [EquatableValue(urlList), ...cacheHashes, cacheOnly], ); return Skeletonizer( - enabled: icon.connectionState != ConnectionState.done, + // Only show the skeleton on the *initial* resolve (no data yet). + // When a fresh favicon arrives later, `useMemoized` produces a new + // future and `useFuture` flips connectionState back to waiting while + // preserving the previous data — without this guard, the existing + // icon would get a skeleton overlay for the brief async gap before + // BrowserIcon.fromBytes resolves, which reads as a flicker. + enabled: + icon.connectionState != ConnectionState.done && icon.data == null, child: SizedBox.square( dimension: iconSize, child: (icon.data != null) @@ -59,4 +100,45 @@ class UrlIcon extends HookConsumerWidget { ), ); } + + Future _resolveIcon( + WidgetRef ref, + List eligibleUrls, + List cachedBytesByUrl, + ) async { + if (eligibleUrls.isEmpty) { + return null; + } + + final cachedBytes = selectFirstCachedIconBytes(cachedBytesByUrl); + if (cachedBytes != null) { + final decoded = await BrowserIcon.fromBytes( + cachedBytes, + dominantColor: null, + source: IconSource.disk, + ); + if (decoded != null) { + if (!cacheOnly) { + _scheduleStaleRefresh(ref, eligibleUrls); + } + return decoded; + } + } + + if (cacheOnly) { + return ref + .read(genericWebsiteServiceProvider.notifier) + .getUrlIcon(urlList, cacheOnly: true); + } + + return ref.read(genericWebsiteServiceProvider.notifier).getUrlIcon(urlList); + } + + void _scheduleStaleRefresh(WidgetRef ref, List eligibleUrls) { + if (eligibleUrls.isEmpty) return; + final service = ref.read(genericWebsiteServiceProvider.notifier); + for (final url in eligibleUrls) { + unawaited(service.refreshIconIfStale(url)); + } + } } diff --git a/apps/weblibre/lib/presentation/widgets/url_list_tile.dart b/apps/weblibre/lib/presentation/widgets/url_list_tile.dart index 019e5b0c..b1a0a3f4 100644 --- a/apps/weblibre/lib/presentation/widgets/url_list_tile.dart +++ b/apps/weblibre/lib/presentation/widgets/url_list_tile.dart @@ -18,6 +18,7 @@ * along with this program. If not, see . */ import 'package:flutter/material.dart'; +import 'package:weblibre/features/geckoview/features/tabs/utils/container_colors.dart'; import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart'; import 'package:weblibre/presentation/widgets/url_icon.dart'; @@ -26,7 +27,7 @@ class UrlListTile extends StatelessWidget { final Uri uri; final Widget? leading; final Widget? trailing; - final Color? borderColor; + final Color? containerColor; final bool showHttpScheme; final VoidCallback? onTap; @@ -36,7 +37,7 @@ class UrlListTile extends StatelessWidget { required this.uri, this.leading, this.trailing, - this.borderColor, + this.containerColor, this.showHttpScheme = true, this.onTap, }); @@ -48,13 +49,17 @@ class UrlListTile extends StatelessWidget { Widget build(BuildContext context) { final textTheme = Theme.of(context).textTheme; final colorScheme = Theme.of(context).colorScheme; + final containerPalette = containerColor != null + ? ContainerColors.palette(context, containerColor!) + : null; return Container( margin: const EdgeInsets.symmetric(vertical: 3.0, horizontal: 4.0), decoration: BoxDecoration( + color: containerPalette?.surfaceColor, borderRadius: _borderRadius, - border: borderColor != null - ? Border(right: BorderSide(color: borderColor!, width: 4.0)) + border: containerPalette != null + ? Border.all(color: containerPalette.outlineColor) : null, ), child: Material( diff --git a/apps/weblibre/lib/presentation/widgets/website_title_tile.dart b/apps/weblibre/lib/presentation/widgets/website_title_tile.dart index 928b8a33..d8f44278 100644 --- a/apps/weblibre/lib/presentation/widgets/website_title_tile.dart +++ b/apps/weblibre/lib/presentation/widgets/website_title_tile.dart @@ -23,10 +23,12 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nullability/nullability.dart'; import 'package:skeletonizer/skeletonizer.dart'; import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart'; +import 'package:weblibre/features/web_search/domain/controllers/sandbox_capture_controller.dart'; import 'package:weblibre/presentation/controllers/website_title.dart'; import 'package:weblibre/presentation/widgets/failure_widget.dart'; import 'package:weblibre/presentation/widgets/safe_raw_image.dart'; import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart'; +import 'package:weblibre/presentation/widgets/url_icon.dart'; class WebsiteTitleTile extends HookConsumerWidget { final TabState initialTabState; @@ -35,6 +37,27 @@ class WebsiteTitleTile extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final sandboxSourceUri = ref.watch( + sandboxSourceUriForTabProvider(tabId: initialTabState.id), + ); + + // Sandbox-captured tabs: render the canonical source URL with the + // cached favicon (no network fetch) and skip the page-info fetch entirely. + // We must not hit the source URL behind the user's back, and the loopback + // URL wouldn't yield anything useful. + if (sandboxSourceUri != null) { + return ListTile( + leading: UrlIcon([sandboxSourceUri], iconSize: 24, cacheOnly: true), + contentPadding: EdgeInsets.zero, + title: Text( + initialTabState.title.whenNotEmpty ?? sandboxSourceUri.authority, + maxLines: 6, + overflow: TextOverflow.ellipsis, + ), + subtitle: UriBreadcrumb(uri: sandboxSourceUri), + ); + } + final pageInfoAsync = ref.watch(completePageInfoProvider(initialTabState)); return Skeletonizer( diff --git a/apps/weblibre/lib/utils/url_canonical.dart b/apps/weblibre/lib/utils/url_canonical.dart new file mode 100644 index 00000000..729ec818 --- /dev/null +++ b/apps/weblibre/lib/utils/url_canonical.dart @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +/// Structural canonicalization helper used by both the local search index +/// (via SQLite custom functions) and any Dart-side ingestion code that wants +/// to dedupe URLs the same way the trigger does. +/// +/// This intentionally does *not* apply url-cleaner rules. The cleaner depends +/// on a runtime-loaded JSON catalog with compiled regexes, which can't be +/// shared into the database isolate cheaply. URL-cleaner integration is +/// tracked as a follow-up; structural canonicalization handles the bulk of +/// dedup (scheme/host casing, fragments, default ports) and keeps the SQL +/// function pure-Dart and isolate-safe. +class CanonicalUrl { + /// Canonical form used as the dedup key (history.url_canonical PK). + final String canonical; + + /// Bare host, lowercased. + final String host; + + /// Path component without query string. Empty string for bare-host URLs. + final String path; + + const CanonicalUrl({ + required this.canonical, + required this.host, + required this.path, + }); +} + +/// Schemes Places' `canAddUri` accepts. Anything else is rejected — internal +/// pages, javascript:/data:/blob: URIs etc. shouldn't enter the local index. +const _indexableSchemes = {'http', 'https'}; + +/// `about:reader?url=...` is the one allowed about: form. +bool _isAboutReader(Uri uri) => uri.scheme == 'about' && uri.path == 'reader'; + +/// Returns true if [rawUrl] is something the local search index should accept. +/// Mirrors `PlacesHistoryStorage.canAddUri` semantics. +bool isUrlIndexable(String rawUrl) { + final uri = Uri.tryParse(rawUrl); + if (uri == null || !uri.hasScheme) return false; + if (_indexableSchemes.contains(uri.scheme)) return uri.host.isNotEmpty; + if (_isAboutReader(uri)) return true; + return false; +} + +/// Canonicalize [rawUrl]. Returns `null` if the URL is unparseable or not +/// indexable. +CanonicalUrl? canonicalizeUrl(String rawUrl) { + final uri = Uri.tryParse(rawUrl); + if (uri == null || !uri.hasScheme) return null; + if (!isUrlIndexable(rawUrl)) return null; + + if (_isAboutReader(uri)) { + final query = uri.hasQuery ? '?${uri.query}' : ''; + return CanonicalUrl( + canonical: 'about:reader$query', + host: '', + path: 'reader', + ); + } + + // Lowercase scheme + host. Strip default ports. Strip fragment. Normalize + // a bare-host trailing slash to empty so `https://example.com` and + // `https://example.com/` collapse. + final scheme = uri.scheme.toLowerCase(); + final host = uri.host.toLowerCase(); + final port = uri.hasPort && !_isDefaultPort(scheme, uri.port) + ? ':${uri.port}' + : ''; + final path = _normalizePath(uri.path); + final query = uri.hasQuery ? '?${uri.query}' : ''; + + final canonical = '$scheme://$host$port$path$query'; + + return CanonicalUrl(canonical: canonical, host: host, path: path); +} + +bool _isDefaultPort(String scheme, int port) => + (scheme == 'http' && port == 80) || (scheme == 'https' && port == 443); + +String _normalizePath(String path) { + if (path.isEmpty || path == '/') return ''; + return path; +} diff --git a/apps/weblibre/pubspec.yaml b/apps/weblibre/pubspec.yaml index 114dd352..20f3ee54 100644 --- a/apps/weblibre/pubspec.yaml +++ b/apps/weblibre/pubspec.yaml @@ -12,10 +12,11 @@ dependencies: background_fetch: ^1.6.2 collection: ^1.19.1 convert: ^3.1.2 - copy_with_extension: ^15.0.0 + copy_with_extension: ^15.0.1 country_codes: ^3.3.0 country_flags: ^4.1.2 crypto: ^3.0.7 + xxh3: ^1.2.0 cryptography_flutter: ^2.3.4 device_info_plus: ^12.4.0 drift: ^2.31.0 @@ -30,16 +31,17 @@ dependencies: sdk: flutter flutter_auto_size_text: ^5.0.0 flutter_hooks: ^0.21.3+1 + flutter_secure_storage: ^10.1.0 flutter_markdown: ^0.7.7+1 - flutter_material_design_icons: ^3.0.0+7447 + flutter_material_design_icons: ^3.1.0+7447 flutter_mozilla_components: path: ../../packages/flutter_mozilla_components flutter_reorderable_grid_view: ^5.6.0 flutter_slidable: ^4.0.3 - flutter_svg: ^2.2.4 + flutter_svg: ^2.3.0 flutter_tor: path: ../../packages/flutter_tor - go_router: ^17.2.2 + go_router: ^17.2.3 google_fonts: ^8.1.0 graphview: ^1.5.1 home_widget: ^0.9.1 @@ -63,6 +65,12 @@ dependencies: path: ^1.9.1 path_provider: ^2.1.5 permission_handler: ^12.0.1 + privacypass_client: + path: ../../../weblibre_account/packages/privacypass_client + search_client: + path: ../../../weblibre_account/packages/search_client + search_backend: + path: ../../../weblibre_account/apps/search_backend pretty_qr_code: ^3.6.0 qr_code_scanner_plus: ^2.1.1 quick_actions: ^1.1.0 @@ -76,6 +84,7 @@ dependencies: git: url: https://github.com/FaFre/secure_archive.git path: packages/secure_archive + ref: 0c515813ac2d29e29ab1c66759a396af825bf8b3 share_plus: ^12.0.2 simple_intent_receiver: path: ../../packages/simple_intent_receiver @@ -86,6 +95,7 @@ dependencies: speech_to_text_dialog: path: ../../packages/speech_to_text_dialog sqlite3: ^2.9.4 + supabase: ^2.10.6 sqlite3_flutter_libs: "0.5.41" synchronized: ^3.4.0+1 text_scroll: ^0.2.1 @@ -94,11 +104,12 @@ dependencies: git: url: https://github.com/FaFre/uri-to-file.git url_launcher: ^6.3.2 + web_socket_channel: ^3.0.3 uuid: ^4.5.3 dev_dependencies: build_runner: ^2.15.0 - copy_with_extension_gen: ^15.0.0 + copy_with_extension_gen: ^15.0.1 flutter_launcher_icons: ^0.14.4 flutter_test: sdk: flutter diff --git a/apps/weblibre/test/domain/services/generic_website_test.dart b/apps/weblibre/test/domain/services/generic_website_test.dart new file mode 100644 index 00000000..88a3e94a --- /dev/null +++ b/apps/weblibre/test/domain/services/generic_website_test.dart @@ -0,0 +1,244 @@ +import 'dart:typed_data'; +import 'dart:ui'; + +import 'package:drift/drift.dart' show Variable; +import 'package:drift/native.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; +import 'package:flutter_mozilla_components/src/pigeons/gecko.g.dart'; +import 'package:riverpod/riverpod.dart'; +import 'package:weblibre/data/database/functions/lexo_rank_functions.dart'; +import 'package:weblibre/domain/services/favicon_resolver.dart'; +import 'package:weblibre/domain/services/generic_website.dart'; +import 'package:weblibre/features/user/data/database/database.dart'; +import 'package:weblibre/features/user/data/icon_cache_marker.dart'; +import 'package:weblibre/features/user/data/providers.dart'; +import 'package:weblibre/features/user/domain/repositories/cache.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late UserDatabase db; + late ProviderContainer container; + late FakeFaviconResolver resolver; + late FakeGeckoIconService geckoIcons; + late GenericWebsiteService service; + late CacheRepository cacheRepository; + + setUp(() { + db = UserDatabase( + NativeDatabase.memory( + setup: (database) { + registerLexorankFunctions(database); + }, + ), + ); + resolver = FakeFaviconResolver(); + geckoIcons = FakeGeckoIconService(); + container = ProviderContainer( + overrides: [ + userDatabaseProvider.overrideWith((ref) => db), + faviconResolverProvider.overrideWithValue(resolver), + geckoIconServiceProvider.overrideWithValue(geckoIcons), + ], + ); + service = container.read(genericWebsiteServiceProvider.notifier); + cacheRepository = container.read(cacheRepositoryProvider.notifier); + }); + + tearDown(() async { + container.dispose(); + await db.close(); + }); + + test('returns cached icons without hitting the resolver', () async { + final url = Uri.parse('https://cached.example/article'); + await cacheRepository.cacheIcon(url, _cachedSvgBytes); + + final icon = await service.getUrlIcon([url]); + + expect(icon, isNotNull); + expect(icon!.source, IconSource.disk); + expect(resolver.callCount, 0); + expect(geckoIcons.callCount, 0); + }); + + test('caches DDG misses and skips retries until the miss expires', () async { + final url = Uri.parse('https://missing.example/'); + resolver.enqueue(const FaviconResolveResult.missing()); + geckoIcons + ..enqueue(_generatorResult) + ..enqueue(_generatorResult); + + final first = await service.getUrlIcon([url]); + final second = await service.getUrlIcon([url]); + + expect(first?.source, IconSource.generator); + expect(second?.source, IconSource.generator); + expect(resolver.callCount, 1); + expect(geckoIcons.callCount, 2); + expect( + isMissingIconMarker(await cacheRepository.getCachedIconRaw(url.origin)), + isTrue, + ); + }); + + test( + 'resolver errors fall back to generation without negative caching', + () async { + final url = Uri.parse('https://flaky.example/'); + resolver.enqueue(const FaviconResolveResult.error()); + resolver.enqueue(const FaviconResolveResult.error()); + geckoIcons + ..enqueue(_generatorResult) + ..enqueue(_generatorResult); + + final first = await service.getUrlIcon([url]); + final second = await service.getUrlIcon([url]); + + expect(first?.source, IconSource.generator); + expect(second?.source, IconSource.generator); + expect(resolver.callCount, 2); + expect(geckoIcons.callCount, 2); + expect(await cacheRepository.getCachedIconRaw(url.origin), isNull); + }, + ); + + test( + 'resolver recovery replaces generated fallback with a real icon', + () async { + final url = Uri.parse('https://recovery.example/'); + resolver + ..enqueue(const FaviconResolveResult.error()) + ..enqueue(FaviconResolveResult.hit(_updatedSvgBytes)); + geckoIcons.enqueue(_generatorResult); + + final first = await service.getUrlIcon([url]); + final second = await service.getUrlIcon([url]); + + expect(first?.source, IconSource.generator); + expect(second?.source, IconSource.download); + expect(resolver.callCount, 2); + expect(geckoIcons.callCount, 1); + expect( + listEquals( + await cacheRepository.getCachedIcon(url.origin), + _updatedSvgBytes, + ), + isTrue, + ); + }, + ); + + test('skips DDG resolution for localhost, onion, and IP hosts', () async { + final urls = [ + Uri.parse('http://localhost:8080/'), + Uri.parse('https://hiddenservice.onion/'), + Uri.parse('https://127.0.0.1/'), + Uri.parse('https://192.168.0.10/'), + ]; + for (var i = 0; i < urls.length; i++) { + geckoIcons.enqueue(_generatorResult); + } + + for (final url in urls) { + final icon = await service.getUrlIcon([url]); + expect(icon?.source, IconSource.generator); + } + + expect(resolver.callCount, 0); + expect(geckoIcons.callCount, urls.length); + }); + + test('refreshIconIfStale updates stale cached icons from DDG', () async { + final url = Uri.parse('https://stale.example/'); + await cacheRepository.cacheIcon(url, _cachedSvgBytes); + await db.customUpdate( + 'UPDATE icon_cache SET fetch_date = ? WHERE origin = ?', + variables: [ + Variable(DateTime.now().subtract(const Duration(days: 31))), + Variable(url.origin), + ], + ); + resolver.enqueue(FaviconResolveResult.hit(_updatedSvgBytes)); + + await service.refreshIconIfStale(url); + + final updatedBytes = await cacheRepository.getCachedIcon(url.origin); + expect(updatedBytes, isNotNull); + expect(listEquals(updatedBytes, _updatedSvgBytes), isTrue); + expect(resolver.callCount, 1); + }); +} + +final class FakeFaviconResolver implements FaviconResolver { + int callCount = 0; + final List _results = []; + + void enqueue(FaviconResolveResult result) { + _results.add(result); + } + + @override + Future resolve(Uri url, {int? proxyPort}) async { + callCount += 1; + if (_results.isEmpty) { + return const FaviconResolveResult.error(); + } + return _results.removeAt(0); + } +} + +final class FakeGeckoIconService extends GeckoIconService { + int callCount = 0; + final List _results = []; + + void enqueue(IconResult result) { + _results.add(result); + } + + @override + Future loadIcon({ + required Uri url, + List resources = const [], + IconSize size = IconSize.defaultSize, + bool isPrivate = false, + bool waitOnNetworkLoad = true, + }) async { + callCount += 1; + if (_results.isEmpty) { + return _generatorResult; + } + return _results.removeAt(0); + } +} + +final _generatorResult = IconResult( + image: _generatedSvgBytes, + color: const Color(0xFF5B8DEF).toARGB32(), + source: IconSource.generator, + maskable: false, +); + +final _cachedSvgBytes = Uint8List.fromList( + '' + '' + '' + .codeUnits, +); + +final _updatedSvgBytes = Uint8List.fromList( + '' + '' + '' + .codeUnits, +); + +final _generatedSvgBytes = Uint8List.fromList( + '' + '' + 'W' + '' + .codeUnits, +); diff --git a/apps/weblibre/test/drift/tabs/generated/schema.dart b/apps/weblibre/test/drift/tabs/generated/schema.dart index 2fe66d7a..208cc0da 100644 --- a/apps/weblibre/test/drift/tabs/generated/schema.dart +++ b/apps/weblibre/test/drift/tabs/generated/schema.dart @@ -12,6 +12,10 @@ import 'schema_v6.dart' as v6; import 'schema_v7.dart' as v7; import 'schema_v8.dart' as v8; import 'schema_v9.dart' as v9; +import 'schema_v10.dart' as v10; +import 'schema_v11.dart' as v11; +import 'schema_v12.dart' as v12; +import 'schema_v13.dart' as v13; class GeneratedHelper implements SchemaInstantiationHelper { @override @@ -33,10 +37,18 @@ class GeneratedHelper implements SchemaInstantiationHelper { return v8.DatabaseAtV8(db); case 9: return v9.DatabaseAtV9(db); + case 10: + return v10.DatabaseAtV10(db); + case 11: + return v11.DatabaseAtV11(db); + case 12: + return v12.DatabaseAtV12(db); + case 13: + return v13.DatabaseAtV13(db); default: throw MissingSchemaException(version, versions); } } - static const versions = const [2, 3, 4, 5, 6, 7, 8, 9]; + static const versions = const [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13]; } diff --git a/apps/weblibre/test/drift/tabs/generated/schema_v10.dart b/apps/weblibre/test/drift/tabs/generated/schema_v10.dart new file mode 100644 index 00000000..86d4b5e6 --- /dev/null +++ b/apps/weblibre/test/drift/tabs/generated/schema_v10.dart @@ -0,0 +1,1893 @@ +// dart format width=80 +// GENERATED BY drift_dev, DO NOT MODIFY. +// ignore_for_file: type=lint,unused_import +// +import 'package:drift/drift.dart'; + +class Container extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Container(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'PRIMARY KEY NOT NULL', + ); + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn color = GeneratedColumn( + 'color', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn metadata = GeneratedColumn( + 'metadata', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + @override + List get $columns => [id, name, color, metadata]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'container'; + @override + Set get $primaryKey => {id}; + @override + ContainerData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return ContainerData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + ), + color: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}color'], + )!, + metadata: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}metadata'], + ), + ); + } + + @override + Container createAlias(String alias) { + return Container(attachedDatabase, alias); + } + + @override + bool get dontWriteConstraints => true; +} + +class ContainerData extends DataClass implements Insertable { + final String id; + final String? name; + final int color; + final String? metadata; + const ContainerData({ + required this.id, + this.name, + required this.color, + this.metadata, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + if (!nullToAbsent || name != null) { + map['name'] = Variable(name); + } + map['color'] = Variable(color); + if (!nullToAbsent || metadata != null) { + map['metadata'] = Variable(metadata); + } + return map; + } + + factory ContainerData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return ContainerData( + id: serializer.fromJson(json['id']), + name: serializer.fromJson(json['name']), + color: serializer.fromJson(json['color']), + metadata: serializer.fromJson(json['metadata']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'name': serializer.toJson(name), + 'color': serializer.toJson(color), + 'metadata': serializer.toJson(metadata), + }; + } + + ContainerData copyWith({ + String? id, + Value name = const Value.absent(), + int? color, + Value metadata = const Value.absent(), + }) => ContainerData( + id: id ?? this.id, + name: name.present ? name.value : this.name, + color: color ?? this.color, + metadata: metadata.present ? metadata.value : this.metadata, + ); + ContainerData copyWithCompanion(ContainerCompanion data) { + return ContainerData( + id: data.id.present ? data.id.value : this.id, + name: data.name.present ? data.name.value : this.name, + color: data.color.present ? data.color.value : this.color, + metadata: data.metadata.present ? data.metadata.value : this.metadata, + ); + } + + @override + String toString() { + return (StringBuffer('ContainerData(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('color: $color, ') + ..write('metadata: $metadata') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, name, color, metadata); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ContainerData && + other.id == this.id && + other.name == this.name && + other.color == this.color && + other.metadata == this.metadata); +} + +class ContainerCompanion extends UpdateCompanion { + final Value id; + final Value name; + final Value color; + final Value metadata; + final Value rowid; + const ContainerCompanion({ + this.id = const Value.absent(), + this.name = const Value.absent(), + this.color = const Value.absent(), + this.metadata = const Value.absent(), + this.rowid = const Value.absent(), + }); + ContainerCompanion.insert({ + required String id, + this.name = const Value.absent(), + required int color, + this.metadata = const Value.absent(), + this.rowid = const Value.absent(), + }) : id = Value(id), + color = Value(color); + static Insertable custom({ + Expression? id, + Expression? name, + Expression? color, + Expression? metadata, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (name != null) 'name': name, + if (color != null) 'color': color, + if (metadata != null) 'metadata': metadata, + if (rowid != null) 'rowid': rowid, + }); + } + + ContainerCompanion copyWith({ + Value? id, + Value? name, + Value? color, + Value? metadata, + Value? rowid, + }) { + return ContainerCompanion( + id: id ?? this.id, + name: name ?? this.name, + color: color ?? this.color, + metadata: metadata ?? this.metadata, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (name.present) { + map['name'] = Variable(name.value); + } + if (color.present) { + map['color'] = Variable(color.value); + } + if (metadata.present) { + map['metadata'] = Variable(metadata.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('ContainerCompanion(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('color: $color, ') + ..write('metadata: $metadata, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class Tab extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Tab(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'PRIMARY KEY NOT NULL', + ); + late final GeneratedColumn source = GeneratedColumn( + 'source', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn parentId = GeneratedColumn( + 'parent_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'REFERENCES tab(id)ON DELETE SET NULL', + ); + late final GeneratedColumn containerId = GeneratedColumn( + 'container_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'REFERENCES container(id)ON DELETE CASCADE', + ); + late final GeneratedColumn orderKey = GeneratedColumn( + 'order_key', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn url = GeneratedColumn( + 'url', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn title = GeneratedColumn( + 'title', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn tabMode = GeneratedColumn( + 'tab_mode', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn isolationContextId = + GeneratedColumn( + 'isolation_context_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn isPinned = GeneratedColumn( + 'is_pinned', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn isProbablyReaderable = GeneratedColumn( + 'is_probably_readerable', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn extractedContentMarkdown = + GeneratedColumn( + 'extracted_content_markdown', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn extractedContentPlain = + GeneratedColumn( + 'extracted_content_plain', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn fullContentMarkdown = + GeneratedColumn( + 'full_content_markdown', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn fullContentPlain = GeneratedColumn( + 'full_content_plain', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn timestamp = GeneratedColumn( + 'timestamp', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + @override + List get $columns => [ + id, + source, + parentId, + containerId, + orderKey, + url, + title, + tabMode, + isolationContextId, + isPinned, + isProbablyReaderable, + extractedContentMarkdown, + extractedContentPlain, + fullContentMarkdown, + fullContentPlain, + timestamp, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'tab'; + @override + Set get $primaryKey => {id}; + @override + TabData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return TabData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + source: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}source'], + )!, + parentId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}parent_id'], + ), + containerId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}container_id'], + ), + orderKey: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}order_key'], + )!, + url: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}url'], + ), + title: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}title'], + ), + tabMode: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}tab_mode'], + )!, + isolationContextId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}isolation_context_id'], + ), + isPinned: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}is_pinned'], + )!, + isProbablyReaderable: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}is_probably_readerable'], + ), + extractedContentMarkdown: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}extracted_content_markdown'], + ), + extractedContentPlain: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}extracted_content_plain'], + ), + fullContentMarkdown: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}full_content_markdown'], + ), + fullContentPlain: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}full_content_plain'], + ), + timestamp: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}timestamp'], + )!, + ); + } + + @override + Tab createAlias(String alias) { + return Tab(attachedDatabase, alias); + } + + @override + List get customConstraints => const [ + 'CHECK((tab_mode = 2 AND isolation_context_id IS NOT NULL)OR(tab_mode != 2 AND isolation_context_id IS NULL))', + ]; + @override + bool get dontWriteConstraints => true; +} + +class TabData extends DataClass implements Insertable { + final String id; + final int source; + final String? parentId; + final String? containerId; + final String orderKey; + final String? url; + final String? title; + final int tabMode; + final String? isolationContextId; + final int isPinned; + final int? isProbablyReaderable; + final String? extractedContentMarkdown; + final String? extractedContentPlain; + final String? fullContentMarkdown; + final String? fullContentPlain; + final int timestamp; + const TabData({ + required this.id, + required this.source, + this.parentId, + this.containerId, + required this.orderKey, + this.url, + this.title, + required this.tabMode, + this.isolationContextId, + required this.isPinned, + this.isProbablyReaderable, + this.extractedContentMarkdown, + this.extractedContentPlain, + this.fullContentMarkdown, + this.fullContentPlain, + required this.timestamp, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['source'] = Variable(source); + if (!nullToAbsent || parentId != null) { + map['parent_id'] = Variable(parentId); + } + if (!nullToAbsent || containerId != null) { + map['container_id'] = Variable(containerId); + } + map['order_key'] = Variable(orderKey); + if (!nullToAbsent || url != null) { + map['url'] = Variable(url); + } + if (!nullToAbsent || title != null) { + map['title'] = Variable(title); + } + map['tab_mode'] = Variable(tabMode); + if (!nullToAbsent || isolationContextId != null) { + map['isolation_context_id'] = Variable(isolationContextId); + } + map['is_pinned'] = Variable(isPinned); + if (!nullToAbsent || isProbablyReaderable != null) { + map['is_probably_readerable'] = Variable(isProbablyReaderable); + } + if (!nullToAbsent || extractedContentMarkdown != null) { + map['extracted_content_markdown'] = Variable( + extractedContentMarkdown, + ); + } + if (!nullToAbsent || extractedContentPlain != null) { + map['extracted_content_plain'] = Variable(extractedContentPlain); + } + if (!nullToAbsent || fullContentMarkdown != null) { + map['full_content_markdown'] = Variable(fullContentMarkdown); + } + if (!nullToAbsent || fullContentPlain != null) { + map['full_content_plain'] = Variable(fullContentPlain); + } + map['timestamp'] = Variable(timestamp); + return map; + } + + factory TabData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return TabData( + id: serializer.fromJson(json['id']), + source: serializer.fromJson(json['source']), + parentId: serializer.fromJson(json['parentId']), + containerId: serializer.fromJson(json['containerId']), + orderKey: serializer.fromJson(json['orderKey']), + url: serializer.fromJson(json['url']), + title: serializer.fromJson(json['title']), + tabMode: serializer.fromJson(json['tabMode']), + isolationContextId: serializer.fromJson( + json['isolationContextId'], + ), + isPinned: serializer.fromJson(json['isPinned']), + isProbablyReaderable: serializer.fromJson( + json['isProbablyReaderable'], + ), + extractedContentMarkdown: serializer.fromJson( + json['extractedContentMarkdown'], + ), + extractedContentPlain: serializer.fromJson( + json['extractedContentPlain'], + ), + fullContentMarkdown: serializer.fromJson( + json['fullContentMarkdown'], + ), + fullContentPlain: serializer.fromJson(json['fullContentPlain']), + timestamp: serializer.fromJson(json['timestamp']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'source': serializer.toJson(source), + 'parentId': serializer.toJson(parentId), + 'containerId': serializer.toJson(containerId), + 'orderKey': serializer.toJson(orderKey), + 'url': serializer.toJson(url), + 'title': serializer.toJson(title), + 'tabMode': serializer.toJson(tabMode), + 'isolationContextId': serializer.toJson(isolationContextId), + 'isPinned': serializer.toJson(isPinned), + 'isProbablyReaderable': serializer.toJson(isProbablyReaderable), + 'extractedContentMarkdown': serializer.toJson( + extractedContentMarkdown, + ), + 'extractedContentPlain': serializer.toJson( + extractedContentPlain, + ), + 'fullContentMarkdown': serializer.toJson(fullContentMarkdown), + 'fullContentPlain': serializer.toJson(fullContentPlain), + 'timestamp': serializer.toJson(timestamp), + }; + } + + TabData copyWith({ + String? id, + int? source, + Value parentId = const Value.absent(), + Value containerId = const Value.absent(), + String? orderKey, + Value url = const Value.absent(), + Value title = const Value.absent(), + int? tabMode, + Value isolationContextId = const Value.absent(), + int? isPinned, + Value isProbablyReaderable = const Value.absent(), + Value extractedContentMarkdown = const Value.absent(), + Value extractedContentPlain = const Value.absent(), + Value fullContentMarkdown = const Value.absent(), + Value fullContentPlain = const Value.absent(), + int? timestamp, + }) => TabData( + id: id ?? this.id, + source: source ?? this.source, + parentId: parentId.present ? parentId.value : this.parentId, + containerId: containerId.present ? containerId.value : this.containerId, + orderKey: orderKey ?? this.orderKey, + url: url.present ? url.value : this.url, + title: title.present ? title.value : this.title, + tabMode: tabMode ?? this.tabMode, + isolationContextId: isolationContextId.present + ? isolationContextId.value + : this.isolationContextId, + isPinned: isPinned ?? this.isPinned, + isProbablyReaderable: isProbablyReaderable.present + ? isProbablyReaderable.value + : this.isProbablyReaderable, + extractedContentMarkdown: extractedContentMarkdown.present + ? extractedContentMarkdown.value + : this.extractedContentMarkdown, + extractedContentPlain: extractedContentPlain.present + ? extractedContentPlain.value + : this.extractedContentPlain, + fullContentMarkdown: fullContentMarkdown.present + ? fullContentMarkdown.value + : this.fullContentMarkdown, + fullContentPlain: fullContentPlain.present + ? fullContentPlain.value + : this.fullContentPlain, + timestamp: timestamp ?? this.timestamp, + ); + TabData copyWithCompanion(TabCompanion data) { + return TabData( + id: data.id.present ? data.id.value : this.id, + source: data.source.present ? data.source.value : this.source, + parentId: data.parentId.present ? data.parentId.value : this.parentId, + containerId: data.containerId.present + ? data.containerId.value + : this.containerId, + orderKey: data.orderKey.present ? data.orderKey.value : this.orderKey, + url: data.url.present ? data.url.value : this.url, + title: data.title.present ? data.title.value : this.title, + tabMode: data.tabMode.present ? data.tabMode.value : this.tabMode, + isolationContextId: data.isolationContextId.present + ? data.isolationContextId.value + : this.isolationContextId, + isPinned: data.isPinned.present ? data.isPinned.value : this.isPinned, + isProbablyReaderable: data.isProbablyReaderable.present + ? data.isProbablyReaderable.value + : this.isProbablyReaderable, + extractedContentMarkdown: data.extractedContentMarkdown.present + ? data.extractedContentMarkdown.value + : this.extractedContentMarkdown, + extractedContentPlain: data.extractedContentPlain.present + ? data.extractedContentPlain.value + : this.extractedContentPlain, + fullContentMarkdown: data.fullContentMarkdown.present + ? data.fullContentMarkdown.value + : this.fullContentMarkdown, + fullContentPlain: data.fullContentPlain.present + ? data.fullContentPlain.value + : this.fullContentPlain, + timestamp: data.timestamp.present ? data.timestamp.value : this.timestamp, + ); + } + + @override + String toString() { + return (StringBuffer('TabData(') + ..write('id: $id, ') + ..write('source: $source, ') + ..write('parentId: $parentId, ') + ..write('containerId: $containerId, ') + ..write('orderKey: $orderKey, ') + ..write('url: $url, ') + ..write('title: $title, ') + ..write('tabMode: $tabMode, ') + ..write('isolationContextId: $isolationContextId, ') + ..write('isPinned: $isPinned, ') + ..write('isProbablyReaderable: $isProbablyReaderable, ') + ..write('extractedContentMarkdown: $extractedContentMarkdown, ') + ..write('extractedContentPlain: $extractedContentPlain, ') + ..write('fullContentMarkdown: $fullContentMarkdown, ') + ..write('fullContentPlain: $fullContentPlain, ') + ..write('timestamp: $timestamp') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + source, + parentId, + containerId, + orderKey, + url, + title, + tabMode, + isolationContextId, + isPinned, + isProbablyReaderable, + extractedContentMarkdown, + extractedContentPlain, + fullContentMarkdown, + fullContentPlain, + timestamp, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is TabData && + other.id == this.id && + other.source == this.source && + other.parentId == this.parentId && + other.containerId == this.containerId && + other.orderKey == this.orderKey && + other.url == this.url && + other.title == this.title && + other.tabMode == this.tabMode && + other.isolationContextId == this.isolationContextId && + other.isPinned == this.isPinned && + other.isProbablyReaderable == this.isProbablyReaderable && + other.extractedContentMarkdown == this.extractedContentMarkdown && + other.extractedContentPlain == this.extractedContentPlain && + other.fullContentMarkdown == this.fullContentMarkdown && + other.fullContentPlain == this.fullContentPlain && + other.timestamp == this.timestamp); +} + +class TabCompanion extends UpdateCompanion { + final Value id; + final Value source; + final Value parentId; + final Value containerId; + final Value orderKey; + final Value url; + final Value title; + final Value tabMode; + final Value isolationContextId; + final Value isPinned; + final Value isProbablyReaderable; + final Value extractedContentMarkdown; + final Value extractedContentPlain; + final Value fullContentMarkdown; + final Value fullContentPlain; + final Value timestamp; + final Value rowid; + const TabCompanion({ + this.id = const Value.absent(), + this.source = const Value.absent(), + this.parentId = const Value.absent(), + this.containerId = const Value.absent(), + this.orderKey = const Value.absent(), + this.url = const Value.absent(), + this.title = const Value.absent(), + this.tabMode = const Value.absent(), + this.isolationContextId = const Value.absent(), + this.isPinned = const Value.absent(), + this.isProbablyReaderable = const Value.absent(), + this.extractedContentMarkdown = const Value.absent(), + this.extractedContentPlain = const Value.absent(), + this.fullContentMarkdown = const Value.absent(), + this.fullContentPlain = const Value.absent(), + this.timestamp = const Value.absent(), + this.rowid = const Value.absent(), + }); + TabCompanion.insert({ + required String id, + required int source, + this.parentId = const Value.absent(), + this.containerId = const Value.absent(), + required String orderKey, + this.url = const Value.absent(), + this.title = const Value.absent(), + this.tabMode = const Value.absent(), + this.isolationContextId = const Value.absent(), + this.isPinned = const Value.absent(), + this.isProbablyReaderable = const Value.absent(), + this.extractedContentMarkdown = const Value.absent(), + this.extractedContentPlain = const Value.absent(), + this.fullContentMarkdown = const Value.absent(), + this.fullContentPlain = const Value.absent(), + required int timestamp, + this.rowid = const Value.absent(), + }) : id = Value(id), + source = Value(source), + orderKey = Value(orderKey), + timestamp = Value(timestamp); + static Insertable custom({ + Expression? id, + Expression? source, + Expression? parentId, + Expression? containerId, + Expression? orderKey, + Expression? url, + Expression? title, + Expression? tabMode, + Expression? isolationContextId, + Expression? isPinned, + Expression? isProbablyReaderable, + Expression? extractedContentMarkdown, + Expression? extractedContentPlain, + Expression? fullContentMarkdown, + Expression? fullContentPlain, + Expression? timestamp, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (source != null) 'source': source, + if (parentId != null) 'parent_id': parentId, + if (containerId != null) 'container_id': containerId, + if (orderKey != null) 'order_key': orderKey, + if (url != null) 'url': url, + if (title != null) 'title': title, + if (tabMode != null) 'tab_mode': tabMode, + if (isolationContextId != null) + 'isolation_context_id': isolationContextId, + if (isPinned != null) 'is_pinned': isPinned, + if (isProbablyReaderable != null) + 'is_probably_readerable': isProbablyReaderable, + if (extractedContentMarkdown != null) + 'extracted_content_markdown': extractedContentMarkdown, + if (extractedContentPlain != null) + 'extracted_content_plain': extractedContentPlain, + if (fullContentMarkdown != null) + 'full_content_markdown': fullContentMarkdown, + if (fullContentPlain != null) 'full_content_plain': fullContentPlain, + if (timestamp != null) 'timestamp': timestamp, + if (rowid != null) 'rowid': rowid, + }); + } + + TabCompanion copyWith({ + Value? id, + Value? source, + Value? parentId, + Value? containerId, + Value? orderKey, + Value? url, + Value? title, + Value? tabMode, + Value? isolationContextId, + Value? isPinned, + Value? isProbablyReaderable, + Value? extractedContentMarkdown, + Value? extractedContentPlain, + Value? fullContentMarkdown, + Value? fullContentPlain, + Value? timestamp, + Value? rowid, + }) { + return TabCompanion( + id: id ?? this.id, + source: source ?? this.source, + parentId: parentId ?? this.parentId, + containerId: containerId ?? this.containerId, + orderKey: orderKey ?? this.orderKey, + url: url ?? this.url, + title: title ?? this.title, + tabMode: tabMode ?? this.tabMode, + isolationContextId: isolationContextId ?? this.isolationContextId, + isPinned: isPinned ?? this.isPinned, + isProbablyReaderable: isProbablyReaderable ?? this.isProbablyReaderable, + extractedContentMarkdown: + extractedContentMarkdown ?? this.extractedContentMarkdown, + extractedContentPlain: + extractedContentPlain ?? this.extractedContentPlain, + fullContentMarkdown: fullContentMarkdown ?? this.fullContentMarkdown, + fullContentPlain: fullContentPlain ?? this.fullContentPlain, + timestamp: timestamp ?? this.timestamp, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (source.present) { + map['source'] = Variable(source.value); + } + if (parentId.present) { + map['parent_id'] = Variable(parentId.value); + } + if (containerId.present) { + map['container_id'] = Variable(containerId.value); + } + if (orderKey.present) { + map['order_key'] = Variable(orderKey.value); + } + if (url.present) { + map['url'] = Variable(url.value); + } + if (title.present) { + map['title'] = Variable(title.value); + } + if (tabMode.present) { + map['tab_mode'] = Variable(tabMode.value); + } + if (isolationContextId.present) { + map['isolation_context_id'] = Variable(isolationContextId.value); + } + if (isPinned.present) { + map['is_pinned'] = Variable(isPinned.value); + } + if (isProbablyReaderable.present) { + map['is_probably_readerable'] = Variable(isProbablyReaderable.value); + } + if (extractedContentMarkdown.present) { + map['extracted_content_markdown'] = Variable( + extractedContentMarkdown.value, + ); + } + if (extractedContentPlain.present) { + map['extracted_content_plain'] = Variable( + extractedContentPlain.value, + ); + } + if (fullContentMarkdown.present) { + map['full_content_markdown'] = Variable( + fullContentMarkdown.value, + ); + } + if (fullContentPlain.present) { + map['full_content_plain'] = Variable(fullContentPlain.value); + } + if (timestamp.present) { + map['timestamp'] = Variable(timestamp.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('TabCompanion(') + ..write('id: $id, ') + ..write('source: $source, ') + ..write('parentId: $parentId, ') + ..write('containerId: $containerId, ') + ..write('orderKey: $orderKey, ') + ..write('url: $url, ') + ..write('title: $title, ') + ..write('tabMode: $tabMode, ') + ..write('isolationContextId: $isolationContextId, ') + ..write('isPinned: $isPinned, ') + ..write('isProbablyReaderable: $isProbablyReaderable, ') + ..write('extractedContentMarkdown: $extractedContentMarkdown, ') + ..write('extractedContentPlain: $extractedContentPlain, ') + ..write('fullContentMarkdown: $fullContentMarkdown, ') + ..write('fullContentPlain: $fullContentPlain, ') + ..write('timestamp: $timestamp, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class ClosedTabTombstone extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + ClosedTabTombstone(this.attachedDatabase, [this._alias]); + late final GeneratedColumn tabId = GeneratedColumn( + 'tab_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'PRIMARY KEY NOT NULL', + ); + late final GeneratedColumn closedAt = GeneratedColumn( + 'closed_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + @override + List get $columns => [tabId, closedAt]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'closed_tab_tombstone'; + @override + Set get $primaryKey => {tabId}; + @override + ClosedTabTombstoneData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return ClosedTabTombstoneData( + tabId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}tab_id'], + )!, + closedAt: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}closed_at'], + )!, + ); + } + + @override + ClosedTabTombstone createAlias(String alias) { + return ClosedTabTombstone(attachedDatabase, alias); + } + + @override + bool get dontWriteConstraints => true; +} + +class ClosedTabTombstoneData extends DataClass + implements Insertable { + final String tabId; + final int closedAt; + const ClosedTabTombstoneData({required this.tabId, required this.closedAt}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['tab_id'] = Variable(tabId); + map['closed_at'] = Variable(closedAt); + return map; + } + + factory ClosedTabTombstoneData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return ClosedTabTombstoneData( + tabId: serializer.fromJson(json['tabId']), + closedAt: serializer.fromJson(json['closedAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'tabId': serializer.toJson(tabId), + 'closedAt': serializer.toJson(closedAt), + }; + } + + ClosedTabTombstoneData copyWith({String? tabId, int? closedAt}) => + ClosedTabTombstoneData( + tabId: tabId ?? this.tabId, + closedAt: closedAt ?? this.closedAt, + ); + ClosedTabTombstoneData copyWithCompanion(ClosedTabTombstoneCompanion data) { + return ClosedTabTombstoneData( + tabId: data.tabId.present ? data.tabId.value : this.tabId, + closedAt: data.closedAt.present ? data.closedAt.value : this.closedAt, + ); + } + + @override + String toString() { + return (StringBuffer('ClosedTabTombstoneData(') + ..write('tabId: $tabId, ') + ..write('closedAt: $closedAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(tabId, closedAt); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ClosedTabTombstoneData && + other.tabId == this.tabId && + other.closedAt == this.closedAt); +} + +class ClosedTabTombstoneCompanion + extends UpdateCompanion { + final Value tabId; + final Value closedAt; + final Value rowid; + const ClosedTabTombstoneCompanion({ + this.tabId = const Value.absent(), + this.closedAt = const Value.absent(), + this.rowid = const Value.absent(), + }); + ClosedTabTombstoneCompanion.insert({ + required String tabId, + required int closedAt, + this.rowid = const Value.absent(), + }) : tabId = Value(tabId), + closedAt = Value(closedAt); + static Insertable custom({ + Expression? tabId, + Expression? closedAt, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (tabId != null) 'tab_id': tabId, + if (closedAt != null) 'closed_at': closedAt, + if (rowid != null) 'rowid': rowid, + }); + } + + ClosedTabTombstoneCompanion copyWith({ + Value? tabId, + Value? closedAt, + Value? rowid, + }) { + return ClosedTabTombstoneCompanion( + tabId: tabId ?? this.tabId, + closedAt: closedAt ?? this.closedAt, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (tabId.present) { + map['tab_id'] = Variable(tabId.value); + } + if (closedAt.present) { + map['closed_at'] = Variable(closedAt.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('ClosedTabTombstoneCompanion(') + ..write('tabId: $tabId, ') + ..write('closedAt: $closedAt, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class CaptureTab extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + CaptureTab(this.attachedDatabase, [this._alias]); + late final GeneratedColumn tabId = GeneratedColumn( + 'tab_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: + 'NOT NULL PRIMARY KEY REFERENCES tab(id)ON DELETE CASCADE', + ); + late final GeneratedColumn captureId = GeneratedColumn( + 'capture_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn sourceUrl = GeneratedColumn( + 'source_url', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn status = GeneratedColumn( + 'status', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT \'pending\'', + defaultValue: const CustomExpression('\'pending\''), + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + @override + List get $columns => [ + tabId, + captureId, + sourceUrl, + status, + createdAt, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'capture_tab'; + @override + Set get $primaryKey => {tabId}; + @override + CaptureTabData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return CaptureTabData( + tabId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}tab_id'], + )!, + captureId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}capture_id'], + )!, + sourceUrl: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}source_url'], + )!, + status: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}status'], + )!, + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}created_at'], + )!, + ); + } + + @override + CaptureTab createAlias(String alias) { + return CaptureTab(attachedDatabase, alias); + } + + @override + bool get dontWriteConstraints => true; +} + +class CaptureTabData extends DataClass implements Insertable { + final String tabId; + final String captureId; + final String sourceUrl; + final String status; + final int createdAt; + const CaptureTabData({ + required this.tabId, + required this.captureId, + required this.sourceUrl, + required this.status, + required this.createdAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['tab_id'] = Variable(tabId); + map['capture_id'] = Variable(captureId); + map['source_url'] = Variable(sourceUrl); + map['status'] = Variable(status); + map['created_at'] = Variable(createdAt); + return map; + } + + factory CaptureTabData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return CaptureTabData( + tabId: serializer.fromJson(json['tabId']), + captureId: serializer.fromJson(json['captureId']), + sourceUrl: serializer.fromJson(json['sourceUrl']), + status: serializer.fromJson(json['status']), + createdAt: serializer.fromJson(json['createdAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'tabId': serializer.toJson(tabId), + 'captureId': serializer.toJson(captureId), + 'sourceUrl': serializer.toJson(sourceUrl), + 'status': serializer.toJson(status), + 'createdAt': serializer.toJson(createdAt), + }; + } + + CaptureTabData copyWith({ + String? tabId, + String? captureId, + String? sourceUrl, + String? status, + int? createdAt, + }) => CaptureTabData( + tabId: tabId ?? this.tabId, + captureId: captureId ?? this.captureId, + sourceUrl: sourceUrl ?? this.sourceUrl, + status: status ?? this.status, + createdAt: createdAt ?? this.createdAt, + ); + CaptureTabData copyWithCompanion(CaptureTabCompanion data) { + return CaptureTabData( + tabId: data.tabId.present ? data.tabId.value : this.tabId, + captureId: data.captureId.present ? data.captureId.value : this.captureId, + sourceUrl: data.sourceUrl.present ? data.sourceUrl.value : this.sourceUrl, + status: data.status.present ? data.status.value : this.status, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + ); + } + + @override + String toString() { + return (StringBuffer('CaptureTabData(') + ..write('tabId: $tabId, ') + ..write('captureId: $captureId, ') + ..write('sourceUrl: $sourceUrl, ') + ..write('status: $status, ') + ..write('createdAt: $createdAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => + Object.hash(tabId, captureId, sourceUrl, status, createdAt); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is CaptureTabData && + other.tabId == this.tabId && + other.captureId == this.captureId && + other.sourceUrl == this.sourceUrl && + other.status == this.status && + other.createdAt == this.createdAt); +} + +class CaptureTabCompanion extends UpdateCompanion { + final Value tabId; + final Value captureId; + final Value sourceUrl; + final Value status; + final Value createdAt; + final Value rowid; + const CaptureTabCompanion({ + this.tabId = const Value.absent(), + this.captureId = const Value.absent(), + this.sourceUrl = const Value.absent(), + this.status = const Value.absent(), + this.createdAt = const Value.absent(), + this.rowid = const Value.absent(), + }); + CaptureTabCompanion.insert({ + required String tabId, + required String captureId, + required String sourceUrl, + this.status = const Value.absent(), + required int createdAt, + this.rowid = const Value.absent(), + }) : tabId = Value(tabId), + captureId = Value(captureId), + sourceUrl = Value(sourceUrl), + createdAt = Value(createdAt); + static Insertable custom({ + Expression? tabId, + Expression? captureId, + Expression? sourceUrl, + Expression? status, + Expression? createdAt, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (tabId != null) 'tab_id': tabId, + if (captureId != null) 'capture_id': captureId, + if (sourceUrl != null) 'source_url': sourceUrl, + if (status != null) 'status': status, + if (createdAt != null) 'created_at': createdAt, + if (rowid != null) 'rowid': rowid, + }); + } + + CaptureTabCompanion copyWith({ + Value? tabId, + Value? captureId, + Value? sourceUrl, + Value? status, + Value? createdAt, + Value? rowid, + }) { + return CaptureTabCompanion( + tabId: tabId ?? this.tabId, + captureId: captureId ?? this.captureId, + sourceUrl: sourceUrl ?? this.sourceUrl, + status: status ?? this.status, + createdAt: createdAt ?? this.createdAt, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (tabId.present) { + map['tab_id'] = Variable(tabId.value); + } + if (captureId.present) { + map['capture_id'] = Variable(captureId.value); + } + if (sourceUrl.present) { + map['source_url'] = Variable(sourceUrl.value); + } + if (status.present) { + map['status'] = Variable(status.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('CaptureTabCompanion(') + ..write('tabId: $tabId, ') + ..write('captureId: $captureId, ') + ..write('sourceUrl: $sourceUrl, ') + ..write('status: $status, ') + ..write('createdAt: $createdAt, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class TabFts extends Table + with TableInfo, VirtualTableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + TabFts(this.attachedDatabase, [this._alias]); + late final GeneratedColumn title = GeneratedColumn( + 'title', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn url = GeneratedColumn( + 'url', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn extractedContentPlain = + GeneratedColumn( + 'extracted_content_plain', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn fullContentPlain = GeneratedColumn( + 'full_content_plain', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + @override + List get $columns => [ + title, + url, + extractedContentPlain, + fullContentPlain, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'tab_fts'; + @override + Set get $primaryKey => const {}; + @override + TabFtsData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return TabFtsData( + title: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}title'], + ), + url: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}url'], + ), + extractedContentPlain: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}extracted_content_plain'], + ), + fullContentPlain: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}full_content_plain'], + ), + ); + } + + @override + TabFts createAlias(String alias) { + return TabFts(attachedDatabase, alias); + } + + @override + bool get dontWriteConstraints => true; + @override + String get moduleAndArgs => + 'fts5(title, url, extracted_content_plain, full_content_plain, content=tab, tokenize="trigram")'; +} + +class TabFtsData extends DataClass implements Insertable { + final String? title; + final String? url; + final String? extractedContentPlain; + final String? fullContentPlain; + const TabFtsData({ + this.title, + this.url, + this.extractedContentPlain, + this.fullContentPlain, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (!nullToAbsent || title != null) { + map['title'] = Variable(title); + } + if (!nullToAbsent || url != null) { + map['url'] = Variable(url); + } + if (!nullToAbsent || extractedContentPlain != null) { + map['extracted_content_plain'] = Variable(extractedContentPlain); + } + if (!nullToAbsent || fullContentPlain != null) { + map['full_content_plain'] = Variable(fullContentPlain); + } + return map; + } + + factory TabFtsData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return TabFtsData( + title: serializer.fromJson(json['title']), + url: serializer.fromJson(json['url']), + extractedContentPlain: serializer.fromJson( + json['extractedContentPlain'], + ), + fullContentPlain: serializer.fromJson(json['fullContentPlain']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'title': serializer.toJson(title), + 'url': serializer.toJson(url), + 'extractedContentPlain': serializer.toJson( + extractedContentPlain, + ), + 'fullContentPlain': serializer.toJson(fullContentPlain), + }; + } + + TabFtsData copyWith({ + Value title = const Value.absent(), + Value url = const Value.absent(), + Value extractedContentPlain = const Value.absent(), + Value fullContentPlain = const Value.absent(), + }) => TabFtsData( + title: title.present ? title.value : this.title, + url: url.present ? url.value : this.url, + extractedContentPlain: extractedContentPlain.present + ? extractedContentPlain.value + : this.extractedContentPlain, + fullContentPlain: fullContentPlain.present + ? fullContentPlain.value + : this.fullContentPlain, + ); + TabFtsData copyWithCompanion(TabFtsCompanion data) { + return TabFtsData( + title: data.title.present ? data.title.value : this.title, + url: data.url.present ? data.url.value : this.url, + extractedContentPlain: data.extractedContentPlain.present + ? data.extractedContentPlain.value + : this.extractedContentPlain, + fullContentPlain: data.fullContentPlain.present + ? data.fullContentPlain.value + : this.fullContentPlain, + ); + } + + @override + String toString() { + return (StringBuffer('TabFtsData(') + ..write('title: $title, ') + ..write('url: $url, ') + ..write('extractedContentPlain: $extractedContentPlain, ') + ..write('fullContentPlain: $fullContentPlain') + ..write(')')) + .toString(); + } + + @override + int get hashCode => + Object.hash(title, url, extractedContentPlain, fullContentPlain); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is TabFtsData && + other.title == this.title && + other.url == this.url && + other.extractedContentPlain == this.extractedContentPlain && + other.fullContentPlain == this.fullContentPlain); +} + +class TabFtsCompanion extends UpdateCompanion { + final Value title; + final Value url; + final Value extractedContentPlain; + final Value fullContentPlain; + final Value rowid; + const TabFtsCompanion({ + this.title = const Value.absent(), + this.url = const Value.absent(), + this.extractedContentPlain = const Value.absent(), + this.fullContentPlain = const Value.absent(), + this.rowid = const Value.absent(), + }); + TabFtsCompanion.insert({ + this.title = const Value.absent(), + this.url = const Value.absent(), + this.extractedContentPlain = const Value.absent(), + this.fullContentPlain = const Value.absent(), + this.rowid = const Value.absent(), + }); + static Insertable custom({ + Expression? title, + Expression? url, + Expression? extractedContentPlain, + Expression? fullContentPlain, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (title != null) 'title': title, + if (url != null) 'url': url, + if (extractedContentPlain != null) + 'extracted_content_plain': extractedContentPlain, + if (fullContentPlain != null) 'full_content_plain': fullContentPlain, + if (rowid != null) 'rowid': rowid, + }); + } + + TabFtsCompanion copyWith({ + Value? title, + Value? url, + Value? extractedContentPlain, + Value? fullContentPlain, + Value? rowid, + }) { + return TabFtsCompanion( + title: title ?? this.title, + url: url ?? this.url, + extractedContentPlain: + extractedContentPlain ?? this.extractedContentPlain, + fullContentPlain: fullContentPlain ?? this.fullContentPlain, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (title.present) { + map['title'] = Variable(title.value); + } + if (url.present) { + map['url'] = Variable(url.value); + } + if (extractedContentPlain.present) { + map['extracted_content_plain'] = Variable( + extractedContentPlain.value, + ); + } + if (fullContentPlain.present) { + map['full_content_plain'] = Variable(fullContentPlain.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('TabFtsCompanion(') + ..write('title: $title, ') + ..write('url: $url, ') + ..write('extractedContentPlain: $extractedContentPlain, ') + ..write('fullContentPlain: $fullContentPlain, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class DatabaseAtV10 extends GeneratedDatabase { + DatabaseAtV10(QueryExecutor e) : super(e); + late final Container container = Container(this); + late final Tab tab = Tab(this); + late final ClosedTabTombstone closedTabTombstone = ClosedTabTombstone(this); + late final Index idxTabParentContainer = Index( + 'idx_tab_parent_container', + 'CREATE INDEX idx_tab_parent_container ON tab (parent_id, container_id)', + ); + late final CaptureTab captureTab = CaptureTab(this); + late final Index idxCaptureTabCaptureId = Index( + 'idx_capture_tab_capture_id', + 'CREATE INDEX idx_capture_tab_capture_id ON capture_tab (capture_id)', + ); + late final TabFts tabFts = TabFts(this); + late final Trigger tabMaintainParentChainOnDelete = Trigger( + 'CREATE TRIGGER tab_maintain_parent_chain_on_delete BEFORE DELETE ON tab BEGIN UPDATE tab SET parent_id = CASE WHEN OLD.parent_id IS NOT NULL AND EXISTS (SELECT 1 FROM tab WHERE id = OLD.parent_id) THEN OLD.parent_id ELSE NULL END WHERE parent_id = OLD.id;END', + 'tab_maintain_parent_chain_on_delete', + ); + late final Trigger tabAfterInsert = Trigger( + 'CREATE TRIGGER tab_after_insert AFTER INSERT ON tab BEGIN INSERT INTO tab_fts ("rowid", title, url, extracted_content_plain, full_content_plain) VALUES (new."rowid", new.title, new.url, new.extracted_content_plain, new.full_content_plain);END', + 'tab_after_insert', + ); + late final Trigger tabAfterDelete = Trigger( + 'CREATE TRIGGER tab_after_delete AFTER DELETE ON tab BEGIN INSERT INTO tab_fts (tab_fts, "rowid", title, url, extracted_content_plain, full_content_plain) VALUES (\'delete\', old."rowid", old.title, old.url, old.extracted_content_plain, old.full_content_plain);END', + 'tab_after_delete', + ); + late final Trigger tabAfterUpdate = Trigger( + 'CREATE TRIGGER tab_after_update AFTER UPDATE ON tab BEGIN INSERT INTO tab_fts (tab_fts, "rowid", title, url, extracted_content_plain, full_content_plain) VALUES (\'delete\', old."rowid", old.title, old.url, old.extracted_content_plain, old.full_content_plain);INSERT INTO tab_fts ("rowid", title, url, extracted_content_plain, full_content_plain) VALUES (new."rowid", new.title, new.url, new.extracted_content_plain, new.full_content_plain);END', + 'tab_after_update', + ); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [ + container, + tab, + closedTabTombstone, + idxTabParentContainer, + captureTab, + idxCaptureTabCaptureId, + tabFts, + tabMaintainParentChainOnDelete, + tabAfterInsert, + tabAfterDelete, + tabAfterUpdate, + ]; + @override + StreamQueryUpdateRules get streamUpdateRules => const StreamQueryUpdateRules([ + WritePropagation( + on: TableUpdateQuery.onTableName( + 'container', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('tab', kind: UpdateKind.delete)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'tab', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('capture_tab', kind: UpdateKind.delete)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'tab', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('tab', kind: UpdateKind.update)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'tab', + limitUpdateKind: UpdateKind.insert, + ), + result: [TableUpdate('tab_fts', kind: UpdateKind.insert)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'tab', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('tab_fts', kind: UpdateKind.insert)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'tab', + limitUpdateKind: UpdateKind.update, + ), + result: [TableUpdate('tab_fts', kind: UpdateKind.insert)], + ), + ]); + @override + int get schemaVersion => 10; +} diff --git a/apps/weblibre/test/drift/tabs/generated/schema_v11.dart b/apps/weblibre/test/drift/tabs/generated/schema_v11.dart new file mode 100644 index 00000000..6d066976 --- /dev/null +++ b/apps/weblibre/test/drift/tabs/generated/schema_v11.dart @@ -0,0 +1,1893 @@ +// dart format width=80 +// GENERATED BY drift_dev, DO NOT MODIFY. +// ignore_for_file: type=lint,unused_import +// +import 'package:drift/drift.dart'; + +class Container extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Container(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'PRIMARY KEY NOT NULL', + ); + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn color = GeneratedColumn( + 'color', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn metadata = GeneratedColumn( + 'metadata', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + @override + List get $columns => [id, name, color, metadata]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'container'; + @override + Set get $primaryKey => {id}; + @override + ContainerData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return ContainerData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + ), + color: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}color'], + )!, + metadata: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}metadata'], + ), + ); + } + + @override + Container createAlias(String alias) { + return Container(attachedDatabase, alias); + } + + @override + bool get dontWriteConstraints => true; +} + +class ContainerData extends DataClass implements Insertable { + final String id; + final String? name; + final int color; + final String? metadata; + const ContainerData({ + required this.id, + this.name, + required this.color, + this.metadata, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + if (!nullToAbsent || name != null) { + map['name'] = Variable(name); + } + map['color'] = Variable(color); + if (!nullToAbsent || metadata != null) { + map['metadata'] = Variable(metadata); + } + return map; + } + + factory ContainerData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return ContainerData( + id: serializer.fromJson(json['id']), + name: serializer.fromJson(json['name']), + color: serializer.fromJson(json['color']), + metadata: serializer.fromJson(json['metadata']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'name': serializer.toJson(name), + 'color': serializer.toJson(color), + 'metadata': serializer.toJson(metadata), + }; + } + + ContainerData copyWith({ + String? id, + Value name = const Value.absent(), + int? color, + Value metadata = const Value.absent(), + }) => ContainerData( + id: id ?? this.id, + name: name.present ? name.value : this.name, + color: color ?? this.color, + metadata: metadata.present ? metadata.value : this.metadata, + ); + ContainerData copyWithCompanion(ContainerCompanion data) { + return ContainerData( + id: data.id.present ? data.id.value : this.id, + name: data.name.present ? data.name.value : this.name, + color: data.color.present ? data.color.value : this.color, + metadata: data.metadata.present ? data.metadata.value : this.metadata, + ); + } + + @override + String toString() { + return (StringBuffer('ContainerData(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('color: $color, ') + ..write('metadata: $metadata') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, name, color, metadata); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ContainerData && + other.id == this.id && + other.name == this.name && + other.color == this.color && + other.metadata == this.metadata); +} + +class ContainerCompanion extends UpdateCompanion { + final Value id; + final Value name; + final Value color; + final Value metadata; + final Value rowid; + const ContainerCompanion({ + this.id = const Value.absent(), + this.name = const Value.absent(), + this.color = const Value.absent(), + this.metadata = const Value.absent(), + this.rowid = const Value.absent(), + }); + ContainerCompanion.insert({ + required String id, + this.name = const Value.absent(), + required int color, + this.metadata = const Value.absent(), + this.rowid = const Value.absent(), + }) : id = Value(id), + color = Value(color); + static Insertable custom({ + Expression? id, + Expression? name, + Expression? color, + Expression? metadata, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (name != null) 'name': name, + if (color != null) 'color': color, + if (metadata != null) 'metadata': metadata, + if (rowid != null) 'rowid': rowid, + }); + } + + ContainerCompanion copyWith({ + Value? id, + Value? name, + Value? color, + Value? metadata, + Value? rowid, + }) { + return ContainerCompanion( + id: id ?? this.id, + name: name ?? this.name, + color: color ?? this.color, + metadata: metadata ?? this.metadata, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (name.present) { + map['name'] = Variable(name.value); + } + if (color.present) { + map['color'] = Variable(color.value); + } + if (metadata.present) { + map['metadata'] = Variable(metadata.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('ContainerCompanion(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('color: $color, ') + ..write('metadata: $metadata, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class Tab extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Tab(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'PRIMARY KEY NOT NULL', + ); + late final GeneratedColumn source = GeneratedColumn( + 'source', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn parentId = GeneratedColumn( + 'parent_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'REFERENCES tab(id)ON DELETE SET NULL', + ); + late final GeneratedColumn containerId = GeneratedColumn( + 'container_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'REFERENCES container(id)ON DELETE CASCADE', + ); + late final GeneratedColumn orderKey = GeneratedColumn( + 'order_key', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn url = GeneratedColumn( + 'url', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn title = GeneratedColumn( + 'title', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn tabMode = GeneratedColumn( + 'tab_mode', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn isolationContextId = + GeneratedColumn( + 'isolation_context_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn isPinned = GeneratedColumn( + 'is_pinned', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn isProbablyReaderable = GeneratedColumn( + 'is_probably_readerable', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn extractedContentMarkdown = + GeneratedColumn( + 'extracted_content_markdown', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn extractedContentPlain = + GeneratedColumn( + 'extracted_content_plain', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn fullContentMarkdown = + GeneratedColumn( + 'full_content_markdown', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn fullContentPlain = GeneratedColumn( + 'full_content_plain', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn timestamp = GeneratedColumn( + 'timestamp', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + @override + List get $columns => [ + id, + source, + parentId, + containerId, + orderKey, + url, + title, + tabMode, + isolationContextId, + isPinned, + isProbablyReaderable, + extractedContentMarkdown, + extractedContentPlain, + fullContentMarkdown, + fullContentPlain, + timestamp, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'tab'; + @override + Set get $primaryKey => {id}; + @override + TabData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return TabData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + source: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}source'], + )!, + parentId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}parent_id'], + ), + containerId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}container_id'], + ), + orderKey: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}order_key'], + )!, + url: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}url'], + ), + title: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}title'], + ), + tabMode: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}tab_mode'], + )!, + isolationContextId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}isolation_context_id'], + ), + isPinned: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}is_pinned'], + )!, + isProbablyReaderable: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}is_probably_readerable'], + ), + extractedContentMarkdown: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}extracted_content_markdown'], + ), + extractedContentPlain: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}extracted_content_plain'], + ), + fullContentMarkdown: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}full_content_markdown'], + ), + fullContentPlain: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}full_content_plain'], + ), + timestamp: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}timestamp'], + )!, + ); + } + + @override + Tab createAlias(String alias) { + return Tab(attachedDatabase, alias); + } + + @override + List get customConstraints => const [ + 'CHECK((tab_mode = 2 AND isolation_context_id IS NOT NULL)OR(tab_mode != 2 AND isolation_context_id IS NULL))', + ]; + @override + bool get dontWriteConstraints => true; +} + +class TabData extends DataClass implements Insertable { + final String id; + final int source; + final String? parentId; + final String? containerId; + final String orderKey; + final String? url; + final String? title; + final int tabMode; + final String? isolationContextId; + final int isPinned; + final int? isProbablyReaderable; + final String? extractedContentMarkdown; + final String? extractedContentPlain; + final String? fullContentMarkdown; + final String? fullContentPlain; + final int timestamp; + const TabData({ + required this.id, + required this.source, + this.parentId, + this.containerId, + required this.orderKey, + this.url, + this.title, + required this.tabMode, + this.isolationContextId, + required this.isPinned, + this.isProbablyReaderable, + this.extractedContentMarkdown, + this.extractedContentPlain, + this.fullContentMarkdown, + this.fullContentPlain, + required this.timestamp, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['source'] = Variable(source); + if (!nullToAbsent || parentId != null) { + map['parent_id'] = Variable(parentId); + } + if (!nullToAbsent || containerId != null) { + map['container_id'] = Variable(containerId); + } + map['order_key'] = Variable(orderKey); + if (!nullToAbsent || url != null) { + map['url'] = Variable(url); + } + if (!nullToAbsent || title != null) { + map['title'] = Variable(title); + } + map['tab_mode'] = Variable(tabMode); + if (!nullToAbsent || isolationContextId != null) { + map['isolation_context_id'] = Variable(isolationContextId); + } + map['is_pinned'] = Variable(isPinned); + if (!nullToAbsent || isProbablyReaderable != null) { + map['is_probably_readerable'] = Variable(isProbablyReaderable); + } + if (!nullToAbsent || extractedContentMarkdown != null) { + map['extracted_content_markdown'] = Variable( + extractedContentMarkdown, + ); + } + if (!nullToAbsent || extractedContentPlain != null) { + map['extracted_content_plain'] = Variable(extractedContentPlain); + } + if (!nullToAbsent || fullContentMarkdown != null) { + map['full_content_markdown'] = Variable(fullContentMarkdown); + } + if (!nullToAbsent || fullContentPlain != null) { + map['full_content_plain'] = Variable(fullContentPlain); + } + map['timestamp'] = Variable(timestamp); + return map; + } + + factory TabData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return TabData( + id: serializer.fromJson(json['id']), + source: serializer.fromJson(json['source']), + parentId: serializer.fromJson(json['parentId']), + containerId: serializer.fromJson(json['containerId']), + orderKey: serializer.fromJson(json['orderKey']), + url: serializer.fromJson(json['url']), + title: serializer.fromJson(json['title']), + tabMode: serializer.fromJson(json['tabMode']), + isolationContextId: serializer.fromJson( + json['isolationContextId'], + ), + isPinned: serializer.fromJson(json['isPinned']), + isProbablyReaderable: serializer.fromJson( + json['isProbablyReaderable'], + ), + extractedContentMarkdown: serializer.fromJson( + json['extractedContentMarkdown'], + ), + extractedContentPlain: serializer.fromJson( + json['extractedContentPlain'], + ), + fullContentMarkdown: serializer.fromJson( + json['fullContentMarkdown'], + ), + fullContentPlain: serializer.fromJson(json['fullContentPlain']), + timestamp: serializer.fromJson(json['timestamp']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'source': serializer.toJson(source), + 'parentId': serializer.toJson(parentId), + 'containerId': serializer.toJson(containerId), + 'orderKey': serializer.toJson(orderKey), + 'url': serializer.toJson(url), + 'title': serializer.toJson(title), + 'tabMode': serializer.toJson(tabMode), + 'isolationContextId': serializer.toJson(isolationContextId), + 'isPinned': serializer.toJson(isPinned), + 'isProbablyReaderable': serializer.toJson(isProbablyReaderable), + 'extractedContentMarkdown': serializer.toJson( + extractedContentMarkdown, + ), + 'extractedContentPlain': serializer.toJson( + extractedContentPlain, + ), + 'fullContentMarkdown': serializer.toJson(fullContentMarkdown), + 'fullContentPlain': serializer.toJson(fullContentPlain), + 'timestamp': serializer.toJson(timestamp), + }; + } + + TabData copyWith({ + String? id, + int? source, + Value parentId = const Value.absent(), + Value containerId = const Value.absent(), + String? orderKey, + Value url = const Value.absent(), + Value title = const Value.absent(), + int? tabMode, + Value isolationContextId = const Value.absent(), + int? isPinned, + Value isProbablyReaderable = const Value.absent(), + Value extractedContentMarkdown = const Value.absent(), + Value extractedContentPlain = const Value.absent(), + Value fullContentMarkdown = const Value.absent(), + Value fullContentPlain = const Value.absent(), + int? timestamp, + }) => TabData( + id: id ?? this.id, + source: source ?? this.source, + parentId: parentId.present ? parentId.value : this.parentId, + containerId: containerId.present ? containerId.value : this.containerId, + orderKey: orderKey ?? this.orderKey, + url: url.present ? url.value : this.url, + title: title.present ? title.value : this.title, + tabMode: tabMode ?? this.tabMode, + isolationContextId: isolationContextId.present + ? isolationContextId.value + : this.isolationContextId, + isPinned: isPinned ?? this.isPinned, + isProbablyReaderable: isProbablyReaderable.present + ? isProbablyReaderable.value + : this.isProbablyReaderable, + extractedContentMarkdown: extractedContentMarkdown.present + ? extractedContentMarkdown.value + : this.extractedContentMarkdown, + extractedContentPlain: extractedContentPlain.present + ? extractedContentPlain.value + : this.extractedContentPlain, + fullContentMarkdown: fullContentMarkdown.present + ? fullContentMarkdown.value + : this.fullContentMarkdown, + fullContentPlain: fullContentPlain.present + ? fullContentPlain.value + : this.fullContentPlain, + timestamp: timestamp ?? this.timestamp, + ); + TabData copyWithCompanion(TabCompanion data) { + return TabData( + id: data.id.present ? data.id.value : this.id, + source: data.source.present ? data.source.value : this.source, + parentId: data.parentId.present ? data.parentId.value : this.parentId, + containerId: data.containerId.present + ? data.containerId.value + : this.containerId, + orderKey: data.orderKey.present ? data.orderKey.value : this.orderKey, + url: data.url.present ? data.url.value : this.url, + title: data.title.present ? data.title.value : this.title, + tabMode: data.tabMode.present ? data.tabMode.value : this.tabMode, + isolationContextId: data.isolationContextId.present + ? data.isolationContextId.value + : this.isolationContextId, + isPinned: data.isPinned.present ? data.isPinned.value : this.isPinned, + isProbablyReaderable: data.isProbablyReaderable.present + ? data.isProbablyReaderable.value + : this.isProbablyReaderable, + extractedContentMarkdown: data.extractedContentMarkdown.present + ? data.extractedContentMarkdown.value + : this.extractedContentMarkdown, + extractedContentPlain: data.extractedContentPlain.present + ? data.extractedContentPlain.value + : this.extractedContentPlain, + fullContentMarkdown: data.fullContentMarkdown.present + ? data.fullContentMarkdown.value + : this.fullContentMarkdown, + fullContentPlain: data.fullContentPlain.present + ? data.fullContentPlain.value + : this.fullContentPlain, + timestamp: data.timestamp.present ? data.timestamp.value : this.timestamp, + ); + } + + @override + String toString() { + return (StringBuffer('TabData(') + ..write('id: $id, ') + ..write('source: $source, ') + ..write('parentId: $parentId, ') + ..write('containerId: $containerId, ') + ..write('orderKey: $orderKey, ') + ..write('url: $url, ') + ..write('title: $title, ') + ..write('tabMode: $tabMode, ') + ..write('isolationContextId: $isolationContextId, ') + ..write('isPinned: $isPinned, ') + ..write('isProbablyReaderable: $isProbablyReaderable, ') + ..write('extractedContentMarkdown: $extractedContentMarkdown, ') + ..write('extractedContentPlain: $extractedContentPlain, ') + ..write('fullContentMarkdown: $fullContentMarkdown, ') + ..write('fullContentPlain: $fullContentPlain, ') + ..write('timestamp: $timestamp') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + source, + parentId, + containerId, + orderKey, + url, + title, + tabMode, + isolationContextId, + isPinned, + isProbablyReaderable, + extractedContentMarkdown, + extractedContentPlain, + fullContentMarkdown, + fullContentPlain, + timestamp, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is TabData && + other.id == this.id && + other.source == this.source && + other.parentId == this.parentId && + other.containerId == this.containerId && + other.orderKey == this.orderKey && + other.url == this.url && + other.title == this.title && + other.tabMode == this.tabMode && + other.isolationContextId == this.isolationContextId && + other.isPinned == this.isPinned && + other.isProbablyReaderable == this.isProbablyReaderable && + other.extractedContentMarkdown == this.extractedContentMarkdown && + other.extractedContentPlain == this.extractedContentPlain && + other.fullContentMarkdown == this.fullContentMarkdown && + other.fullContentPlain == this.fullContentPlain && + other.timestamp == this.timestamp); +} + +class TabCompanion extends UpdateCompanion { + final Value id; + final Value source; + final Value parentId; + final Value containerId; + final Value orderKey; + final Value url; + final Value title; + final Value tabMode; + final Value isolationContextId; + final Value isPinned; + final Value isProbablyReaderable; + final Value extractedContentMarkdown; + final Value extractedContentPlain; + final Value fullContentMarkdown; + final Value fullContentPlain; + final Value timestamp; + final Value rowid; + const TabCompanion({ + this.id = const Value.absent(), + this.source = const Value.absent(), + this.parentId = const Value.absent(), + this.containerId = const Value.absent(), + this.orderKey = const Value.absent(), + this.url = const Value.absent(), + this.title = const Value.absent(), + this.tabMode = const Value.absent(), + this.isolationContextId = const Value.absent(), + this.isPinned = const Value.absent(), + this.isProbablyReaderable = const Value.absent(), + this.extractedContentMarkdown = const Value.absent(), + this.extractedContentPlain = const Value.absent(), + this.fullContentMarkdown = const Value.absent(), + this.fullContentPlain = const Value.absent(), + this.timestamp = const Value.absent(), + this.rowid = const Value.absent(), + }); + TabCompanion.insert({ + required String id, + required int source, + this.parentId = const Value.absent(), + this.containerId = const Value.absent(), + required String orderKey, + this.url = const Value.absent(), + this.title = const Value.absent(), + this.tabMode = const Value.absent(), + this.isolationContextId = const Value.absent(), + this.isPinned = const Value.absent(), + this.isProbablyReaderable = const Value.absent(), + this.extractedContentMarkdown = const Value.absent(), + this.extractedContentPlain = const Value.absent(), + this.fullContentMarkdown = const Value.absent(), + this.fullContentPlain = const Value.absent(), + required int timestamp, + this.rowid = const Value.absent(), + }) : id = Value(id), + source = Value(source), + orderKey = Value(orderKey), + timestamp = Value(timestamp); + static Insertable custom({ + Expression? id, + Expression? source, + Expression? parentId, + Expression? containerId, + Expression? orderKey, + Expression? url, + Expression? title, + Expression? tabMode, + Expression? isolationContextId, + Expression? isPinned, + Expression? isProbablyReaderable, + Expression? extractedContentMarkdown, + Expression? extractedContentPlain, + Expression? fullContentMarkdown, + Expression? fullContentPlain, + Expression? timestamp, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (source != null) 'source': source, + if (parentId != null) 'parent_id': parentId, + if (containerId != null) 'container_id': containerId, + if (orderKey != null) 'order_key': orderKey, + if (url != null) 'url': url, + if (title != null) 'title': title, + if (tabMode != null) 'tab_mode': tabMode, + if (isolationContextId != null) + 'isolation_context_id': isolationContextId, + if (isPinned != null) 'is_pinned': isPinned, + if (isProbablyReaderable != null) + 'is_probably_readerable': isProbablyReaderable, + if (extractedContentMarkdown != null) + 'extracted_content_markdown': extractedContentMarkdown, + if (extractedContentPlain != null) + 'extracted_content_plain': extractedContentPlain, + if (fullContentMarkdown != null) + 'full_content_markdown': fullContentMarkdown, + if (fullContentPlain != null) 'full_content_plain': fullContentPlain, + if (timestamp != null) 'timestamp': timestamp, + if (rowid != null) 'rowid': rowid, + }); + } + + TabCompanion copyWith({ + Value? id, + Value? source, + Value? parentId, + Value? containerId, + Value? orderKey, + Value? url, + Value? title, + Value? tabMode, + Value? isolationContextId, + Value? isPinned, + Value? isProbablyReaderable, + Value? extractedContentMarkdown, + Value? extractedContentPlain, + Value? fullContentMarkdown, + Value? fullContentPlain, + Value? timestamp, + Value? rowid, + }) { + return TabCompanion( + id: id ?? this.id, + source: source ?? this.source, + parentId: parentId ?? this.parentId, + containerId: containerId ?? this.containerId, + orderKey: orderKey ?? this.orderKey, + url: url ?? this.url, + title: title ?? this.title, + tabMode: tabMode ?? this.tabMode, + isolationContextId: isolationContextId ?? this.isolationContextId, + isPinned: isPinned ?? this.isPinned, + isProbablyReaderable: isProbablyReaderable ?? this.isProbablyReaderable, + extractedContentMarkdown: + extractedContentMarkdown ?? this.extractedContentMarkdown, + extractedContentPlain: + extractedContentPlain ?? this.extractedContentPlain, + fullContentMarkdown: fullContentMarkdown ?? this.fullContentMarkdown, + fullContentPlain: fullContentPlain ?? this.fullContentPlain, + timestamp: timestamp ?? this.timestamp, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (source.present) { + map['source'] = Variable(source.value); + } + if (parentId.present) { + map['parent_id'] = Variable(parentId.value); + } + if (containerId.present) { + map['container_id'] = Variable(containerId.value); + } + if (orderKey.present) { + map['order_key'] = Variable(orderKey.value); + } + if (url.present) { + map['url'] = Variable(url.value); + } + if (title.present) { + map['title'] = Variable(title.value); + } + if (tabMode.present) { + map['tab_mode'] = Variable(tabMode.value); + } + if (isolationContextId.present) { + map['isolation_context_id'] = Variable(isolationContextId.value); + } + if (isPinned.present) { + map['is_pinned'] = Variable(isPinned.value); + } + if (isProbablyReaderable.present) { + map['is_probably_readerable'] = Variable(isProbablyReaderable.value); + } + if (extractedContentMarkdown.present) { + map['extracted_content_markdown'] = Variable( + extractedContentMarkdown.value, + ); + } + if (extractedContentPlain.present) { + map['extracted_content_plain'] = Variable( + extractedContentPlain.value, + ); + } + if (fullContentMarkdown.present) { + map['full_content_markdown'] = Variable( + fullContentMarkdown.value, + ); + } + if (fullContentPlain.present) { + map['full_content_plain'] = Variable(fullContentPlain.value); + } + if (timestamp.present) { + map['timestamp'] = Variable(timestamp.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('TabCompanion(') + ..write('id: $id, ') + ..write('source: $source, ') + ..write('parentId: $parentId, ') + ..write('containerId: $containerId, ') + ..write('orderKey: $orderKey, ') + ..write('url: $url, ') + ..write('title: $title, ') + ..write('tabMode: $tabMode, ') + ..write('isolationContextId: $isolationContextId, ') + ..write('isPinned: $isPinned, ') + ..write('isProbablyReaderable: $isProbablyReaderable, ') + ..write('extractedContentMarkdown: $extractedContentMarkdown, ') + ..write('extractedContentPlain: $extractedContentPlain, ') + ..write('fullContentMarkdown: $fullContentMarkdown, ') + ..write('fullContentPlain: $fullContentPlain, ') + ..write('timestamp: $timestamp, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class ClosedTabTombstone extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + ClosedTabTombstone(this.attachedDatabase, [this._alias]); + late final GeneratedColumn tabId = GeneratedColumn( + 'tab_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'PRIMARY KEY NOT NULL', + ); + late final GeneratedColumn closedAt = GeneratedColumn( + 'closed_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + @override + List get $columns => [tabId, closedAt]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'closed_tab_tombstone'; + @override + Set get $primaryKey => {tabId}; + @override + ClosedTabTombstoneData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return ClosedTabTombstoneData( + tabId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}tab_id'], + )!, + closedAt: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}closed_at'], + )!, + ); + } + + @override + ClosedTabTombstone createAlias(String alias) { + return ClosedTabTombstone(attachedDatabase, alias); + } + + @override + bool get dontWriteConstraints => true; +} + +class ClosedTabTombstoneData extends DataClass + implements Insertable { + final String tabId; + final int closedAt; + const ClosedTabTombstoneData({required this.tabId, required this.closedAt}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['tab_id'] = Variable(tabId); + map['closed_at'] = Variable(closedAt); + return map; + } + + factory ClosedTabTombstoneData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return ClosedTabTombstoneData( + tabId: serializer.fromJson(json['tabId']), + closedAt: serializer.fromJson(json['closedAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'tabId': serializer.toJson(tabId), + 'closedAt': serializer.toJson(closedAt), + }; + } + + ClosedTabTombstoneData copyWith({String? tabId, int? closedAt}) => + ClosedTabTombstoneData( + tabId: tabId ?? this.tabId, + closedAt: closedAt ?? this.closedAt, + ); + ClosedTabTombstoneData copyWithCompanion(ClosedTabTombstoneCompanion data) { + return ClosedTabTombstoneData( + tabId: data.tabId.present ? data.tabId.value : this.tabId, + closedAt: data.closedAt.present ? data.closedAt.value : this.closedAt, + ); + } + + @override + String toString() { + return (StringBuffer('ClosedTabTombstoneData(') + ..write('tabId: $tabId, ') + ..write('closedAt: $closedAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(tabId, closedAt); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ClosedTabTombstoneData && + other.tabId == this.tabId && + other.closedAt == this.closedAt); +} + +class ClosedTabTombstoneCompanion + extends UpdateCompanion { + final Value tabId; + final Value closedAt; + final Value rowid; + const ClosedTabTombstoneCompanion({ + this.tabId = const Value.absent(), + this.closedAt = const Value.absent(), + this.rowid = const Value.absent(), + }); + ClosedTabTombstoneCompanion.insert({ + required String tabId, + required int closedAt, + this.rowid = const Value.absent(), + }) : tabId = Value(tabId), + closedAt = Value(closedAt); + static Insertable custom({ + Expression? tabId, + Expression? closedAt, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (tabId != null) 'tab_id': tabId, + if (closedAt != null) 'closed_at': closedAt, + if (rowid != null) 'rowid': rowid, + }); + } + + ClosedTabTombstoneCompanion copyWith({ + Value? tabId, + Value? closedAt, + Value? rowid, + }) { + return ClosedTabTombstoneCompanion( + tabId: tabId ?? this.tabId, + closedAt: closedAt ?? this.closedAt, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (tabId.present) { + map['tab_id'] = Variable(tabId.value); + } + if (closedAt.present) { + map['closed_at'] = Variable(closedAt.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('ClosedTabTombstoneCompanion(') + ..write('tabId: $tabId, ') + ..write('closedAt: $closedAt, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class CaptureTab extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + CaptureTab(this.attachedDatabase, [this._alias]); + late final GeneratedColumn tabId = GeneratedColumn( + 'tab_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: + 'NOT NULL PRIMARY KEY REFERENCES tab(id)ON DELETE CASCADE', + ); + late final GeneratedColumn captureId = GeneratedColumn( + 'capture_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn sourceUrl = GeneratedColumn( + 'source_url', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn status = GeneratedColumn( + 'status', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT \'pending\'', + defaultValue: const CustomExpression('\'pending\''), + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + @override + List get $columns => [ + tabId, + captureId, + sourceUrl, + status, + createdAt, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'capture_tab'; + @override + Set get $primaryKey => {tabId}; + @override + CaptureTabData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return CaptureTabData( + tabId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}tab_id'], + )!, + captureId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}capture_id'], + )!, + sourceUrl: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}source_url'], + )!, + status: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}status'], + )!, + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}created_at'], + )!, + ); + } + + @override + CaptureTab createAlias(String alias) { + return CaptureTab(attachedDatabase, alias); + } + + @override + bool get dontWriteConstraints => true; +} + +class CaptureTabData extends DataClass implements Insertable { + final String tabId; + final String captureId; + final String sourceUrl; + final String status; + final int createdAt; + const CaptureTabData({ + required this.tabId, + required this.captureId, + required this.sourceUrl, + required this.status, + required this.createdAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['tab_id'] = Variable(tabId); + map['capture_id'] = Variable(captureId); + map['source_url'] = Variable(sourceUrl); + map['status'] = Variable(status); + map['created_at'] = Variable(createdAt); + return map; + } + + factory CaptureTabData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return CaptureTabData( + tabId: serializer.fromJson(json['tabId']), + captureId: serializer.fromJson(json['captureId']), + sourceUrl: serializer.fromJson(json['sourceUrl']), + status: serializer.fromJson(json['status']), + createdAt: serializer.fromJson(json['createdAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'tabId': serializer.toJson(tabId), + 'captureId': serializer.toJson(captureId), + 'sourceUrl': serializer.toJson(sourceUrl), + 'status': serializer.toJson(status), + 'createdAt': serializer.toJson(createdAt), + }; + } + + CaptureTabData copyWith({ + String? tabId, + String? captureId, + String? sourceUrl, + String? status, + int? createdAt, + }) => CaptureTabData( + tabId: tabId ?? this.tabId, + captureId: captureId ?? this.captureId, + sourceUrl: sourceUrl ?? this.sourceUrl, + status: status ?? this.status, + createdAt: createdAt ?? this.createdAt, + ); + CaptureTabData copyWithCompanion(CaptureTabCompanion data) { + return CaptureTabData( + tabId: data.tabId.present ? data.tabId.value : this.tabId, + captureId: data.captureId.present ? data.captureId.value : this.captureId, + sourceUrl: data.sourceUrl.present ? data.sourceUrl.value : this.sourceUrl, + status: data.status.present ? data.status.value : this.status, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + ); + } + + @override + String toString() { + return (StringBuffer('CaptureTabData(') + ..write('tabId: $tabId, ') + ..write('captureId: $captureId, ') + ..write('sourceUrl: $sourceUrl, ') + ..write('status: $status, ') + ..write('createdAt: $createdAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => + Object.hash(tabId, captureId, sourceUrl, status, createdAt); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is CaptureTabData && + other.tabId == this.tabId && + other.captureId == this.captureId && + other.sourceUrl == this.sourceUrl && + other.status == this.status && + other.createdAt == this.createdAt); +} + +class CaptureTabCompanion extends UpdateCompanion { + final Value tabId; + final Value captureId; + final Value sourceUrl; + final Value status; + final Value createdAt; + final Value rowid; + const CaptureTabCompanion({ + this.tabId = const Value.absent(), + this.captureId = const Value.absent(), + this.sourceUrl = const Value.absent(), + this.status = const Value.absent(), + this.createdAt = const Value.absent(), + this.rowid = const Value.absent(), + }); + CaptureTabCompanion.insert({ + required String tabId, + required String captureId, + required String sourceUrl, + this.status = const Value.absent(), + required int createdAt, + this.rowid = const Value.absent(), + }) : tabId = Value(tabId), + captureId = Value(captureId), + sourceUrl = Value(sourceUrl), + createdAt = Value(createdAt); + static Insertable custom({ + Expression? tabId, + Expression? captureId, + Expression? sourceUrl, + Expression? status, + Expression? createdAt, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (tabId != null) 'tab_id': tabId, + if (captureId != null) 'capture_id': captureId, + if (sourceUrl != null) 'source_url': sourceUrl, + if (status != null) 'status': status, + if (createdAt != null) 'created_at': createdAt, + if (rowid != null) 'rowid': rowid, + }); + } + + CaptureTabCompanion copyWith({ + Value? tabId, + Value? captureId, + Value? sourceUrl, + Value? status, + Value? createdAt, + Value? rowid, + }) { + return CaptureTabCompanion( + tabId: tabId ?? this.tabId, + captureId: captureId ?? this.captureId, + sourceUrl: sourceUrl ?? this.sourceUrl, + status: status ?? this.status, + createdAt: createdAt ?? this.createdAt, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (tabId.present) { + map['tab_id'] = Variable(tabId.value); + } + if (captureId.present) { + map['capture_id'] = Variable(captureId.value); + } + if (sourceUrl.present) { + map['source_url'] = Variable(sourceUrl.value); + } + if (status.present) { + map['status'] = Variable(status.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('CaptureTabCompanion(') + ..write('tabId: $tabId, ') + ..write('captureId: $captureId, ') + ..write('sourceUrl: $sourceUrl, ') + ..write('status: $status, ') + ..write('createdAt: $createdAt, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class TabFts extends Table + with TableInfo, VirtualTableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + TabFts(this.attachedDatabase, [this._alias]); + late final GeneratedColumn title = GeneratedColumn( + 'title', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn url = GeneratedColumn( + 'url', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn extractedContentPlain = + GeneratedColumn( + 'extracted_content_plain', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn fullContentPlain = GeneratedColumn( + 'full_content_plain', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + @override + List get $columns => [ + title, + url, + extractedContentPlain, + fullContentPlain, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'tab_fts'; + @override + Set get $primaryKey => const {}; + @override + TabFtsData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return TabFtsData( + title: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}title'], + ), + url: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}url'], + ), + extractedContentPlain: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}extracted_content_plain'], + ), + fullContentPlain: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}full_content_plain'], + ), + ); + } + + @override + TabFts createAlias(String alias) { + return TabFts(attachedDatabase, alias); + } + + @override + bool get dontWriteConstraints => true; + @override + String get moduleAndArgs => + 'fts5(title, url, extracted_content_plain, full_content_plain, content=tab, tokenize="trigram")'; +} + +class TabFtsData extends DataClass implements Insertable { + final String? title; + final String? url; + final String? extractedContentPlain; + final String? fullContentPlain; + const TabFtsData({ + this.title, + this.url, + this.extractedContentPlain, + this.fullContentPlain, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (!nullToAbsent || title != null) { + map['title'] = Variable(title); + } + if (!nullToAbsent || url != null) { + map['url'] = Variable(url); + } + if (!nullToAbsent || extractedContentPlain != null) { + map['extracted_content_plain'] = Variable(extractedContentPlain); + } + if (!nullToAbsent || fullContentPlain != null) { + map['full_content_plain'] = Variable(fullContentPlain); + } + return map; + } + + factory TabFtsData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return TabFtsData( + title: serializer.fromJson(json['title']), + url: serializer.fromJson(json['url']), + extractedContentPlain: serializer.fromJson( + json['extractedContentPlain'], + ), + fullContentPlain: serializer.fromJson(json['fullContentPlain']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'title': serializer.toJson(title), + 'url': serializer.toJson(url), + 'extractedContentPlain': serializer.toJson( + extractedContentPlain, + ), + 'fullContentPlain': serializer.toJson(fullContentPlain), + }; + } + + TabFtsData copyWith({ + Value title = const Value.absent(), + Value url = const Value.absent(), + Value extractedContentPlain = const Value.absent(), + Value fullContentPlain = const Value.absent(), + }) => TabFtsData( + title: title.present ? title.value : this.title, + url: url.present ? url.value : this.url, + extractedContentPlain: extractedContentPlain.present + ? extractedContentPlain.value + : this.extractedContentPlain, + fullContentPlain: fullContentPlain.present + ? fullContentPlain.value + : this.fullContentPlain, + ); + TabFtsData copyWithCompanion(TabFtsCompanion data) { + return TabFtsData( + title: data.title.present ? data.title.value : this.title, + url: data.url.present ? data.url.value : this.url, + extractedContentPlain: data.extractedContentPlain.present + ? data.extractedContentPlain.value + : this.extractedContentPlain, + fullContentPlain: data.fullContentPlain.present + ? data.fullContentPlain.value + : this.fullContentPlain, + ); + } + + @override + String toString() { + return (StringBuffer('TabFtsData(') + ..write('title: $title, ') + ..write('url: $url, ') + ..write('extractedContentPlain: $extractedContentPlain, ') + ..write('fullContentPlain: $fullContentPlain') + ..write(')')) + .toString(); + } + + @override + int get hashCode => + Object.hash(title, url, extractedContentPlain, fullContentPlain); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is TabFtsData && + other.title == this.title && + other.url == this.url && + other.extractedContentPlain == this.extractedContentPlain && + other.fullContentPlain == this.fullContentPlain); +} + +class TabFtsCompanion extends UpdateCompanion { + final Value title; + final Value url; + final Value extractedContentPlain; + final Value fullContentPlain; + final Value rowid; + const TabFtsCompanion({ + this.title = const Value.absent(), + this.url = const Value.absent(), + this.extractedContentPlain = const Value.absent(), + this.fullContentPlain = const Value.absent(), + this.rowid = const Value.absent(), + }); + TabFtsCompanion.insert({ + this.title = const Value.absent(), + this.url = const Value.absent(), + this.extractedContentPlain = const Value.absent(), + this.fullContentPlain = const Value.absent(), + this.rowid = const Value.absent(), + }); + static Insertable custom({ + Expression? title, + Expression? url, + Expression? extractedContentPlain, + Expression? fullContentPlain, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (title != null) 'title': title, + if (url != null) 'url': url, + if (extractedContentPlain != null) + 'extracted_content_plain': extractedContentPlain, + if (fullContentPlain != null) 'full_content_plain': fullContentPlain, + if (rowid != null) 'rowid': rowid, + }); + } + + TabFtsCompanion copyWith({ + Value? title, + Value? url, + Value? extractedContentPlain, + Value? fullContentPlain, + Value? rowid, + }) { + return TabFtsCompanion( + title: title ?? this.title, + url: url ?? this.url, + extractedContentPlain: + extractedContentPlain ?? this.extractedContentPlain, + fullContentPlain: fullContentPlain ?? this.fullContentPlain, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (title.present) { + map['title'] = Variable(title.value); + } + if (url.present) { + map['url'] = Variable(url.value); + } + if (extractedContentPlain.present) { + map['extracted_content_plain'] = Variable( + extractedContentPlain.value, + ); + } + if (fullContentPlain.present) { + map['full_content_plain'] = Variable(fullContentPlain.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('TabFtsCompanion(') + ..write('title: $title, ') + ..write('url: $url, ') + ..write('extractedContentPlain: $extractedContentPlain, ') + ..write('fullContentPlain: $fullContentPlain, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class DatabaseAtV11 extends GeneratedDatabase { + DatabaseAtV11(QueryExecutor e) : super(e); + late final Container container = Container(this); + late final Tab tab = Tab(this); + late final ClosedTabTombstone closedTabTombstone = ClosedTabTombstone(this); + late final Index idxTabParentContainer = Index( + 'idx_tab_parent_container', + 'CREATE INDEX idx_tab_parent_container ON tab (parent_id, container_id)', + ); + late final CaptureTab captureTab = CaptureTab(this); + late final Index idxCaptureTabCaptureId = Index( + 'idx_capture_tab_capture_id', + 'CREATE INDEX idx_capture_tab_capture_id ON capture_tab (capture_id)', + ); + late final TabFts tabFts = TabFts(this); + late final Trigger tabMaintainParentChainOnDelete = Trigger( + 'CREATE TRIGGER tab_maintain_parent_chain_on_delete BEFORE DELETE ON tab BEGIN UPDATE tab SET parent_id = CASE WHEN OLD.parent_id IS NOT NULL AND EXISTS (SELECT 1 FROM tab WHERE id = OLD.parent_id) THEN OLD.parent_id ELSE NULL END WHERE parent_id = OLD.id;END', + 'tab_maintain_parent_chain_on_delete', + ); + late final Trigger tabAfterInsert = Trigger( + 'CREATE TRIGGER tab_after_insert AFTER INSERT ON tab BEGIN INSERT INTO tab_fts ("rowid", title, url, extracted_content_plain, full_content_plain) VALUES (new."rowid", new.title, new.url, new.extracted_content_plain, new.full_content_plain);END', + 'tab_after_insert', + ); + late final Trigger tabAfterDelete = Trigger( + 'CREATE TRIGGER tab_after_delete AFTER DELETE ON tab BEGIN INSERT INTO tab_fts (tab_fts, "rowid", title, url, extracted_content_plain, full_content_plain) VALUES (\'delete\', old."rowid", old.title, old.url, old.extracted_content_plain, old.full_content_plain);END', + 'tab_after_delete', + ); + late final Trigger tabAfterUpdate = Trigger( + 'CREATE TRIGGER tab_after_update AFTER UPDATE OF title, url, extracted_content_plain, full_content_plain ON tab BEGIN INSERT INTO tab_fts (tab_fts, "rowid", title, url, extracted_content_plain, full_content_plain) VALUES (\'delete\', old."rowid", old.title, old.url, old.extracted_content_plain, old.full_content_plain);INSERT INTO tab_fts ("rowid", title, url, extracted_content_plain, full_content_plain) VALUES (new."rowid", new.title, new.url, new.extracted_content_plain, new.full_content_plain);END', + 'tab_after_update', + ); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [ + container, + tab, + closedTabTombstone, + idxTabParentContainer, + captureTab, + idxCaptureTabCaptureId, + tabFts, + tabMaintainParentChainOnDelete, + tabAfterInsert, + tabAfterDelete, + tabAfterUpdate, + ]; + @override + StreamQueryUpdateRules get streamUpdateRules => const StreamQueryUpdateRules([ + WritePropagation( + on: TableUpdateQuery.onTableName( + 'container', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('tab', kind: UpdateKind.delete)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'tab', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('capture_tab', kind: UpdateKind.delete)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'tab', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('tab', kind: UpdateKind.update)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'tab', + limitUpdateKind: UpdateKind.insert, + ), + result: [TableUpdate('tab_fts', kind: UpdateKind.insert)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'tab', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('tab_fts', kind: UpdateKind.insert)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'tab', + limitUpdateKind: UpdateKind.update, + ), + result: [TableUpdate('tab_fts', kind: UpdateKind.insert)], + ), + ]); + @override + int get schemaVersion => 11; +} diff --git a/apps/weblibre/test/drift/tabs/generated/schema_v12.dart b/apps/weblibre/test/drift/tabs/generated/schema_v12.dart new file mode 100644 index 00000000..09dd8d3f --- /dev/null +++ b/apps/weblibre/test/drift/tabs/generated/schema_v12.dart @@ -0,0 +1,3092 @@ +// dart format width=80 +// GENERATED BY drift_dev, DO NOT MODIFY. +// ignore_for_file: type=lint,unused_import +// +import 'package:drift/drift.dart'; + +class Container extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Container(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'PRIMARY KEY NOT NULL', + ); + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn color = GeneratedColumn( + 'color', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn metadata = GeneratedColumn( + 'metadata', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + @override + List get $columns => [id, name, color, metadata]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'container'; + @override + Set get $primaryKey => {id}; + @override + ContainerData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return ContainerData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + ), + color: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}color'], + )!, + metadata: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}metadata'], + ), + ); + } + + @override + Container createAlias(String alias) { + return Container(attachedDatabase, alias); + } + + @override + bool get dontWriteConstraints => true; +} + +class ContainerData extends DataClass implements Insertable { + final String id; + final String? name; + final int color; + final String? metadata; + const ContainerData({ + required this.id, + this.name, + required this.color, + this.metadata, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + if (!nullToAbsent || name != null) { + map['name'] = Variable(name); + } + map['color'] = Variable(color); + if (!nullToAbsent || metadata != null) { + map['metadata'] = Variable(metadata); + } + return map; + } + + factory ContainerData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return ContainerData( + id: serializer.fromJson(json['id']), + name: serializer.fromJson(json['name']), + color: serializer.fromJson(json['color']), + metadata: serializer.fromJson(json['metadata']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'name': serializer.toJson(name), + 'color': serializer.toJson(color), + 'metadata': serializer.toJson(metadata), + }; + } + + ContainerData copyWith({ + String? id, + Value name = const Value.absent(), + int? color, + Value metadata = const Value.absent(), + }) => ContainerData( + id: id ?? this.id, + name: name.present ? name.value : this.name, + color: color ?? this.color, + metadata: metadata.present ? metadata.value : this.metadata, + ); + ContainerData copyWithCompanion(ContainerCompanion data) { + return ContainerData( + id: data.id.present ? data.id.value : this.id, + name: data.name.present ? data.name.value : this.name, + color: data.color.present ? data.color.value : this.color, + metadata: data.metadata.present ? data.metadata.value : this.metadata, + ); + } + + @override + String toString() { + return (StringBuffer('ContainerData(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('color: $color, ') + ..write('metadata: $metadata') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, name, color, metadata); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ContainerData && + other.id == this.id && + other.name == this.name && + other.color == this.color && + other.metadata == this.metadata); +} + +class ContainerCompanion extends UpdateCompanion { + final Value id; + final Value name; + final Value color; + final Value metadata; + final Value rowid; + const ContainerCompanion({ + this.id = const Value.absent(), + this.name = const Value.absent(), + this.color = const Value.absent(), + this.metadata = const Value.absent(), + this.rowid = const Value.absent(), + }); + ContainerCompanion.insert({ + required String id, + this.name = const Value.absent(), + required int color, + this.metadata = const Value.absent(), + this.rowid = const Value.absent(), + }) : id = Value(id), + color = Value(color); + static Insertable custom({ + Expression? id, + Expression? name, + Expression? color, + Expression? metadata, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (name != null) 'name': name, + if (color != null) 'color': color, + if (metadata != null) 'metadata': metadata, + if (rowid != null) 'rowid': rowid, + }); + } + + ContainerCompanion copyWith({ + Value? id, + Value? name, + Value? color, + Value? metadata, + Value? rowid, + }) { + return ContainerCompanion( + id: id ?? this.id, + name: name ?? this.name, + color: color ?? this.color, + metadata: metadata ?? this.metadata, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (name.present) { + map['name'] = Variable(name.value); + } + if (color.present) { + map['color'] = Variable(color.value); + } + if (metadata.present) { + map['metadata'] = Variable(metadata.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('ContainerCompanion(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('color: $color, ') + ..write('metadata: $metadata, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class Tab extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Tab(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'PRIMARY KEY NOT NULL', + ); + late final GeneratedColumn source = GeneratedColumn( + 'source', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn parentId = GeneratedColumn( + 'parent_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'REFERENCES tab(id)ON DELETE SET NULL', + ); + late final GeneratedColumn containerId = GeneratedColumn( + 'container_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'REFERENCES container(id)ON DELETE CASCADE', + ); + late final GeneratedColumn orderKey = GeneratedColumn( + 'order_key', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn url = GeneratedColumn( + 'url', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn title = GeneratedColumn( + 'title', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn tabMode = GeneratedColumn( + 'tab_mode', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn isolationContextId = + GeneratedColumn( + 'isolation_context_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn isPinned = GeneratedColumn( + 'is_pinned', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn isProbablyReaderable = GeneratedColumn( + 'is_probably_readerable', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn extractedContentMarkdown = + GeneratedColumn( + 'extracted_content_markdown', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn extractedContentPlain = + GeneratedColumn( + 'extracted_content_plain', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn fullContentMarkdown = + GeneratedColumn( + 'full_content_markdown', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn fullContentPlain = GeneratedColumn( + 'full_content_plain', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn timestamp = GeneratedColumn( + 'timestamp', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn contentHash = GeneratedColumn( + 'content_hash', + aliasedName, + true, + generatedAs: GeneratedAs( + const CustomExpression( + 'generate_content_hash(title, extracted_content_plain, full_content_plain)', + ), + false, + ), + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: + 'GENERATED ALWAYS AS (generate_content_hash(title, extracted_content_plain, full_content_plain)) VIRTUAL', + ); + @override + List get $columns => [ + id, + source, + parentId, + containerId, + orderKey, + url, + title, + tabMode, + isolationContextId, + isPinned, + isProbablyReaderable, + extractedContentMarkdown, + extractedContentPlain, + fullContentMarkdown, + fullContentPlain, + timestamp, + contentHash, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'tab'; + @override + Set get $primaryKey => {id}; + @override + TabData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return TabData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + source: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}source'], + )!, + parentId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}parent_id'], + ), + containerId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}container_id'], + ), + orderKey: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}order_key'], + )!, + url: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}url'], + ), + title: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}title'], + ), + tabMode: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}tab_mode'], + )!, + isolationContextId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}isolation_context_id'], + ), + isPinned: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}is_pinned'], + )!, + isProbablyReaderable: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}is_probably_readerable'], + ), + extractedContentMarkdown: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}extracted_content_markdown'], + ), + extractedContentPlain: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}extracted_content_plain'], + ), + fullContentMarkdown: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}full_content_markdown'], + ), + fullContentPlain: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}full_content_plain'], + ), + timestamp: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}timestamp'], + )!, + contentHash: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}content_hash'], + ), + ); + } + + @override + Tab createAlias(String alias) { + return Tab(attachedDatabase, alias); + } + + @override + List get customConstraints => const [ + 'CHECK((tab_mode = 2 AND isolation_context_id IS NOT NULL)OR(tab_mode != 2 AND isolation_context_id IS NULL))', + ]; + @override + bool get dontWriteConstraints => true; +} + +class TabData extends DataClass implements Insertable { + final String id; + final int source; + final String? parentId; + final String? containerId; + final String orderKey; + final String? url; + final String? title; + final int tabMode; + final String? isolationContextId; + final int isPinned; + final int? isProbablyReaderable; + final String? extractedContentMarkdown; + final String? extractedContentPlain; + final String? fullContentMarkdown; + final String? fullContentPlain; + final int timestamp; + final int? contentHash; + const TabData({ + required this.id, + required this.source, + this.parentId, + this.containerId, + required this.orderKey, + this.url, + this.title, + required this.tabMode, + this.isolationContextId, + required this.isPinned, + this.isProbablyReaderable, + this.extractedContentMarkdown, + this.extractedContentPlain, + this.fullContentMarkdown, + this.fullContentPlain, + required this.timestamp, + this.contentHash, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['source'] = Variable(source); + if (!nullToAbsent || parentId != null) { + map['parent_id'] = Variable(parentId); + } + if (!nullToAbsent || containerId != null) { + map['container_id'] = Variable(containerId); + } + map['order_key'] = Variable(orderKey); + if (!nullToAbsent || url != null) { + map['url'] = Variable(url); + } + if (!nullToAbsent || title != null) { + map['title'] = Variable(title); + } + map['tab_mode'] = Variable(tabMode); + if (!nullToAbsent || isolationContextId != null) { + map['isolation_context_id'] = Variable(isolationContextId); + } + map['is_pinned'] = Variable(isPinned); + if (!nullToAbsent || isProbablyReaderable != null) { + map['is_probably_readerable'] = Variable(isProbablyReaderable); + } + if (!nullToAbsent || extractedContentMarkdown != null) { + map['extracted_content_markdown'] = Variable( + extractedContentMarkdown, + ); + } + if (!nullToAbsent || extractedContentPlain != null) { + map['extracted_content_plain'] = Variable(extractedContentPlain); + } + if (!nullToAbsent || fullContentMarkdown != null) { + map['full_content_markdown'] = Variable(fullContentMarkdown); + } + if (!nullToAbsent || fullContentPlain != null) { + map['full_content_plain'] = Variable(fullContentPlain); + } + map['timestamp'] = Variable(timestamp); + return map; + } + + factory TabData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return TabData( + id: serializer.fromJson(json['id']), + source: serializer.fromJson(json['source']), + parentId: serializer.fromJson(json['parentId']), + containerId: serializer.fromJson(json['containerId']), + orderKey: serializer.fromJson(json['orderKey']), + url: serializer.fromJson(json['url']), + title: serializer.fromJson(json['title']), + tabMode: serializer.fromJson(json['tabMode']), + isolationContextId: serializer.fromJson( + json['isolationContextId'], + ), + isPinned: serializer.fromJson(json['isPinned']), + isProbablyReaderable: serializer.fromJson( + json['isProbablyReaderable'], + ), + extractedContentMarkdown: serializer.fromJson( + json['extractedContentMarkdown'], + ), + extractedContentPlain: serializer.fromJson( + json['extractedContentPlain'], + ), + fullContentMarkdown: serializer.fromJson( + json['fullContentMarkdown'], + ), + fullContentPlain: serializer.fromJson(json['fullContentPlain']), + timestamp: serializer.fromJson(json['timestamp']), + contentHash: serializer.fromJson(json['contentHash']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'source': serializer.toJson(source), + 'parentId': serializer.toJson(parentId), + 'containerId': serializer.toJson(containerId), + 'orderKey': serializer.toJson(orderKey), + 'url': serializer.toJson(url), + 'title': serializer.toJson(title), + 'tabMode': serializer.toJson(tabMode), + 'isolationContextId': serializer.toJson(isolationContextId), + 'isPinned': serializer.toJson(isPinned), + 'isProbablyReaderable': serializer.toJson(isProbablyReaderable), + 'extractedContentMarkdown': serializer.toJson( + extractedContentMarkdown, + ), + 'extractedContentPlain': serializer.toJson( + extractedContentPlain, + ), + 'fullContentMarkdown': serializer.toJson(fullContentMarkdown), + 'fullContentPlain': serializer.toJson(fullContentPlain), + 'timestamp': serializer.toJson(timestamp), + 'contentHash': serializer.toJson(contentHash), + }; + } + + TabData copyWith({ + String? id, + int? source, + Value parentId = const Value.absent(), + Value containerId = const Value.absent(), + String? orderKey, + Value url = const Value.absent(), + Value title = const Value.absent(), + int? tabMode, + Value isolationContextId = const Value.absent(), + int? isPinned, + Value isProbablyReaderable = const Value.absent(), + Value extractedContentMarkdown = const Value.absent(), + Value extractedContentPlain = const Value.absent(), + Value fullContentMarkdown = const Value.absent(), + Value fullContentPlain = const Value.absent(), + int? timestamp, + Value contentHash = const Value.absent(), + }) => TabData( + id: id ?? this.id, + source: source ?? this.source, + parentId: parentId.present ? parentId.value : this.parentId, + containerId: containerId.present ? containerId.value : this.containerId, + orderKey: orderKey ?? this.orderKey, + url: url.present ? url.value : this.url, + title: title.present ? title.value : this.title, + tabMode: tabMode ?? this.tabMode, + isolationContextId: isolationContextId.present + ? isolationContextId.value + : this.isolationContextId, + isPinned: isPinned ?? this.isPinned, + isProbablyReaderable: isProbablyReaderable.present + ? isProbablyReaderable.value + : this.isProbablyReaderable, + extractedContentMarkdown: extractedContentMarkdown.present + ? extractedContentMarkdown.value + : this.extractedContentMarkdown, + extractedContentPlain: extractedContentPlain.present + ? extractedContentPlain.value + : this.extractedContentPlain, + fullContentMarkdown: fullContentMarkdown.present + ? fullContentMarkdown.value + : this.fullContentMarkdown, + fullContentPlain: fullContentPlain.present + ? fullContentPlain.value + : this.fullContentPlain, + timestamp: timestamp ?? this.timestamp, + contentHash: contentHash.present ? contentHash.value : this.contentHash, + ); + @override + String toString() { + return (StringBuffer('TabData(') + ..write('id: $id, ') + ..write('source: $source, ') + ..write('parentId: $parentId, ') + ..write('containerId: $containerId, ') + ..write('orderKey: $orderKey, ') + ..write('url: $url, ') + ..write('title: $title, ') + ..write('tabMode: $tabMode, ') + ..write('isolationContextId: $isolationContextId, ') + ..write('isPinned: $isPinned, ') + ..write('isProbablyReaderable: $isProbablyReaderable, ') + ..write('extractedContentMarkdown: $extractedContentMarkdown, ') + ..write('extractedContentPlain: $extractedContentPlain, ') + ..write('fullContentMarkdown: $fullContentMarkdown, ') + ..write('fullContentPlain: $fullContentPlain, ') + ..write('timestamp: $timestamp, ') + ..write('contentHash: $contentHash') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + source, + parentId, + containerId, + orderKey, + url, + title, + tabMode, + isolationContextId, + isPinned, + isProbablyReaderable, + extractedContentMarkdown, + extractedContentPlain, + fullContentMarkdown, + fullContentPlain, + timestamp, + contentHash, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is TabData && + other.id == this.id && + other.source == this.source && + other.parentId == this.parentId && + other.containerId == this.containerId && + other.orderKey == this.orderKey && + other.url == this.url && + other.title == this.title && + other.tabMode == this.tabMode && + other.isolationContextId == this.isolationContextId && + other.isPinned == this.isPinned && + other.isProbablyReaderable == this.isProbablyReaderable && + other.extractedContentMarkdown == this.extractedContentMarkdown && + other.extractedContentPlain == this.extractedContentPlain && + other.fullContentMarkdown == this.fullContentMarkdown && + other.fullContentPlain == this.fullContentPlain && + other.timestamp == this.timestamp && + other.contentHash == this.contentHash); +} + +class TabCompanion extends UpdateCompanion { + final Value id; + final Value source; + final Value parentId; + final Value containerId; + final Value orderKey; + final Value url; + final Value title; + final Value tabMode; + final Value isolationContextId; + final Value isPinned; + final Value isProbablyReaderable; + final Value extractedContentMarkdown; + final Value extractedContentPlain; + final Value fullContentMarkdown; + final Value fullContentPlain; + final Value timestamp; + final Value rowid; + const TabCompanion({ + this.id = const Value.absent(), + this.source = const Value.absent(), + this.parentId = const Value.absent(), + this.containerId = const Value.absent(), + this.orderKey = const Value.absent(), + this.url = const Value.absent(), + this.title = const Value.absent(), + this.tabMode = const Value.absent(), + this.isolationContextId = const Value.absent(), + this.isPinned = const Value.absent(), + this.isProbablyReaderable = const Value.absent(), + this.extractedContentMarkdown = const Value.absent(), + this.extractedContentPlain = const Value.absent(), + this.fullContentMarkdown = const Value.absent(), + this.fullContentPlain = const Value.absent(), + this.timestamp = const Value.absent(), + this.rowid = const Value.absent(), + }); + TabCompanion.insert({ + required String id, + required int source, + this.parentId = const Value.absent(), + this.containerId = const Value.absent(), + required String orderKey, + this.url = const Value.absent(), + this.title = const Value.absent(), + this.tabMode = const Value.absent(), + this.isolationContextId = const Value.absent(), + this.isPinned = const Value.absent(), + this.isProbablyReaderable = const Value.absent(), + this.extractedContentMarkdown = const Value.absent(), + this.extractedContentPlain = const Value.absent(), + this.fullContentMarkdown = const Value.absent(), + this.fullContentPlain = const Value.absent(), + required int timestamp, + this.rowid = const Value.absent(), + }) : id = Value(id), + source = Value(source), + orderKey = Value(orderKey), + timestamp = Value(timestamp); + static Insertable custom({ + Expression? id, + Expression? source, + Expression? parentId, + Expression? containerId, + Expression? orderKey, + Expression? url, + Expression? title, + Expression? tabMode, + Expression? isolationContextId, + Expression? isPinned, + Expression? isProbablyReaderable, + Expression? extractedContentMarkdown, + Expression? extractedContentPlain, + Expression? fullContentMarkdown, + Expression? fullContentPlain, + Expression? timestamp, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (source != null) 'source': source, + if (parentId != null) 'parent_id': parentId, + if (containerId != null) 'container_id': containerId, + if (orderKey != null) 'order_key': orderKey, + if (url != null) 'url': url, + if (title != null) 'title': title, + if (tabMode != null) 'tab_mode': tabMode, + if (isolationContextId != null) + 'isolation_context_id': isolationContextId, + if (isPinned != null) 'is_pinned': isPinned, + if (isProbablyReaderable != null) + 'is_probably_readerable': isProbablyReaderable, + if (extractedContentMarkdown != null) + 'extracted_content_markdown': extractedContentMarkdown, + if (extractedContentPlain != null) + 'extracted_content_plain': extractedContentPlain, + if (fullContentMarkdown != null) + 'full_content_markdown': fullContentMarkdown, + if (fullContentPlain != null) 'full_content_plain': fullContentPlain, + if (timestamp != null) 'timestamp': timestamp, + if (rowid != null) 'rowid': rowid, + }); + } + + TabCompanion copyWith({ + Value? id, + Value? source, + Value? parentId, + Value? containerId, + Value? orderKey, + Value? url, + Value? title, + Value? tabMode, + Value? isolationContextId, + Value? isPinned, + Value? isProbablyReaderable, + Value? extractedContentMarkdown, + Value? extractedContentPlain, + Value? fullContentMarkdown, + Value? fullContentPlain, + Value? timestamp, + Value? rowid, + }) { + return TabCompanion( + id: id ?? this.id, + source: source ?? this.source, + parentId: parentId ?? this.parentId, + containerId: containerId ?? this.containerId, + orderKey: orderKey ?? this.orderKey, + url: url ?? this.url, + title: title ?? this.title, + tabMode: tabMode ?? this.tabMode, + isolationContextId: isolationContextId ?? this.isolationContextId, + isPinned: isPinned ?? this.isPinned, + isProbablyReaderable: isProbablyReaderable ?? this.isProbablyReaderable, + extractedContentMarkdown: + extractedContentMarkdown ?? this.extractedContentMarkdown, + extractedContentPlain: + extractedContentPlain ?? this.extractedContentPlain, + fullContentMarkdown: fullContentMarkdown ?? this.fullContentMarkdown, + fullContentPlain: fullContentPlain ?? this.fullContentPlain, + timestamp: timestamp ?? this.timestamp, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (source.present) { + map['source'] = Variable(source.value); + } + if (parentId.present) { + map['parent_id'] = Variable(parentId.value); + } + if (containerId.present) { + map['container_id'] = Variable(containerId.value); + } + if (orderKey.present) { + map['order_key'] = Variable(orderKey.value); + } + if (url.present) { + map['url'] = Variable(url.value); + } + if (title.present) { + map['title'] = Variable(title.value); + } + if (tabMode.present) { + map['tab_mode'] = Variable(tabMode.value); + } + if (isolationContextId.present) { + map['isolation_context_id'] = Variable(isolationContextId.value); + } + if (isPinned.present) { + map['is_pinned'] = Variable(isPinned.value); + } + if (isProbablyReaderable.present) { + map['is_probably_readerable'] = Variable(isProbablyReaderable.value); + } + if (extractedContentMarkdown.present) { + map['extracted_content_markdown'] = Variable( + extractedContentMarkdown.value, + ); + } + if (extractedContentPlain.present) { + map['extracted_content_plain'] = Variable( + extractedContentPlain.value, + ); + } + if (fullContentMarkdown.present) { + map['full_content_markdown'] = Variable( + fullContentMarkdown.value, + ); + } + if (fullContentPlain.present) { + map['full_content_plain'] = Variable(fullContentPlain.value); + } + if (timestamp.present) { + map['timestamp'] = Variable(timestamp.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('TabCompanion(') + ..write('id: $id, ') + ..write('source: $source, ') + ..write('parentId: $parentId, ') + ..write('containerId: $containerId, ') + ..write('orderKey: $orderKey, ') + ..write('url: $url, ') + ..write('title: $title, ') + ..write('tabMode: $tabMode, ') + ..write('isolationContextId: $isolationContextId, ') + ..write('isPinned: $isPinned, ') + ..write('isProbablyReaderable: $isProbablyReaderable, ') + ..write('extractedContentMarkdown: $extractedContentMarkdown, ') + ..write('extractedContentPlain: $extractedContentPlain, ') + ..write('fullContentMarkdown: $fullContentMarkdown, ') + ..write('fullContentPlain: $fullContentPlain, ') + ..write('timestamp: $timestamp, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class ClosedTabTombstone extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + ClosedTabTombstone(this.attachedDatabase, [this._alias]); + late final GeneratedColumn tabId = GeneratedColumn( + 'tab_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'PRIMARY KEY NOT NULL', + ); + late final GeneratedColumn closedAt = GeneratedColumn( + 'closed_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + @override + List get $columns => [tabId, closedAt]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'closed_tab_tombstone'; + @override + Set get $primaryKey => {tabId}; + @override + ClosedTabTombstoneData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return ClosedTabTombstoneData( + tabId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}tab_id'], + )!, + closedAt: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}closed_at'], + )!, + ); + } + + @override + ClosedTabTombstone createAlias(String alias) { + return ClosedTabTombstone(attachedDatabase, alias); + } + + @override + bool get dontWriteConstraints => true; +} + +class ClosedTabTombstoneData extends DataClass + implements Insertable { + final String tabId; + final int closedAt; + const ClosedTabTombstoneData({required this.tabId, required this.closedAt}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['tab_id'] = Variable(tabId); + map['closed_at'] = Variable(closedAt); + return map; + } + + factory ClosedTabTombstoneData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return ClosedTabTombstoneData( + tabId: serializer.fromJson(json['tabId']), + closedAt: serializer.fromJson(json['closedAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'tabId': serializer.toJson(tabId), + 'closedAt': serializer.toJson(closedAt), + }; + } + + ClosedTabTombstoneData copyWith({String? tabId, int? closedAt}) => + ClosedTabTombstoneData( + tabId: tabId ?? this.tabId, + closedAt: closedAt ?? this.closedAt, + ); + ClosedTabTombstoneData copyWithCompanion(ClosedTabTombstoneCompanion data) { + return ClosedTabTombstoneData( + tabId: data.tabId.present ? data.tabId.value : this.tabId, + closedAt: data.closedAt.present ? data.closedAt.value : this.closedAt, + ); + } + + @override + String toString() { + return (StringBuffer('ClosedTabTombstoneData(') + ..write('tabId: $tabId, ') + ..write('closedAt: $closedAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(tabId, closedAt); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ClosedTabTombstoneData && + other.tabId == this.tabId && + other.closedAt == this.closedAt); +} + +class ClosedTabTombstoneCompanion + extends UpdateCompanion { + final Value tabId; + final Value closedAt; + final Value rowid; + const ClosedTabTombstoneCompanion({ + this.tabId = const Value.absent(), + this.closedAt = const Value.absent(), + this.rowid = const Value.absent(), + }); + ClosedTabTombstoneCompanion.insert({ + required String tabId, + required int closedAt, + this.rowid = const Value.absent(), + }) : tabId = Value(tabId), + closedAt = Value(closedAt); + static Insertable custom({ + Expression? tabId, + Expression? closedAt, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (tabId != null) 'tab_id': tabId, + if (closedAt != null) 'closed_at': closedAt, + if (rowid != null) 'rowid': rowid, + }); + } + + ClosedTabTombstoneCompanion copyWith({ + Value? tabId, + Value? closedAt, + Value? rowid, + }) { + return ClosedTabTombstoneCompanion( + tabId: tabId ?? this.tabId, + closedAt: closedAt ?? this.closedAt, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (tabId.present) { + map['tab_id'] = Variable(tabId.value); + } + if (closedAt.present) { + map['closed_at'] = Variable(closedAt.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('ClosedTabTombstoneCompanion(') + ..write('tabId: $tabId, ') + ..write('closedAt: $closedAt, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class CaptureTab extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + CaptureTab(this.attachedDatabase, [this._alias]); + late final GeneratedColumn tabId = GeneratedColumn( + 'tab_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: + 'NOT NULL PRIMARY KEY REFERENCES tab(id)ON DELETE CASCADE', + ); + late final GeneratedColumn captureId = GeneratedColumn( + 'capture_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn sourceUrl = GeneratedColumn( + 'source_url', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn status = GeneratedColumn( + 'status', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT \'pending\'', + defaultValue: const CustomExpression('\'pending\''), + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + @override + List get $columns => [ + tabId, + captureId, + sourceUrl, + status, + createdAt, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'capture_tab'; + @override + Set get $primaryKey => {tabId}; + @override + CaptureTabData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return CaptureTabData( + tabId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}tab_id'], + )!, + captureId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}capture_id'], + )!, + sourceUrl: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}source_url'], + )!, + status: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}status'], + )!, + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}created_at'], + )!, + ); + } + + @override + CaptureTab createAlias(String alias) { + return CaptureTab(attachedDatabase, alias); + } + + @override + bool get dontWriteConstraints => true; +} + +class CaptureTabData extends DataClass implements Insertable { + final String tabId; + final String captureId; + final String sourceUrl; + final String status; + final int createdAt; + const CaptureTabData({ + required this.tabId, + required this.captureId, + required this.sourceUrl, + required this.status, + required this.createdAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['tab_id'] = Variable(tabId); + map['capture_id'] = Variable(captureId); + map['source_url'] = Variable(sourceUrl); + map['status'] = Variable(status); + map['created_at'] = Variable(createdAt); + return map; + } + + factory CaptureTabData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return CaptureTabData( + tabId: serializer.fromJson(json['tabId']), + captureId: serializer.fromJson(json['captureId']), + sourceUrl: serializer.fromJson(json['sourceUrl']), + status: serializer.fromJson(json['status']), + createdAt: serializer.fromJson(json['createdAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'tabId': serializer.toJson(tabId), + 'captureId': serializer.toJson(captureId), + 'sourceUrl': serializer.toJson(sourceUrl), + 'status': serializer.toJson(status), + 'createdAt': serializer.toJson(createdAt), + }; + } + + CaptureTabData copyWith({ + String? tabId, + String? captureId, + String? sourceUrl, + String? status, + int? createdAt, + }) => CaptureTabData( + tabId: tabId ?? this.tabId, + captureId: captureId ?? this.captureId, + sourceUrl: sourceUrl ?? this.sourceUrl, + status: status ?? this.status, + createdAt: createdAt ?? this.createdAt, + ); + CaptureTabData copyWithCompanion(CaptureTabCompanion data) { + return CaptureTabData( + tabId: data.tabId.present ? data.tabId.value : this.tabId, + captureId: data.captureId.present ? data.captureId.value : this.captureId, + sourceUrl: data.sourceUrl.present ? data.sourceUrl.value : this.sourceUrl, + status: data.status.present ? data.status.value : this.status, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + ); + } + + @override + String toString() { + return (StringBuffer('CaptureTabData(') + ..write('tabId: $tabId, ') + ..write('captureId: $captureId, ') + ..write('sourceUrl: $sourceUrl, ') + ..write('status: $status, ') + ..write('createdAt: $createdAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => + Object.hash(tabId, captureId, sourceUrl, status, createdAt); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is CaptureTabData && + other.tabId == this.tabId && + other.captureId == this.captureId && + other.sourceUrl == this.sourceUrl && + other.status == this.status && + other.createdAt == this.createdAt); +} + +class CaptureTabCompanion extends UpdateCompanion { + final Value tabId; + final Value captureId; + final Value sourceUrl; + final Value status; + final Value createdAt; + final Value rowid; + const CaptureTabCompanion({ + this.tabId = const Value.absent(), + this.captureId = const Value.absent(), + this.sourceUrl = const Value.absent(), + this.status = const Value.absent(), + this.createdAt = const Value.absent(), + this.rowid = const Value.absent(), + }); + CaptureTabCompanion.insert({ + required String tabId, + required String captureId, + required String sourceUrl, + this.status = const Value.absent(), + required int createdAt, + this.rowid = const Value.absent(), + }) : tabId = Value(tabId), + captureId = Value(captureId), + sourceUrl = Value(sourceUrl), + createdAt = Value(createdAt); + static Insertable custom({ + Expression? tabId, + Expression? captureId, + Expression? sourceUrl, + Expression? status, + Expression? createdAt, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (tabId != null) 'tab_id': tabId, + if (captureId != null) 'capture_id': captureId, + if (sourceUrl != null) 'source_url': sourceUrl, + if (status != null) 'status': status, + if (createdAt != null) 'created_at': createdAt, + if (rowid != null) 'rowid': rowid, + }); + } + + CaptureTabCompanion copyWith({ + Value? tabId, + Value? captureId, + Value? sourceUrl, + Value? status, + Value? createdAt, + Value? rowid, + }) { + return CaptureTabCompanion( + tabId: tabId ?? this.tabId, + captureId: captureId ?? this.captureId, + sourceUrl: sourceUrl ?? this.sourceUrl, + status: status ?? this.status, + createdAt: createdAt ?? this.createdAt, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (tabId.present) { + map['tab_id'] = Variable(tabId.value); + } + if (captureId.present) { + map['capture_id'] = Variable(captureId.value); + } + if (sourceUrl.present) { + map['source_url'] = Variable(sourceUrl.value); + } + if (status.present) { + map['status'] = Variable(status.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('CaptureTabCompanion(') + ..write('tabId: $tabId, ') + ..write('captureId: $captureId, ') + ..write('sourceUrl: $sourceUrl, ') + ..write('status: $status, ') + ..write('createdAt: $createdAt, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class TabFts extends Table + with TableInfo, VirtualTableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + TabFts(this.attachedDatabase, [this._alias]); + late final GeneratedColumn title = GeneratedColumn( + 'title', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn url = GeneratedColumn( + 'url', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn extractedContentPlain = + GeneratedColumn( + 'extracted_content_plain', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn fullContentPlain = GeneratedColumn( + 'full_content_plain', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + @override + List get $columns => [ + title, + url, + extractedContentPlain, + fullContentPlain, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'tab_fts'; + @override + Set get $primaryKey => const {}; + @override + TabFtsData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return TabFtsData( + title: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}title'], + ), + url: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}url'], + ), + extractedContentPlain: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}extracted_content_plain'], + ), + fullContentPlain: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}full_content_plain'], + ), + ); + } + + @override + TabFts createAlias(String alias) { + return TabFts(attachedDatabase, alias); + } + + @override + bool get dontWriteConstraints => true; + @override + String get moduleAndArgs => + 'fts5(title, url, extracted_content_plain, full_content_plain, content=tab, tokenize="trigram")'; +} + +class TabFtsData extends DataClass implements Insertable { + final String? title; + final String? url; + final String? extractedContentPlain; + final String? fullContentPlain; + const TabFtsData({ + this.title, + this.url, + this.extractedContentPlain, + this.fullContentPlain, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (!nullToAbsent || title != null) { + map['title'] = Variable(title); + } + if (!nullToAbsent || url != null) { + map['url'] = Variable(url); + } + if (!nullToAbsent || extractedContentPlain != null) { + map['extracted_content_plain'] = Variable(extractedContentPlain); + } + if (!nullToAbsent || fullContentPlain != null) { + map['full_content_plain'] = Variable(fullContentPlain); + } + return map; + } + + factory TabFtsData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return TabFtsData( + title: serializer.fromJson(json['title']), + url: serializer.fromJson(json['url']), + extractedContentPlain: serializer.fromJson( + json['extractedContentPlain'], + ), + fullContentPlain: serializer.fromJson(json['fullContentPlain']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'title': serializer.toJson(title), + 'url': serializer.toJson(url), + 'extractedContentPlain': serializer.toJson( + extractedContentPlain, + ), + 'fullContentPlain': serializer.toJson(fullContentPlain), + }; + } + + TabFtsData copyWith({ + Value title = const Value.absent(), + Value url = const Value.absent(), + Value extractedContentPlain = const Value.absent(), + Value fullContentPlain = const Value.absent(), + }) => TabFtsData( + title: title.present ? title.value : this.title, + url: url.present ? url.value : this.url, + extractedContentPlain: extractedContentPlain.present + ? extractedContentPlain.value + : this.extractedContentPlain, + fullContentPlain: fullContentPlain.present + ? fullContentPlain.value + : this.fullContentPlain, + ); + TabFtsData copyWithCompanion(TabFtsCompanion data) { + return TabFtsData( + title: data.title.present ? data.title.value : this.title, + url: data.url.present ? data.url.value : this.url, + extractedContentPlain: data.extractedContentPlain.present + ? data.extractedContentPlain.value + : this.extractedContentPlain, + fullContentPlain: data.fullContentPlain.present + ? data.fullContentPlain.value + : this.fullContentPlain, + ); + } + + @override + String toString() { + return (StringBuffer('TabFtsData(') + ..write('title: $title, ') + ..write('url: $url, ') + ..write('extractedContentPlain: $extractedContentPlain, ') + ..write('fullContentPlain: $fullContentPlain') + ..write(')')) + .toString(); + } + + @override + int get hashCode => + Object.hash(title, url, extractedContentPlain, fullContentPlain); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is TabFtsData && + other.title == this.title && + other.url == this.url && + other.extractedContentPlain == this.extractedContentPlain && + other.fullContentPlain == this.fullContentPlain); +} + +class TabFtsCompanion extends UpdateCompanion { + final Value title; + final Value url; + final Value extractedContentPlain; + final Value fullContentPlain; + final Value rowid; + const TabFtsCompanion({ + this.title = const Value.absent(), + this.url = const Value.absent(), + this.extractedContentPlain = const Value.absent(), + this.fullContentPlain = const Value.absent(), + this.rowid = const Value.absent(), + }); + TabFtsCompanion.insert({ + this.title = const Value.absent(), + this.url = const Value.absent(), + this.extractedContentPlain = const Value.absent(), + this.fullContentPlain = const Value.absent(), + this.rowid = const Value.absent(), + }); + static Insertable custom({ + Expression? title, + Expression? url, + Expression? extractedContentPlain, + Expression? fullContentPlain, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (title != null) 'title': title, + if (url != null) 'url': url, + if (extractedContentPlain != null) + 'extracted_content_plain': extractedContentPlain, + if (fullContentPlain != null) 'full_content_plain': fullContentPlain, + if (rowid != null) 'rowid': rowid, + }); + } + + TabFtsCompanion copyWith({ + Value? title, + Value? url, + Value? extractedContentPlain, + Value? fullContentPlain, + Value? rowid, + }) { + return TabFtsCompanion( + title: title ?? this.title, + url: url ?? this.url, + extractedContentPlain: + extractedContentPlain ?? this.extractedContentPlain, + fullContentPlain: fullContentPlain ?? this.fullContentPlain, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (title.present) { + map['title'] = Variable(title.value); + } + if (url.present) { + map['url'] = Variable(url.value); + } + if (extractedContentPlain.present) { + map['extracted_content_plain'] = Variable( + extractedContentPlain.value, + ); + } + if (fullContentPlain.present) { + map['full_content_plain'] = Variable(fullContentPlain.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('TabFtsCompanion(') + ..write('title: $title, ') + ..write('url: $url, ') + ..write('extractedContentPlain: $extractedContentPlain, ') + ..write('fullContentPlain: $fullContentPlain, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class LocalIndexSetting extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + LocalIndexSetting(this.attachedDatabase, [this._alias]); + late final GeneratedColumn key = GeneratedColumn( + 'key', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'PRIMARY KEY NOT NULL', + ); + late final GeneratedColumn value = GeneratedColumn( + 'value', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + @override + List get $columns => [key, value]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'local_index_setting'; + @override + Set get $primaryKey => {key}; + @override + LocalIndexSettingData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return LocalIndexSettingData( + key: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}key'], + )!, + value: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}value'], + )!, + ); + } + + @override + LocalIndexSetting createAlias(String alias) { + return LocalIndexSetting(attachedDatabase, alias); + } + + @override + bool get isStrict => true; + @override + bool get dontWriteConstraints => true; +} + +class LocalIndexSettingData extends DataClass + implements Insertable { + final String key; + final int value; + const LocalIndexSettingData({required this.key, required this.value}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['key'] = Variable(key); + map['value'] = Variable(value); + return map; + } + + factory LocalIndexSettingData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return LocalIndexSettingData( + key: serializer.fromJson(json['key']), + value: serializer.fromJson(json['value']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'key': serializer.toJson(key), + 'value': serializer.toJson(value), + }; + } + + LocalIndexSettingData copyWith({String? key, int? value}) => + LocalIndexSettingData(key: key ?? this.key, value: value ?? this.value); + LocalIndexSettingData copyWithCompanion(LocalIndexSettingCompanion data) { + return LocalIndexSettingData( + key: data.key.present ? data.key.value : this.key, + value: data.value.present ? data.value.value : this.value, + ); + } + + @override + String toString() { + return (StringBuffer('LocalIndexSettingData(') + ..write('key: $key, ') + ..write('value: $value') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(key, value); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is LocalIndexSettingData && + other.key == this.key && + other.value == this.value); +} + +class LocalIndexSettingCompanion + extends UpdateCompanion { + final Value key; + final Value value; + final Value rowid; + const LocalIndexSettingCompanion({ + this.key = const Value.absent(), + this.value = const Value.absent(), + this.rowid = const Value.absent(), + }); + LocalIndexSettingCompanion.insert({ + required String key, + required int value, + this.rowid = const Value.absent(), + }) : key = Value(key), + value = Value(value); + static Insertable custom({ + Expression? key, + Expression? value, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (key != null) 'key': key, + if (value != null) 'value': value, + if (rowid != null) 'rowid': rowid, + }); + } + + LocalIndexSettingCompanion copyWith({ + Value? key, + Value? value, + Value? rowid, + }) { + return LocalIndexSettingCompanion( + key: key ?? this.key, + value: value ?? this.value, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (key.present) { + map['key'] = Variable(key.value); + } + if (value.present) { + map['value'] = Variable(value.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('LocalIndexSettingCompanion(') + ..write('key: $key, ') + ..write('value: $value, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class History extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + History(this.attachedDatabase, [this._alias]); + late final GeneratedColumn urlCanonical = GeneratedColumn( + 'url_canonical', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'PRIMARY KEY NOT NULL', + ); + late final GeneratedColumn urlHost = GeneratedColumn( + 'url_host', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn urlPath = GeneratedColumn( + 'url_path', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn title = GeneratedColumn( + 'title', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn isProbablyReaderable = GeneratedColumn( + 'is_probably_readerable', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn extractedContentMarkdown = + GeneratedColumn( + 'extracted_content_markdown', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn extractedContentPlain = + GeneratedColumn( + 'extracted_content_plain', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn fullContentMarkdown = + GeneratedColumn( + 'full_content_markdown', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn fullContentPlain = GeneratedColumn( + 'full_content_plain', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn contentHash = GeneratedColumn( + 'content_hash', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn observedAt = GeneratedColumn( + 'observed_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn observedCount = GeneratedColumn( + 'observed_count', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const CustomExpression('1'), + ); + @override + List get $columns => [ + urlCanonical, + urlHost, + urlPath, + title, + isProbablyReaderable, + extractedContentMarkdown, + extractedContentPlain, + fullContentMarkdown, + fullContentPlain, + contentHash, + observedAt, + observedCount, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'history'; + @override + Set get $primaryKey => {urlCanonical}; + @override + HistoryData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return HistoryData( + urlCanonical: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}url_canonical'], + )!, + urlHost: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}url_host'], + )!, + urlPath: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}url_path'], + ), + title: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}title'], + ), + isProbablyReaderable: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}is_probably_readerable'], + ), + extractedContentMarkdown: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}extracted_content_markdown'], + ), + extractedContentPlain: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}extracted_content_plain'], + ), + fullContentMarkdown: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}full_content_markdown'], + ), + fullContentPlain: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}full_content_plain'], + ), + contentHash: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}content_hash'], + ), + observedAt: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}observed_at'], + )!, + observedCount: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}observed_count'], + )!, + ); + } + + @override + History createAlias(String alias) { + return History(attachedDatabase, alias); + } + + @override + bool get dontWriteConstraints => true; +} + +class HistoryData extends DataClass implements Insertable { + final String urlCanonical; + final String urlHost; + final String? urlPath; + final String? title; + final int? isProbablyReaderable; + final String? extractedContentMarkdown; + final String? extractedContentPlain; + final String? fullContentMarkdown; + final String? fullContentPlain; + final int? contentHash; + final int observedAt; + final int observedCount; + const HistoryData({ + required this.urlCanonical, + required this.urlHost, + this.urlPath, + this.title, + this.isProbablyReaderable, + this.extractedContentMarkdown, + this.extractedContentPlain, + this.fullContentMarkdown, + this.fullContentPlain, + this.contentHash, + required this.observedAt, + required this.observedCount, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['url_canonical'] = Variable(urlCanonical); + map['url_host'] = Variable(urlHost); + if (!nullToAbsent || urlPath != null) { + map['url_path'] = Variable(urlPath); + } + if (!nullToAbsent || title != null) { + map['title'] = Variable(title); + } + if (!nullToAbsent || isProbablyReaderable != null) { + map['is_probably_readerable'] = Variable(isProbablyReaderable); + } + if (!nullToAbsent || extractedContentMarkdown != null) { + map['extracted_content_markdown'] = Variable( + extractedContentMarkdown, + ); + } + if (!nullToAbsent || extractedContentPlain != null) { + map['extracted_content_plain'] = Variable(extractedContentPlain); + } + if (!nullToAbsent || fullContentMarkdown != null) { + map['full_content_markdown'] = Variable(fullContentMarkdown); + } + if (!nullToAbsent || fullContentPlain != null) { + map['full_content_plain'] = Variable(fullContentPlain); + } + if (!nullToAbsent || contentHash != null) { + map['content_hash'] = Variable(contentHash); + } + map['observed_at'] = Variable(observedAt); + map['observed_count'] = Variable(observedCount); + return map; + } + + factory HistoryData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return HistoryData( + urlCanonical: serializer.fromJson(json['urlCanonical']), + urlHost: serializer.fromJson(json['urlHost']), + urlPath: serializer.fromJson(json['urlPath']), + title: serializer.fromJson(json['title']), + isProbablyReaderable: serializer.fromJson( + json['isProbablyReaderable'], + ), + extractedContentMarkdown: serializer.fromJson( + json['extractedContentMarkdown'], + ), + extractedContentPlain: serializer.fromJson( + json['extractedContentPlain'], + ), + fullContentMarkdown: serializer.fromJson( + json['fullContentMarkdown'], + ), + fullContentPlain: serializer.fromJson(json['fullContentPlain']), + contentHash: serializer.fromJson(json['contentHash']), + observedAt: serializer.fromJson(json['observedAt']), + observedCount: serializer.fromJson(json['observedCount']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'urlCanonical': serializer.toJson(urlCanonical), + 'urlHost': serializer.toJson(urlHost), + 'urlPath': serializer.toJson(urlPath), + 'title': serializer.toJson(title), + 'isProbablyReaderable': serializer.toJson(isProbablyReaderable), + 'extractedContentMarkdown': serializer.toJson( + extractedContentMarkdown, + ), + 'extractedContentPlain': serializer.toJson( + extractedContentPlain, + ), + 'fullContentMarkdown': serializer.toJson(fullContentMarkdown), + 'fullContentPlain': serializer.toJson(fullContentPlain), + 'contentHash': serializer.toJson(contentHash), + 'observedAt': serializer.toJson(observedAt), + 'observedCount': serializer.toJson(observedCount), + }; + } + + HistoryData copyWith({ + String? urlCanonical, + String? urlHost, + Value urlPath = const Value.absent(), + Value title = const Value.absent(), + Value isProbablyReaderable = const Value.absent(), + Value extractedContentMarkdown = const Value.absent(), + Value extractedContentPlain = const Value.absent(), + Value fullContentMarkdown = const Value.absent(), + Value fullContentPlain = const Value.absent(), + Value contentHash = const Value.absent(), + int? observedAt, + int? observedCount, + }) => HistoryData( + urlCanonical: urlCanonical ?? this.urlCanonical, + urlHost: urlHost ?? this.urlHost, + urlPath: urlPath.present ? urlPath.value : this.urlPath, + title: title.present ? title.value : this.title, + isProbablyReaderable: isProbablyReaderable.present + ? isProbablyReaderable.value + : this.isProbablyReaderable, + extractedContentMarkdown: extractedContentMarkdown.present + ? extractedContentMarkdown.value + : this.extractedContentMarkdown, + extractedContentPlain: extractedContentPlain.present + ? extractedContentPlain.value + : this.extractedContentPlain, + fullContentMarkdown: fullContentMarkdown.present + ? fullContentMarkdown.value + : this.fullContentMarkdown, + fullContentPlain: fullContentPlain.present + ? fullContentPlain.value + : this.fullContentPlain, + contentHash: contentHash.present ? contentHash.value : this.contentHash, + observedAt: observedAt ?? this.observedAt, + observedCount: observedCount ?? this.observedCount, + ); + HistoryData copyWithCompanion(HistoryCompanion data) { + return HistoryData( + urlCanonical: data.urlCanonical.present + ? data.urlCanonical.value + : this.urlCanonical, + urlHost: data.urlHost.present ? data.urlHost.value : this.urlHost, + urlPath: data.urlPath.present ? data.urlPath.value : this.urlPath, + title: data.title.present ? data.title.value : this.title, + isProbablyReaderable: data.isProbablyReaderable.present + ? data.isProbablyReaderable.value + : this.isProbablyReaderable, + extractedContentMarkdown: data.extractedContentMarkdown.present + ? data.extractedContentMarkdown.value + : this.extractedContentMarkdown, + extractedContentPlain: data.extractedContentPlain.present + ? data.extractedContentPlain.value + : this.extractedContentPlain, + fullContentMarkdown: data.fullContentMarkdown.present + ? data.fullContentMarkdown.value + : this.fullContentMarkdown, + fullContentPlain: data.fullContentPlain.present + ? data.fullContentPlain.value + : this.fullContentPlain, + contentHash: data.contentHash.present + ? data.contentHash.value + : this.contentHash, + observedAt: data.observedAt.present + ? data.observedAt.value + : this.observedAt, + observedCount: data.observedCount.present + ? data.observedCount.value + : this.observedCount, + ); + } + + @override + String toString() { + return (StringBuffer('HistoryData(') + ..write('urlCanonical: $urlCanonical, ') + ..write('urlHost: $urlHost, ') + ..write('urlPath: $urlPath, ') + ..write('title: $title, ') + ..write('isProbablyReaderable: $isProbablyReaderable, ') + ..write('extractedContentMarkdown: $extractedContentMarkdown, ') + ..write('extractedContentPlain: $extractedContentPlain, ') + ..write('fullContentMarkdown: $fullContentMarkdown, ') + ..write('fullContentPlain: $fullContentPlain, ') + ..write('contentHash: $contentHash, ') + ..write('observedAt: $observedAt, ') + ..write('observedCount: $observedCount') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + urlCanonical, + urlHost, + urlPath, + title, + isProbablyReaderable, + extractedContentMarkdown, + extractedContentPlain, + fullContentMarkdown, + fullContentPlain, + contentHash, + observedAt, + observedCount, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is HistoryData && + other.urlCanonical == this.urlCanonical && + other.urlHost == this.urlHost && + other.urlPath == this.urlPath && + other.title == this.title && + other.isProbablyReaderable == this.isProbablyReaderable && + other.extractedContentMarkdown == this.extractedContentMarkdown && + other.extractedContentPlain == this.extractedContentPlain && + other.fullContentMarkdown == this.fullContentMarkdown && + other.fullContentPlain == this.fullContentPlain && + other.contentHash == this.contentHash && + other.observedAt == this.observedAt && + other.observedCount == this.observedCount); +} + +class HistoryCompanion extends UpdateCompanion { + final Value urlCanonical; + final Value urlHost; + final Value urlPath; + final Value title; + final Value isProbablyReaderable; + final Value extractedContentMarkdown; + final Value extractedContentPlain; + final Value fullContentMarkdown; + final Value fullContentPlain; + final Value contentHash; + final Value observedAt; + final Value observedCount; + final Value rowid; + const HistoryCompanion({ + this.urlCanonical = const Value.absent(), + this.urlHost = const Value.absent(), + this.urlPath = const Value.absent(), + this.title = const Value.absent(), + this.isProbablyReaderable = const Value.absent(), + this.extractedContentMarkdown = const Value.absent(), + this.extractedContentPlain = const Value.absent(), + this.fullContentMarkdown = const Value.absent(), + this.fullContentPlain = const Value.absent(), + this.contentHash = const Value.absent(), + this.observedAt = const Value.absent(), + this.observedCount = const Value.absent(), + this.rowid = const Value.absent(), + }); + HistoryCompanion.insert({ + required String urlCanonical, + required String urlHost, + this.urlPath = const Value.absent(), + this.title = const Value.absent(), + this.isProbablyReaderable = const Value.absent(), + this.extractedContentMarkdown = const Value.absent(), + this.extractedContentPlain = const Value.absent(), + this.fullContentMarkdown = const Value.absent(), + this.fullContentPlain = const Value.absent(), + this.contentHash = const Value.absent(), + required int observedAt, + this.observedCount = const Value.absent(), + this.rowid = const Value.absent(), + }) : urlCanonical = Value(urlCanonical), + urlHost = Value(urlHost), + observedAt = Value(observedAt); + static Insertable custom({ + Expression? urlCanonical, + Expression? urlHost, + Expression? urlPath, + Expression? title, + Expression? isProbablyReaderable, + Expression? extractedContentMarkdown, + Expression? extractedContentPlain, + Expression? fullContentMarkdown, + Expression? fullContentPlain, + Expression? contentHash, + Expression? observedAt, + Expression? observedCount, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (urlCanonical != null) 'url_canonical': urlCanonical, + if (urlHost != null) 'url_host': urlHost, + if (urlPath != null) 'url_path': urlPath, + if (title != null) 'title': title, + if (isProbablyReaderable != null) + 'is_probably_readerable': isProbablyReaderable, + if (extractedContentMarkdown != null) + 'extracted_content_markdown': extractedContentMarkdown, + if (extractedContentPlain != null) + 'extracted_content_plain': extractedContentPlain, + if (fullContentMarkdown != null) + 'full_content_markdown': fullContentMarkdown, + if (fullContentPlain != null) 'full_content_plain': fullContentPlain, + if (contentHash != null) 'content_hash': contentHash, + if (observedAt != null) 'observed_at': observedAt, + if (observedCount != null) 'observed_count': observedCount, + if (rowid != null) 'rowid': rowid, + }); + } + + HistoryCompanion copyWith({ + Value? urlCanonical, + Value? urlHost, + Value? urlPath, + Value? title, + Value? isProbablyReaderable, + Value? extractedContentMarkdown, + Value? extractedContentPlain, + Value? fullContentMarkdown, + Value? fullContentPlain, + Value? contentHash, + Value? observedAt, + Value? observedCount, + Value? rowid, + }) { + return HistoryCompanion( + urlCanonical: urlCanonical ?? this.urlCanonical, + urlHost: urlHost ?? this.urlHost, + urlPath: urlPath ?? this.urlPath, + title: title ?? this.title, + isProbablyReaderable: isProbablyReaderable ?? this.isProbablyReaderable, + extractedContentMarkdown: + extractedContentMarkdown ?? this.extractedContentMarkdown, + extractedContentPlain: + extractedContentPlain ?? this.extractedContentPlain, + fullContentMarkdown: fullContentMarkdown ?? this.fullContentMarkdown, + fullContentPlain: fullContentPlain ?? this.fullContentPlain, + contentHash: contentHash ?? this.contentHash, + observedAt: observedAt ?? this.observedAt, + observedCount: observedCount ?? this.observedCount, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (urlCanonical.present) { + map['url_canonical'] = Variable(urlCanonical.value); + } + if (urlHost.present) { + map['url_host'] = Variable(urlHost.value); + } + if (urlPath.present) { + map['url_path'] = Variable(urlPath.value); + } + if (title.present) { + map['title'] = Variable(title.value); + } + if (isProbablyReaderable.present) { + map['is_probably_readerable'] = Variable(isProbablyReaderable.value); + } + if (extractedContentMarkdown.present) { + map['extracted_content_markdown'] = Variable( + extractedContentMarkdown.value, + ); + } + if (extractedContentPlain.present) { + map['extracted_content_plain'] = Variable( + extractedContentPlain.value, + ); + } + if (fullContentMarkdown.present) { + map['full_content_markdown'] = Variable( + fullContentMarkdown.value, + ); + } + if (fullContentPlain.present) { + map['full_content_plain'] = Variable(fullContentPlain.value); + } + if (contentHash.present) { + map['content_hash'] = Variable(contentHash.value); + } + if (observedAt.present) { + map['observed_at'] = Variable(observedAt.value); + } + if (observedCount.present) { + map['observed_count'] = Variable(observedCount.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('HistoryCompanion(') + ..write('urlCanonical: $urlCanonical, ') + ..write('urlHost: $urlHost, ') + ..write('urlPath: $urlPath, ') + ..write('title: $title, ') + ..write('isProbablyReaderable: $isProbablyReaderable, ') + ..write('extractedContentMarkdown: $extractedContentMarkdown, ') + ..write('extractedContentPlain: $extractedContentPlain, ') + ..write('fullContentMarkdown: $fullContentMarkdown, ') + ..write('fullContentPlain: $fullContentPlain, ') + ..write('contentHash: $contentHash, ') + ..write('observedAt: $observedAt, ') + ..write('observedCount: $observedCount, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class HistoryFts extends Table + with + TableInfo, + VirtualTableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + HistoryFts(this.attachedDatabase, [this._alias]); + late final GeneratedColumn title = GeneratedColumn( + 'title', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn urlHost = GeneratedColumn( + 'url_host', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn urlPath = GeneratedColumn( + 'url_path', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn extractedContentPlain = + GeneratedColumn( + 'extracted_content_plain', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn fullContentPlain = GeneratedColumn( + 'full_content_plain', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + @override + List get $columns => [ + title, + urlHost, + urlPath, + extractedContentPlain, + fullContentPlain, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'history_fts'; + @override + Set get $primaryKey => const {}; + @override + HistoryFtsData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return HistoryFtsData( + title: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}title'], + ), + urlHost: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}url_host'], + ), + urlPath: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}url_path'], + ), + extractedContentPlain: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}extracted_content_plain'], + ), + fullContentPlain: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}full_content_plain'], + ), + ); + } + + @override + HistoryFts createAlias(String alias) { + return HistoryFts(attachedDatabase, alias); + } + + @override + bool get dontWriteConstraints => true; + @override + String get moduleAndArgs => + 'fts5(title, url_host, url_path, extracted_content_plain, full_content_plain, content=history, tokenize="trigram")'; +} + +class HistoryFtsData extends DataClass implements Insertable { + final String? title; + final String? urlHost; + final String? urlPath; + final String? extractedContentPlain; + final String? fullContentPlain; + const HistoryFtsData({ + this.title, + this.urlHost, + this.urlPath, + this.extractedContentPlain, + this.fullContentPlain, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (!nullToAbsent || title != null) { + map['title'] = Variable(title); + } + if (!nullToAbsent || urlHost != null) { + map['url_host'] = Variable(urlHost); + } + if (!nullToAbsent || urlPath != null) { + map['url_path'] = Variable(urlPath); + } + if (!nullToAbsent || extractedContentPlain != null) { + map['extracted_content_plain'] = Variable(extractedContentPlain); + } + if (!nullToAbsent || fullContentPlain != null) { + map['full_content_plain'] = Variable(fullContentPlain); + } + return map; + } + + factory HistoryFtsData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return HistoryFtsData( + title: serializer.fromJson(json['title']), + urlHost: serializer.fromJson(json['urlHost']), + urlPath: serializer.fromJson(json['urlPath']), + extractedContentPlain: serializer.fromJson( + json['extractedContentPlain'], + ), + fullContentPlain: serializer.fromJson(json['fullContentPlain']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'title': serializer.toJson(title), + 'urlHost': serializer.toJson(urlHost), + 'urlPath': serializer.toJson(urlPath), + 'extractedContentPlain': serializer.toJson( + extractedContentPlain, + ), + 'fullContentPlain': serializer.toJson(fullContentPlain), + }; + } + + HistoryFtsData copyWith({ + Value title = const Value.absent(), + Value urlHost = const Value.absent(), + Value urlPath = const Value.absent(), + Value extractedContentPlain = const Value.absent(), + Value fullContentPlain = const Value.absent(), + }) => HistoryFtsData( + title: title.present ? title.value : this.title, + urlHost: urlHost.present ? urlHost.value : this.urlHost, + urlPath: urlPath.present ? urlPath.value : this.urlPath, + extractedContentPlain: extractedContentPlain.present + ? extractedContentPlain.value + : this.extractedContentPlain, + fullContentPlain: fullContentPlain.present + ? fullContentPlain.value + : this.fullContentPlain, + ); + HistoryFtsData copyWithCompanion(HistoryFtsCompanion data) { + return HistoryFtsData( + title: data.title.present ? data.title.value : this.title, + urlHost: data.urlHost.present ? data.urlHost.value : this.urlHost, + urlPath: data.urlPath.present ? data.urlPath.value : this.urlPath, + extractedContentPlain: data.extractedContentPlain.present + ? data.extractedContentPlain.value + : this.extractedContentPlain, + fullContentPlain: data.fullContentPlain.present + ? data.fullContentPlain.value + : this.fullContentPlain, + ); + } + + @override + String toString() { + return (StringBuffer('HistoryFtsData(') + ..write('title: $title, ') + ..write('urlHost: $urlHost, ') + ..write('urlPath: $urlPath, ') + ..write('extractedContentPlain: $extractedContentPlain, ') + ..write('fullContentPlain: $fullContentPlain') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + title, + urlHost, + urlPath, + extractedContentPlain, + fullContentPlain, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is HistoryFtsData && + other.title == this.title && + other.urlHost == this.urlHost && + other.urlPath == this.urlPath && + other.extractedContentPlain == this.extractedContentPlain && + other.fullContentPlain == this.fullContentPlain); +} + +class HistoryFtsCompanion extends UpdateCompanion { + final Value title; + final Value urlHost; + final Value urlPath; + final Value extractedContentPlain; + final Value fullContentPlain; + final Value rowid; + const HistoryFtsCompanion({ + this.title = const Value.absent(), + this.urlHost = const Value.absent(), + this.urlPath = const Value.absent(), + this.extractedContentPlain = const Value.absent(), + this.fullContentPlain = const Value.absent(), + this.rowid = const Value.absent(), + }); + HistoryFtsCompanion.insert({ + this.title = const Value.absent(), + this.urlHost = const Value.absent(), + this.urlPath = const Value.absent(), + this.extractedContentPlain = const Value.absent(), + this.fullContentPlain = const Value.absent(), + this.rowid = const Value.absent(), + }); + static Insertable custom({ + Expression? title, + Expression? urlHost, + Expression? urlPath, + Expression? extractedContentPlain, + Expression? fullContentPlain, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (title != null) 'title': title, + if (urlHost != null) 'url_host': urlHost, + if (urlPath != null) 'url_path': urlPath, + if (extractedContentPlain != null) + 'extracted_content_plain': extractedContentPlain, + if (fullContentPlain != null) 'full_content_plain': fullContentPlain, + if (rowid != null) 'rowid': rowid, + }); + } + + HistoryFtsCompanion copyWith({ + Value? title, + Value? urlHost, + Value? urlPath, + Value? extractedContentPlain, + Value? fullContentPlain, + Value? rowid, + }) { + return HistoryFtsCompanion( + title: title ?? this.title, + urlHost: urlHost ?? this.urlHost, + urlPath: urlPath ?? this.urlPath, + extractedContentPlain: + extractedContentPlain ?? this.extractedContentPlain, + fullContentPlain: fullContentPlain ?? this.fullContentPlain, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (title.present) { + map['title'] = Variable(title.value); + } + if (urlHost.present) { + map['url_host'] = Variable(urlHost.value); + } + if (urlPath.present) { + map['url_path'] = Variable(urlPath.value); + } + if (extractedContentPlain.present) { + map['extracted_content_plain'] = Variable( + extractedContentPlain.value, + ); + } + if (fullContentPlain.present) { + map['full_content_plain'] = Variable(fullContentPlain.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('HistoryFtsCompanion(') + ..write('title: $title, ') + ..write('urlHost: $urlHost, ') + ..write('urlPath: $urlPath, ') + ..write('extractedContentPlain: $extractedContentPlain, ') + ..write('fullContentPlain: $fullContentPlain, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class DatabaseAtV12 extends GeneratedDatabase { + DatabaseAtV12(QueryExecutor e) : super(e); + late final Container container = Container(this); + late final Tab tab = Tab(this); + late final ClosedTabTombstone closedTabTombstone = ClosedTabTombstone(this); + late final Index idxTabParentContainer = Index( + 'idx_tab_parent_container', + 'CREATE INDEX idx_tab_parent_container ON tab (parent_id, container_id)', + ); + late final CaptureTab captureTab = CaptureTab(this); + late final Index idxCaptureTabCaptureId = Index( + 'idx_capture_tab_capture_id', + 'CREATE INDEX idx_capture_tab_capture_id ON capture_tab (capture_id)', + ); + late final TabFts tabFts = TabFts(this); + late final Trigger tabMaintainParentChainOnDelete = Trigger( + 'CREATE TRIGGER tab_maintain_parent_chain_on_delete BEFORE DELETE ON tab BEGIN UPDATE tab SET parent_id = CASE WHEN OLD.parent_id IS NOT NULL AND EXISTS (SELECT 1 FROM tab WHERE id = OLD.parent_id) THEN OLD.parent_id ELSE NULL END WHERE parent_id = OLD.id;END', + 'tab_maintain_parent_chain_on_delete', + ); + late final Trigger tabAfterInsert = Trigger( + 'CREATE TRIGGER tab_after_insert AFTER INSERT ON tab BEGIN INSERT INTO tab_fts ("rowid", title, url, extracted_content_plain, full_content_plain) VALUES (new."rowid", new.title, new.url, new.extracted_content_plain, new.full_content_plain);END', + 'tab_after_insert', + ); + late final Trigger tabAfterDelete = Trigger( + 'CREATE TRIGGER tab_after_delete AFTER DELETE ON tab BEGIN INSERT INTO tab_fts (tab_fts, "rowid", title, url, extracted_content_plain, full_content_plain) VALUES (\'delete\', old."rowid", old.title, old.url, old.extracted_content_plain, old.full_content_plain);END', + 'tab_after_delete', + ); + late final Trigger tabAfterUpdate = Trigger( + 'CREATE TRIGGER tab_after_update AFTER UPDATE OF title, url, extracted_content_plain, full_content_plain ON tab WHEN OLD.content_hash IS NOT NEW.content_hash OR OLD.url IS NOT NEW.url BEGIN INSERT INTO tab_fts (tab_fts, "rowid", title, url, extracted_content_plain, full_content_plain) VALUES (\'delete\', old."rowid", old.title, old.url, old.extracted_content_plain, old.full_content_plain);INSERT INTO tab_fts ("rowid", title, url, extracted_content_plain, full_content_plain) VALUES (new."rowid", new.title, new.url, new.extracted_content_plain, new.full_content_plain);END', + 'tab_after_update', + ); + late final LocalIndexSetting localIndexSetting = LocalIndexSetting(this); + late final History history = History(this); + late final Index idxHistoryHost = Index( + 'idx_history_host', + 'CREATE INDEX idx_history_host ON history (url_host)', + ); + late final Index idxHistoryObserved = Index( + 'idx_history_observed', + 'CREATE INDEX idx_history_observed ON history (observed_at DESC)', + ); + late final HistoryFts historyFts = HistoryFts(this); + late final Trigger historyAfterInsert = Trigger( + 'CREATE TRIGGER history_after_insert AFTER INSERT ON history BEGIN INSERT INTO history_fts ("rowid", title, url_host, url_path, extracted_content_plain, full_content_plain) VALUES (new."rowid", new.title, new.url_host, new.url_path, new.extracted_content_plain, new.full_content_plain);END', + 'history_after_insert', + ); + late final Trigger historyAfterDelete = Trigger( + 'CREATE TRIGGER history_after_delete AFTER DELETE ON history BEGIN INSERT INTO history_fts (history_fts, "rowid", title, url_host, url_path, extracted_content_plain, full_content_plain) VALUES (\'delete\', old."rowid", old.title, old.url_host, old.url_path, old.extracted_content_plain, old.full_content_plain);END', + 'history_after_delete', + ); + late final Trigger historyAfterUpdate = Trigger( + 'CREATE TRIGGER history_after_update AFTER UPDATE OF title, url_host, url_path, extracted_content_plain, full_content_plain ON history BEGIN INSERT INTO history_fts (history_fts, "rowid", title, url_host, url_path, extracted_content_plain, full_content_plain) VALUES (\'delete\', old."rowid", old.title, old.url_host, old.url_path, old.extracted_content_plain, old.full_content_plain);INSERT INTO history_fts ("rowid", title, url_host, url_path, extracted_content_plain, full_content_plain) VALUES (new."rowid", new.title, new.url_host, new.url_path, new.extracted_content_plain, new.full_content_plain);END', + 'history_after_update', + ); + late final Trigger tabToHistoryOnInsert = Trigger( + 'CREATE TRIGGER tab_to_history_on_insert AFTER INSERT ON tab WHEN NEW.url IS NOT NULL AND url_indexable(CAST(NEW.url AS TEXT)) = 1 AND (SELECT value FROM local_index_setting WHERE "key" = \'enabled\') = 1 AND(NEW.tab_mode != 1 OR (SELECT value FROM local_index_setting WHERE "key" = \'index_private\') = 1)BEGIN INSERT INTO history (url_canonical, url_host, url_path, title, is_probably_readerable, extracted_content_markdown, extracted_content_plain, full_content_markdown, full_content_plain, content_hash, observed_at, observed_count) VALUES (url_canonical(CAST(NEW.url AS TEXT)), url_host(CAST(NEW.url AS TEXT)), url_path(CAST(NEW.url AS TEXT)), NEW.title, NEW.is_probably_readerable, NEW.extracted_content_markdown, NEW.extracted_content_plain, NEW.full_content_markdown, NEW.full_content_plain, NEW.content_hash, strftime(\'%s\', \'now\') * 1000, 1) ON CONFLICT (url_canonical) DO UPDATE SET title = COALESCE(excluded.title, history.title), is_probably_readerable = excluded.is_probably_readerable, extracted_content_markdown = excluded.extracted_content_markdown, extracted_content_plain = excluded.extracted_content_plain, full_content_markdown = excluded.full_content_markdown, full_content_plain = excluded.full_content_plain, content_hash = excluded.content_hash, observed_at = excluded.observed_at, observed_count = history.observed_count + 1 WHERE history.content_hash IS NOT excluded.content_hash;END', + 'tab_to_history_on_insert', + ); + late final Trigger tabToHistoryOnUpdate = Trigger( + 'CREATE TRIGGER tab_to_history_on_update AFTER UPDATE OF title, url, extracted_content_plain, extracted_content_markdown, full_content_plain, full_content_markdown, is_probably_readerable ON tab WHEN NEW.url IS NOT NULL AND url_indexable(CAST(NEW.url AS TEXT)) = 1 AND(OLD.content_hash IS NOT NEW.content_hash OR OLD.url IS NOT NEW.url)AND (SELECT value FROM local_index_setting WHERE "key" = \'enabled\') = 1 AND(NEW.tab_mode != 1 OR (SELECT value FROM local_index_setting WHERE "key" = \'index_private\') = 1)BEGIN INSERT INTO history (url_canonical, url_host, url_path, title, is_probably_readerable, extracted_content_markdown, extracted_content_plain, full_content_markdown, full_content_plain, content_hash, observed_at, observed_count) VALUES (url_canonical(CAST(NEW.url AS TEXT)), url_host(CAST(NEW.url AS TEXT)), url_path(CAST(NEW.url AS TEXT)), NEW.title, NEW.is_probably_readerable, NEW.extracted_content_markdown, NEW.extracted_content_plain, NEW.full_content_markdown, NEW.full_content_plain, NEW.content_hash, strftime(\'%s\', \'now\') * 1000, 1) ON CONFLICT (url_canonical) DO UPDATE SET title = COALESCE(excluded.title, history.title), is_probably_readerable = excluded.is_probably_readerable, extracted_content_markdown = excluded.extracted_content_markdown, extracted_content_plain = excluded.extracted_content_plain, full_content_markdown = excluded.full_content_markdown, full_content_plain = excluded.full_content_plain, content_hash = excluded.content_hash, observed_at = excluded.observed_at, observed_count = history.observed_count + 1 WHERE history.content_hash IS NOT excluded.content_hash;END', + 'tab_to_history_on_update', + ); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [ + container, + tab, + closedTabTombstone, + idxTabParentContainer, + captureTab, + idxCaptureTabCaptureId, + tabFts, + tabMaintainParentChainOnDelete, + tabAfterInsert, + tabAfterDelete, + tabAfterUpdate, + localIndexSetting, + history, + idxHistoryHost, + idxHistoryObserved, + historyFts, + historyAfterInsert, + historyAfterDelete, + historyAfterUpdate, + tabToHistoryOnInsert, + tabToHistoryOnUpdate, + ]; + @override + StreamQueryUpdateRules get streamUpdateRules => const StreamQueryUpdateRules([ + WritePropagation( + on: TableUpdateQuery.onTableName( + 'container', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('tab', kind: UpdateKind.delete)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'tab', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('capture_tab', kind: UpdateKind.delete)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'tab', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('tab', kind: UpdateKind.update)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'tab', + limitUpdateKind: UpdateKind.insert, + ), + result: [TableUpdate('tab_fts', kind: UpdateKind.insert)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'tab', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('tab_fts', kind: UpdateKind.insert)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'tab', + limitUpdateKind: UpdateKind.update, + ), + result: [TableUpdate('tab_fts', kind: UpdateKind.insert)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'history', + limitUpdateKind: UpdateKind.insert, + ), + result: [TableUpdate('history_fts', kind: UpdateKind.insert)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'history', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('history_fts', kind: UpdateKind.insert)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'history', + limitUpdateKind: UpdateKind.update, + ), + result: [TableUpdate('history_fts', kind: UpdateKind.insert)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'tab', + limitUpdateKind: UpdateKind.insert, + ), + result: [TableUpdate('history', kind: UpdateKind.insert)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'tab', + limitUpdateKind: UpdateKind.update, + ), + result: [TableUpdate('history', kind: UpdateKind.insert)], + ), + ]); + @override + int get schemaVersion => 12; +} diff --git a/apps/weblibre/test/drift/tabs/generated/schema_v13.dart b/apps/weblibre/test/drift/tabs/generated/schema_v13.dart new file mode 100644 index 00000000..3918fdcb --- /dev/null +++ b/apps/weblibre/test/drift/tabs/generated/schema_v13.dart @@ -0,0 +1,3168 @@ +// dart format width=80 +// GENERATED BY drift_dev, DO NOT MODIFY. +// ignore_for_file: type=lint,unused_import +// +import 'package:drift/drift.dart'; + +class Container extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Container(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'PRIMARY KEY NOT NULL', + ); + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn color = GeneratedColumn( + 'color', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn orderKey = GeneratedColumn( + 'order_key', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn isPinned = GeneratedColumn( + 'is_pinned', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn metadata = GeneratedColumn( + 'metadata', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + @override + List get $columns => [ + id, + name, + color, + orderKey, + isPinned, + metadata, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'container'; + @override + Set get $primaryKey => {id}; + @override + ContainerData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return ContainerData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + ), + color: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}color'], + )!, + orderKey: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}order_key'], + )!, + isPinned: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}is_pinned'], + )!, + metadata: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}metadata'], + ), + ); + } + + @override + Container createAlias(String alias) { + return Container(attachedDatabase, alias); + } + + @override + bool get dontWriteConstraints => true; +} + +class ContainerData extends DataClass implements Insertable { + final String id; + final String? name; + final int color; + final String orderKey; + final int isPinned; + final String? metadata; + const ContainerData({ + required this.id, + this.name, + required this.color, + required this.orderKey, + required this.isPinned, + this.metadata, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + if (!nullToAbsent || name != null) { + map['name'] = Variable(name); + } + map['color'] = Variable(color); + map['order_key'] = Variable(orderKey); + map['is_pinned'] = Variable(isPinned); + if (!nullToAbsent || metadata != null) { + map['metadata'] = Variable(metadata); + } + return map; + } + + factory ContainerData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return ContainerData( + id: serializer.fromJson(json['id']), + name: serializer.fromJson(json['name']), + color: serializer.fromJson(json['color']), + orderKey: serializer.fromJson(json['orderKey']), + isPinned: serializer.fromJson(json['isPinned']), + metadata: serializer.fromJson(json['metadata']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'name': serializer.toJson(name), + 'color': serializer.toJson(color), + 'orderKey': serializer.toJson(orderKey), + 'isPinned': serializer.toJson(isPinned), + 'metadata': serializer.toJson(metadata), + }; + } + + ContainerData copyWith({ + String? id, + Value name = const Value.absent(), + int? color, + String? orderKey, + int? isPinned, + Value metadata = const Value.absent(), + }) => ContainerData( + id: id ?? this.id, + name: name.present ? name.value : this.name, + color: color ?? this.color, + orderKey: orderKey ?? this.orderKey, + isPinned: isPinned ?? this.isPinned, + metadata: metadata.present ? metadata.value : this.metadata, + ); + ContainerData copyWithCompanion(ContainerCompanion data) { + return ContainerData( + id: data.id.present ? data.id.value : this.id, + name: data.name.present ? data.name.value : this.name, + color: data.color.present ? data.color.value : this.color, + orderKey: data.orderKey.present ? data.orderKey.value : this.orderKey, + isPinned: data.isPinned.present ? data.isPinned.value : this.isPinned, + metadata: data.metadata.present ? data.metadata.value : this.metadata, + ); + } + + @override + String toString() { + return (StringBuffer('ContainerData(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('color: $color, ') + ..write('orderKey: $orderKey, ') + ..write('isPinned: $isPinned, ') + ..write('metadata: $metadata') + ..write(')')) + .toString(); + } + + @override + int get hashCode => + Object.hash(id, name, color, orderKey, isPinned, metadata); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ContainerData && + other.id == this.id && + other.name == this.name && + other.color == this.color && + other.orderKey == this.orderKey && + other.isPinned == this.isPinned && + other.metadata == this.metadata); +} + +class ContainerCompanion extends UpdateCompanion { + final Value id; + final Value name; + final Value color; + final Value orderKey; + final Value isPinned; + final Value metadata; + final Value rowid; + const ContainerCompanion({ + this.id = const Value.absent(), + this.name = const Value.absent(), + this.color = const Value.absent(), + this.orderKey = const Value.absent(), + this.isPinned = const Value.absent(), + this.metadata = const Value.absent(), + this.rowid = const Value.absent(), + }); + ContainerCompanion.insert({ + required String id, + this.name = const Value.absent(), + required int color, + required String orderKey, + this.isPinned = const Value.absent(), + this.metadata = const Value.absent(), + this.rowid = const Value.absent(), + }) : id = Value(id), + color = Value(color), + orderKey = Value(orderKey); + static Insertable custom({ + Expression? id, + Expression? name, + Expression? color, + Expression? orderKey, + Expression? isPinned, + Expression? metadata, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (name != null) 'name': name, + if (color != null) 'color': color, + if (orderKey != null) 'order_key': orderKey, + if (isPinned != null) 'is_pinned': isPinned, + if (metadata != null) 'metadata': metadata, + if (rowid != null) 'rowid': rowid, + }); + } + + ContainerCompanion copyWith({ + Value? id, + Value? name, + Value? color, + Value? orderKey, + Value? isPinned, + Value? metadata, + Value? rowid, + }) { + return ContainerCompanion( + id: id ?? this.id, + name: name ?? this.name, + color: color ?? this.color, + orderKey: orderKey ?? this.orderKey, + isPinned: isPinned ?? this.isPinned, + metadata: metadata ?? this.metadata, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (name.present) { + map['name'] = Variable(name.value); + } + if (color.present) { + map['color'] = Variable(color.value); + } + if (orderKey.present) { + map['order_key'] = Variable(orderKey.value); + } + if (isPinned.present) { + map['is_pinned'] = Variable(isPinned.value); + } + if (metadata.present) { + map['metadata'] = Variable(metadata.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('ContainerCompanion(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('color: $color, ') + ..write('orderKey: $orderKey, ') + ..write('isPinned: $isPinned, ') + ..write('metadata: $metadata, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class Tab extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Tab(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'PRIMARY KEY NOT NULL', + ); + late final GeneratedColumn source = GeneratedColumn( + 'source', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn parentId = GeneratedColumn( + 'parent_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'REFERENCES tab(id)ON DELETE SET NULL', + ); + late final GeneratedColumn containerId = GeneratedColumn( + 'container_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'REFERENCES container(id)ON DELETE CASCADE', + ); + late final GeneratedColumn orderKey = GeneratedColumn( + 'order_key', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn url = GeneratedColumn( + 'url', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn title = GeneratedColumn( + 'title', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn tabMode = GeneratedColumn( + 'tab_mode', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn isolationContextId = + GeneratedColumn( + 'isolation_context_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn isPinned = GeneratedColumn( + 'is_pinned', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn isProbablyReaderable = GeneratedColumn( + 'is_probably_readerable', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn extractedContentMarkdown = + GeneratedColumn( + 'extracted_content_markdown', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn extractedContentPlain = + GeneratedColumn( + 'extracted_content_plain', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn fullContentMarkdown = + GeneratedColumn( + 'full_content_markdown', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn fullContentPlain = GeneratedColumn( + 'full_content_plain', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn timestamp = GeneratedColumn( + 'timestamp', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn contentHash = GeneratedColumn( + 'content_hash', + aliasedName, + true, + generatedAs: GeneratedAs( + const CustomExpression( + 'generate_content_hash(title, extracted_content_plain, full_content_plain)', + ), + false, + ), + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: + 'GENERATED ALWAYS AS (generate_content_hash(title, extracted_content_plain, full_content_plain)) VIRTUAL', + ); + @override + List get $columns => [ + id, + source, + parentId, + containerId, + orderKey, + url, + title, + tabMode, + isolationContextId, + isPinned, + isProbablyReaderable, + extractedContentMarkdown, + extractedContentPlain, + fullContentMarkdown, + fullContentPlain, + timestamp, + contentHash, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'tab'; + @override + Set get $primaryKey => {id}; + @override + TabData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return TabData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + source: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}source'], + )!, + parentId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}parent_id'], + ), + containerId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}container_id'], + ), + orderKey: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}order_key'], + )!, + url: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}url'], + ), + title: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}title'], + ), + tabMode: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}tab_mode'], + )!, + isolationContextId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}isolation_context_id'], + ), + isPinned: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}is_pinned'], + )!, + isProbablyReaderable: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}is_probably_readerable'], + ), + extractedContentMarkdown: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}extracted_content_markdown'], + ), + extractedContentPlain: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}extracted_content_plain'], + ), + fullContentMarkdown: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}full_content_markdown'], + ), + fullContentPlain: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}full_content_plain'], + ), + timestamp: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}timestamp'], + )!, + contentHash: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}content_hash'], + ), + ); + } + + @override + Tab createAlias(String alias) { + return Tab(attachedDatabase, alias); + } + + @override + List get customConstraints => const [ + 'CHECK((tab_mode = 2 AND isolation_context_id IS NOT NULL)OR(tab_mode != 2 AND isolation_context_id IS NULL))', + ]; + @override + bool get dontWriteConstraints => true; +} + +class TabData extends DataClass implements Insertable { + final String id; + final int source; + final String? parentId; + final String? containerId; + final String orderKey; + final String? url; + final String? title; + final int tabMode; + final String? isolationContextId; + final int isPinned; + final int? isProbablyReaderable; + final String? extractedContentMarkdown; + final String? extractedContentPlain; + final String? fullContentMarkdown; + final String? fullContentPlain; + final int timestamp; + final int? contentHash; + const TabData({ + required this.id, + required this.source, + this.parentId, + this.containerId, + required this.orderKey, + this.url, + this.title, + required this.tabMode, + this.isolationContextId, + required this.isPinned, + this.isProbablyReaderable, + this.extractedContentMarkdown, + this.extractedContentPlain, + this.fullContentMarkdown, + this.fullContentPlain, + required this.timestamp, + this.contentHash, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['source'] = Variable(source); + if (!nullToAbsent || parentId != null) { + map['parent_id'] = Variable(parentId); + } + if (!nullToAbsent || containerId != null) { + map['container_id'] = Variable(containerId); + } + map['order_key'] = Variable(orderKey); + if (!nullToAbsent || url != null) { + map['url'] = Variable(url); + } + if (!nullToAbsent || title != null) { + map['title'] = Variable(title); + } + map['tab_mode'] = Variable(tabMode); + if (!nullToAbsent || isolationContextId != null) { + map['isolation_context_id'] = Variable(isolationContextId); + } + map['is_pinned'] = Variable(isPinned); + if (!nullToAbsent || isProbablyReaderable != null) { + map['is_probably_readerable'] = Variable(isProbablyReaderable); + } + if (!nullToAbsent || extractedContentMarkdown != null) { + map['extracted_content_markdown'] = Variable( + extractedContentMarkdown, + ); + } + if (!nullToAbsent || extractedContentPlain != null) { + map['extracted_content_plain'] = Variable(extractedContentPlain); + } + if (!nullToAbsent || fullContentMarkdown != null) { + map['full_content_markdown'] = Variable(fullContentMarkdown); + } + if (!nullToAbsent || fullContentPlain != null) { + map['full_content_plain'] = Variable(fullContentPlain); + } + map['timestamp'] = Variable(timestamp); + return map; + } + + factory TabData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return TabData( + id: serializer.fromJson(json['id']), + source: serializer.fromJson(json['source']), + parentId: serializer.fromJson(json['parentId']), + containerId: serializer.fromJson(json['containerId']), + orderKey: serializer.fromJson(json['orderKey']), + url: serializer.fromJson(json['url']), + title: serializer.fromJson(json['title']), + tabMode: serializer.fromJson(json['tabMode']), + isolationContextId: serializer.fromJson( + json['isolationContextId'], + ), + isPinned: serializer.fromJson(json['isPinned']), + isProbablyReaderable: serializer.fromJson( + json['isProbablyReaderable'], + ), + extractedContentMarkdown: serializer.fromJson( + json['extractedContentMarkdown'], + ), + extractedContentPlain: serializer.fromJson( + json['extractedContentPlain'], + ), + fullContentMarkdown: serializer.fromJson( + json['fullContentMarkdown'], + ), + fullContentPlain: serializer.fromJson(json['fullContentPlain']), + timestamp: serializer.fromJson(json['timestamp']), + contentHash: serializer.fromJson(json['contentHash']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'source': serializer.toJson(source), + 'parentId': serializer.toJson(parentId), + 'containerId': serializer.toJson(containerId), + 'orderKey': serializer.toJson(orderKey), + 'url': serializer.toJson(url), + 'title': serializer.toJson(title), + 'tabMode': serializer.toJson(tabMode), + 'isolationContextId': serializer.toJson(isolationContextId), + 'isPinned': serializer.toJson(isPinned), + 'isProbablyReaderable': serializer.toJson(isProbablyReaderable), + 'extractedContentMarkdown': serializer.toJson( + extractedContentMarkdown, + ), + 'extractedContentPlain': serializer.toJson( + extractedContentPlain, + ), + 'fullContentMarkdown': serializer.toJson(fullContentMarkdown), + 'fullContentPlain': serializer.toJson(fullContentPlain), + 'timestamp': serializer.toJson(timestamp), + 'contentHash': serializer.toJson(contentHash), + }; + } + + TabData copyWith({ + String? id, + int? source, + Value parentId = const Value.absent(), + Value containerId = const Value.absent(), + String? orderKey, + Value url = const Value.absent(), + Value title = const Value.absent(), + int? tabMode, + Value isolationContextId = const Value.absent(), + int? isPinned, + Value isProbablyReaderable = const Value.absent(), + Value extractedContentMarkdown = const Value.absent(), + Value extractedContentPlain = const Value.absent(), + Value fullContentMarkdown = const Value.absent(), + Value fullContentPlain = const Value.absent(), + int? timestamp, + Value contentHash = const Value.absent(), + }) => TabData( + id: id ?? this.id, + source: source ?? this.source, + parentId: parentId.present ? parentId.value : this.parentId, + containerId: containerId.present ? containerId.value : this.containerId, + orderKey: orderKey ?? this.orderKey, + url: url.present ? url.value : this.url, + title: title.present ? title.value : this.title, + tabMode: tabMode ?? this.tabMode, + isolationContextId: isolationContextId.present + ? isolationContextId.value + : this.isolationContextId, + isPinned: isPinned ?? this.isPinned, + isProbablyReaderable: isProbablyReaderable.present + ? isProbablyReaderable.value + : this.isProbablyReaderable, + extractedContentMarkdown: extractedContentMarkdown.present + ? extractedContentMarkdown.value + : this.extractedContentMarkdown, + extractedContentPlain: extractedContentPlain.present + ? extractedContentPlain.value + : this.extractedContentPlain, + fullContentMarkdown: fullContentMarkdown.present + ? fullContentMarkdown.value + : this.fullContentMarkdown, + fullContentPlain: fullContentPlain.present + ? fullContentPlain.value + : this.fullContentPlain, + timestamp: timestamp ?? this.timestamp, + contentHash: contentHash.present ? contentHash.value : this.contentHash, + ); + @override + String toString() { + return (StringBuffer('TabData(') + ..write('id: $id, ') + ..write('source: $source, ') + ..write('parentId: $parentId, ') + ..write('containerId: $containerId, ') + ..write('orderKey: $orderKey, ') + ..write('url: $url, ') + ..write('title: $title, ') + ..write('tabMode: $tabMode, ') + ..write('isolationContextId: $isolationContextId, ') + ..write('isPinned: $isPinned, ') + ..write('isProbablyReaderable: $isProbablyReaderable, ') + ..write('extractedContentMarkdown: $extractedContentMarkdown, ') + ..write('extractedContentPlain: $extractedContentPlain, ') + ..write('fullContentMarkdown: $fullContentMarkdown, ') + ..write('fullContentPlain: $fullContentPlain, ') + ..write('timestamp: $timestamp, ') + ..write('contentHash: $contentHash') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + source, + parentId, + containerId, + orderKey, + url, + title, + tabMode, + isolationContextId, + isPinned, + isProbablyReaderable, + extractedContentMarkdown, + extractedContentPlain, + fullContentMarkdown, + fullContentPlain, + timestamp, + contentHash, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is TabData && + other.id == this.id && + other.source == this.source && + other.parentId == this.parentId && + other.containerId == this.containerId && + other.orderKey == this.orderKey && + other.url == this.url && + other.title == this.title && + other.tabMode == this.tabMode && + other.isolationContextId == this.isolationContextId && + other.isPinned == this.isPinned && + other.isProbablyReaderable == this.isProbablyReaderable && + other.extractedContentMarkdown == this.extractedContentMarkdown && + other.extractedContentPlain == this.extractedContentPlain && + other.fullContentMarkdown == this.fullContentMarkdown && + other.fullContentPlain == this.fullContentPlain && + other.timestamp == this.timestamp && + other.contentHash == this.contentHash); +} + +class TabCompanion extends UpdateCompanion { + final Value id; + final Value source; + final Value parentId; + final Value containerId; + final Value orderKey; + final Value url; + final Value title; + final Value tabMode; + final Value isolationContextId; + final Value isPinned; + final Value isProbablyReaderable; + final Value extractedContentMarkdown; + final Value extractedContentPlain; + final Value fullContentMarkdown; + final Value fullContentPlain; + final Value timestamp; + final Value rowid; + const TabCompanion({ + this.id = const Value.absent(), + this.source = const Value.absent(), + this.parentId = const Value.absent(), + this.containerId = const Value.absent(), + this.orderKey = const Value.absent(), + this.url = const Value.absent(), + this.title = const Value.absent(), + this.tabMode = const Value.absent(), + this.isolationContextId = const Value.absent(), + this.isPinned = const Value.absent(), + this.isProbablyReaderable = const Value.absent(), + this.extractedContentMarkdown = const Value.absent(), + this.extractedContentPlain = const Value.absent(), + this.fullContentMarkdown = const Value.absent(), + this.fullContentPlain = const Value.absent(), + this.timestamp = const Value.absent(), + this.rowid = const Value.absent(), + }); + TabCompanion.insert({ + required String id, + required int source, + this.parentId = const Value.absent(), + this.containerId = const Value.absent(), + required String orderKey, + this.url = const Value.absent(), + this.title = const Value.absent(), + this.tabMode = const Value.absent(), + this.isolationContextId = const Value.absent(), + this.isPinned = const Value.absent(), + this.isProbablyReaderable = const Value.absent(), + this.extractedContentMarkdown = const Value.absent(), + this.extractedContentPlain = const Value.absent(), + this.fullContentMarkdown = const Value.absent(), + this.fullContentPlain = const Value.absent(), + required int timestamp, + this.rowid = const Value.absent(), + }) : id = Value(id), + source = Value(source), + orderKey = Value(orderKey), + timestamp = Value(timestamp); + static Insertable custom({ + Expression? id, + Expression? source, + Expression? parentId, + Expression? containerId, + Expression? orderKey, + Expression? url, + Expression? title, + Expression? tabMode, + Expression? isolationContextId, + Expression? isPinned, + Expression? isProbablyReaderable, + Expression? extractedContentMarkdown, + Expression? extractedContentPlain, + Expression? fullContentMarkdown, + Expression? fullContentPlain, + Expression? timestamp, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (source != null) 'source': source, + if (parentId != null) 'parent_id': parentId, + if (containerId != null) 'container_id': containerId, + if (orderKey != null) 'order_key': orderKey, + if (url != null) 'url': url, + if (title != null) 'title': title, + if (tabMode != null) 'tab_mode': tabMode, + if (isolationContextId != null) + 'isolation_context_id': isolationContextId, + if (isPinned != null) 'is_pinned': isPinned, + if (isProbablyReaderable != null) + 'is_probably_readerable': isProbablyReaderable, + if (extractedContentMarkdown != null) + 'extracted_content_markdown': extractedContentMarkdown, + if (extractedContentPlain != null) + 'extracted_content_plain': extractedContentPlain, + if (fullContentMarkdown != null) + 'full_content_markdown': fullContentMarkdown, + if (fullContentPlain != null) 'full_content_plain': fullContentPlain, + if (timestamp != null) 'timestamp': timestamp, + if (rowid != null) 'rowid': rowid, + }); + } + + TabCompanion copyWith({ + Value? id, + Value? source, + Value? parentId, + Value? containerId, + Value? orderKey, + Value? url, + Value? title, + Value? tabMode, + Value? isolationContextId, + Value? isPinned, + Value? isProbablyReaderable, + Value? extractedContentMarkdown, + Value? extractedContentPlain, + Value? fullContentMarkdown, + Value? fullContentPlain, + Value? timestamp, + Value? rowid, + }) { + return TabCompanion( + id: id ?? this.id, + source: source ?? this.source, + parentId: parentId ?? this.parentId, + containerId: containerId ?? this.containerId, + orderKey: orderKey ?? this.orderKey, + url: url ?? this.url, + title: title ?? this.title, + tabMode: tabMode ?? this.tabMode, + isolationContextId: isolationContextId ?? this.isolationContextId, + isPinned: isPinned ?? this.isPinned, + isProbablyReaderable: isProbablyReaderable ?? this.isProbablyReaderable, + extractedContentMarkdown: + extractedContentMarkdown ?? this.extractedContentMarkdown, + extractedContentPlain: + extractedContentPlain ?? this.extractedContentPlain, + fullContentMarkdown: fullContentMarkdown ?? this.fullContentMarkdown, + fullContentPlain: fullContentPlain ?? this.fullContentPlain, + timestamp: timestamp ?? this.timestamp, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (source.present) { + map['source'] = Variable(source.value); + } + if (parentId.present) { + map['parent_id'] = Variable(parentId.value); + } + if (containerId.present) { + map['container_id'] = Variable(containerId.value); + } + if (orderKey.present) { + map['order_key'] = Variable(orderKey.value); + } + if (url.present) { + map['url'] = Variable(url.value); + } + if (title.present) { + map['title'] = Variable(title.value); + } + if (tabMode.present) { + map['tab_mode'] = Variable(tabMode.value); + } + if (isolationContextId.present) { + map['isolation_context_id'] = Variable(isolationContextId.value); + } + if (isPinned.present) { + map['is_pinned'] = Variable(isPinned.value); + } + if (isProbablyReaderable.present) { + map['is_probably_readerable'] = Variable(isProbablyReaderable.value); + } + if (extractedContentMarkdown.present) { + map['extracted_content_markdown'] = Variable( + extractedContentMarkdown.value, + ); + } + if (extractedContentPlain.present) { + map['extracted_content_plain'] = Variable( + extractedContentPlain.value, + ); + } + if (fullContentMarkdown.present) { + map['full_content_markdown'] = Variable( + fullContentMarkdown.value, + ); + } + if (fullContentPlain.present) { + map['full_content_plain'] = Variable(fullContentPlain.value); + } + if (timestamp.present) { + map['timestamp'] = Variable(timestamp.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('TabCompanion(') + ..write('id: $id, ') + ..write('source: $source, ') + ..write('parentId: $parentId, ') + ..write('containerId: $containerId, ') + ..write('orderKey: $orderKey, ') + ..write('url: $url, ') + ..write('title: $title, ') + ..write('tabMode: $tabMode, ') + ..write('isolationContextId: $isolationContextId, ') + ..write('isPinned: $isPinned, ') + ..write('isProbablyReaderable: $isProbablyReaderable, ') + ..write('extractedContentMarkdown: $extractedContentMarkdown, ') + ..write('extractedContentPlain: $extractedContentPlain, ') + ..write('fullContentMarkdown: $fullContentMarkdown, ') + ..write('fullContentPlain: $fullContentPlain, ') + ..write('timestamp: $timestamp, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class ClosedTabTombstone extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + ClosedTabTombstone(this.attachedDatabase, [this._alias]); + late final GeneratedColumn tabId = GeneratedColumn( + 'tab_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'PRIMARY KEY NOT NULL', + ); + late final GeneratedColumn closedAt = GeneratedColumn( + 'closed_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + @override + List get $columns => [tabId, closedAt]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'closed_tab_tombstone'; + @override + Set get $primaryKey => {tabId}; + @override + ClosedTabTombstoneData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return ClosedTabTombstoneData( + tabId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}tab_id'], + )!, + closedAt: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}closed_at'], + )!, + ); + } + + @override + ClosedTabTombstone createAlias(String alias) { + return ClosedTabTombstone(attachedDatabase, alias); + } + + @override + bool get dontWriteConstraints => true; +} + +class ClosedTabTombstoneData extends DataClass + implements Insertable { + final String tabId; + final int closedAt; + const ClosedTabTombstoneData({required this.tabId, required this.closedAt}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['tab_id'] = Variable(tabId); + map['closed_at'] = Variable(closedAt); + return map; + } + + factory ClosedTabTombstoneData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return ClosedTabTombstoneData( + tabId: serializer.fromJson(json['tabId']), + closedAt: serializer.fromJson(json['closedAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'tabId': serializer.toJson(tabId), + 'closedAt': serializer.toJson(closedAt), + }; + } + + ClosedTabTombstoneData copyWith({String? tabId, int? closedAt}) => + ClosedTabTombstoneData( + tabId: tabId ?? this.tabId, + closedAt: closedAt ?? this.closedAt, + ); + ClosedTabTombstoneData copyWithCompanion(ClosedTabTombstoneCompanion data) { + return ClosedTabTombstoneData( + tabId: data.tabId.present ? data.tabId.value : this.tabId, + closedAt: data.closedAt.present ? data.closedAt.value : this.closedAt, + ); + } + + @override + String toString() { + return (StringBuffer('ClosedTabTombstoneData(') + ..write('tabId: $tabId, ') + ..write('closedAt: $closedAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(tabId, closedAt); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ClosedTabTombstoneData && + other.tabId == this.tabId && + other.closedAt == this.closedAt); +} + +class ClosedTabTombstoneCompanion + extends UpdateCompanion { + final Value tabId; + final Value closedAt; + final Value rowid; + const ClosedTabTombstoneCompanion({ + this.tabId = const Value.absent(), + this.closedAt = const Value.absent(), + this.rowid = const Value.absent(), + }); + ClosedTabTombstoneCompanion.insert({ + required String tabId, + required int closedAt, + this.rowid = const Value.absent(), + }) : tabId = Value(tabId), + closedAt = Value(closedAt); + static Insertable custom({ + Expression? tabId, + Expression? closedAt, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (tabId != null) 'tab_id': tabId, + if (closedAt != null) 'closed_at': closedAt, + if (rowid != null) 'rowid': rowid, + }); + } + + ClosedTabTombstoneCompanion copyWith({ + Value? tabId, + Value? closedAt, + Value? rowid, + }) { + return ClosedTabTombstoneCompanion( + tabId: tabId ?? this.tabId, + closedAt: closedAt ?? this.closedAt, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (tabId.present) { + map['tab_id'] = Variable(tabId.value); + } + if (closedAt.present) { + map['closed_at'] = Variable(closedAt.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('ClosedTabTombstoneCompanion(') + ..write('tabId: $tabId, ') + ..write('closedAt: $closedAt, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class CaptureTab extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + CaptureTab(this.attachedDatabase, [this._alias]); + late final GeneratedColumn tabId = GeneratedColumn( + 'tab_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: + 'NOT NULL PRIMARY KEY REFERENCES tab(id)ON DELETE CASCADE', + ); + late final GeneratedColumn captureId = GeneratedColumn( + 'capture_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn sourceUrl = GeneratedColumn( + 'source_url', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn status = GeneratedColumn( + 'status', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT \'pending\'', + defaultValue: const CustomExpression('\'pending\''), + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + @override + List get $columns => [ + tabId, + captureId, + sourceUrl, + status, + createdAt, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'capture_tab'; + @override + Set get $primaryKey => {tabId}; + @override + CaptureTabData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return CaptureTabData( + tabId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}tab_id'], + )!, + captureId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}capture_id'], + )!, + sourceUrl: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}source_url'], + )!, + status: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}status'], + )!, + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}created_at'], + )!, + ); + } + + @override + CaptureTab createAlias(String alias) { + return CaptureTab(attachedDatabase, alias); + } + + @override + bool get dontWriteConstraints => true; +} + +class CaptureTabData extends DataClass implements Insertable { + final String tabId; + final String captureId; + final String sourceUrl; + final String status; + final int createdAt; + const CaptureTabData({ + required this.tabId, + required this.captureId, + required this.sourceUrl, + required this.status, + required this.createdAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['tab_id'] = Variable(tabId); + map['capture_id'] = Variable(captureId); + map['source_url'] = Variable(sourceUrl); + map['status'] = Variable(status); + map['created_at'] = Variable(createdAt); + return map; + } + + factory CaptureTabData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return CaptureTabData( + tabId: serializer.fromJson(json['tabId']), + captureId: serializer.fromJson(json['captureId']), + sourceUrl: serializer.fromJson(json['sourceUrl']), + status: serializer.fromJson(json['status']), + createdAt: serializer.fromJson(json['createdAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'tabId': serializer.toJson(tabId), + 'captureId': serializer.toJson(captureId), + 'sourceUrl': serializer.toJson(sourceUrl), + 'status': serializer.toJson(status), + 'createdAt': serializer.toJson(createdAt), + }; + } + + CaptureTabData copyWith({ + String? tabId, + String? captureId, + String? sourceUrl, + String? status, + int? createdAt, + }) => CaptureTabData( + tabId: tabId ?? this.tabId, + captureId: captureId ?? this.captureId, + sourceUrl: sourceUrl ?? this.sourceUrl, + status: status ?? this.status, + createdAt: createdAt ?? this.createdAt, + ); + CaptureTabData copyWithCompanion(CaptureTabCompanion data) { + return CaptureTabData( + tabId: data.tabId.present ? data.tabId.value : this.tabId, + captureId: data.captureId.present ? data.captureId.value : this.captureId, + sourceUrl: data.sourceUrl.present ? data.sourceUrl.value : this.sourceUrl, + status: data.status.present ? data.status.value : this.status, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + ); + } + + @override + String toString() { + return (StringBuffer('CaptureTabData(') + ..write('tabId: $tabId, ') + ..write('captureId: $captureId, ') + ..write('sourceUrl: $sourceUrl, ') + ..write('status: $status, ') + ..write('createdAt: $createdAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => + Object.hash(tabId, captureId, sourceUrl, status, createdAt); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is CaptureTabData && + other.tabId == this.tabId && + other.captureId == this.captureId && + other.sourceUrl == this.sourceUrl && + other.status == this.status && + other.createdAt == this.createdAt); +} + +class CaptureTabCompanion extends UpdateCompanion { + final Value tabId; + final Value captureId; + final Value sourceUrl; + final Value status; + final Value createdAt; + final Value rowid; + const CaptureTabCompanion({ + this.tabId = const Value.absent(), + this.captureId = const Value.absent(), + this.sourceUrl = const Value.absent(), + this.status = const Value.absent(), + this.createdAt = const Value.absent(), + this.rowid = const Value.absent(), + }); + CaptureTabCompanion.insert({ + required String tabId, + required String captureId, + required String sourceUrl, + this.status = const Value.absent(), + required int createdAt, + this.rowid = const Value.absent(), + }) : tabId = Value(tabId), + captureId = Value(captureId), + sourceUrl = Value(sourceUrl), + createdAt = Value(createdAt); + static Insertable custom({ + Expression? tabId, + Expression? captureId, + Expression? sourceUrl, + Expression? status, + Expression? createdAt, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (tabId != null) 'tab_id': tabId, + if (captureId != null) 'capture_id': captureId, + if (sourceUrl != null) 'source_url': sourceUrl, + if (status != null) 'status': status, + if (createdAt != null) 'created_at': createdAt, + if (rowid != null) 'rowid': rowid, + }); + } + + CaptureTabCompanion copyWith({ + Value? tabId, + Value? captureId, + Value? sourceUrl, + Value? status, + Value? createdAt, + Value? rowid, + }) { + return CaptureTabCompanion( + tabId: tabId ?? this.tabId, + captureId: captureId ?? this.captureId, + sourceUrl: sourceUrl ?? this.sourceUrl, + status: status ?? this.status, + createdAt: createdAt ?? this.createdAt, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (tabId.present) { + map['tab_id'] = Variable(tabId.value); + } + if (captureId.present) { + map['capture_id'] = Variable(captureId.value); + } + if (sourceUrl.present) { + map['source_url'] = Variable(sourceUrl.value); + } + if (status.present) { + map['status'] = Variable(status.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('CaptureTabCompanion(') + ..write('tabId: $tabId, ') + ..write('captureId: $captureId, ') + ..write('sourceUrl: $sourceUrl, ') + ..write('status: $status, ') + ..write('createdAt: $createdAt, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class TabFts extends Table + with TableInfo, VirtualTableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + TabFts(this.attachedDatabase, [this._alias]); + late final GeneratedColumn title = GeneratedColumn( + 'title', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn url = GeneratedColumn( + 'url', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn extractedContentPlain = + GeneratedColumn( + 'extracted_content_plain', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn fullContentPlain = GeneratedColumn( + 'full_content_plain', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + @override + List get $columns => [ + title, + url, + extractedContentPlain, + fullContentPlain, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'tab_fts'; + @override + Set get $primaryKey => const {}; + @override + TabFtsData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return TabFtsData( + title: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}title'], + ), + url: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}url'], + ), + extractedContentPlain: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}extracted_content_plain'], + ), + fullContentPlain: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}full_content_plain'], + ), + ); + } + + @override + TabFts createAlias(String alias) { + return TabFts(attachedDatabase, alias); + } + + @override + bool get dontWriteConstraints => true; + @override + String get moduleAndArgs => + 'fts5(title, url, extracted_content_plain, full_content_plain, content=tab, tokenize="trigram")'; +} + +class TabFtsData extends DataClass implements Insertable { + final String? title; + final String? url; + final String? extractedContentPlain; + final String? fullContentPlain; + const TabFtsData({ + this.title, + this.url, + this.extractedContentPlain, + this.fullContentPlain, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (!nullToAbsent || title != null) { + map['title'] = Variable(title); + } + if (!nullToAbsent || url != null) { + map['url'] = Variable(url); + } + if (!nullToAbsent || extractedContentPlain != null) { + map['extracted_content_plain'] = Variable(extractedContentPlain); + } + if (!nullToAbsent || fullContentPlain != null) { + map['full_content_plain'] = Variable(fullContentPlain); + } + return map; + } + + factory TabFtsData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return TabFtsData( + title: serializer.fromJson(json['title']), + url: serializer.fromJson(json['url']), + extractedContentPlain: serializer.fromJson( + json['extractedContentPlain'], + ), + fullContentPlain: serializer.fromJson(json['fullContentPlain']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'title': serializer.toJson(title), + 'url': serializer.toJson(url), + 'extractedContentPlain': serializer.toJson( + extractedContentPlain, + ), + 'fullContentPlain': serializer.toJson(fullContentPlain), + }; + } + + TabFtsData copyWith({ + Value title = const Value.absent(), + Value url = const Value.absent(), + Value extractedContentPlain = const Value.absent(), + Value fullContentPlain = const Value.absent(), + }) => TabFtsData( + title: title.present ? title.value : this.title, + url: url.present ? url.value : this.url, + extractedContentPlain: extractedContentPlain.present + ? extractedContentPlain.value + : this.extractedContentPlain, + fullContentPlain: fullContentPlain.present + ? fullContentPlain.value + : this.fullContentPlain, + ); + TabFtsData copyWithCompanion(TabFtsCompanion data) { + return TabFtsData( + title: data.title.present ? data.title.value : this.title, + url: data.url.present ? data.url.value : this.url, + extractedContentPlain: data.extractedContentPlain.present + ? data.extractedContentPlain.value + : this.extractedContentPlain, + fullContentPlain: data.fullContentPlain.present + ? data.fullContentPlain.value + : this.fullContentPlain, + ); + } + + @override + String toString() { + return (StringBuffer('TabFtsData(') + ..write('title: $title, ') + ..write('url: $url, ') + ..write('extractedContentPlain: $extractedContentPlain, ') + ..write('fullContentPlain: $fullContentPlain') + ..write(')')) + .toString(); + } + + @override + int get hashCode => + Object.hash(title, url, extractedContentPlain, fullContentPlain); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is TabFtsData && + other.title == this.title && + other.url == this.url && + other.extractedContentPlain == this.extractedContentPlain && + other.fullContentPlain == this.fullContentPlain); +} + +class TabFtsCompanion extends UpdateCompanion { + final Value title; + final Value url; + final Value extractedContentPlain; + final Value fullContentPlain; + final Value rowid; + const TabFtsCompanion({ + this.title = const Value.absent(), + this.url = const Value.absent(), + this.extractedContentPlain = const Value.absent(), + this.fullContentPlain = const Value.absent(), + this.rowid = const Value.absent(), + }); + TabFtsCompanion.insert({ + this.title = const Value.absent(), + this.url = const Value.absent(), + this.extractedContentPlain = const Value.absent(), + this.fullContentPlain = const Value.absent(), + this.rowid = const Value.absent(), + }); + static Insertable custom({ + Expression? title, + Expression? url, + Expression? extractedContentPlain, + Expression? fullContentPlain, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (title != null) 'title': title, + if (url != null) 'url': url, + if (extractedContentPlain != null) + 'extracted_content_plain': extractedContentPlain, + if (fullContentPlain != null) 'full_content_plain': fullContentPlain, + if (rowid != null) 'rowid': rowid, + }); + } + + TabFtsCompanion copyWith({ + Value? title, + Value? url, + Value? extractedContentPlain, + Value? fullContentPlain, + Value? rowid, + }) { + return TabFtsCompanion( + title: title ?? this.title, + url: url ?? this.url, + extractedContentPlain: + extractedContentPlain ?? this.extractedContentPlain, + fullContentPlain: fullContentPlain ?? this.fullContentPlain, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (title.present) { + map['title'] = Variable(title.value); + } + if (url.present) { + map['url'] = Variable(url.value); + } + if (extractedContentPlain.present) { + map['extracted_content_plain'] = Variable( + extractedContentPlain.value, + ); + } + if (fullContentPlain.present) { + map['full_content_plain'] = Variable(fullContentPlain.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('TabFtsCompanion(') + ..write('title: $title, ') + ..write('url: $url, ') + ..write('extractedContentPlain: $extractedContentPlain, ') + ..write('fullContentPlain: $fullContentPlain, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class LocalIndexSetting extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + LocalIndexSetting(this.attachedDatabase, [this._alias]); + late final GeneratedColumn key = GeneratedColumn( + 'key', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'PRIMARY KEY NOT NULL', + ); + late final GeneratedColumn value = GeneratedColumn( + 'value', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + @override + List get $columns => [key, value]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'local_index_setting'; + @override + Set get $primaryKey => {key}; + @override + LocalIndexSettingData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return LocalIndexSettingData( + key: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}key'], + )!, + value: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}value'], + )!, + ); + } + + @override + LocalIndexSetting createAlias(String alias) { + return LocalIndexSetting(attachedDatabase, alias); + } + + @override + bool get isStrict => true; + @override + bool get dontWriteConstraints => true; +} + +class LocalIndexSettingData extends DataClass + implements Insertable { + final String key; + final int value; + const LocalIndexSettingData({required this.key, required this.value}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['key'] = Variable(key); + map['value'] = Variable(value); + return map; + } + + factory LocalIndexSettingData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return LocalIndexSettingData( + key: serializer.fromJson(json['key']), + value: serializer.fromJson(json['value']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'key': serializer.toJson(key), + 'value': serializer.toJson(value), + }; + } + + LocalIndexSettingData copyWith({String? key, int? value}) => + LocalIndexSettingData(key: key ?? this.key, value: value ?? this.value); + LocalIndexSettingData copyWithCompanion(LocalIndexSettingCompanion data) { + return LocalIndexSettingData( + key: data.key.present ? data.key.value : this.key, + value: data.value.present ? data.value.value : this.value, + ); + } + + @override + String toString() { + return (StringBuffer('LocalIndexSettingData(') + ..write('key: $key, ') + ..write('value: $value') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(key, value); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is LocalIndexSettingData && + other.key == this.key && + other.value == this.value); +} + +class LocalIndexSettingCompanion + extends UpdateCompanion { + final Value key; + final Value value; + final Value rowid; + const LocalIndexSettingCompanion({ + this.key = const Value.absent(), + this.value = const Value.absent(), + this.rowid = const Value.absent(), + }); + LocalIndexSettingCompanion.insert({ + required String key, + required int value, + this.rowid = const Value.absent(), + }) : key = Value(key), + value = Value(value); + static Insertable custom({ + Expression? key, + Expression? value, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (key != null) 'key': key, + if (value != null) 'value': value, + if (rowid != null) 'rowid': rowid, + }); + } + + LocalIndexSettingCompanion copyWith({ + Value? key, + Value? value, + Value? rowid, + }) { + return LocalIndexSettingCompanion( + key: key ?? this.key, + value: value ?? this.value, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (key.present) { + map['key'] = Variable(key.value); + } + if (value.present) { + map['value'] = Variable(value.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('LocalIndexSettingCompanion(') + ..write('key: $key, ') + ..write('value: $value, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class History extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + History(this.attachedDatabase, [this._alias]); + late final GeneratedColumn urlCanonical = GeneratedColumn( + 'url_canonical', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'PRIMARY KEY NOT NULL', + ); + late final GeneratedColumn urlHost = GeneratedColumn( + 'url_host', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn urlPath = GeneratedColumn( + 'url_path', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn title = GeneratedColumn( + 'title', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn isProbablyReaderable = GeneratedColumn( + 'is_probably_readerable', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn extractedContentMarkdown = + GeneratedColumn( + 'extracted_content_markdown', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn extractedContentPlain = + GeneratedColumn( + 'extracted_content_plain', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn fullContentMarkdown = + GeneratedColumn( + 'full_content_markdown', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn fullContentPlain = GeneratedColumn( + 'full_content_plain', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn contentHash = GeneratedColumn( + 'content_hash', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn observedAt = GeneratedColumn( + 'observed_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn observedCount = GeneratedColumn( + 'observed_count', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const CustomExpression('1'), + ); + @override + List get $columns => [ + urlCanonical, + urlHost, + urlPath, + title, + isProbablyReaderable, + extractedContentMarkdown, + extractedContentPlain, + fullContentMarkdown, + fullContentPlain, + contentHash, + observedAt, + observedCount, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'history'; + @override + Set get $primaryKey => {urlCanonical}; + @override + HistoryData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return HistoryData( + urlCanonical: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}url_canonical'], + )!, + urlHost: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}url_host'], + )!, + urlPath: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}url_path'], + ), + title: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}title'], + ), + isProbablyReaderable: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}is_probably_readerable'], + ), + extractedContentMarkdown: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}extracted_content_markdown'], + ), + extractedContentPlain: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}extracted_content_plain'], + ), + fullContentMarkdown: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}full_content_markdown'], + ), + fullContentPlain: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}full_content_plain'], + ), + contentHash: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}content_hash'], + ), + observedAt: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}observed_at'], + )!, + observedCount: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}observed_count'], + )!, + ); + } + + @override + History createAlias(String alias) { + return History(attachedDatabase, alias); + } + + @override + bool get dontWriteConstraints => true; +} + +class HistoryData extends DataClass implements Insertable { + final String urlCanonical; + final String urlHost; + final String? urlPath; + final String? title; + final int? isProbablyReaderable; + final String? extractedContentMarkdown; + final String? extractedContentPlain; + final String? fullContentMarkdown; + final String? fullContentPlain; + final int? contentHash; + final int observedAt; + final int observedCount; + const HistoryData({ + required this.urlCanonical, + required this.urlHost, + this.urlPath, + this.title, + this.isProbablyReaderable, + this.extractedContentMarkdown, + this.extractedContentPlain, + this.fullContentMarkdown, + this.fullContentPlain, + this.contentHash, + required this.observedAt, + required this.observedCount, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['url_canonical'] = Variable(urlCanonical); + map['url_host'] = Variable(urlHost); + if (!nullToAbsent || urlPath != null) { + map['url_path'] = Variable(urlPath); + } + if (!nullToAbsent || title != null) { + map['title'] = Variable(title); + } + if (!nullToAbsent || isProbablyReaderable != null) { + map['is_probably_readerable'] = Variable(isProbablyReaderable); + } + if (!nullToAbsent || extractedContentMarkdown != null) { + map['extracted_content_markdown'] = Variable( + extractedContentMarkdown, + ); + } + if (!nullToAbsent || extractedContentPlain != null) { + map['extracted_content_plain'] = Variable(extractedContentPlain); + } + if (!nullToAbsent || fullContentMarkdown != null) { + map['full_content_markdown'] = Variable(fullContentMarkdown); + } + if (!nullToAbsent || fullContentPlain != null) { + map['full_content_plain'] = Variable(fullContentPlain); + } + if (!nullToAbsent || contentHash != null) { + map['content_hash'] = Variable(contentHash); + } + map['observed_at'] = Variable(observedAt); + map['observed_count'] = Variable(observedCount); + return map; + } + + factory HistoryData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return HistoryData( + urlCanonical: serializer.fromJson(json['urlCanonical']), + urlHost: serializer.fromJson(json['urlHost']), + urlPath: serializer.fromJson(json['urlPath']), + title: serializer.fromJson(json['title']), + isProbablyReaderable: serializer.fromJson( + json['isProbablyReaderable'], + ), + extractedContentMarkdown: serializer.fromJson( + json['extractedContentMarkdown'], + ), + extractedContentPlain: serializer.fromJson( + json['extractedContentPlain'], + ), + fullContentMarkdown: serializer.fromJson( + json['fullContentMarkdown'], + ), + fullContentPlain: serializer.fromJson(json['fullContentPlain']), + contentHash: serializer.fromJson(json['contentHash']), + observedAt: serializer.fromJson(json['observedAt']), + observedCount: serializer.fromJson(json['observedCount']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'urlCanonical': serializer.toJson(urlCanonical), + 'urlHost': serializer.toJson(urlHost), + 'urlPath': serializer.toJson(urlPath), + 'title': serializer.toJson(title), + 'isProbablyReaderable': serializer.toJson(isProbablyReaderable), + 'extractedContentMarkdown': serializer.toJson( + extractedContentMarkdown, + ), + 'extractedContentPlain': serializer.toJson( + extractedContentPlain, + ), + 'fullContentMarkdown': serializer.toJson(fullContentMarkdown), + 'fullContentPlain': serializer.toJson(fullContentPlain), + 'contentHash': serializer.toJson(contentHash), + 'observedAt': serializer.toJson(observedAt), + 'observedCount': serializer.toJson(observedCount), + }; + } + + HistoryData copyWith({ + String? urlCanonical, + String? urlHost, + Value urlPath = const Value.absent(), + Value title = const Value.absent(), + Value isProbablyReaderable = const Value.absent(), + Value extractedContentMarkdown = const Value.absent(), + Value extractedContentPlain = const Value.absent(), + Value fullContentMarkdown = const Value.absent(), + Value fullContentPlain = const Value.absent(), + Value contentHash = const Value.absent(), + int? observedAt, + int? observedCount, + }) => HistoryData( + urlCanonical: urlCanonical ?? this.urlCanonical, + urlHost: urlHost ?? this.urlHost, + urlPath: urlPath.present ? urlPath.value : this.urlPath, + title: title.present ? title.value : this.title, + isProbablyReaderable: isProbablyReaderable.present + ? isProbablyReaderable.value + : this.isProbablyReaderable, + extractedContentMarkdown: extractedContentMarkdown.present + ? extractedContentMarkdown.value + : this.extractedContentMarkdown, + extractedContentPlain: extractedContentPlain.present + ? extractedContentPlain.value + : this.extractedContentPlain, + fullContentMarkdown: fullContentMarkdown.present + ? fullContentMarkdown.value + : this.fullContentMarkdown, + fullContentPlain: fullContentPlain.present + ? fullContentPlain.value + : this.fullContentPlain, + contentHash: contentHash.present ? contentHash.value : this.contentHash, + observedAt: observedAt ?? this.observedAt, + observedCount: observedCount ?? this.observedCount, + ); + HistoryData copyWithCompanion(HistoryCompanion data) { + return HistoryData( + urlCanonical: data.urlCanonical.present + ? data.urlCanonical.value + : this.urlCanonical, + urlHost: data.urlHost.present ? data.urlHost.value : this.urlHost, + urlPath: data.urlPath.present ? data.urlPath.value : this.urlPath, + title: data.title.present ? data.title.value : this.title, + isProbablyReaderable: data.isProbablyReaderable.present + ? data.isProbablyReaderable.value + : this.isProbablyReaderable, + extractedContentMarkdown: data.extractedContentMarkdown.present + ? data.extractedContentMarkdown.value + : this.extractedContentMarkdown, + extractedContentPlain: data.extractedContentPlain.present + ? data.extractedContentPlain.value + : this.extractedContentPlain, + fullContentMarkdown: data.fullContentMarkdown.present + ? data.fullContentMarkdown.value + : this.fullContentMarkdown, + fullContentPlain: data.fullContentPlain.present + ? data.fullContentPlain.value + : this.fullContentPlain, + contentHash: data.contentHash.present + ? data.contentHash.value + : this.contentHash, + observedAt: data.observedAt.present + ? data.observedAt.value + : this.observedAt, + observedCount: data.observedCount.present + ? data.observedCount.value + : this.observedCount, + ); + } + + @override + String toString() { + return (StringBuffer('HistoryData(') + ..write('urlCanonical: $urlCanonical, ') + ..write('urlHost: $urlHost, ') + ..write('urlPath: $urlPath, ') + ..write('title: $title, ') + ..write('isProbablyReaderable: $isProbablyReaderable, ') + ..write('extractedContentMarkdown: $extractedContentMarkdown, ') + ..write('extractedContentPlain: $extractedContentPlain, ') + ..write('fullContentMarkdown: $fullContentMarkdown, ') + ..write('fullContentPlain: $fullContentPlain, ') + ..write('contentHash: $contentHash, ') + ..write('observedAt: $observedAt, ') + ..write('observedCount: $observedCount') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + urlCanonical, + urlHost, + urlPath, + title, + isProbablyReaderable, + extractedContentMarkdown, + extractedContentPlain, + fullContentMarkdown, + fullContentPlain, + contentHash, + observedAt, + observedCount, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is HistoryData && + other.urlCanonical == this.urlCanonical && + other.urlHost == this.urlHost && + other.urlPath == this.urlPath && + other.title == this.title && + other.isProbablyReaderable == this.isProbablyReaderable && + other.extractedContentMarkdown == this.extractedContentMarkdown && + other.extractedContentPlain == this.extractedContentPlain && + other.fullContentMarkdown == this.fullContentMarkdown && + other.fullContentPlain == this.fullContentPlain && + other.contentHash == this.contentHash && + other.observedAt == this.observedAt && + other.observedCount == this.observedCount); +} + +class HistoryCompanion extends UpdateCompanion { + final Value urlCanonical; + final Value urlHost; + final Value urlPath; + final Value title; + final Value isProbablyReaderable; + final Value extractedContentMarkdown; + final Value extractedContentPlain; + final Value fullContentMarkdown; + final Value fullContentPlain; + final Value contentHash; + final Value observedAt; + final Value observedCount; + final Value rowid; + const HistoryCompanion({ + this.urlCanonical = const Value.absent(), + this.urlHost = const Value.absent(), + this.urlPath = const Value.absent(), + this.title = const Value.absent(), + this.isProbablyReaderable = const Value.absent(), + this.extractedContentMarkdown = const Value.absent(), + this.extractedContentPlain = const Value.absent(), + this.fullContentMarkdown = const Value.absent(), + this.fullContentPlain = const Value.absent(), + this.contentHash = const Value.absent(), + this.observedAt = const Value.absent(), + this.observedCount = const Value.absent(), + this.rowid = const Value.absent(), + }); + HistoryCompanion.insert({ + required String urlCanonical, + required String urlHost, + this.urlPath = const Value.absent(), + this.title = const Value.absent(), + this.isProbablyReaderable = const Value.absent(), + this.extractedContentMarkdown = const Value.absent(), + this.extractedContentPlain = const Value.absent(), + this.fullContentMarkdown = const Value.absent(), + this.fullContentPlain = const Value.absent(), + this.contentHash = const Value.absent(), + required int observedAt, + this.observedCount = const Value.absent(), + this.rowid = const Value.absent(), + }) : urlCanonical = Value(urlCanonical), + urlHost = Value(urlHost), + observedAt = Value(observedAt); + static Insertable custom({ + Expression? urlCanonical, + Expression? urlHost, + Expression? urlPath, + Expression? title, + Expression? isProbablyReaderable, + Expression? extractedContentMarkdown, + Expression? extractedContentPlain, + Expression? fullContentMarkdown, + Expression? fullContentPlain, + Expression? contentHash, + Expression? observedAt, + Expression? observedCount, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (urlCanonical != null) 'url_canonical': urlCanonical, + if (urlHost != null) 'url_host': urlHost, + if (urlPath != null) 'url_path': urlPath, + if (title != null) 'title': title, + if (isProbablyReaderable != null) + 'is_probably_readerable': isProbablyReaderable, + if (extractedContentMarkdown != null) + 'extracted_content_markdown': extractedContentMarkdown, + if (extractedContentPlain != null) + 'extracted_content_plain': extractedContentPlain, + if (fullContentMarkdown != null) + 'full_content_markdown': fullContentMarkdown, + if (fullContentPlain != null) 'full_content_plain': fullContentPlain, + if (contentHash != null) 'content_hash': contentHash, + if (observedAt != null) 'observed_at': observedAt, + if (observedCount != null) 'observed_count': observedCount, + if (rowid != null) 'rowid': rowid, + }); + } + + HistoryCompanion copyWith({ + Value? urlCanonical, + Value? urlHost, + Value? urlPath, + Value? title, + Value? isProbablyReaderable, + Value? extractedContentMarkdown, + Value? extractedContentPlain, + Value? fullContentMarkdown, + Value? fullContentPlain, + Value? contentHash, + Value? observedAt, + Value? observedCount, + Value? rowid, + }) { + return HistoryCompanion( + urlCanonical: urlCanonical ?? this.urlCanonical, + urlHost: urlHost ?? this.urlHost, + urlPath: urlPath ?? this.urlPath, + title: title ?? this.title, + isProbablyReaderable: isProbablyReaderable ?? this.isProbablyReaderable, + extractedContentMarkdown: + extractedContentMarkdown ?? this.extractedContentMarkdown, + extractedContentPlain: + extractedContentPlain ?? this.extractedContentPlain, + fullContentMarkdown: fullContentMarkdown ?? this.fullContentMarkdown, + fullContentPlain: fullContentPlain ?? this.fullContentPlain, + contentHash: contentHash ?? this.contentHash, + observedAt: observedAt ?? this.observedAt, + observedCount: observedCount ?? this.observedCount, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (urlCanonical.present) { + map['url_canonical'] = Variable(urlCanonical.value); + } + if (urlHost.present) { + map['url_host'] = Variable(urlHost.value); + } + if (urlPath.present) { + map['url_path'] = Variable(urlPath.value); + } + if (title.present) { + map['title'] = Variable(title.value); + } + if (isProbablyReaderable.present) { + map['is_probably_readerable'] = Variable(isProbablyReaderable.value); + } + if (extractedContentMarkdown.present) { + map['extracted_content_markdown'] = Variable( + extractedContentMarkdown.value, + ); + } + if (extractedContentPlain.present) { + map['extracted_content_plain'] = Variable( + extractedContentPlain.value, + ); + } + if (fullContentMarkdown.present) { + map['full_content_markdown'] = Variable( + fullContentMarkdown.value, + ); + } + if (fullContentPlain.present) { + map['full_content_plain'] = Variable(fullContentPlain.value); + } + if (contentHash.present) { + map['content_hash'] = Variable(contentHash.value); + } + if (observedAt.present) { + map['observed_at'] = Variable(observedAt.value); + } + if (observedCount.present) { + map['observed_count'] = Variable(observedCount.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('HistoryCompanion(') + ..write('urlCanonical: $urlCanonical, ') + ..write('urlHost: $urlHost, ') + ..write('urlPath: $urlPath, ') + ..write('title: $title, ') + ..write('isProbablyReaderable: $isProbablyReaderable, ') + ..write('extractedContentMarkdown: $extractedContentMarkdown, ') + ..write('extractedContentPlain: $extractedContentPlain, ') + ..write('fullContentMarkdown: $fullContentMarkdown, ') + ..write('fullContentPlain: $fullContentPlain, ') + ..write('contentHash: $contentHash, ') + ..write('observedAt: $observedAt, ') + ..write('observedCount: $observedCount, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class HistoryFts extends Table + with + TableInfo, + VirtualTableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + HistoryFts(this.attachedDatabase, [this._alias]); + late final GeneratedColumn title = GeneratedColumn( + 'title', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn urlHost = GeneratedColumn( + 'url_host', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn urlPath = GeneratedColumn( + 'url_path', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn extractedContentPlain = + GeneratedColumn( + 'extracted_content_plain', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn fullContentPlain = GeneratedColumn( + 'full_content_plain', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + @override + List get $columns => [ + title, + urlHost, + urlPath, + extractedContentPlain, + fullContentPlain, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'history_fts'; + @override + Set get $primaryKey => const {}; + @override + HistoryFtsData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return HistoryFtsData( + title: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}title'], + ), + urlHost: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}url_host'], + ), + urlPath: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}url_path'], + ), + extractedContentPlain: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}extracted_content_plain'], + ), + fullContentPlain: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}full_content_plain'], + ), + ); + } + + @override + HistoryFts createAlias(String alias) { + return HistoryFts(attachedDatabase, alias); + } + + @override + bool get dontWriteConstraints => true; + @override + String get moduleAndArgs => + 'fts5(title, url_host, url_path, extracted_content_plain, full_content_plain, content=history, tokenize="trigram")'; +} + +class HistoryFtsData extends DataClass implements Insertable { + final String? title; + final String? urlHost; + final String? urlPath; + final String? extractedContentPlain; + final String? fullContentPlain; + const HistoryFtsData({ + this.title, + this.urlHost, + this.urlPath, + this.extractedContentPlain, + this.fullContentPlain, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (!nullToAbsent || title != null) { + map['title'] = Variable(title); + } + if (!nullToAbsent || urlHost != null) { + map['url_host'] = Variable(urlHost); + } + if (!nullToAbsent || urlPath != null) { + map['url_path'] = Variable(urlPath); + } + if (!nullToAbsent || extractedContentPlain != null) { + map['extracted_content_plain'] = Variable(extractedContentPlain); + } + if (!nullToAbsent || fullContentPlain != null) { + map['full_content_plain'] = Variable(fullContentPlain); + } + return map; + } + + factory HistoryFtsData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return HistoryFtsData( + title: serializer.fromJson(json['title']), + urlHost: serializer.fromJson(json['urlHost']), + urlPath: serializer.fromJson(json['urlPath']), + extractedContentPlain: serializer.fromJson( + json['extractedContentPlain'], + ), + fullContentPlain: serializer.fromJson(json['fullContentPlain']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'title': serializer.toJson(title), + 'urlHost': serializer.toJson(urlHost), + 'urlPath': serializer.toJson(urlPath), + 'extractedContentPlain': serializer.toJson( + extractedContentPlain, + ), + 'fullContentPlain': serializer.toJson(fullContentPlain), + }; + } + + HistoryFtsData copyWith({ + Value title = const Value.absent(), + Value urlHost = const Value.absent(), + Value urlPath = const Value.absent(), + Value extractedContentPlain = const Value.absent(), + Value fullContentPlain = const Value.absent(), + }) => HistoryFtsData( + title: title.present ? title.value : this.title, + urlHost: urlHost.present ? urlHost.value : this.urlHost, + urlPath: urlPath.present ? urlPath.value : this.urlPath, + extractedContentPlain: extractedContentPlain.present + ? extractedContentPlain.value + : this.extractedContentPlain, + fullContentPlain: fullContentPlain.present + ? fullContentPlain.value + : this.fullContentPlain, + ); + HistoryFtsData copyWithCompanion(HistoryFtsCompanion data) { + return HistoryFtsData( + title: data.title.present ? data.title.value : this.title, + urlHost: data.urlHost.present ? data.urlHost.value : this.urlHost, + urlPath: data.urlPath.present ? data.urlPath.value : this.urlPath, + extractedContentPlain: data.extractedContentPlain.present + ? data.extractedContentPlain.value + : this.extractedContentPlain, + fullContentPlain: data.fullContentPlain.present + ? data.fullContentPlain.value + : this.fullContentPlain, + ); + } + + @override + String toString() { + return (StringBuffer('HistoryFtsData(') + ..write('title: $title, ') + ..write('urlHost: $urlHost, ') + ..write('urlPath: $urlPath, ') + ..write('extractedContentPlain: $extractedContentPlain, ') + ..write('fullContentPlain: $fullContentPlain') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + title, + urlHost, + urlPath, + extractedContentPlain, + fullContentPlain, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is HistoryFtsData && + other.title == this.title && + other.urlHost == this.urlHost && + other.urlPath == this.urlPath && + other.extractedContentPlain == this.extractedContentPlain && + other.fullContentPlain == this.fullContentPlain); +} + +class HistoryFtsCompanion extends UpdateCompanion { + final Value title; + final Value urlHost; + final Value urlPath; + final Value extractedContentPlain; + final Value fullContentPlain; + final Value rowid; + const HistoryFtsCompanion({ + this.title = const Value.absent(), + this.urlHost = const Value.absent(), + this.urlPath = const Value.absent(), + this.extractedContentPlain = const Value.absent(), + this.fullContentPlain = const Value.absent(), + this.rowid = const Value.absent(), + }); + HistoryFtsCompanion.insert({ + this.title = const Value.absent(), + this.urlHost = const Value.absent(), + this.urlPath = const Value.absent(), + this.extractedContentPlain = const Value.absent(), + this.fullContentPlain = const Value.absent(), + this.rowid = const Value.absent(), + }); + static Insertable custom({ + Expression? title, + Expression? urlHost, + Expression? urlPath, + Expression? extractedContentPlain, + Expression? fullContentPlain, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (title != null) 'title': title, + if (urlHost != null) 'url_host': urlHost, + if (urlPath != null) 'url_path': urlPath, + if (extractedContentPlain != null) + 'extracted_content_plain': extractedContentPlain, + if (fullContentPlain != null) 'full_content_plain': fullContentPlain, + if (rowid != null) 'rowid': rowid, + }); + } + + HistoryFtsCompanion copyWith({ + Value? title, + Value? urlHost, + Value? urlPath, + Value? extractedContentPlain, + Value? fullContentPlain, + Value? rowid, + }) { + return HistoryFtsCompanion( + title: title ?? this.title, + urlHost: urlHost ?? this.urlHost, + urlPath: urlPath ?? this.urlPath, + extractedContentPlain: + extractedContentPlain ?? this.extractedContentPlain, + fullContentPlain: fullContentPlain ?? this.fullContentPlain, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (title.present) { + map['title'] = Variable(title.value); + } + if (urlHost.present) { + map['url_host'] = Variable(urlHost.value); + } + if (urlPath.present) { + map['url_path'] = Variable(urlPath.value); + } + if (extractedContentPlain.present) { + map['extracted_content_plain'] = Variable( + extractedContentPlain.value, + ); + } + if (fullContentPlain.present) { + map['full_content_plain'] = Variable(fullContentPlain.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('HistoryFtsCompanion(') + ..write('title: $title, ') + ..write('urlHost: $urlHost, ') + ..write('urlPath: $urlPath, ') + ..write('extractedContentPlain: $extractedContentPlain, ') + ..write('fullContentPlain: $fullContentPlain, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class DatabaseAtV13 extends GeneratedDatabase { + DatabaseAtV13(QueryExecutor e) : super(e); + late final Container container = Container(this); + late final Tab tab = Tab(this); + late final ClosedTabTombstone closedTabTombstone = ClosedTabTombstone(this); + late final Index idxTabParentContainer = Index( + 'idx_tab_parent_container', + 'CREATE INDEX idx_tab_parent_container ON tab (parent_id, container_id)', + ); + late final CaptureTab captureTab = CaptureTab(this); + late final Index idxCaptureTabCaptureId = Index( + 'idx_capture_tab_capture_id', + 'CREATE INDEX idx_capture_tab_capture_id ON capture_tab (capture_id)', + ); + late final TabFts tabFts = TabFts(this); + late final Trigger tabMaintainParentChainOnDelete = Trigger( + 'CREATE TRIGGER tab_maintain_parent_chain_on_delete BEFORE DELETE ON tab BEGIN UPDATE tab SET parent_id = CASE WHEN OLD.parent_id IS NOT NULL AND EXISTS (SELECT 1 FROM tab WHERE id = OLD.parent_id) THEN OLD.parent_id ELSE NULL END WHERE parent_id = OLD.id;END', + 'tab_maintain_parent_chain_on_delete', + ); + late final Trigger tabAfterInsert = Trigger( + 'CREATE TRIGGER tab_after_insert AFTER INSERT ON tab BEGIN INSERT INTO tab_fts ("rowid", title, url, extracted_content_plain, full_content_plain) VALUES (new."rowid", new.title, new.url, new.extracted_content_plain, new.full_content_plain);END', + 'tab_after_insert', + ); + late final Trigger tabAfterDelete = Trigger( + 'CREATE TRIGGER tab_after_delete AFTER DELETE ON tab BEGIN INSERT INTO tab_fts (tab_fts, "rowid", title, url, extracted_content_plain, full_content_plain) VALUES (\'delete\', old."rowid", old.title, old.url, old.extracted_content_plain, old.full_content_plain);END', + 'tab_after_delete', + ); + late final Trigger tabAfterUpdate = Trigger( + 'CREATE TRIGGER tab_after_update AFTER UPDATE OF title, url, extracted_content_plain, full_content_plain ON tab WHEN OLD.content_hash IS NOT NEW.content_hash OR OLD.url IS NOT NEW.url BEGIN INSERT INTO tab_fts (tab_fts, "rowid", title, url, extracted_content_plain, full_content_plain) VALUES (\'delete\', old."rowid", old.title, old.url, old.extracted_content_plain, old.full_content_plain);INSERT INTO tab_fts ("rowid", title, url, extracted_content_plain, full_content_plain) VALUES (new."rowid", new.title, new.url, new.extracted_content_plain, new.full_content_plain);END', + 'tab_after_update', + ); + late final LocalIndexSetting localIndexSetting = LocalIndexSetting(this); + late final History history = History(this); + late final Index idxHistoryHost = Index( + 'idx_history_host', + 'CREATE INDEX idx_history_host ON history (url_host)', + ); + late final Index idxHistoryObserved = Index( + 'idx_history_observed', + 'CREATE INDEX idx_history_observed ON history (observed_at DESC)', + ); + late final HistoryFts historyFts = HistoryFts(this); + late final Trigger historyAfterInsert = Trigger( + 'CREATE TRIGGER history_after_insert AFTER INSERT ON history BEGIN INSERT INTO history_fts ("rowid", title, url_host, url_path, extracted_content_plain, full_content_plain) VALUES (new."rowid", new.title, new.url_host, new.url_path, new.extracted_content_plain, new.full_content_plain);END', + 'history_after_insert', + ); + late final Trigger historyAfterDelete = Trigger( + 'CREATE TRIGGER history_after_delete AFTER DELETE ON history BEGIN INSERT INTO history_fts (history_fts, "rowid", title, url_host, url_path, extracted_content_plain, full_content_plain) VALUES (\'delete\', old."rowid", old.title, old.url_host, old.url_path, old.extracted_content_plain, old.full_content_plain);END', + 'history_after_delete', + ); + late final Trigger historyAfterUpdate = Trigger( + 'CREATE TRIGGER history_after_update AFTER UPDATE OF title, url_host, url_path, extracted_content_plain, full_content_plain ON history BEGIN INSERT INTO history_fts (history_fts, "rowid", title, url_host, url_path, extracted_content_plain, full_content_plain) VALUES (\'delete\', old."rowid", old.title, old.url_host, old.url_path, old.extracted_content_plain, old.full_content_plain);INSERT INTO history_fts ("rowid", title, url_host, url_path, extracted_content_plain, full_content_plain) VALUES (new."rowid", new.title, new.url_host, new.url_path, new.extracted_content_plain, new.full_content_plain);END', + 'history_after_update', + ); + late final Trigger tabToHistoryOnInsert = Trigger( + 'CREATE TRIGGER tab_to_history_on_insert AFTER INSERT ON tab WHEN NEW.url IS NOT NULL AND url_indexable(CAST(NEW.url AS TEXT)) = 1 AND (SELECT value FROM local_index_setting WHERE "key" = \'enabled\') = 1 AND(NEW.tab_mode != 1 OR (SELECT value FROM local_index_setting WHERE "key" = \'index_private\') = 1)BEGIN INSERT INTO history (url_canonical, url_host, url_path, title, is_probably_readerable, extracted_content_markdown, extracted_content_plain, full_content_markdown, full_content_plain, content_hash, observed_at, observed_count) VALUES (url_canonical(CAST(NEW.url AS TEXT)), url_host(CAST(NEW.url AS TEXT)), url_path(CAST(NEW.url AS TEXT)), NEW.title, NEW.is_probably_readerable, NEW.extracted_content_markdown, NEW.extracted_content_plain, NEW.full_content_markdown, NEW.full_content_plain, NEW.content_hash, strftime(\'%s\', \'now\') * 1000, 1) ON CONFLICT (url_canonical) DO UPDATE SET title = COALESCE(excluded.title, history.title), is_probably_readerable = excluded.is_probably_readerable, extracted_content_markdown = excluded.extracted_content_markdown, extracted_content_plain = excluded.extracted_content_plain, full_content_markdown = excluded.full_content_markdown, full_content_plain = excluded.full_content_plain, content_hash = excluded.content_hash, observed_at = excluded.observed_at, observed_count = history.observed_count + 1 WHERE history.content_hash IS NOT excluded.content_hash;END', + 'tab_to_history_on_insert', + ); + late final Trigger tabToHistoryOnUpdate = Trigger( + 'CREATE TRIGGER tab_to_history_on_update AFTER UPDATE OF title, url, extracted_content_plain, extracted_content_markdown, full_content_plain, full_content_markdown, is_probably_readerable ON tab WHEN NEW.url IS NOT NULL AND url_indexable(CAST(NEW.url AS TEXT)) = 1 AND(OLD.content_hash IS NOT NEW.content_hash OR OLD.url IS NOT NEW.url)AND (SELECT value FROM local_index_setting WHERE "key" = \'enabled\') = 1 AND(NEW.tab_mode != 1 OR (SELECT value FROM local_index_setting WHERE "key" = \'index_private\') = 1)BEGIN INSERT INTO history (url_canonical, url_host, url_path, title, is_probably_readerable, extracted_content_markdown, extracted_content_plain, full_content_markdown, full_content_plain, content_hash, observed_at, observed_count) VALUES (url_canonical(CAST(NEW.url AS TEXT)), url_host(CAST(NEW.url AS TEXT)), url_path(CAST(NEW.url AS TEXT)), NEW.title, NEW.is_probably_readerable, NEW.extracted_content_markdown, NEW.extracted_content_plain, NEW.full_content_markdown, NEW.full_content_plain, NEW.content_hash, strftime(\'%s\', \'now\') * 1000, 1) ON CONFLICT (url_canonical) DO UPDATE SET title = COALESCE(excluded.title, history.title), is_probably_readerable = excluded.is_probably_readerable, extracted_content_markdown = excluded.extracted_content_markdown, extracted_content_plain = excluded.extracted_content_plain, full_content_markdown = excluded.full_content_markdown, full_content_plain = excluded.full_content_plain, content_hash = excluded.content_hash, observed_at = excluded.observed_at, observed_count = history.observed_count + 1 WHERE history.content_hash IS NOT excluded.content_hash;END', + 'tab_to_history_on_update', + ); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [ + container, + tab, + closedTabTombstone, + idxTabParentContainer, + captureTab, + idxCaptureTabCaptureId, + tabFts, + tabMaintainParentChainOnDelete, + tabAfterInsert, + tabAfterDelete, + tabAfterUpdate, + localIndexSetting, + history, + idxHistoryHost, + idxHistoryObserved, + historyFts, + historyAfterInsert, + historyAfterDelete, + historyAfterUpdate, + tabToHistoryOnInsert, + tabToHistoryOnUpdate, + ]; + @override + StreamQueryUpdateRules get streamUpdateRules => const StreamQueryUpdateRules([ + WritePropagation( + on: TableUpdateQuery.onTableName( + 'container', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('tab', kind: UpdateKind.delete)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'tab', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('capture_tab', kind: UpdateKind.delete)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'tab', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('tab', kind: UpdateKind.update)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'tab', + limitUpdateKind: UpdateKind.insert, + ), + result: [TableUpdate('tab_fts', kind: UpdateKind.insert)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'tab', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('tab_fts', kind: UpdateKind.insert)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'tab', + limitUpdateKind: UpdateKind.update, + ), + result: [TableUpdate('tab_fts', kind: UpdateKind.insert)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'history', + limitUpdateKind: UpdateKind.insert, + ), + result: [TableUpdate('history_fts', kind: UpdateKind.insert)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'history', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('history_fts', kind: UpdateKind.insert)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'history', + limitUpdateKind: UpdateKind.update, + ), + result: [TableUpdate('history_fts', kind: UpdateKind.insert)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'tab', + limitUpdateKind: UpdateKind.insert, + ), + result: [TableUpdate('history', kind: UpdateKind.insert)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'tab', + limitUpdateKind: UpdateKind.update, + ), + result: [TableUpdate('history', kind: UpdateKind.insert)], + ), + ]); + @override + int get schemaVersion => 13; +} diff --git a/apps/weblibre/test/drift/user/generated/schema.dart b/apps/weblibre/test/drift/user/generated/schema.dart index f7feae41..9dccfb29 100644 --- a/apps/weblibre/test/drift/user/generated/schema.dart +++ b/apps/weblibre/test/drift/user/generated/schema.dart @@ -7,6 +7,8 @@ import 'package:drift/internal/migrations.dart'; import 'schema_v1.dart' as v1; import 'schema_v2.dart' as v2; import 'schema_v3.dart' as v3; +import 'schema_v4.dart' as v4; +import 'schema_v5.dart' as v5; class GeneratedHelper implements SchemaInstantiationHelper { @override @@ -18,10 +20,14 @@ class GeneratedHelper implements SchemaInstantiationHelper { return v2.DatabaseAtV2(db); case 3: return v3.DatabaseAtV3(db); + case 4: + return v4.DatabaseAtV4(db); + case 5: + return v5.DatabaseAtV5(db); default: throw MissingSchemaException(version, versions); } } - static const versions = const [1, 2, 3]; + static const versions = const [1, 2, 3, 4, 5]; } diff --git a/apps/weblibre/test/drift/user/generated/schema_v4.dart b/apps/weblibre/test/drift/user/generated/schema_v4.dart new file mode 100644 index 00000000..64658f97 --- /dev/null +++ b/apps/weblibre/test/drift/user/generated/schema_v4.dart @@ -0,0 +1,1449 @@ +// dart format width=80 +import 'dart:typed_data' as i2; +// GENERATED BY drift_dev, DO NOT MODIFY. +// ignore_for_file: type=lint,unused_import +// +import 'package:drift/drift.dart'; + +class Setting extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Setting(this.attachedDatabase, [this._alias]); + late final GeneratedColumn key = GeneratedColumn( + 'key', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'PRIMARY KEY NOT NULL', + ); + late final GeneratedColumn partitionKey = GeneratedColumn( + 'partition_key', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn value = GeneratedColumn( + 'value', + aliasedName, + true, + type: DriftSqlType.any, + requiredDuringInsert: false, + $customConstraints: '', + ); + @override + List get $columns => [key, partitionKey, value]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'setting'; + @override + Set get $primaryKey => {key}; + @override + SettingData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return SettingData( + key: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}key'], + )!, + partitionKey: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}partition_key'], + ), + value: attachedDatabase.typeMapping.read( + DriftSqlType.any, + data['${effectivePrefix}value'], + ), + ); + } + + @override + Setting createAlias(String alias) { + return Setting(attachedDatabase, alias); + } + + @override + bool get isStrict => true; + @override + bool get dontWriteConstraints => true; +} + +class SettingData extends DataClass implements Insertable { + final String key; + final String? partitionKey; + final DriftAny? value; + const SettingData({required this.key, this.partitionKey, this.value}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['key'] = Variable(key); + if (!nullToAbsent || partitionKey != null) { + map['partition_key'] = Variable(partitionKey); + } + if (!nullToAbsent || value != null) { + map['value'] = Variable(value); + } + return map; + } + + factory SettingData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return SettingData( + key: serializer.fromJson(json['key']), + partitionKey: serializer.fromJson(json['partitionKey']), + value: serializer.fromJson(json['value']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'key': serializer.toJson(key), + 'partitionKey': serializer.toJson(partitionKey), + 'value': serializer.toJson(value), + }; + } + + SettingData copyWith({ + String? key, + Value partitionKey = const Value.absent(), + Value value = const Value.absent(), + }) => SettingData( + key: key ?? this.key, + partitionKey: partitionKey.present ? partitionKey.value : this.partitionKey, + value: value.present ? value.value : this.value, + ); + SettingData copyWithCompanion(SettingCompanion data) { + return SettingData( + key: data.key.present ? data.key.value : this.key, + partitionKey: data.partitionKey.present + ? data.partitionKey.value + : this.partitionKey, + value: data.value.present ? data.value.value : this.value, + ); + } + + @override + String toString() { + return (StringBuffer('SettingData(') + ..write('key: $key, ') + ..write('partitionKey: $partitionKey, ') + ..write('value: $value') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(key, partitionKey, value); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is SettingData && + other.key == this.key && + other.partitionKey == this.partitionKey && + other.value == this.value); +} + +class SettingCompanion extends UpdateCompanion { + final Value key; + final Value partitionKey; + final Value value; + final Value rowid; + const SettingCompanion({ + this.key = const Value.absent(), + this.partitionKey = const Value.absent(), + this.value = const Value.absent(), + this.rowid = const Value.absent(), + }); + SettingCompanion.insert({ + required String key, + this.partitionKey = const Value.absent(), + this.value = const Value.absent(), + this.rowid = const Value.absent(), + }) : key = Value(key); + static Insertable custom({ + Expression? key, + Expression? partitionKey, + Expression? value, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (key != null) 'key': key, + if (partitionKey != null) 'partition_key': partitionKey, + if (value != null) 'value': value, + if (rowid != null) 'rowid': rowid, + }); + } + + SettingCompanion copyWith({ + Value? key, + Value? partitionKey, + Value? value, + Value? rowid, + }) { + return SettingCompanion( + key: key ?? this.key, + partitionKey: partitionKey ?? this.partitionKey, + value: value ?? this.value, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (key.present) { + map['key'] = Variable(key.value); + } + if (partitionKey.present) { + map['partition_key'] = Variable(partitionKey.value); + } + if (value.present) { + map['value'] = Variable(value.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('SettingCompanion(') + ..write('key: $key, ') + ..write('partitionKey: $partitionKey, ') + ..write('value: $value, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class IconCache extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + IconCache(this.attachedDatabase, [this._alias]); + late final GeneratedColumn origin = GeneratedColumn( + 'origin', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'PRIMARY KEY NOT NULL', + ); + late final GeneratedColumn iconData = + GeneratedColumn( + 'icon_data', + aliasedName, + false, + type: DriftSqlType.blob, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn fetchDate = GeneratedColumn( + 'fetch_date', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + @override + List get $columns => [origin, iconData, fetchDate]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'icon_cache'; + @override + Set get $primaryKey => {origin}; + @override + IconCacheData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return IconCacheData( + origin: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}origin'], + )!, + iconData: attachedDatabase.typeMapping.read( + DriftSqlType.blob, + data['${effectivePrefix}icon_data'], + )!, + fetchDate: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}fetch_date'], + )!, + ); + } + + @override + IconCache createAlias(String alias) { + return IconCache(attachedDatabase, alias); + } + + @override + bool get dontWriteConstraints => true; +} + +class IconCacheData extends DataClass implements Insertable { + final String origin; + final i2.Uint8List iconData; + final int fetchDate; + const IconCacheData({ + required this.origin, + required this.iconData, + required this.fetchDate, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['origin'] = Variable(origin); + map['icon_data'] = Variable(iconData); + map['fetch_date'] = Variable(fetchDate); + return map; + } + + factory IconCacheData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return IconCacheData( + origin: serializer.fromJson(json['origin']), + iconData: serializer.fromJson(json['iconData']), + fetchDate: serializer.fromJson(json['fetchDate']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'origin': serializer.toJson(origin), + 'iconData': serializer.toJson(iconData), + 'fetchDate': serializer.toJson(fetchDate), + }; + } + + IconCacheData copyWith({ + String? origin, + i2.Uint8List? iconData, + int? fetchDate, + }) => IconCacheData( + origin: origin ?? this.origin, + iconData: iconData ?? this.iconData, + fetchDate: fetchDate ?? this.fetchDate, + ); + IconCacheData copyWithCompanion(IconCacheCompanion data) { + return IconCacheData( + origin: data.origin.present ? data.origin.value : this.origin, + iconData: data.iconData.present ? data.iconData.value : this.iconData, + fetchDate: data.fetchDate.present ? data.fetchDate.value : this.fetchDate, + ); + } + + @override + String toString() { + return (StringBuffer('IconCacheData(') + ..write('origin: $origin, ') + ..write('iconData: $iconData, ') + ..write('fetchDate: $fetchDate') + ..write(')')) + .toString(); + } + + @override + int get hashCode => + Object.hash(origin, $driftBlobEquality.hash(iconData), fetchDate); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is IconCacheData && + other.origin == this.origin && + $driftBlobEquality.equals(other.iconData, this.iconData) && + other.fetchDate == this.fetchDate); +} + +class IconCacheCompanion extends UpdateCompanion { + final Value origin; + final Value iconData; + final Value fetchDate; + final Value rowid; + const IconCacheCompanion({ + this.origin = const Value.absent(), + this.iconData = const Value.absent(), + this.fetchDate = const Value.absent(), + this.rowid = const Value.absent(), + }); + IconCacheCompanion.insert({ + required String origin, + required i2.Uint8List iconData, + required int fetchDate, + this.rowid = const Value.absent(), + }) : origin = Value(origin), + iconData = Value(iconData), + fetchDate = Value(fetchDate); + static Insertable custom({ + Expression? origin, + Expression? iconData, + Expression? fetchDate, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (origin != null) 'origin': origin, + if (iconData != null) 'icon_data': iconData, + if (fetchDate != null) 'fetch_date': fetchDate, + if (rowid != null) 'rowid': rowid, + }); + } + + IconCacheCompanion copyWith({ + Value? origin, + Value? iconData, + Value? fetchDate, + Value? rowid, + }) { + return IconCacheCompanion( + origin: origin ?? this.origin, + iconData: iconData ?? this.iconData, + fetchDate: fetchDate ?? this.fetchDate, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (origin.present) { + map['origin'] = Variable(origin.value); + } + if (iconData.present) { + map['icon_data'] = Variable(iconData.value); + } + if (fetchDate.present) { + map['fetch_date'] = Variable(fetchDate.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('IconCacheCompanion(') + ..write('origin: $origin, ') + ..write('iconData: $iconData, ') + ..write('fetchDate: $fetchDate, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class Onboarding extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Onboarding(this.attachedDatabase, [this._alias]); + late final GeneratedColumn revision = GeneratedColumn( + 'revision', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn completionDate = GeneratedColumn( + 'completion_date', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + @override + List get $columns => [revision, completionDate]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'onboarding'; + @override + Set get $primaryKey => const {}; + @override + OnboardingData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return OnboardingData( + revision: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}revision'], + )!, + completionDate: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}completion_date'], + )!, + ); + } + + @override + Onboarding createAlias(String alias) { + return Onboarding(attachedDatabase, alias); + } + + @override + bool get dontWriteConstraints => true; +} + +class OnboardingData extends DataClass implements Insertable { + final int revision; + final int completionDate; + const OnboardingData({required this.revision, required this.completionDate}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['revision'] = Variable(revision); + map['completion_date'] = Variable(completionDate); + return map; + } + + factory OnboardingData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return OnboardingData( + revision: serializer.fromJson(json['revision']), + completionDate: serializer.fromJson(json['completionDate']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'revision': serializer.toJson(revision), + 'completionDate': serializer.toJson(completionDate), + }; + } + + OnboardingData copyWith({int? revision, int? completionDate}) => + OnboardingData( + revision: revision ?? this.revision, + completionDate: completionDate ?? this.completionDate, + ); + OnboardingData copyWithCompanion(OnboardingCompanion data) { + return OnboardingData( + revision: data.revision.present ? data.revision.value : this.revision, + completionDate: data.completionDate.present + ? data.completionDate.value + : this.completionDate, + ); + } + + @override + String toString() { + return (StringBuffer('OnboardingData(') + ..write('revision: $revision, ') + ..write('completionDate: $completionDate') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(revision, completionDate); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is OnboardingData && + other.revision == this.revision && + other.completionDate == this.completionDate); +} + +class OnboardingCompanion extends UpdateCompanion { + final Value revision; + final Value completionDate; + final Value rowid; + const OnboardingCompanion({ + this.revision = const Value.absent(), + this.completionDate = const Value.absent(), + this.rowid = const Value.absent(), + }); + OnboardingCompanion.insert({ + required int revision, + required int completionDate, + this.rowid = const Value.absent(), + }) : revision = Value(revision), + completionDate = Value(completionDate); + static Insertable custom({ + Expression? revision, + Expression? completionDate, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (revision != null) 'revision': revision, + if (completionDate != null) 'completion_date': completionDate, + if (rowid != null) 'rowid': rowid, + }); + } + + OnboardingCompanion copyWith({ + Value? revision, + Value? completionDate, + Value? rowid, + }) { + return OnboardingCompanion( + revision: revision ?? this.revision, + completionDate: completionDate ?? this.completionDate, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (revision.present) { + map['revision'] = Variable(revision.value); + } + if (completionDate.present) { + map['completion_date'] = Variable(completionDate.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('OnboardingCompanion(') + ..write('revision: $revision, ') + ..write('completionDate: $completionDate, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class Riverpod extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Riverpod(this.attachedDatabase, [this._alias]); + late final GeneratedColumn key = GeneratedColumn( + 'key', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'PRIMARY KEY NOT NULL', + ); + late final GeneratedColumn json = GeneratedColumn( + 'json', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn expireAt = GeneratedColumn( + 'expireAt', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn destroyKey = GeneratedColumn( + 'destroyKey', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + @override + List get $columns => [key, json, expireAt, destroyKey]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'riverpod'; + @override + Set get $primaryKey => {key}; + @override + RiverpodData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return RiverpodData( + key: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}key'], + )!, + json: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}json'], + )!, + expireAt: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}expireAt'], + ), + destroyKey: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}destroyKey'], + ), + ); + } + + @override + Riverpod createAlias(String alias) { + return Riverpod(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get dontWriteConstraints => true; +} + +class RiverpodData extends DataClass implements Insertable { + final String key; + final String json; + final int? expireAt; + final String? destroyKey; + const RiverpodData({ + required this.key, + required this.json, + this.expireAt, + this.destroyKey, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['key'] = Variable(key); + map['json'] = Variable(json); + if (!nullToAbsent || expireAt != null) { + map['expireAt'] = Variable(expireAt); + } + if (!nullToAbsent || destroyKey != null) { + map['destroyKey'] = Variable(destroyKey); + } + return map; + } + + factory RiverpodData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return RiverpodData( + key: serializer.fromJson(json['key']), + json: serializer.fromJson(json['json']), + expireAt: serializer.fromJson(json['expireAt']), + destroyKey: serializer.fromJson(json['destroyKey']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'key': serializer.toJson(key), + 'json': serializer.toJson(json), + 'expireAt': serializer.toJson(expireAt), + 'destroyKey': serializer.toJson(destroyKey), + }; + } + + RiverpodData copyWith({ + String? key, + String? json, + Value expireAt = const Value.absent(), + Value destroyKey = const Value.absent(), + }) => RiverpodData( + key: key ?? this.key, + json: json ?? this.json, + expireAt: expireAt.present ? expireAt.value : this.expireAt, + destroyKey: destroyKey.present ? destroyKey.value : this.destroyKey, + ); + RiverpodData copyWithCompanion(RiverpodCompanion data) { + return RiverpodData( + key: data.key.present ? data.key.value : this.key, + json: data.json.present ? data.json.value : this.json, + expireAt: data.expireAt.present ? data.expireAt.value : this.expireAt, + destroyKey: data.destroyKey.present + ? data.destroyKey.value + : this.destroyKey, + ); + } + + @override + String toString() { + return (StringBuffer('RiverpodData(') + ..write('key: $key, ') + ..write('json: $json, ') + ..write('expireAt: $expireAt, ') + ..write('destroyKey: $destroyKey') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(key, json, expireAt, destroyKey); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is RiverpodData && + other.key == this.key && + other.json == this.json && + other.expireAt == this.expireAt && + other.destroyKey == this.destroyKey); +} + +class RiverpodCompanion extends UpdateCompanion { + final Value key; + final Value json; + final Value expireAt; + final Value destroyKey; + const RiverpodCompanion({ + this.key = const Value.absent(), + this.json = const Value.absent(), + this.expireAt = const Value.absent(), + this.destroyKey = const Value.absent(), + }); + RiverpodCompanion.insert({ + required String key, + required String json, + this.expireAt = const Value.absent(), + this.destroyKey = const Value.absent(), + }) : key = Value(key), + json = Value(json); + static Insertable custom({ + Expression? key, + Expression? json, + Expression? expireAt, + Expression? destroyKey, + }) { + return RawValuesInsertable({ + if (key != null) 'key': key, + if (json != null) 'json': json, + if (expireAt != null) 'expireAt': expireAt, + if (destroyKey != null) 'destroyKey': destroyKey, + }); + } + + RiverpodCompanion copyWith({ + Value? key, + Value? json, + Value? expireAt, + Value? destroyKey, + }) { + return RiverpodCompanion( + key: key ?? this.key, + json: json ?? this.json, + expireAt: expireAt ?? this.expireAt, + destroyKey: destroyKey ?? this.destroyKey, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (key.present) { + map['key'] = Variable(key.value); + } + if (json.present) { + map['json'] = Variable(json.value); + } + if (expireAt.present) { + map['expireAt'] = Variable(expireAt.value); + } + if (destroyKey.present) { + map['destroyKey'] = Variable(destroyKey.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('RiverpodCompanion(') + ..write('key: $key, ') + ..write('json: $json, ') + ..write('expireAt: $expireAt, ') + ..write('destroyKey: $destroyKey') + ..write(')')) + .toString(); + } +} + +class ToolbarButtonConfigs extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + ToolbarButtonConfigs(this.attachedDatabase, [this._alias]); + late final GeneratedColumn buttonId = GeneratedColumn( + 'button_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL PRIMARY KEY', + ); + late final GeneratedColumn orderKey = GeneratedColumn( + 'order_key', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn isVisible = GeneratedColumn( + 'is_visible', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT TRUE', + defaultValue: const CustomExpression('TRUE'), + ); + late final GeneratedColumn fallbackId = GeneratedColumn( + 'fallback_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: + 'REFERENCES toolbar_button_configs(button_id)ON DELETE SET NULL', + ); + @override + List get $columns => [ + buttonId, + orderKey, + isVisible, + fallbackId, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'toolbar_button_configs'; + @override + Set get $primaryKey => {buttonId}; + @override + ToolbarButtonConfigsData map( + Map data, { + String? tablePrefix, + }) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return ToolbarButtonConfigsData( + buttonId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}button_id'], + )!, + orderKey: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}order_key'], + )!, + isVisible: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}is_visible'], + )!, + fallbackId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}fallback_id'], + ), + ); + } + + @override + ToolbarButtonConfigs createAlias(String alias) { + return ToolbarButtonConfigs(attachedDatabase, alias); + } + + @override + bool get dontWriteConstraints => true; +} + +class ToolbarButtonConfigsData extends DataClass + implements Insertable { + final String buttonId; + final String orderKey; + final int isVisible; + final String? fallbackId; + const ToolbarButtonConfigsData({ + required this.buttonId, + required this.orderKey, + required this.isVisible, + this.fallbackId, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['button_id'] = Variable(buttonId); + map['order_key'] = Variable(orderKey); + map['is_visible'] = Variable(isVisible); + if (!nullToAbsent || fallbackId != null) { + map['fallback_id'] = Variable(fallbackId); + } + return map; + } + + factory ToolbarButtonConfigsData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return ToolbarButtonConfigsData( + buttonId: serializer.fromJson(json['buttonId']), + orderKey: serializer.fromJson(json['orderKey']), + isVisible: serializer.fromJson(json['isVisible']), + fallbackId: serializer.fromJson(json['fallbackId']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'buttonId': serializer.toJson(buttonId), + 'orderKey': serializer.toJson(orderKey), + 'isVisible': serializer.toJson(isVisible), + 'fallbackId': serializer.toJson(fallbackId), + }; + } + + ToolbarButtonConfigsData copyWith({ + String? buttonId, + String? orderKey, + int? isVisible, + Value fallbackId = const Value.absent(), + }) => ToolbarButtonConfigsData( + buttonId: buttonId ?? this.buttonId, + orderKey: orderKey ?? this.orderKey, + isVisible: isVisible ?? this.isVisible, + fallbackId: fallbackId.present ? fallbackId.value : this.fallbackId, + ); + ToolbarButtonConfigsData copyWithCompanion( + ToolbarButtonConfigsCompanion data, + ) { + return ToolbarButtonConfigsData( + buttonId: data.buttonId.present ? data.buttonId.value : this.buttonId, + orderKey: data.orderKey.present ? data.orderKey.value : this.orderKey, + isVisible: data.isVisible.present ? data.isVisible.value : this.isVisible, + fallbackId: data.fallbackId.present + ? data.fallbackId.value + : this.fallbackId, + ); + } + + @override + String toString() { + return (StringBuffer('ToolbarButtonConfigsData(') + ..write('buttonId: $buttonId, ') + ..write('orderKey: $orderKey, ') + ..write('isVisible: $isVisible, ') + ..write('fallbackId: $fallbackId') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(buttonId, orderKey, isVisible, fallbackId); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ToolbarButtonConfigsData && + other.buttonId == this.buttonId && + other.orderKey == this.orderKey && + other.isVisible == this.isVisible && + other.fallbackId == this.fallbackId); +} + +class ToolbarButtonConfigsCompanion + extends UpdateCompanion { + final Value buttonId; + final Value orderKey; + final Value isVisible; + final Value fallbackId; + final Value rowid; + const ToolbarButtonConfigsCompanion({ + this.buttonId = const Value.absent(), + this.orderKey = const Value.absent(), + this.isVisible = const Value.absent(), + this.fallbackId = const Value.absent(), + this.rowid = const Value.absent(), + }); + ToolbarButtonConfigsCompanion.insert({ + required String buttonId, + required String orderKey, + this.isVisible = const Value.absent(), + this.fallbackId = const Value.absent(), + this.rowid = const Value.absent(), + }) : buttonId = Value(buttonId), + orderKey = Value(orderKey); + static Insertable custom({ + Expression? buttonId, + Expression? orderKey, + Expression? isVisible, + Expression? fallbackId, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (buttonId != null) 'button_id': buttonId, + if (orderKey != null) 'order_key': orderKey, + if (isVisible != null) 'is_visible': isVisible, + if (fallbackId != null) 'fallback_id': fallbackId, + if (rowid != null) 'rowid': rowid, + }); + } + + ToolbarButtonConfigsCompanion copyWith({ + Value? buttonId, + Value? orderKey, + Value? isVisible, + Value? fallbackId, + Value? rowid, + }) { + return ToolbarButtonConfigsCompanion( + buttonId: buttonId ?? this.buttonId, + orderKey: orderKey ?? this.orderKey, + isVisible: isVisible ?? this.isVisible, + fallbackId: fallbackId ?? this.fallbackId, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (buttonId.present) { + map['button_id'] = Variable(buttonId.value); + } + if (orderKey.present) { + map['order_key'] = Variable(orderKey.value); + } + if (isVisible.present) { + map['is_visible'] = Variable(isVisible.value); + } + if (fallbackId.present) { + map['fallback_id'] = Variable(fallbackId.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('ToolbarButtonConfigsCompanion(') + ..write('buttonId: $buttonId, ') + ..write('orderKey: $orderKey, ') + ..write('isVisible: $isVisible, ') + ..write('fallbackId: $fallbackId, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class SearchTokens extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + SearchTokens(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'PRIMARY KEY AUTOINCREMENT', + ); + late final GeneratedColumn token = + GeneratedColumn( + 'token', + aliasedName, + false, + type: DriftSqlType.blob, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn insertedAt = GeneratedColumn( + 'inserted_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn issuerKeyVersion = GeneratedColumn( + 'issuer_key_version', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + @override + List get $columns => [ + id, + token, + insertedAt, + issuerKeyVersion, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'search_tokens'; + @override + Set get $primaryKey => {id}; + @override + SearchTokensData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return SearchTokensData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + token: attachedDatabase.typeMapping.read( + DriftSqlType.blob, + data['${effectivePrefix}token'], + )!, + insertedAt: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}inserted_at'], + )!, + issuerKeyVersion: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}issuer_key_version'], + )!, + ); + } + + @override + SearchTokens createAlias(String alias) { + return SearchTokens(attachedDatabase, alias); + } + + @override + bool get dontWriteConstraints => true; +} + +class SearchTokensData extends DataClass + implements Insertable { + final int id; + final i2.Uint8List token; + final int insertedAt; + final String issuerKeyVersion; + const SearchTokensData({ + required this.id, + required this.token, + required this.insertedAt, + required this.issuerKeyVersion, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['token'] = Variable(token); + map['inserted_at'] = Variable(insertedAt); + map['issuer_key_version'] = Variable(issuerKeyVersion); + return map; + } + + factory SearchTokensData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return SearchTokensData( + id: serializer.fromJson(json['id']), + token: serializer.fromJson(json['token']), + insertedAt: serializer.fromJson(json['insertedAt']), + issuerKeyVersion: serializer.fromJson(json['issuerKeyVersion']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'token': serializer.toJson(token), + 'insertedAt': serializer.toJson(insertedAt), + 'issuerKeyVersion': serializer.toJson(issuerKeyVersion), + }; + } + + SearchTokensData copyWith({ + int? id, + i2.Uint8List? token, + int? insertedAt, + String? issuerKeyVersion, + }) => SearchTokensData( + id: id ?? this.id, + token: token ?? this.token, + insertedAt: insertedAt ?? this.insertedAt, + issuerKeyVersion: issuerKeyVersion ?? this.issuerKeyVersion, + ); + SearchTokensData copyWithCompanion(SearchTokensCompanion data) { + return SearchTokensData( + id: data.id.present ? data.id.value : this.id, + token: data.token.present ? data.token.value : this.token, + insertedAt: data.insertedAt.present + ? data.insertedAt.value + : this.insertedAt, + issuerKeyVersion: data.issuerKeyVersion.present + ? data.issuerKeyVersion.value + : this.issuerKeyVersion, + ); + } + + @override + String toString() { + return (StringBuffer('SearchTokensData(') + ..write('id: $id, ') + ..write('token: $token, ') + ..write('insertedAt: $insertedAt, ') + ..write('issuerKeyVersion: $issuerKeyVersion') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + $driftBlobEquality.hash(token), + insertedAt, + issuerKeyVersion, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is SearchTokensData && + other.id == this.id && + $driftBlobEquality.equals(other.token, this.token) && + other.insertedAt == this.insertedAt && + other.issuerKeyVersion == this.issuerKeyVersion); +} + +class SearchTokensCompanion extends UpdateCompanion { + final Value id; + final Value token; + final Value insertedAt; + final Value issuerKeyVersion; + const SearchTokensCompanion({ + this.id = const Value.absent(), + this.token = const Value.absent(), + this.insertedAt = const Value.absent(), + this.issuerKeyVersion = const Value.absent(), + }); + SearchTokensCompanion.insert({ + this.id = const Value.absent(), + required i2.Uint8List token, + required int insertedAt, + required String issuerKeyVersion, + }) : token = Value(token), + insertedAt = Value(insertedAt), + issuerKeyVersion = Value(issuerKeyVersion); + static Insertable custom({ + Expression? id, + Expression? token, + Expression? insertedAt, + Expression? issuerKeyVersion, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (token != null) 'token': token, + if (insertedAt != null) 'inserted_at': insertedAt, + if (issuerKeyVersion != null) 'issuer_key_version': issuerKeyVersion, + }); + } + + SearchTokensCompanion copyWith({ + Value? id, + Value? token, + Value? insertedAt, + Value? issuerKeyVersion, + }) { + return SearchTokensCompanion( + id: id ?? this.id, + token: token ?? this.token, + insertedAt: insertedAt ?? this.insertedAt, + issuerKeyVersion: issuerKeyVersion ?? this.issuerKeyVersion, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (token.present) { + map['token'] = Variable(token.value); + } + if (insertedAt.present) { + map['inserted_at'] = Variable(insertedAt.value); + } + if (issuerKeyVersion.present) { + map['issuer_key_version'] = Variable(issuerKeyVersion.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('SearchTokensCompanion(') + ..write('id: $id, ') + ..write('token: $token, ') + ..write('insertedAt: $insertedAt, ') + ..write('issuerKeyVersion: $issuerKeyVersion') + ..write(')')) + .toString(); + } +} + +class DatabaseAtV4 extends GeneratedDatabase { + DatabaseAtV4(QueryExecutor e) : super(e); + late final Setting setting = Setting(this); + late final IconCache iconCache = IconCache(this); + late final Onboarding onboarding = Onboarding(this); + late final Riverpod riverpod = Riverpod(this); + late final ToolbarButtonConfigs toolbarButtonConfigs = ToolbarButtonConfigs( + this, + ); + late final Index idxToolbarOrderKey = Index( + 'idx_toolbar_order_key', + 'CREATE INDEX idx_toolbar_order_key ON toolbar_button_configs (order_key)', + ); + late final SearchTokens searchTokens = SearchTokens(this); + late final Index idxSearchTokensInsertedAt = Index( + 'idx_search_tokens_inserted_at', + 'CREATE INDEX idx_search_tokens_inserted_at ON search_tokens (inserted_at)', + ); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [ + setting, + iconCache, + onboarding, + riverpod, + toolbarButtonConfigs, + idxToolbarOrderKey, + searchTokens, + idxSearchTokensInsertedAt, + ]; + @override + int get schemaVersion => 4; +} diff --git a/apps/weblibre/test/drift/user/generated/schema_v5.dart b/apps/weblibre/test/drift/user/generated/schema_v5.dart new file mode 100644 index 00000000..0464ca65 --- /dev/null +++ b/apps/weblibre/test/drift/user/generated/schema_v5.dart @@ -0,0 +1,1493 @@ +// dart format width=80 +import 'dart:typed_data' as i2; +// GENERATED BY drift_dev, DO NOT MODIFY. +// ignore_for_file: type=lint,unused_import +// +import 'package:drift/drift.dart'; + +class Setting extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Setting(this.attachedDatabase, [this._alias]); + late final GeneratedColumn key = GeneratedColumn( + 'key', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'PRIMARY KEY NOT NULL', + ); + late final GeneratedColumn partitionKey = GeneratedColumn( + 'partition_key', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn value = GeneratedColumn( + 'value', + aliasedName, + true, + type: DriftSqlType.any, + requiredDuringInsert: false, + $customConstraints: '', + ); + @override + List get $columns => [key, partitionKey, value]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'setting'; + @override + Set get $primaryKey => {key}; + @override + SettingData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return SettingData( + key: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}key'], + )!, + partitionKey: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}partition_key'], + ), + value: attachedDatabase.typeMapping.read( + DriftSqlType.any, + data['${effectivePrefix}value'], + ), + ); + } + + @override + Setting createAlias(String alias) { + return Setting(attachedDatabase, alias); + } + + @override + bool get isStrict => true; + @override + bool get dontWriteConstraints => true; +} + +class SettingData extends DataClass implements Insertable { + final String key; + final String? partitionKey; + final DriftAny? value; + const SettingData({required this.key, this.partitionKey, this.value}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['key'] = Variable(key); + if (!nullToAbsent || partitionKey != null) { + map['partition_key'] = Variable(partitionKey); + } + if (!nullToAbsent || value != null) { + map['value'] = Variable(value); + } + return map; + } + + factory SettingData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return SettingData( + key: serializer.fromJson(json['key']), + partitionKey: serializer.fromJson(json['partitionKey']), + value: serializer.fromJson(json['value']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'key': serializer.toJson(key), + 'partitionKey': serializer.toJson(partitionKey), + 'value': serializer.toJson(value), + }; + } + + SettingData copyWith({ + String? key, + Value partitionKey = const Value.absent(), + Value value = const Value.absent(), + }) => SettingData( + key: key ?? this.key, + partitionKey: partitionKey.present ? partitionKey.value : this.partitionKey, + value: value.present ? value.value : this.value, + ); + SettingData copyWithCompanion(SettingCompanion data) { + return SettingData( + key: data.key.present ? data.key.value : this.key, + partitionKey: data.partitionKey.present + ? data.partitionKey.value + : this.partitionKey, + value: data.value.present ? data.value.value : this.value, + ); + } + + @override + String toString() { + return (StringBuffer('SettingData(') + ..write('key: $key, ') + ..write('partitionKey: $partitionKey, ') + ..write('value: $value') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(key, partitionKey, value); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is SettingData && + other.key == this.key && + other.partitionKey == this.partitionKey && + other.value == this.value); +} + +class SettingCompanion extends UpdateCompanion { + final Value key; + final Value partitionKey; + final Value value; + final Value rowid; + const SettingCompanion({ + this.key = const Value.absent(), + this.partitionKey = const Value.absent(), + this.value = const Value.absent(), + this.rowid = const Value.absent(), + }); + SettingCompanion.insert({ + required String key, + this.partitionKey = const Value.absent(), + this.value = const Value.absent(), + this.rowid = const Value.absent(), + }) : key = Value(key); + static Insertable custom({ + Expression? key, + Expression? partitionKey, + Expression? value, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (key != null) 'key': key, + if (partitionKey != null) 'partition_key': partitionKey, + if (value != null) 'value': value, + if (rowid != null) 'rowid': rowid, + }); + } + + SettingCompanion copyWith({ + Value? key, + Value? partitionKey, + Value? value, + Value? rowid, + }) { + return SettingCompanion( + key: key ?? this.key, + partitionKey: partitionKey ?? this.partitionKey, + value: value ?? this.value, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (key.present) { + map['key'] = Variable(key.value); + } + if (partitionKey.present) { + map['partition_key'] = Variable(partitionKey.value); + } + if (value.present) { + map['value'] = Variable(value.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('SettingCompanion(') + ..write('key: $key, ') + ..write('partitionKey: $partitionKey, ') + ..write('value: $value, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class IconCache extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + IconCache(this.attachedDatabase, [this._alias]); + late final GeneratedColumn origin = GeneratedColumn( + 'origin', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'PRIMARY KEY NOT NULL', + ); + late final GeneratedColumn iconData = + GeneratedColumn( + 'icon_data', + aliasedName, + false, + type: DriftSqlType.blob, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn fetchDate = GeneratedColumn( + 'fetch_date', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + @override + List get $columns => [origin, iconData, fetchDate]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'icon_cache'; + @override + Set get $primaryKey => {origin}; + @override + IconCacheData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return IconCacheData( + origin: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}origin'], + )!, + iconData: attachedDatabase.typeMapping.read( + DriftSqlType.blob, + data['${effectivePrefix}icon_data'], + )!, + fetchDate: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}fetch_date'], + )!, + ); + } + + @override + IconCache createAlias(String alias) { + return IconCache(attachedDatabase, alias); + } + + @override + bool get dontWriteConstraints => true; +} + +class IconCacheData extends DataClass implements Insertable { + final String origin; + final i2.Uint8List iconData; + final int fetchDate; + const IconCacheData({ + required this.origin, + required this.iconData, + required this.fetchDate, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['origin'] = Variable(origin); + map['icon_data'] = Variable(iconData); + map['fetch_date'] = Variable(fetchDate); + return map; + } + + factory IconCacheData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return IconCacheData( + origin: serializer.fromJson(json['origin']), + iconData: serializer.fromJson(json['iconData']), + fetchDate: serializer.fromJson(json['fetchDate']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'origin': serializer.toJson(origin), + 'iconData': serializer.toJson(iconData), + 'fetchDate': serializer.toJson(fetchDate), + }; + } + + IconCacheData copyWith({ + String? origin, + i2.Uint8List? iconData, + int? fetchDate, + }) => IconCacheData( + origin: origin ?? this.origin, + iconData: iconData ?? this.iconData, + fetchDate: fetchDate ?? this.fetchDate, + ); + IconCacheData copyWithCompanion(IconCacheCompanion data) { + return IconCacheData( + origin: data.origin.present ? data.origin.value : this.origin, + iconData: data.iconData.present ? data.iconData.value : this.iconData, + fetchDate: data.fetchDate.present ? data.fetchDate.value : this.fetchDate, + ); + } + + @override + String toString() { + return (StringBuffer('IconCacheData(') + ..write('origin: $origin, ') + ..write('iconData: $iconData, ') + ..write('fetchDate: $fetchDate') + ..write(')')) + .toString(); + } + + @override + int get hashCode => + Object.hash(origin, $driftBlobEquality.hash(iconData), fetchDate); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is IconCacheData && + other.origin == this.origin && + $driftBlobEquality.equals(other.iconData, this.iconData) && + other.fetchDate == this.fetchDate); +} + +class IconCacheCompanion extends UpdateCompanion { + final Value origin; + final Value iconData; + final Value fetchDate; + final Value rowid; + const IconCacheCompanion({ + this.origin = const Value.absent(), + this.iconData = const Value.absent(), + this.fetchDate = const Value.absent(), + this.rowid = const Value.absent(), + }); + IconCacheCompanion.insert({ + required String origin, + required i2.Uint8List iconData, + required int fetchDate, + this.rowid = const Value.absent(), + }) : origin = Value(origin), + iconData = Value(iconData), + fetchDate = Value(fetchDate); + static Insertable custom({ + Expression? origin, + Expression? iconData, + Expression? fetchDate, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (origin != null) 'origin': origin, + if (iconData != null) 'icon_data': iconData, + if (fetchDate != null) 'fetch_date': fetchDate, + if (rowid != null) 'rowid': rowid, + }); + } + + IconCacheCompanion copyWith({ + Value? origin, + Value? iconData, + Value? fetchDate, + Value? rowid, + }) { + return IconCacheCompanion( + origin: origin ?? this.origin, + iconData: iconData ?? this.iconData, + fetchDate: fetchDate ?? this.fetchDate, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (origin.present) { + map['origin'] = Variable(origin.value); + } + if (iconData.present) { + map['icon_data'] = Variable(iconData.value); + } + if (fetchDate.present) { + map['fetch_date'] = Variable(fetchDate.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('IconCacheCompanion(') + ..write('origin: $origin, ') + ..write('iconData: $iconData, ') + ..write('fetchDate: $fetchDate, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class Onboarding extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Onboarding(this.attachedDatabase, [this._alias]); + late final GeneratedColumn revision = GeneratedColumn( + 'revision', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn completionDate = GeneratedColumn( + 'completion_date', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + @override + List get $columns => [revision, completionDate]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'onboarding'; + @override + Set get $primaryKey => const {}; + @override + OnboardingData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return OnboardingData( + revision: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}revision'], + )!, + completionDate: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}completion_date'], + )!, + ); + } + + @override + Onboarding createAlias(String alias) { + return Onboarding(attachedDatabase, alias); + } + + @override + bool get dontWriteConstraints => true; +} + +class OnboardingData extends DataClass implements Insertable { + final int revision; + final int completionDate; + const OnboardingData({required this.revision, required this.completionDate}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['revision'] = Variable(revision); + map['completion_date'] = Variable(completionDate); + return map; + } + + factory OnboardingData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return OnboardingData( + revision: serializer.fromJson(json['revision']), + completionDate: serializer.fromJson(json['completionDate']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'revision': serializer.toJson(revision), + 'completionDate': serializer.toJson(completionDate), + }; + } + + OnboardingData copyWith({int? revision, int? completionDate}) => + OnboardingData( + revision: revision ?? this.revision, + completionDate: completionDate ?? this.completionDate, + ); + OnboardingData copyWithCompanion(OnboardingCompanion data) { + return OnboardingData( + revision: data.revision.present ? data.revision.value : this.revision, + completionDate: data.completionDate.present + ? data.completionDate.value + : this.completionDate, + ); + } + + @override + String toString() { + return (StringBuffer('OnboardingData(') + ..write('revision: $revision, ') + ..write('completionDate: $completionDate') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(revision, completionDate); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is OnboardingData && + other.revision == this.revision && + other.completionDate == this.completionDate); +} + +class OnboardingCompanion extends UpdateCompanion { + final Value revision; + final Value completionDate; + final Value rowid; + const OnboardingCompanion({ + this.revision = const Value.absent(), + this.completionDate = const Value.absent(), + this.rowid = const Value.absent(), + }); + OnboardingCompanion.insert({ + required int revision, + required int completionDate, + this.rowid = const Value.absent(), + }) : revision = Value(revision), + completionDate = Value(completionDate); + static Insertable custom({ + Expression? revision, + Expression? completionDate, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (revision != null) 'revision': revision, + if (completionDate != null) 'completion_date': completionDate, + if (rowid != null) 'rowid': rowid, + }); + } + + OnboardingCompanion copyWith({ + Value? revision, + Value? completionDate, + Value? rowid, + }) { + return OnboardingCompanion( + revision: revision ?? this.revision, + completionDate: completionDate ?? this.completionDate, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (revision.present) { + map['revision'] = Variable(revision.value); + } + if (completionDate.present) { + map['completion_date'] = Variable(completionDate.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('OnboardingCompanion(') + ..write('revision: $revision, ') + ..write('completionDate: $completionDate, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class Riverpod extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Riverpod(this.attachedDatabase, [this._alias]); + late final GeneratedColumn key = GeneratedColumn( + 'key', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'PRIMARY KEY NOT NULL', + ); + late final GeneratedColumn json = GeneratedColumn( + 'json', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn expireAt = GeneratedColumn( + 'expireAt', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn destroyKey = GeneratedColumn( + 'destroyKey', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + @override + List get $columns => [key, json, expireAt, destroyKey]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'riverpod'; + @override + Set get $primaryKey => {key}; + @override + RiverpodData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return RiverpodData( + key: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}key'], + )!, + json: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}json'], + )!, + expireAt: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}expireAt'], + ), + destroyKey: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}destroyKey'], + ), + ); + } + + @override + Riverpod createAlias(String alias) { + return Riverpod(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get dontWriteConstraints => true; +} + +class RiverpodData extends DataClass implements Insertable { + final String key; + final String json; + final int? expireAt; + final String? destroyKey; + const RiverpodData({ + required this.key, + required this.json, + this.expireAt, + this.destroyKey, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['key'] = Variable(key); + map['json'] = Variable(json); + if (!nullToAbsent || expireAt != null) { + map['expireAt'] = Variable(expireAt); + } + if (!nullToAbsent || destroyKey != null) { + map['destroyKey'] = Variable(destroyKey); + } + return map; + } + + factory RiverpodData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return RiverpodData( + key: serializer.fromJson(json['key']), + json: serializer.fromJson(json['json']), + expireAt: serializer.fromJson(json['expireAt']), + destroyKey: serializer.fromJson(json['destroyKey']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'key': serializer.toJson(key), + 'json': serializer.toJson(json), + 'expireAt': serializer.toJson(expireAt), + 'destroyKey': serializer.toJson(destroyKey), + }; + } + + RiverpodData copyWith({ + String? key, + String? json, + Value expireAt = const Value.absent(), + Value destroyKey = const Value.absent(), + }) => RiverpodData( + key: key ?? this.key, + json: json ?? this.json, + expireAt: expireAt.present ? expireAt.value : this.expireAt, + destroyKey: destroyKey.present ? destroyKey.value : this.destroyKey, + ); + RiverpodData copyWithCompanion(RiverpodCompanion data) { + return RiverpodData( + key: data.key.present ? data.key.value : this.key, + json: data.json.present ? data.json.value : this.json, + expireAt: data.expireAt.present ? data.expireAt.value : this.expireAt, + destroyKey: data.destroyKey.present + ? data.destroyKey.value + : this.destroyKey, + ); + } + + @override + String toString() { + return (StringBuffer('RiverpodData(') + ..write('key: $key, ') + ..write('json: $json, ') + ..write('expireAt: $expireAt, ') + ..write('destroyKey: $destroyKey') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(key, json, expireAt, destroyKey); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is RiverpodData && + other.key == this.key && + other.json == this.json && + other.expireAt == this.expireAt && + other.destroyKey == this.destroyKey); +} + +class RiverpodCompanion extends UpdateCompanion { + final Value key; + final Value json; + final Value expireAt; + final Value destroyKey; + const RiverpodCompanion({ + this.key = const Value.absent(), + this.json = const Value.absent(), + this.expireAt = const Value.absent(), + this.destroyKey = const Value.absent(), + }); + RiverpodCompanion.insert({ + required String key, + required String json, + this.expireAt = const Value.absent(), + this.destroyKey = const Value.absent(), + }) : key = Value(key), + json = Value(json); + static Insertable custom({ + Expression? key, + Expression? json, + Expression? expireAt, + Expression? destroyKey, + }) { + return RawValuesInsertable({ + if (key != null) 'key': key, + if (json != null) 'json': json, + if (expireAt != null) 'expireAt': expireAt, + if (destroyKey != null) 'destroyKey': destroyKey, + }); + } + + RiverpodCompanion copyWith({ + Value? key, + Value? json, + Value? expireAt, + Value? destroyKey, + }) { + return RiverpodCompanion( + key: key ?? this.key, + json: json ?? this.json, + expireAt: expireAt ?? this.expireAt, + destroyKey: destroyKey ?? this.destroyKey, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (key.present) { + map['key'] = Variable(key.value); + } + if (json.present) { + map['json'] = Variable(json.value); + } + if (expireAt.present) { + map['expireAt'] = Variable(expireAt.value); + } + if (destroyKey.present) { + map['destroyKey'] = Variable(destroyKey.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('RiverpodCompanion(') + ..write('key: $key, ') + ..write('json: $json, ') + ..write('expireAt: $expireAt, ') + ..write('destroyKey: $destroyKey') + ..write(')')) + .toString(); + } +} + +class ToolbarButtonConfigs extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + ToolbarButtonConfigs(this.attachedDatabase, [this._alias]); + late final GeneratedColumn buttonId = GeneratedColumn( + 'button_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL PRIMARY KEY', + ); + late final GeneratedColumn orderKey = GeneratedColumn( + 'order_key', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn isVisible = GeneratedColumn( + 'is_visible', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT TRUE', + defaultValue: const CustomExpression('TRUE'), + ); + late final GeneratedColumn fallbackId = GeneratedColumn( + 'fallback_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: + 'REFERENCES toolbar_button_configs(button_id)ON DELETE SET NULL', + ); + @override + List get $columns => [ + buttonId, + orderKey, + isVisible, + fallbackId, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'toolbar_button_configs'; + @override + Set get $primaryKey => {buttonId}; + @override + ToolbarButtonConfigsData map( + Map data, { + String? tablePrefix, + }) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return ToolbarButtonConfigsData( + buttonId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}button_id'], + )!, + orderKey: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}order_key'], + )!, + isVisible: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}is_visible'], + )!, + fallbackId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}fallback_id'], + ), + ); + } + + @override + ToolbarButtonConfigs createAlias(String alias) { + return ToolbarButtonConfigs(attachedDatabase, alias); + } + + @override + bool get dontWriteConstraints => true; +} + +class ToolbarButtonConfigsData extends DataClass + implements Insertable { + final String buttonId; + final String orderKey; + final int isVisible; + final String? fallbackId; + const ToolbarButtonConfigsData({ + required this.buttonId, + required this.orderKey, + required this.isVisible, + this.fallbackId, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['button_id'] = Variable(buttonId); + map['order_key'] = Variable(orderKey); + map['is_visible'] = Variable(isVisible); + if (!nullToAbsent || fallbackId != null) { + map['fallback_id'] = Variable(fallbackId); + } + return map; + } + + factory ToolbarButtonConfigsData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return ToolbarButtonConfigsData( + buttonId: serializer.fromJson(json['buttonId']), + orderKey: serializer.fromJson(json['orderKey']), + isVisible: serializer.fromJson(json['isVisible']), + fallbackId: serializer.fromJson(json['fallbackId']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'buttonId': serializer.toJson(buttonId), + 'orderKey': serializer.toJson(orderKey), + 'isVisible': serializer.toJson(isVisible), + 'fallbackId': serializer.toJson(fallbackId), + }; + } + + ToolbarButtonConfigsData copyWith({ + String? buttonId, + String? orderKey, + int? isVisible, + Value fallbackId = const Value.absent(), + }) => ToolbarButtonConfigsData( + buttonId: buttonId ?? this.buttonId, + orderKey: orderKey ?? this.orderKey, + isVisible: isVisible ?? this.isVisible, + fallbackId: fallbackId.present ? fallbackId.value : this.fallbackId, + ); + ToolbarButtonConfigsData copyWithCompanion( + ToolbarButtonConfigsCompanion data, + ) { + return ToolbarButtonConfigsData( + buttonId: data.buttonId.present ? data.buttonId.value : this.buttonId, + orderKey: data.orderKey.present ? data.orderKey.value : this.orderKey, + isVisible: data.isVisible.present ? data.isVisible.value : this.isVisible, + fallbackId: data.fallbackId.present + ? data.fallbackId.value + : this.fallbackId, + ); + } + + @override + String toString() { + return (StringBuffer('ToolbarButtonConfigsData(') + ..write('buttonId: $buttonId, ') + ..write('orderKey: $orderKey, ') + ..write('isVisible: $isVisible, ') + ..write('fallbackId: $fallbackId') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(buttonId, orderKey, isVisible, fallbackId); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ToolbarButtonConfigsData && + other.buttonId == this.buttonId && + other.orderKey == this.orderKey && + other.isVisible == this.isVisible && + other.fallbackId == this.fallbackId); +} + +class ToolbarButtonConfigsCompanion + extends UpdateCompanion { + final Value buttonId; + final Value orderKey; + final Value isVisible; + final Value fallbackId; + final Value rowid; + const ToolbarButtonConfigsCompanion({ + this.buttonId = const Value.absent(), + this.orderKey = const Value.absent(), + this.isVisible = const Value.absent(), + this.fallbackId = const Value.absent(), + this.rowid = const Value.absent(), + }); + ToolbarButtonConfigsCompanion.insert({ + required String buttonId, + required String orderKey, + this.isVisible = const Value.absent(), + this.fallbackId = const Value.absent(), + this.rowid = const Value.absent(), + }) : buttonId = Value(buttonId), + orderKey = Value(orderKey); + static Insertable custom({ + Expression? buttonId, + Expression? orderKey, + Expression? isVisible, + Expression? fallbackId, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (buttonId != null) 'button_id': buttonId, + if (orderKey != null) 'order_key': orderKey, + if (isVisible != null) 'is_visible': isVisible, + if (fallbackId != null) 'fallback_id': fallbackId, + if (rowid != null) 'rowid': rowid, + }); + } + + ToolbarButtonConfigsCompanion copyWith({ + Value? buttonId, + Value? orderKey, + Value? isVisible, + Value? fallbackId, + Value? rowid, + }) { + return ToolbarButtonConfigsCompanion( + buttonId: buttonId ?? this.buttonId, + orderKey: orderKey ?? this.orderKey, + isVisible: isVisible ?? this.isVisible, + fallbackId: fallbackId ?? this.fallbackId, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (buttonId.present) { + map['button_id'] = Variable(buttonId.value); + } + if (orderKey.present) { + map['order_key'] = Variable(orderKey.value); + } + if (isVisible.present) { + map['is_visible'] = Variable(isVisible.value); + } + if (fallbackId.present) { + map['fallback_id'] = Variable(fallbackId.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('ToolbarButtonConfigsCompanion(') + ..write('buttonId: $buttonId, ') + ..write('orderKey: $orderKey, ') + ..write('isVisible: $isVisible, ') + ..write('fallbackId: $fallbackId, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class SearchTokens extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + SearchTokens(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'PRIMARY KEY AUTOINCREMENT', + ); + late final GeneratedColumn token = + GeneratedColumn( + 'token', + aliasedName, + false, + type: DriftSqlType.blob, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn insertedAt = GeneratedColumn( + 'inserted_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn issuerKeyVersion = GeneratedColumn( + 'issuer_key_version', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn reservedAt = GeneratedColumn( + 'reserved_at', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: '', + ); + @override + List get $columns => [ + id, + token, + insertedAt, + issuerKeyVersion, + reservedAt, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'search_tokens'; + @override + Set get $primaryKey => {id}; + @override + SearchTokensData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return SearchTokensData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + token: attachedDatabase.typeMapping.read( + DriftSqlType.blob, + data['${effectivePrefix}token'], + )!, + insertedAt: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}inserted_at'], + )!, + issuerKeyVersion: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}issuer_key_version'], + )!, + reservedAt: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}reserved_at'], + ), + ); + } + + @override + SearchTokens createAlias(String alias) { + return SearchTokens(attachedDatabase, alias); + } + + @override + bool get dontWriteConstraints => true; +} + +class SearchTokensData extends DataClass + implements Insertable { + final int id; + final i2.Uint8List token; + final int insertedAt; + final String issuerKeyVersion; + final int? reservedAt; + const SearchTokensData({ + required this.id, + required this.token, + required this.insertedAt, + required this.issuerKeyVersion, + this.reservedAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['token'] = Variable(token); + map['inserted_at'] = Variable(insertedAt); + map['issuer_key_version'] = Variable(issuerKeyVersion); + if (!nullToAbsent || reservedAt != null) { + map['reserved_at'] = Variable(reservedAt); + } + return map; + } + + factory SearchTokensData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return SearchTokensData( + id: serializer.fromJson(json['id']), + token: serializer.fromJson(json['token']), + insertedAt: serializer.fromJson(json['insertedAt']), + issuerKeyVersion: serializer.fromJson(json['issuerKeyVersion']), + reservedAt: serializer.fromJson(json['reservedAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'token': serializer.toJson(token), + 'insertedAt': serializer.toJson(insertedAt), + 'issuerKeyVersion': serializer.toJson(issuerKeyVersion), + 'reservedAt': serializer.toJson(reservedAt), + }; + } + + SearchTokensData copyWith({ + int? id, + i2.Uint8List? token, + int? insertedAt, + String? issuerKeyVersion, + Value reservedAt = const Value.absent(), + }) => SearchTokensData( + id: id ?? this.id, + token: token ?? this.token, + insertedAt: insertedAt ?? this.insertedAt, + issuerKeyVersion: issuerKeyVersion ?? this.issuerKeyVersion, + reservedAt: reservedAt.present ? reservedAt.value : this.reservedAt, + ); + SearchTokensData copyWithCompanion(SearchTokensCompanion data) { + return SearchTokensData( + id: data.id.present ? data.id.value : this.id, + token: data.token.present ? data.token.value : this.token, + insertedAt: data.insertedAt.present + ? data.insertedAt.value + : this.insertedAt, + issuerKeyVersion: data.issuerKeyVersion.present + ? data.issuerKeyVersion.value + : this.issuerKeyVersion, + reservedAt: data.reservedAt.present + ? data.reservedAt.value + : this.reservedAt, + ); + } + + @override + String toString() { + return (StringBuffer('SearchTokensData(') + ..write('id: $id, ') + ..write('token: $token, ') + ..write('insertedAt: $insertedAt, ') + ..write('issuerKeyVersion: $issuerKeyVersion, ') + ..write('reservedAt: $reservedAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + $driftBlobEquality.hash(token), + insertedAt, + issuerKeyVersion, + reservedAt, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is SearchTokensData && + other.id == this.id && + $driftBlobEquality.equals(other.token, this.token) && + other.insertedAt == this.insertedAt && + other.issuerKeyVersion == this.issuerKeyVersion && + other.reservedAt == this.reservedAt); +} + +class SearchTokensCompanion extends UpdateCompanion { + final Value id; + final Value token; + final Value insertedAt; + final Value issuerKeyVersion; + final Value reservedAt; + const SearchTokensCompanion({ + this.id = const Value.absent(), + this.token = const Value.absent(), + this.insertedAt = const Value.absent(), + this.issuerKeyVersion = const Value.absent(), + this.reservedAt = const Value.absent(), + }); + SearchTokensCompanion.insert({ + this.id = const Value.absent(), + required i2.Uint8List token, + required int insertedAt, + required String issuerKeyVersion, + this.reservedAt = const Value.absent(), + }) : token = Value(token), + insertedAt = Value(insertedAt), + issuerKeyVersion = Value(issuerKeyVersion); + static Insertable custom({ + Expression? id, + Expression? token, + Expression? insertedAt, + Expression? issuerKeyVersion, + Expression? reservedAt, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (token != null) 'token': token, + if (insertedAt != null) 'inserted_at': insertedAt, + if (issuerKeyVersion != null) 'issuer_key_version': issuerKeyVersion, + if (reservedAt != null) 'reserved_at': reservedAt, + }); + } + + SearchTokensCompanion copyWith({ + Value? id, + Value? token, + Value? insertedAt, + Value? issuerKeyVersion, + Value? reservedAt, + }) { + return SearchTokensCompanion( + id: id ?? this.id, + token: token ?? this.token, + insertedAt: insertedAt ?? this.insertedAt, + issuerKeyVersion: issuerKeyVersion ?? this.issuerKeyVersion, + reservedAt: reservedAt ?? this.reservedAt, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (token.present) { + map['token'] = Variable(token.value); + } + if (insertedAt.present) { + map['inserted_at'] = Variable(insertedAt.value); + } + if (issuerKeyVersion.present) { + map['issuer_key_version'] = Variable(issuerKeyVersion.value); + } + if (reservedAt.present) { + map['reserved_at'] = Variable(reservedAt.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('SearchTokensCompanion(') + ..write('id: $id, ') + ..write('token: $token, ') + ..write('insertedAt: $insertedAt, ') + ..write('issuerKeyVersion: $issuerKeyVersion, ') + ..write('reservedAt: $reservedAt') + ..write(')')) + .toString(); + } +} + +class DatabaseAtV5 extends GeneratedDatabase { + DatabaseAtV5(QueryExecutor e) : super(e); + late final Setting setting = Setting(this); + late final IconCache iconCache = IconCache(this); + late final Onboarding onboarding = Onboarding(this); + late final Riverpod riverpod = Riverpod(this); + late final ToolbarButtonConfigs toolbarButtonConfigs = ToolbarButtonConfigs( + this, + ); + late final Index idxToolbarOrderKey = Index( + 'idx_toolbar_order_key', + 'CREATE INDEX idx_toolbar_order_key ON toolbar_button_configs (order_key)', + ); + late final SearchTokens searchTokens = SearchTokens(this); + late final Index idxSearchTokensInsertedAt = Index( + 'idx_search_tokens_inserted_at', + 'CREATE INDEX idx_search_tokens_inserted_at ON search_tokens (inserted_at)', + ); + late final Index idxSearchTokensReservedAt = Index( + 'idx_search_tokens_reserved_at', + 'CREATE INDEX idx_search_tokens_reserved_at ON search_tokens (reserved_at)', + ); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [ + setting, + iconCache, + onboarding, + riverpod, + toolbarButtonConfigs, + idxToolbarOrderKey, + searchTokens, + idxSearchTokensInsertedAt, + idxSearchTokensReservedAt, + ]; + @override + int get schemaVersion => 5; +} diff --git a/apps/weblibre/test/features/account/domain/services/prefs_js_reader_test.dart b/apps/weblibre/test/features/account/domain/services/prefs_js_reader_test.dart new file mode 100644 index 00000000..3369562f --- /dev/null +++ b/apps/weblibre/test/features/account/domain/services/prefs_js_reader_test.dart @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:weblibre/features/account/domain/services/prefs_js_reader.dart'; + +void main() { + late Directory profileDir; + + setUp(() { + profileDir = Directory.systemTemp.createTempSync('prefs_js_reader_test_'); + }); + + tearDown(() { + if (profileDir.existsSync()) { + profileDir.deleteSync(recursive: true); + } + }); + + Directory makeGeckoProfile(String id) { + final dir = Directory(p.join(profileDir.path, 'files', 'mozilla', id)) + ..createSync(recursive: true); + return dir; + } + + test('returns empty map when no Gecko profile exists', () async { + final reader = PrefsJsReader(selectedProfileDir: profileDir); + expect(await reader.readUserPrefs(), isEmpty); + }); + + test('returns empty map when prefs.js is missing', () async { + makeGeckoProfile('abc.default'); + + final reader = PrefsJsReader(selectedProfileDir: profileDir); + expect(await reader.readUserPrefs(), isEmpty); + }); + + test('parses a realistic prefs.js fixture', () async { + final geckoDir = makeGeckoProfile('abc.default'); + File(p.join(geckoDir.path, 'prefs.js')).writeAsStringSync(''' +// Mozilla User Preferences +user_pref("browser.startup.homepage", "about:home"); +user_pref("dom.webgpu.enabled", true); +user_pref("network.http.max-connections", 900); +'''); + + final reader = PrefsJsReader(selectedProfileDir: profileDir); + final prefs = await reader.readUserPrefs(); + expect(prefs, { + 'browser.startup.homepage': 'about:home', + 'dom.webgpu.enabled': true, + 'network.http.max-connections': 900, + }); + }); + + test('picks newest prefs.js when multiple Gecko profiles exist', () async { + final oldDir = makeGeckoProfile('old.default'); + final newDir = makeGeckoProfile('new.default'); + + final oldFile = File(p.join(oldDir.path, 'prefs.js')) + ..writeAsStringSync('user_pref("a.pref", "old");\n'); + File( + p.join(newDir.path, 'prefs.js'), + ).writeAsStringSync('user_pref("a.pref", "new");\n'); + + final past = DateTime.now().subtract(const Duration(hours: 1)); + oldFile.setLastModifiedSync(past); + + final reader = PrefsJsReader(selectedProfileDir: profileDir); + final prefs = await reader.readUserPrefs(); + expect(prefs['a.pref'], 'new'); + }); +} diff --git a/apps/weblibre/test/features/account/domain/utils/user_js_parser_test.dart b/apps/weblibre/test/features/account/domain/utils/user_js_parser_test.dart new file mode 100644 index 00000000..f846cf03 --- /dev/null +++ b/apps/weblibre/test/features/account/domain/utils/user_js_parser_test.dart @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import 'package:flutter_test/flutter_test.dart'; +import 'package:weblibre/features/account/domain/utils/user_js_parser.dart'; + +void main() { + group('parseUserJs', () { + test('parses empty and whitespace-only input', () { + expect(parseUserJs('').prefs, isEmpty); + expect(parseUserJs(' \t\n\r\v\f ').prefs, isEmpty); + }); + + test('ignores Firefox comment forms and parses metadata comments', () { + final result = parseUserJs(''' +// schema_version=7 +# ignored comment +/* block comment */ +// exported_at=2026-04-15T12:00:00Z +'''); + + expect(result.prefs, isEmpty); + expect(result.schemaVersion, 7); + expect(result.exportedAt, '2026-04-15T12:00:00Z'); + }); + + test('parses user_pref scalar values', () { + final result = parseUserJs(''' +user_pref("bool.pref", true); +user_pref("int.pref", 123); +user_pref("string.pref", "value"); +'''); + + expect(result.prefs, { + 'bool.pref': true, + 'int.pref': 123, + 'string.pref': 'value', + }); + }); + + test('parses signed integers with trivia after the sign', () { + final result = parseUserJs(''' +user_pref("int.spaces", + 345); +user_pref("int.comment", - /* hmm */ 456); +user_pref("int.newline", - +567); +user_pref("int.max", +2147483647); +user_pref("int.min", -2147483648); +'''); + + expect(result.prefs['int.spaces'], 345); + expect(result.prefs['int.comment'], -456); + expect(result.prefs['int.newline'], -567); + expect(result.prefs['int.max'], 2147483647); + expect(result.prefs['int.min'], -2147483648); + }); + + test('duplicate pref names use last-write-wins semantics', () { + final result = parseUserJs(''' +user_pref("dup.pref", 1); +user_pref("dup.pref", 2); +'''); + + expect(result.prefs['dup.pref'], 2); + }); + + test('accepts only user_pref entries in user.js input', () { + final result = parseUserJs(''' +pref("ignored.pref", true); +user_pref("kept.pref", false); +'''); + + expect(result.prefs, {'kept.pref': false}); + }); + + test('preserves comment-looking text inside strings', () { + final result = parseUserJs( + '''user_pref("comment.text", "before /* keep */ // still text after");''', + ); + + expect( + result.prefs['comment.text'], + 'before /* keep */ // still text after', + ); + }); + + test('parses single-quoted strings', () { + final result = parseUserJs( + """user_pref('single.pref', 'single quoted value');""", + ); + + expect(result.prefs['single.pref'], 'single quoted value'); + }); + + test('parses Firefox hex and unicode escapes', () { + final result = parseUserJs( + r'''user_pref("escaped.pref", "quote \" slash \\ newline \n carriage \r hex \x41 unicode \u0042");''', + ); + + expect( + result.prefs['escaped.pref'], + 'quote " slash \\ newline \n carriage \r hex A unicode B' + .replaceAll(r'\n', '\n') + .replaceAll(r'\r', '\r'), + ); + }); + + test('preserves raw line breaks inside string literals', () { + final result = parseUserJs( + ''' +user_pref("raw.newlines", "line 1 +line 2\rline 3"); +''' + .replaceAll(r'\r', '\r'), + ); + + expect(result.prefs['raw.newlines'], 'line 1\nline 2\rline 3'); + }); + }); +} diff --git a/apps/weblibre/test/features/account/domain/utils/user_js_serializer_test.dart b/apps/weblibre/test/features/account/domain/utils/user_js_serializer_test.dart new file mode 100644 index 00000000..1dfa3d24 --- /dev/null +++ b/apps/weblibre/test/features/account/domain/utils/user_js_serializer_test.dart @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import 'package:flutter_test/flutter_test.dart'; +import 'package:weblibre/features/account/domain/utils/user_js_parser.dart'; +import 'package:weblibre/features/account/domain/utils/user_js_serializer.dart'; + +void main() { + group('serializeUserJs', () { + test('serializes syncable prefs in sorted order, excluding URL-style', () { + final text = serializeUserJs( + userPrefs: { + 'zeta.pref': 7, + 'alpha.pref': 'value', + 'beta.pref': false, + 'unsyncable.pref': 'blob:https://example.com', + }, + schemaVersion: 1, + exportedAt: '2026-04-15T12:00:00Z', + ); + + expect(text, ''' +// WebLibre Gecko prefs snapshot +// schema_version=1 +// exported_at=2026-04-15T12:00:00Z +user_pref("alpha.pref", "value"); +user_pref("beta.pref", false); +user_pref("zeta.pref", 7); +'''); + }); + + test('uses Firefox-compatible escaping and round-trips through parser', () { + final prefValue = 'quote " slash \\ newline \n carriage \r tab \t' + .replaceAll(r'\n', '\n') + .replaceAll(r'\r', '\r') + .replaceAll(r'\t', '\t'); + + final text = serializeUserJs( + userPrefs: {'text.pref': prefValue}, + schemaVersion: 1, + exportedAt: '2026-04-15T12:00:00Z', + ); + + expect( + text, + contains( + 'user_pref("text.pref", "quote \\" slash \\\\ newline \\n carriage \\r tab \t");\n', + ), + ); + + final parsed = parseUserJs(text); + expect(parsed.prefs['text.pref'], prefValue); + }); + }); +} diff --git a/apps/weblibre/test/features/account/presentation/widgets/subscription_card_test.dart b/apps/weblibre/test/features/account/presentation/widgets/subscription_card_test.dart new file mode 100644 index 00000000..e5572c5e --- /dev/null +++ b/apps/weblibre/test/features/account/presentation/widgets/subscription_card_test.dart @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:weblibre/features/account/data/models/subscription_status.dart'; +import 'package:weblibre/features/account/domain/repositories/subscription_repository.dart'; +import 'package:weblibre/features/account/presentation/widgets/subscription_card.dart'; + +void main() { + testWidgets('refreshes subscription status when the app resumes', ( + tester, + ) async { + final repository = _FakeSubscriptionRepository(); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + subscriptionRepositoryProvider.overrideWith(() => repository), + ], + child: MaterialApp( + home: Scaffold( + body: SubscriptionCard( + subscriptionAsync: AsyncData(SubscriptionStatus.inactive), + ), + ), + ), + ), + ); + + expect(repository.refreshCount, 0); + + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused); + await tester.pump(); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + await tester.pump(); + + expect(repository.refreshCount, 1); + }); + + testWidgets( + 'lifecycle refresh still fires when card is in the error branch', + (tester) async { + final repository = _FakeSubscriptionRepository(); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + subscriptionRepositoryProvider.overrideWith(() => repository), + ], + child: MaterialApp( + home: Scaffold( + body: SubscriptionCard( + subscriptionAsync: AsyncError( + Exception('offline'), + StackTrace.current, + ), + ), + ), + ), + ), + ); + + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused); + await tester.pump(); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + await tester.pump(); + + expect(repository.refreshCount, 1); + }, + ); +} + +class _FakeSubscriptionRepository extends SubscriptionRepository { + int refreshCount = 0; + + @override + Future build() async => SubscriptionStatus.inactive; + + @override + Future refresh() async { + refreshCount++; + } +} diff --git a/apps/weblibre/test/features/geckoview/features/search/presentation/widgets/frequent_bangs_section_test.dart b/apps/weblibre/test/features/geckoview/features/search/presentation/widgets/frequent_bangs_section_test.dart new file mode 100644 index 00000000..43cafbc3 --- /dev/null +++ b/apps/weblibre/test/features/geckoview/features/search/presentation/widgets/frequent_bangs_section_test.dart @@ -0,0 +1,173 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'package:flutter/material.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:weblibre/features/bangs/data/models/bang_data.dart'; +import 'package:weblibre/features/bangs/data/models/bang_group.dart'; +import 'package:weblibre/features/geckoview/features/search/presentation/widgets/bang_chip_strip.dart'; +import 'package:weblibre/features/geckoview/features/search/presentation/widgets/empty_state/frequent_bangs_section.dart'; + +BangData _bang(String trigger, String name) => BangData( + websiteName: name, + domain: '$trigger.example', + trigger: trigger, + urlTemplate: 'https://$trigger.example/?q={{{s}}}', + group: BangGroup.general, + searxngApi: false, +); + +void main() { + group('buildFrequentBangDisplayList', () { + test('keeps selected bang first and default bang trailing', () { + final selectedBang = _bang('ddg', 'DuckDuckGo'); + final defaultBang = _bang('wl', 'WebLibre Search'); + final frequentBangs = [defaultBang, _bang('g', 'Google')]; + + final result = buildFrequentBangDisplayList( + frequentBangs: frequentBangs, + selectedBang: selectedBang, + defaultBang: defaultBang, + ); + + expect(result.map((bang) => bang.trigger), ['ddg', 'g', 'wl']); + }); + + test('does not duplicate the same bang when default is selected', () { + final defaultBang = _bang('wl', 'WebLibre Search'); + final frequentBangs = [defaultBang, _bang('g', 'Google')]; + + final result = buildFrequentBangDisplayList( + frequentBangs: frequentBangs, + selectedBang: defaultBang, + defaultBang: defaultBang, + ); + + expect(result.map((bang) => bang.trigger), ['wl', 'g']); + }); + }); + + group('canDeleteFrequentBang', () { + test('implicit default bang is not deletable', () { + final defaultBang = _bang('wl', 'WebLibre Search'); + + expect( + canDeleteFrequentBang(bang: defaultBang, defaultBang: defaultBang), + isFalse, + ); + }); + + test('explicitly selected default bang remains clearable', () { + final defaultBang = _bang('wl', 'WebLibre Search'); + + expect( + canDeleteFrequentBang( + bang: defaultBang, + selectedBang: defaultBang, + defaultBang: defaultBang, + ), + isTrue, + ); + }); + }); + + group('BangChipStrip delete affordances', () { + test('only selected bang keeps clear when frequency reset is disabled', () { + final selectedBang = _bang('ddg', 'DuckDuckGo'); + final otherBang = _bang('wl', 'WebLibre Search'); + + expect( + canDeleteBangChip( + selectedBang, + selectedBang: selectedBang, + allowFrequencyResetAction: false, + ), + isTrue, + ); + expect( + canDeleteBangChip( + otherBang, + selectedBang: selectedBang, + allowFrequencyResetAction: false, + ), + isFalse, + ); + expect( + bangChipDeleteIcon( + selectedBang, + selectedBang: selectedBang, + allowFrequencyResetAction: false, + ), + Icons.clear, + ); + expect( + bangChipDeleteIcon( + otherBang, + selectedBang: selectedBang, + allowFrequencyResetAction: false, + ), + isNull, + ); + }); + + test('non-selected bangs use restore when frequency reset is enabled', () { + final selectedBang = _bang('ddg', 'DuckDuckGo'); + final otherBang = _bang('wl', 'WebLibre Search'); + + expect( + canDeleteBangChip( + otherBang, + selectedBang: selectedBang, + allowFrequencyResetAction: true, + ), + isTrue, + ); + expect( + bangChipDeleteIcon( + selectedBang, + selectedBang: selectedBang, + allowFrequencyResetAction: true, + ), + Icons.clear, + ); + expect( + bangChipDeleteIcon( + otherBang, + selectedBang: selectedBang, + allowFrequencyResetAction: true, + ), + MdiIcons.restore, + ); + }); + + test('selected bangs always use clear even when restore is enabled', () { + final selectedBang = _bang('wl', 'WebLibre Search'); + + expect( + bangChipDeleteIcon( + selectedBang, + selectedBang: selectedBang, + allowFrequencyResetAction: true, + ), + Icons.clear, + ); + }); + }); +} diff --git a/apps/weblibre/test/features/geckoview/utils/image_helper_test.dart b/apps/weblibre/test/features/geckoview/utils/image_helper_test.dart new file mode 100644 index 00000000..0bf331aa --- /dev/null +++ b/apps/weblibre/test/features/geckoview/utils/image_helper_test.dart @@ -0,0 +1,53 @@ +import 'dart:convert'; +import 'dart:typed_data'; +import 'dart:ui'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:weblibre/features/geckoview/utils/image_helper.dart'; + +void main() { + testWidgets('tryDecodeImage rasterizes svg bytes at favicon size', ( + tester, + ) async { + clearImageCache(); + final svgBytes = Uint8List.fromList(utf8.encode(_svgIcon)); + + final image = await tryDecodeImage(svgBytes); + + expect(image, isNotNull); + expect(image!.value, isNotNull); + expect(image.value!.width, 32); + expect(image.value!.height, 32); + + final byteData = await image.value!.toByteData( + format: ImageByteFormat.rawRgba, + ); + expect(byteData, isNotNull); + + // A pixel in the right half should be painted once the SVG is scaled + // to the requested raster size instead of being left in the top-left. + expect(_rgbaAt(byteData!, width: 32, x: 24, y: 16), [47, 128, 237, 255]); + }); +} + +List _rgbaAt( + ByteData data, { + required int width, + required int x, + required int y, +}) { + final offset = (y * width + x) * 4; + return [ + data.getUint8(offset), + data.getUint8(offset + 1), + data.getUint8(offset + 2), + data.getUint8(offset + 3), + ]; +} + +const _svgIcon = ''' + + + + +'''; diff --git a/apps/weblibre/test/features/settings/presentation/widgets/settings_detail_test.dart b/apps/weblibre/test/features/settings/presentation/widgets/settings_detail_test.dart new file mode 100644 index 00000000..206adab8 --- /dev/null +++ b/apps/weblibre/test/features/settings/presentation/widgets/settings_detail_test.dart @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart'; + +void main() { + group('filterSettingsSections', () { + const sections = [ + SettingsSectionDefinition( + title: 'Appearance', + entries: [ + SettingsEntryDefinition( + title: 'Theme', + subtitle: 'Choose system, light, or dark mode', + child: SizedBox.shrink(), + ), + SettingsEntryDefinition( + title: 'User Interface Zoom', + subtitle: 'Make the user interface smaller or larger', + child: SizedBox.shrink(), + ), + ], + ), + SettingsSectionDefinition( + title: 'Downloads', + entries: [ + SettingsEntryDefinition( + title: 'Use external download manager', + subtitle: 'Manage downloads with another app', + child: SizedBox.shrink(), + ), + ], + ), + ]; + + test('returns a single matching entry when query targets an item', () { + final filtered = filterSettingsSections( + sections: sections, + query: 'zoom', + ); + + expect(filtered, hasLength(1)); + expect(filtered.first.title, 'Appearance'); + expect(filtered.first.entries, hasLength(1)); + expect(filtered.first.entries.first.title, 'User Interface Zoom'); + }); + + test('returns the full section when query matches the section title', () { + final filtered = filterSettingsSections( + sections: sections, + query: 'downloads', + ); + + expect(filtered, hasLength(1)); + expect(filtered.first.title, 'Downloads'); + expect(filtered.first.entries, hasLength(1)); + expect( + filtered.first.entries.first.title, + 'Use external download manager', + ); + }); + }); +} diff --git a/apps/weblibre/test/features/web_search/domain/controllers/search_controller_test.dart b/apps/weblibre/test/features/web_search/domain/controllers/search_controller_test.dart new file mode 100644 index 00000000..e9501b1e --- /dev/null +++ b/apps/weblibre/test/features/web_search/domain/controllers/search_controller_test.dart @@ -0,0 +1,719 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:riverpod/experimental/persist.dart'; +import 'package:riverpod/riverpod.dart'; +import 'package:search_backend/search_backend.dart'; +import 'package:weblibre/features/search_credits/domain/controllers/search_token_issuance_controller.dart'; +import 'package:weblibre/features/user/data/providers.dart'; +import 'package:weblibre/features/web_search/domain/controllers/search_controller.dart'; +import 'package:weblibre/features/web_search/domain/entities/fetch_method.dart'; +import 'package:weblibre/features/web_search/domain/services/capture_artifact_downloader.dart'; + +void main() { + group('MetaSearchController', () { + test('transitions to needsCredits when no token is available', () async { + final container = ProviderContainer( + overrides: [ + metaSearchEnsureTokenAvailableProvider.overrideWithValue( + () async => TokenAvailabilityOutcome.noCredits, + ), + riverpodDatabaseStorageProvider.overrideWith( + (ref) => Storage.inMemory(), + ), + ], + ); + addTearDown(container.dispose); + + final sub = container.listen( + metaSearchControllerProvider, + (_, _) {}, + fireImmediately: true, + ); + addTearDown(sub.close); + + await container + .read(metaSearchControllerProvider.notifier) + .submit('lensai'); + + final state = container.read(metaSearchControllerProvider); + expect(state.status, WebSearchStatus.needsCredits); + expect(state.query, 'lensai'); + }); + + test('transitions to error when token issuance fails', () async { + final container = ProviderContainer( + overrides: [ + metaSearchEnsureTokenAvailableProvider.overrideWithValue( + () async => TokenAvailabilityOutcome.issuanceFailed, + ), + riverpodDatabaseStorageProvider.overrideWith( + (ref) => Storage.inMemory(), + ), + ], + ); + addTearDown(container.dispose); + + final sub = container.listen( + metaSearchControllerProvider, + (_, _) {}, + fireImmediately: true, + ); + addTearDown(sub.close); + + await container + .read(metaSearchControllerProvider.notifier) + .submit('lensai'); + + final state = container.read(metaSearchControllerProvider); + expect(state.status, WebSearchStatus.error); + expect( + state.errorMessage, + 'Could not issue search tokens. Please try again.', + ); + expect(state.query, 'lensai'); + }); + + test('transitions to error when token availability check fails', () async { + final container = ProviderContainer( + overrides: [ + metaSearchEnsureTokenAvailableProvider.overrideWithValue( + () async => throw Exception('balance unavailable'), + ), + riverpodDatabaseStorageProvider.overrideWith( + (ref) => Storage.inMemory(), + ), + ], + ); + addTearDown(container.dispose); + + final sub = container.listen( + metaSearchControllerProvider, + (_, _) {}, + fireImmediately: true, + ); + addTearDown(sub.close); + + await container + .read(metaSearchControllerProvider.notifier) + .submit('lensai'); + + final state = container.read(metaSearchControllerProvider); + expect(state.status, WebSearchStatus.error); + expect( + state.errorMessage, + 'Could not check search credits. Please try again.', + ); + expect(state.query, 'lensai'); + }); + + test('search results populate the ready state', () async { + final session = _FakeMetaSearchSession(); + final container = _createContainer(session); + addTearDown(container.dispose); + + final sub = container.listen( + metaSearchControllerProvider, + (_, _) {}, + fireImmediately: true, + ); + addTearDown(sub.close); + + await container + .read(metaSearchControllerProvider.notifier) + .submit('lensai'); + + session.emit( + StreamMessage( + type: MessageTypes.searchResults, + data: _searchResultsPayload( + query: 'lensai', + results: [ + { + 'title': 'LensAI result', + 'url': 'https://example.com/result', + 'content': 'Result summary', + 'publishedDate': null, + 'img_src': null, + 'thumbnail': null, + }, + ], + ), + ), + ); + await _flushMicrotasks(); + + final state = container.read(metaSearchControllerProvider); + expect(state.status, WebSearchStatus.ready); + expect(state.results, hasLength(1)); + expect(state.results.single.title, 'LensAI result'); + expect(session.submittedQueries, ['lensai']); + }); + + test('image frames update cached media maps', () async { + final session = _FakeMetaSearchSession(); + final container = _createContainer(session); + addTearDown(container.dispose); + + final sub = container.listen( + metaSearchControllerProvider, + (_, _) {}, + fireImmediately: true, + ); + addTearDown(sub.close); + + await container + .read(metaSearchControllerProvider.notifier) + .submit('lensai'); + + session.emit( + StreamMessage( + type: MessageTypes.image, + data: { + 'url': 'https://example.com/image.png', + 'bytes': base64Encode(Uint8List.fromList([4, 5, 6])), + }, + ), + ); + await _flushMicrotasks(); + + final state = container.read(metaSearchControllerProvider); + expect(state.imagesByUrl['https://example.com/image.png'], [4, 5, 6]); + }); + + test( + 'fetchPage marks a result in flight and stores fetched documents', + () async { + final session = _FakeMetaSearchSession(); + final container = _createContainer(session); + addTearDown(container.dispose); + + final sub = container.listen( + metaSearchControllerProvider, + (_, _) {}, + fireImmediately: true, + ); + addTearDown(sub.close); + + const resultUrl = 'https://example.com/result'; + await container + .read(metaSearchControllerProvider.notifier) + .submit('lensai'); + session.emit( + StreamMessage( + type: MessageTypes.searchResults, + data: _searchResultsPayload( + query: 'lensai', + results: [ + { + 'title': 'LensAI result', + 'url': resultUrl, + 'content': null, + 'publishedDate': null, + 'img_src': null, + 'thumbnail': null, + }, + ], + ), + ), + ); + await _flushMicrotasks(); + + await container + .read(metaSearchControllerProvider.notifier) + .fetchPage(Uri.parse(resultUrl)); + + expect( + container.read(metaSearchControllerProvider).fetchingUrls, + contains(Uri.parse(resultUrl)), + ); + expect(session.fetchRequests, [Uri.parse(resultUrl)]); + + session.emit( + StreamMessage( + type: MessageTypes.document, + data: { + 'url': resultUrl, + 'content': '# Preview\n\nFetched content', + 'metadata': { + 'date': null, + 'description': null, + 'filedate': null, + 'image': null, + 'language': null, + 'pagetype': null, + 'sitename': null, + 'title': 'LensAI result', + 'author': null, + 'license': null, + }, + }, + ), + ); + await _flushMicrotasks(); + + final state = container.read(metaSearchControllerProvider); + expect(state.fetchingUrls, isEmpty); + expect(state.documentsByUrl.keys, contains(Uri.parse(resultUrl))); + expect( + state.documentsByUrl[Uri.parse(resultUrl)]?.content, + contains('Fetched content'), + ); + }, + ); + + test( + 'fetch failures clear in-flight state and preserve search results', + () async { + final session = _FakeMetaSearchSession(); + final container = _createContainer(session); + addTearDown(container.dispose); + + final sub = container.listen( + metaSearchControllerProvider, + (_, _) {}, + fireImmediately: true, + ); + addTearDown(sub.close); + + const resultUrl = 'https://example.com/result'; + await container + .read(metaSearchControllerProvider.notifier) + .submit('lensai'); + session.emit( + StreamMessage( + type: MessageTypes.searchResults, + data: _searchResultsPayload( + query: 'lensai', + results: [ + { + 'title': 'LensAI result', + 'url': resultUrl, + 'content': null, + 'publishedDate': null, + 'img_src': null, + 'thumbnail': null, + }, + ], + ), + ), + ); + await _flushMicrotasks(); + + await container + .read(metaSearchControllerProvider.notifier) + .fetchPage(Uri.parse(resultUrl)); + session.emit( + StreamMessage( + type: MessageTypes.error, + data: {'message': 'Document fetch failed'}, + ), + ); + await _flushMicrotasks(); + + final state = container.read(metaSearchControllerProvider); + expect(state.status, WebSearchStatus.ready); + expect(state.results, hasLength(1)); + expect(state.fetchingUrls, isEmpty); + expect(state.errorMessage, contains('Document fetch failed')); + }, + ); + + test( + 'capturePage dispatches capture request and tracks capturing state', + () async { + final session = _FakeMetaSearchSession(); + final container = _createContainer(session); + addTearDown(container.dispose); + + final sub = container.listen( + metaSearchControllerProvider, + (_, _) {}, + fireImmediately: true, + ); + addTearDown(sub.close); + + const resultUrl = 'https://example.com/result'; + await container + .read(metaSearchControllerProvider.notifier) + .submit('lensai'); + session.emit( + StreamMessage( + type: MessageTypes.searchResults, + data: _searchResultsPayload( + query: 'lensai', + results: [ + { + 'title': 'LensAI result', + 'url': resultUrl, + 'content': null, + 'publishedDate': null, + 'img_src': null, + 'thumbnail': null, + }, + ], + ), + ), + ); + await _flushMicrotasks(); + + final uri = Uri.parse(resultUrl); + await container + .read(metaSearchControllerProvider.notifier) + .capturePage(uri, choice: FetchMethodChoice.singlefileHtml); + + expect( + container + .read(metaSearchControllerProvider) + .isCapturing(uri, FetchMethodChoice.singlefileHtml), + isTrue, + ); + expect(session.captureRequests, hasLength(1)); + expect(session.captureRequests.first.url, uri); + expect(session.captureRequests.first.method, 'singlefile'); + expect(session.captureRequests.first.variant, 'balanced'); + }, + ); + + test('capture frame clears capturing state', () async { + final session = _FakeMetaSearchSession(); + final container = _createContainer(session); + addTearDown(container.dispose); + + final sub = container.listen( + metaSearchControllerProvider, + (_, _) {}, + fireImmediately: true, + ); + addTearDown(sub.close); + + const resultUrl = 'https://example.com/result'; + final uri = Uri.parse(resultUrl); + await container + .read(metaSearchControllerProvider.notifier) + .submit('lensai'); + session.emit( + StreamMessage( + type: MessageTypes.searchResults, + data: _searchResultsPayload( + query: 'lensai', + results: [ + { + 'title': 'LensAI result', + 'url': resultUrl, + 'content': null, + 'publishedDate': null, + 'img_src': null, + 'thumbnail': null, + }, + ], + ), + ), + ); + await _flushMicrotasks(); + + await container + .read(metaSearchControllerProvider.notifier) + .capturePage(uri, choice: FetchMethodChoice.singlefileHtml); + + expect( + container + .read(metaSearchControllerProvider) + .isCapturing(uri, FetchMethodChoice.singlefileHtml), + isTrue, + ); + + session.emit( + StreamMessage( + type: MessageTypes.capture, + data: { + 'captureId': 'abc123', + 'url': resultUrl, + 'method': 'singlefile', + 'variant': 'balanced', + 'contentType': 'text/html; charset=utf-8', + 'byteLength': 42, + 'downloadToken': 'tok-1', + 'filename': null, + 'finalUrl': null, + }, + ), + ); + await _flushMicrotasks(); + expect( + container.read(metaSearchControllerProvider).capturingByUrl, + isEmpty, + ); + }); + + test('capture messages do not populate documentsByUrl', () async { + final session = _FakeMetaSearchSession(); + final container = _createContainer(session); + addTearDown(container.dispose); + + final sub = container.listen( + metaSearchControllerProvider, + (_, _) {}, + fireImmediately: true, + ); + addTearDown(sub.close); + + const resultUrl = 'https://example.com/result'; + final uri = Uri.parse(resultUrl); + await container + .read(metaSearchControllerProvider.notifier) + .submit('lensai'); + session.emit( + StreamMessage( + type: MessageTypes.searchResults, + data: _searchResultsPayload( + query: 'lensai', + results: [ + { + 'title': 'LensAI result', + 'url': resultUrl, + 'content': null, + 'publishedDate': null, + 'img_src': null, + 'thumbnail': null, + }, + ], + ), + ), + ); + await _flushMicrotasks(); + + await container + .read(metaSearchControllerProvider.notifier) + .capturePage(uri, choice: FetchMethodChoice.singlefileHtml); + + session.emit( + StreamMessage( + type: MessageTypes.capture, + data: { + 'captureId': 'abc123', + 'url': resultUrl, + 'method': 'singlefile', + 'variant': 'balanced', + 'contentType': 'text/html; charset=utf-8', + 'byteLength': 42, + 'downloadToken': 'tok-1', + 'filename': null, + 'finalUrl': null, + }, + ), + ); + await _flushMicrotasks(); + + final state = container.read(metaSearchControllerProvider); + expect(state.documentsByUrl, isEmpty); + expect(state.capturingByUrl, isEmpty); + final captured = state.capturedPage( + uri, + FetchMethodChoice.singlefileHtml, + ); + expect(captured, isNotNull); + expect(captured?.captureId, 'abc123'); + }); + + test( + 'URL-scoped error clears specific fetching and capturing URLs', + () async { + final session = _FakeMetaSearchSession(); + final container = _createContainer(session); + addTearDown(container.dispose); + + final sub = container.listen( + metaSearchControllerProvider, + (_, _) {}, + fireImmediately: true, + ); + addTearDown(sub.close); + + const resultUrl = 'https://example.com/result'; + await container + .read(metaSearchControllerProvider.notifier) + .submit('lensai'); + session.emit( + StreamMessage( + type: MessageTypes.searchResults, + data: _searchResultsPayload( + query: 'lensai', + results: [ + { + 'title': 'LensAI result', + 'url': resultUrl, + 'content': null, + 'publishedDate': null, + 'img_src': null, + 'thumbnail': null, + }, + ], + ), + ), + ); + await _flushMicrotasks(); + + final uri = Uri.parse(resultUrl); + await container + .read(metaSearchControllerProvider.notifier) + .capturePage(uri, choice: FetchMethodChoice.singlefileHtml); + + session.emit( + StreamMessage( + type: MessageTypes.error, + data: {'message': 'Capture failed', 'url': resultUrl}, + ), + ); + await _flushMicrotasks(); + + final state = container.read(metaSearchControllerProvider); + expect(state.capturingByUrl, isEmpty); + expect(state.fetchingUrls, isEmpty); + expect(state.status, WebSearchStatus.ready); + expect(state.results, hasLength(1)); + }, + ); + }); +} + +Map _searchResultsPayload({ + required String query, + required List> results, + int page = 0, + int pageSize = 10, + int? totalResults, + bool hasMore = false, +}) { + return { + 'query': query, + 'infos': const [], + 'results': results, + 'page': page, + 'pageSize': pageSize, + 'totalResults': totalResults ?? results.length, + 'hasMore': hasMore, + }; +} + +ProviderContainer _createContainer(_FakeMetaSearchSession session) { + return ProviderContainer( + overrides: [ + metaSearchEnsureTokenAvailableProvider.overrideWithValue( + () async => TokenAvailabilityOutcome.available, + ), + metaSearchSessionFactoryProvider.overrideWithValue(() async => session), + captureArtifactDownloaderProvider.overrideWith( + (ref) => _FakeCaptureDownloader(), + ), + riverpodDatabaseStorageProvider.overrideWith((ref) => Storage.inMemory()), + ], + ); +} + +class _FakeCaptureDownloader implements CaptureArtifactDownloader { + @override + Future download(CaptureArtifactReceipt receipt) async { + return '/fake/path/${receipt.captureId}.html'; + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +Future _flushMicrotasks() { + return Future.delayed(Duration.zero); +} + +class _FakeMetaSearchSession implements MetaSearchSession { + final _messages = StreamController.broadcast(); + + final List submittedQueries = []; + final List fetchRequests = []; + final List<_CaptureRequest> captureRequests = <_CaptureRequest>[]; + bool _closed = false; + + @override + Stream get messages => _messages.stream; + + @override + bool get isClosed => _closed; + + void emit(StreamMessage message) { + _messages.add(message); + } + + @override + void fetchPage(Uri url) { + fetchRequests.add(url); + } + + @override + void capturePage( + Uri url, { + required String method, + required String variant, + CaptureDimensions? dimensions, + }) { + captureRequests.add( + _CaptureRequest(url: url, method: method, variant: variant), + ); + } + + @override + Future close() async { + _closed = true; + await _messages.close(); + } + + @override + void submitQuery( + String query, { + required SearchMode mode, + String? language, + String? region, + SafeSearch? safeSearch, + TimeRange? timeRange, + }) { + submittedQueries.add(query); + } + + final List loadPageRequests = []; + + @override + void loadPage(int page) { + loadPageRequests.add(page); + } +} + +class _CaptureRequest { + final Uri url; + final String method; + final String variant; + _CaptureRequest({ + required this.url, + required this.method, + required this.variant, + }); +} diff --git a/apps/weblibre/test/features/web_search/domain/services/capture_server_test.dart b/apps/weblibre/test/features/web_search/domain/services/capture_server_test.dart new file mode 100644 index 00000000..81346754 --- /dev/null +++ b/apps/weblibre/test/features/web_search/domain/services/capture_server_test.dart @@ -0,0 +1,271 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:weblibre/features/web_search/domain/services/capture_server.dart'; + +void main() { + late Directory tempDir; + late CaptureServer server; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('capture_server_test_'); + server = CaptureServer(storageDir: tempDir); + }); + + tearDown(() async { + await server.stop(); + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + Future writeCapture(String captureId, String html) async { + final file = File(p.join(tempDir.path, '$captureId.html')); + await file.writeAsString(html); + return file; + } + + group('captures route', () { + test('serves stored HTML with CSP + nosniff', () async { + await writeCapture('abc123', 'hi'); + final url = await server.publish('abc123'); + + final res = await _get(url); + expect(res.statusCode, 200); + expect(res.body, contains('hi')); + expect( + res.headers['content-security-policy'], + contains("script-src 'none'"), + ); + expect(res.headers['x-content-type-options'], 'nosniff'); + expect(res.headers['cache-control'], 'no-store'); + }); + + test('rejects wrong token with 403', () async { + await writeCapture('abc123', 'x'); + await server.publish('abc123'); + final base = await server.publish('abc123'); + final bad = base.replace(queryParameters: {'t': 'invalid'}); + final res = await _get(bad); + expect(res.statusCode, 403); + }); + + test('rejects unknown capture with 403', () async { + final port = await server.ensureStarted(); + final url = Uri.parse( + 'http://127.0.0.1:$port/captures/unknown.html?t=irrelevant', + ); + final res = await _get(url); + expect(res.statusCode, 403); + }); + }); + + group('loader route', () { + test('pending capture returns long-poll shell + locked CSP', () async { + final url = await server.loaderUrl(tabId: 'tab-1', captureId: 'pending1'); + final res = await _get(url); + expect(res.statusCode, 200); + // New design: no meta-refresh; the shell ships an inline long-poll + // script that hits /loader/wait. Sanity-check the script is present + // and the pending UI starts in the visible state. + expect(res.body, contains('Capturing')); + expect(res.body, contains('/loader/wait?tab=')); + expect(res.body, contains('id="pending"')); + expect(res.body, contains('window.__CAPTURE_ID__="pending1"')); + // Loader CSP must allow inline script (for the long-poll JS) but + // forbid remote subresources — this is the safety boundary that + // makes the inline script acceptable. + expect( + res.headers['content-security-policy'], + contains("script-src 'unsafe-inline'"), + ); + expect( + res.headers['content-security-policy'], + contains("default-src 'none'"), + ); + }); + + test('ready capture exposes captureUrl via /loader/wait', () async { + await writeCapture('ready1', 'final'); + await server.publish('ready1'); + final port = await server.ensureStarted(); + // The transition to ready is observed via the long-poll endpoint, not + // a Refresh header on /loader. Hit /loader/wait directly and assert + // it returns the loopback capture URL. + final waitUrl = Uri.parse( + 'http://127.0.0.1:$port/loader/wait?tab=tab-1&capture=ready1', + ); + final res = await _get(waitUrl); + expect(res.statusCode, 200); + expect(res.headers['content-type'], startsWith('application/json')); + final decoded = jsonDecode(res.body) as Map; + expect(decoded['status'], 'ready'); + expect(decoded['url'], contains('/captures/ready1.html')); + expect(decoded['url'], contains('t=')); + }); + + test('err=1 starts the loader in the error pane with a retry button', + () async { + final url = await server.loaderUrl( + tabId: 'tab-9', + captureId: 'failed9', + error: true, + ); + final res = await _get(url); + expect(res.statusCode, 200); + // Retry is now a JS-driven button, not an HTML form — assert the + // shell starts in error mode (error pane visible, pending hidden) + // and that the button + JS retry endpoint string are present. + expect(res.body, contains('id="retry"')); + expect(res.body, contains('id="error" class="error"')); + expect(res.body, contains('id="pending" class="hidden"')); + expect(res.body, contains('/loader/retry?tab=')); + }); + + test('loader with invalid capture id returns 404', () async { + final port = await server.ensureStarted(); + final url = Uri.parse( + 'http://127.0.0.1:$port/loader?tab=t&capture=not%2Fclean', + ); + final res = await _get(url); + expect(res.statusCode, 404); + }); + }); + + group('/loader/wait', () { + test('failed capture returns status=failed immediately', () async { + await server.publish('cap-failed'); + server.markFailed('cap-failed'); + final port = await server.ensureStarted(); + final res = await _get( + Uri.parse( + 'http://127.0.0.1:$port/loader/wait?tab=t&capture=cap-failed', + ), + ); + expect(res.statusCode, 200); + final decoded = jsonDecode(res.body) as Map; + expect(decoded['status'], 'failed'); + expect(decoded.containsKey('url'), isFalse); + }); + + test('publish() wakes an in-flight long-poll', () async { + final port = await server.ensureStarted(); + // Start the long-poll while nothing has been published yet — it + // should block on the per-id completer, not busy-poll the filesystem. + final pollFuture = _get( + Uri.parse( + 'http://127.0.0.1:$port/loader/wait?tab=t&capture=signaled', + ), + ); + // Race-free: publish only after the request hit the handler. A short + // microtask flush gives the handler time to register its waiter. + await Future.delayed(const Duration(milliseconds: 100)); + await writeCapture('signaled', ''); + await server.publish('signaled'); + + final res = await pollFuture.timeout(const Duration(seconds: 2)); + final decoded = jsonDecode(res.body) as Map; + expect(decoded['status'], 'ready'); + expect(decoded['url'], contains('/captures/signaled.html')); + }); + }); + + group('retry route', () { + test('emits a RetryRequest on retryRequests stream and returns 204', + () async { + final port = await server.ensureStarted(); + final retryFuture = server.retryRequests.first; + + final res = await _post( + Uri.parse( + 'http://127.0.0.1:$port/loader/retry?tab=tab-42&capture=cap42', + ), + ); + // 204 No Content — the JS loader re-polls; a body would be wasted. + expect(res.statusCode, 204); + + final req = await retryFuture.timeout(const Duration(seconds: 2)); + expect(req.tabId, 'tab-42'); + expect(req.captureId, 'cap42'); + }); + + test('retry rejects GET with 405', () async { + final port = await server.ensureStarted(); + final res = await _get( + Uri.parse('http://127.0.0.1:$port/loader/retry?tab=x&capture=y'), + ); + expect(res.statusCode, 405); + }); + }); + + group('isReady', () { + test('false until publish + file exist', () async { + expect(server.isReady('x'), isFalse); + await server.publish('x'); + expect(server.isReady('x'), isFalse); + await writeCapture('x', ''); + expect(server.isReady('x'), isTrue); + }); + }); +} + +class _Response { + final int statusCode; + final Map headers; + final String body; + _Response(this.statusCode, this.headers, this.body); +} + +Future<_Response> _get(Uri url) async { + final client = HttpClient(); + try { + final req = await client.getUrl(url); + req.followRedirects = false; + final res = await req.close(); + return await _readResponse(res); + } finally { + client.close(force: true); + } +} + +Future<_Response> _post(Uri url) async { + final client = HttpClient(); + try { + final req = await client.postUrl(url); + req.followRedirects = false; + final res = await req.close(); + return await _readResponse(res); + } finally { + client.close(force: true); + } +} + +Future<_Response> _readResponse(HttpClientResponse response) async { + final bytes = await response.fold>([], (a, c) => a..addAll(c)); + final headers = {}; + response.headers.forEach((name, values) { + headers[name.toLowerCase()] = values.join(','); + }); + return _Response(response.statusCode, headers, utf8.decode(bytes)); +} diff --git a/apps/weblibre/test/features/web_search/presentation/screens/page_preview_test.dart b/apps/weblibre/test/features/web_search/presentation/screens/page_preview_test.dart new file mode 100644 index 00000000..4d5bb6f7 --- /dev/null +++ b/apps/weblibre/test/features/web_search/presentation/screens/page_preview_test.dart @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:search_backend/search_backend.dart'; +import 'package:weblibre/features/geckoview/domain/entities/tab_container_selection.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart'; +import 'package:weblibre/features/user/domain/providers.dart'; +import 'package:weblibre/features/web_search/domain/controllers/search_controller.dart'; +import 'package:weblibre/features/web_search/presentation/open_in_new_tab.dart'; +import 'package:weblibre/features/web_search/presentation/screens/page_preview.dart'; + +import '../../test_harness.dart'; + +const _testOpenTarget = WebSearchOpenTarget( + tabMode: RegularTabMode(), + containerSelection: TabContainerSelection.unassigned(), +); + +void main() { + testWidgets('renders markdown for a fetched document', (tester) async { + const url = 'https://example.com/article'; + final opener = _FakeWebSearchTabOpener(); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + ...webSearchTestOverrides(), + metaSearchControllerProvider.overrideWithValue( + MetaSearchState( + status: WebSearchStatus.ready, + query: 'lensai', + results: [ + CompactSearchResult( + title: 'LensAI article', + url: Uri.parse(url), + ), + ], + documentsByUrl: { + Uri.parse(url): FetchedDocument( + url: Uri.parse(url), + content: '# Heading\n\nBody copy', + metadata: PageMetadata( + title: 'LensAI article', + author: 'Fabian', + ), + ), + }, + ), + ), + webSearchTabOpenerProvider.overrideWithValue(opener), + watchCachedIconBytesProvider.overrideWith((ref, origin) { + return Stream.value(null); + }), + ], + child: MaterialApp( + home: PagePreviewScreen( + uri: Uri.parse(url), + resolveOpenTarget: () => _testOpenTarget, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Heading'), findsOneWidget); + expect(find.text('Body copy'), findsOneWidget); + expect(find.text('Fabian'), findsOneWidget); + + await tester.tap(find.byTooltip('Open in browser')); + await tester.pumpAndSettle(); + + expect(opener.openedUris, [Uri.parse(url)]); + }); +} + +class _FakeWebSearchTabOpener implements WebSearchTabOpener { + final List openedUris = []; + final List openedCaptureIds = []; + + @override + Future open( + BuildContext context, + WidgetRef ref, + Uri uri, { + required WebSearchOpenTarget target, + }) async { + openedUris.add(uri); + } + + @override + Future openCapture( + BuildContext context, + WidgetRef ref, { + required String captureId, + required Uri sourceUrl, + required WebSearchOpenTarget target, + String? contentType, + String? method, + String? variant, + }) async { + openedCaptureIds.add(captureId); + } +} diff --git a/apps/weblibre/test/features/web_search/presentation/widgets/search_result_card_test.dart b/apps/weblibre/test/features/web_search/presentation/widgets/search_result_card_test.dart new file mode 100644 index 00000000..6e4a2336 --- /dev/null +++ b/apps/weblibre/test/features/web_search/presentation/widgets/search_result_card_test.dart @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:search_backend/search_backend.dart'; +import 'package:weblibre/features/user/domain/providers.dart'; +import 'package:weblibre/features/web_search/domain/controllers/search_controller.dart'; +import 'package:weblibre/features/web_search/presentation/widgets/search_result_card.dart'; + +import '../../test_harness.dart'; + +void main() { + testWidgets('tapping a result uses the open callback', (tester) async { + final openedUris = []; + const url = 'https://example.com/result'; + + await tester.pumpWidget( + ProviderScope( + overrides: [ + ...webSearchTestOverrides(), + metaSearchControllerProvider.overrideWithValue( + MetaSearchState(status: WebSearchStatus.ready), + ), + watchCachedIconBytesProvider.overrideWith((ref, origin) { + return Stream.value(null); + }), + ], + child: MaterialApp( + home: Scaffold( + body: WebSearchResultCard( + result: CompactSearchResult( + title: 'LensAI result', + url: Uri.parse(url), + content: 'Result summary', + ), + onOpen: (uri) async { + openedUris.add(uri); + }, + onFetch: (_) async {}, + onPreview: (_) async {}, + onOpenCapture: (_) async {}, + ), + ), + ), + ), + ); + + await tester.tap(find.text('LensAI result')); + await tester.pumpAndSettle(); + + expect(openedUris, [Uri.parse(url)]); + }); + + testWidgets( + 'renders thumbnail image when imgSrc is empty but thumbnail bytes exist', + (tester) async { + const thumbnailUrl = 'https://example.com/thumb.png'; + final pngBytes = base64Decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + ...webSearchTestOverrides(), + metaSearchControllerProvider.overrideWithValue( + MetaSearchState( + status: WebSearchStatus.ready, + imagesByUrl: {thumbnailUrl: Uint8List.fromList(pngBytes)}, + ), + ), + watchCachedIconBytesProvider.overrideWith((ref, origin) { + return Stream.value(null); + }), + ], + child: MaterialApp( + home: Scaffold( + body: WebSearchResultCard( + result: CompactSearchResult( + title: 'LensAI result', + url: Uri.parse('https://example.com/result'), + content: 'Result summary', + imgSrc: '', + thumbnail: thumbnailUrl, + ), + onOpen: (_) async {}, + onFetch: (_) async {}, + onPreview: (_) async {}, + onOpenCapture: (_) async {}, + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.byType(Image), findsOneWidget); + }, + ); +} diff --git a/apps/weblibre/test/features/web_search/test_harness.dart b/apps/weblibre/test/features/web_search/test_harness.dart new file mode 100644 index 00000000..590696bd --- /dev/null +++ b/apps/weblibre/test/features/web_search/test_harness.dart @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +/// Shared fixtures for web_search presentation tests. +/// +/// `UrlIcon` (used by every result card / preview / infobox) reaches into +/// `userDatabaseProvider` → `cacheRepositoryProvider` and +/// `geckoIconServiceProvider`. Both touch native code that isn't available in +/// the test runtime, so we wire in-memory + no-op fakes via this helper to +/// keep individual test setup small. +library; + +import 'dart:typed_data'; + +import 'package:drift/native.dart'; +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; +import 'package:flutter_mozilla_components/src/pigeons/gecko.g.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:riverpod/experimental/persist.dart'; +import 'package:riverpod/misc.dart' show Override; +import 'package:weblibre/data/database/functions/lexo_rank_functions.dart'; +import 'package:weblibre/domain/services/generic_website.dart'; +import 'package:weblibre/features/user/data/database/database.dart'; +import 'package:weblibre/features/user/data/providers.dart'; + +/// Builds the standard set of overrides that any web_search presentation test +/// needs to render `UrlIcon` / settings-backed widgets without touching the +/// real filesystem profile databases. Registers `addTearDown` for the +/// in-memory drift db so the caller doesn't have to. +List webSearchTestOverrides() { + final userDb = UserDatabase( + NativeDatabase.memory( + setup: (database) => registerLexorankFunctions(database), + ), + ); + addTearDown(userDb.close); + + return [ + userDatabaseProvider.overrideWith((ref) => userDb), + riverpodDatabaseStorageProvider.overrideWith((ref) => Storage.inMemory()), + geckoIconServiceProvider.overrideWithValue(_FakeGeckoIconService()), + ]; +} + +/// No-op GeckoIconService — returns a generator-source icon with empty bytes +/// so the cacheOnly branch in UrlIcon falls through to the MdiIcons.web +/// fallback. The real service is a platform-channel adapter and would crash +/// in the test runtime. +final class _FakeGeckoIconService extends GeckoIconService { + @override + Future loadIcon({ + required Uri url, + List resources = const [], + IconSize size = IconSize.defaultSize, + bool isPrivate = false, + bool waitOnNetworkLoad = true, + }) async { + return IconResult( + image: Uint8List(0), + color: null, + source: IconSource.generator, + maskable: false, + ); + } +} diff --git a/apps/weblibre/test/presentation/widgets/url_icon_test.dart b/apps/weblibre/test/presentation/widgets/url_icon_test.dart new file mode 100644 index 00000000..4a33cf32 --- /dev/null +++ b/apps/weblibre/test/presentation/widgets/url_icon_test.dart @@ -0,0 +1,168 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:drift/native.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; +import 'package:flutter_mozilla_components/src/pigeons/gecko.g.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:weblibre/data/database/functions/lexo_rank_functions.dart'; +import 'package:weblibre/domain/services/favicon_resolver.dart'; +import 'package:weblibre/domain/services/generic_website.dart'; +import 'package:weblibre/features/user/data/database/database.dart'; +import 'package:weblibre/features/user/data/providers.dart'; +import 'package:weblibre/features/user/domain/providers.dart'; +import 'package:weblibre/presentation/widgets/url_icon.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('selectFirstCachedIconBytes preserves url-list fallback order', () { + final secondBytes = Uint8List.fromList([2, 3, 4]); + + final selected = selectFirstCachedIconBytes([ + null, + secondBytes, + Uint8List.fromList([9, 9, 9]), + ]); + + expect(selected, same(secondBytes)); + }); + + testWidgets('distinct cached icons do not collapse to one image', ( + tester, + ) async { + final firstUrl = Uri.parse('https://first.example/result'); + final secondUrl = Uri.parse('https://second.example/result'); + final db = UserDatabase( + NativeDatabase.memory( + setup: (database) { + registerLexorankFunctions(database); + }, + ), + ); + + addTearDown(() async { + await db.close(); + }); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + userDatabaseProvider.overrideWith((ref) => db), + faviconResolverProvider.overrideWithValue(_NeverCalledResolver()), + geckoIconServiceProvider.overrideWithValue(_FakeGeckoIconService()), + watchCachedIconBytesProvider( + firstUrl.origin, + ).overrideWith((ref) => Stream.value(_firstSvgBytes)), + watchCachedIconBytesProvider( + secondUrl.origin, + ).overrideWith((ref) => Stream.value(_secondSvgBytes)), + ], + child: MaterialApp( + home: Row( + children: [ + UrlIcon([firstUrl], iconSize: 24, cacheOnly: true), + UrlIcon([secondUrl], iconSize: 24, cacheOnly: true), + ], + ), + ), + ), + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + final rawImages = tester + .widgetList(find.byType(RawImage)) + .toList(); + expect(rawImages, hasLength(2)); + expect(identical(rawImages[0].image, rawImages[1].image), isFalse); + + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pumpAndSettle(); + }); + + testWidgets('cacheOnly falls back to a generated icon on cache miss', ( + tester, + ) async { + final db = UserDatabase( + NativeDatabase.memory( + setup: (database) { + registerLexorankFunctions(database); + }, + ), + ); + + addTearDown(() async { + await db.close(); + }); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + userDatabaseProvider.overrideWith((ref) => db), + faviconResolverProvider.overrideWithValue(_NeverCalledResolver()), + geckoIconServiceProvider.overrideWithValue(_FakeGeckoIconService()), + ], + child: MaterialApp( + home: UrlIcon( + [Uri.parse('https://generated.example')], + iconSize: 24, + cacheOnly: true, + ), + ), + ), + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(find.byType(RawImage), findsOneWidget); + + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pumpAndSettle(); + }); +} + +final class _NeverCalledResolver implements FaviconResolver { + @override + Future resolve(Uri url, {int? proxyPort}) async { + throw UnimplementedError('cacheOnly should not hit the resolver'); + } +} + +final class _FakeGeckoIconService extends GeckoIconService { + @override + Future loadIcon({ + required Uri url, + List resources = const [], + IconSize size = IconSize.defaultSize, + bool isPrivate = false, + bool waitOnNetworkLoad = true, + }) async { + return IconResult( + image: _firstSvgBytes, + color: null, + source: IconSource.generator, + maskable: false, + ); + } +} + +final _firstSvgBytes = Uint8List.fromList( + utf8.encode(''' + + + +'''), +); + +final _secondSvgBytes = Uint8List.fromList( + utf8.encode(''' + + + +'''), +); diff --git a/apps/weblibre/test/utils/url_canonical_test.dart b/apps/weblibre/test/utils/url_canonical_test.dart new file mode 100644 index 00000000..af421f8a --- /dev/null +++ b/apps/weblibre/test/utils/url_canonical_test.dart @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import 'package:flutter_test/flutter_test.dart'; +import 'package:weblibre/utils/url_canonical.dart'; + +void main() { + group('canonicalizeUrl', () { + test('canonicalizes http URLs structurally', () { + final canonical = canonicalizeUrl( + 'HTTPS://Example.com:443/path?q=1#fragment', + ); + + expect(canonical, isNotNull); + expect(canonical!.canonical, 'https://example.com/path?q=1'); + expect(canonical.host, 'example.com'); + expect(canonical.path, '/path'); + }); + + test('preserves about reader URL shape', () { + const readerUrl = 'about:reader?url=https%3A%2F%2Fexample.com%2Fstory'; + + final canonical = canonicalizeUrl(readerUrl); + + expect(canonical, isNotNull); + expect(canonical!.canonical, readerUrl); + expect(canonical.host, isEmpty); + expect(canonical.path, 'reader'); + expect(Uri.parse(canonical.canonical).toString(), readerUrl); + }); + }); + + group('isUrlIndexable', () { + test('accepts about reader URLs', () { + expect( + isUrlIndexable('about:reader?url=https%3A%2F%2Fexample.com%2Fstory'), + isTrue, + ); + }); + + test('rejects other about URLs', () { + expect(isUrlIndexable('about:config'), isFalse); + }); + }); +} diff --git a/packages/flutter_mozilla_components/android/src/main/assets/extensions/sandbox_capture/background.js b/packages/flutter_mozilla_components/android/src/main/assets/extensions/sandbox_capture/background.js new file mode 100644 index 00000000..26ab58fb --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/assets/extensions/sandbox_capture/background.js @@ -0,0 +1,28 @@ +// WebLibre sandbox capture subresource firewall. +// +// Cancels any request issued from a document served by the local capture +// server (http://127.0.0.1:*/captures/* or /loader*) unless the request +// also targets the same loopback server. This is the second line of +// defence behind AppRequestInterceptor — SingleFile captures are already +// fully-inlined, so this firewall should rarely have anything to block +// in practice, but it guarantees the privacy contract. + +const LOOPBACK_PATTERN = /^http:\/\/127\.0\.0\.1:\d+\/(captures|loader)(\/|\?|$)/; + +browser.webRequest.onBeforeRequest.addListener( + (details) => { + const doc = details.documentUrl || details.originUrl; + if (!doc || !LOOPBACK_PATTERN.test(doc)) { + return {}; + } + if (LOOPBACK_PATTERN.test(details.url)) { + return {}; + } + if (details.url.startsWith("data:") || details.url.startsWith("blob:")) { + return {}; + } + return { cancel: true }; + }, + { urls: [""] }, + ["blocking"] +); diff --git a/packages/flutter_mozilla_components/android/src/main/assets/extensions/sandbox_capture/manifest.json b/packages/flutter_mozilla_components/android/src/main/assets/extensions/sandbox_capture/manifest.json new file mode 100644 index 00000000..3cb500ae --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/assets/extensions/sandbox_capture/manifest.json @@ -0,0 +1,20 @@ +{ + "manifest_version": 2, + "name": "WebLibre Sandbox Capture", + "version": "0.1.0", + "description": "Blocks all non-loopback requests from documents served by the WebLibre sandbox capture server.", + "browser_specific_settings": { + "gecko": { + "id": "sandbox-capture@weblibre.eu" + } + }, + "permissions": [ + "", + "geckoViewAddons", + "webRequest", + "webRequestBlocking" + ], + "background": { + "scripts": ["background.js"] + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/BaseBrowserFragment.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/BaseBrowserFragment.kt index 96a2cda7..229fca29 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/BaseBrowserFragment.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/BaseBrowserFragment.kt @@ -109,6 +109,8 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit // Browser scroll-handling detection feature private var browserHandlingScrollFeature: BrowserHandlingScrollFeature? = null + protected open val shouldStartBrowserHandlingScrollFeature: Boolean = true + // Registers a photo picker activity launcher in single-select mode. private val singleMediaPicker = AndroidPhotoPicker.singleMediaPicker( @@ -568,8 +570,10 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit it.start(binding.root) } - browserHandlingScrollFeature = BrowserHandlingScrollFeature(viewportEvents).also { - it.start() + if (shouldStartBrowserHandlingScrollFeature) { + browserHandlingScrollFeature = BrowserHandlingScrollFeature(viewportEvents).also { + it.start() + } } } diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/EngineProvider.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/EngineProvider.kt index a23062f2..38478ecb 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/EngineProvider.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/EngineProvider.kt @@ -9,6 +9,7 @@ import eu.weblibre.flutter_mozilla_components.feature.ContainerProxyFeature import eu.weblibre.flutter_mozilla_components.feature.CookieManagerFeature import eu.weblibre.flutter_mozilla_components.feature.BrowserExtensionFeature import eu.weblibre.flutter_mozilla_components.feature.MLEngineFeature +import eu.weblibre.flutter_mozilla_components.feature.SandboxCaptureFeature import eu.weblibre.flutter_mozilla_components.pigeons.BounceTrackingProtectionMode import eu.weblibre.flutter_mozilla_components.pigeons.BrowserExtensionEvents import eu.weblibre.flutter_mozilla_components.pigeons.GeckoStateEvents @@ -132,6 +133,8 @@ object EngineProvider { "mozacReaderExtract", ).install(it) + SandboxCaptureFeature.install(it) + BuiltInWebExtensionController( "readerview@mozac.org", "resource://android/assets/extensions/readerview/", diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/ExternalAppBrowserFragment.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/ExternalAppBrowserFragment.kt index 7001f680..db2fe278 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/ExternalAppBrowserFragment.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/ExternalAppBrowserFragment.kt @@ -12,12 +12,13 @@ import android.os.Environment import android.view.Gravity import android.view.LayoutInflater import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout import android.widget.ImageButton import android.widget.ImageView import android.widget.PopupWindow import androidx.annotation.CallSuper import androidx.lifecycle.lifecycleScope -import com.google.android.material.appbar.AppBarLayout import com.google.android.material.color.MaterialColors import com.google.android.material.materialswitch.MaterialSwitch import com.mikepenz.iconics.IconicsDrawable @@ -56,6 +57,9 @@ class ExternalAppBrowserFragment : BaseBrowserFragment(), UserInteractionHandler private var customTabToolbar: CustomTabToolbar? = null private var activePopup: PopupWindow? = null + private var fixedToolbarVisible = false + + override val shouldStartBrowserHandlingScrollFeature: Boolean = false private val customTabSessionId: String? get() = arguments?.getString(CUSTOM_TAB_SESSION_ID_KEY) @@ -130,15 +134,22 @@ class ExternalAppBrowserFragment : BaseBrowserFragment(), UserInteractionHandler if (!isPwaOrTwa) { setupCustomTabToolbar(sessionId) } + + resetExternalDynamicToolbar() } private fun setupCustomTabToolbar(sessionId: String) { val view = requireView() + if (customTabToolbar != null) { + setFixedToolbarVisible(true) + return + } + val toolbar = CustomTabToolbar(requireContext()).apply { - layoutParams = AppBarLayout.LayoutParams( - AppBarLayout.LayoutParams.MATCH_PARENT, - AppBarLayout.LayoutParams.WRAP_CONTENT + layoutParams = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.WRAP_CONTENT ) } customTabToolbar = toolbar @@ -146,6 +157,9 @@ class ExternalAppBrowserFragment : BaseBrowserFragment(), UserInteractionHandler binding.customTabAppBar.apply { removeAllViews() addView(toolbar) + addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> + updateBrowserContentMargins() + } visibility = View.VISIBLE } @@ -172,6 +186,38 @@ class ExternalAppBrowserFragment : BaseBrowserFragment(), UserInteractionHandler owner = this, view = view ) + + setFixedToolbarVisible(true) + } + + private fun setFixedToolbarVisible(visible: Boolean) { + fixedToolbarVisible = visible + binding.customTabAppBar.visibility = if (visible) View.VISIBLE else View.GONE + resetExternalDynamicToolbar() + updateBrowserContentMargins() + + if (visible) { + binding.customTabAppBar.post { updateBrowserContentMargins() } + } + } + + private fun updateBrowserContentMargins() { + val toolbarHeight = if (fixedToolbarVisible) binding.customTabAppBar.height else 0 + val layoutParams = binding.browserContent.layoutParams as ViewGroup.MarginLayoutParams + if (layoutParams.topMargin == toolbarHeight && layoutParams.bottomMargin == 0) { + return + } + + layoutParams.topMargin = toolbarHeight + layoutParams.bottomMargin = 0 + binding.browserContent.layoutParams = layoutParams + } + + private fun resetExternalDynamicToolbar() { + components.externalAppEngineView?.apply { + setDynamicToolbarMaxHeight(0) + setVerticalClipping(0) + } } private fun showCustomTabMenu(sessionId: String) { @@ -316,7 +362,7 @@ class ExternalAppBrowserFragment : BaseBrowserFragment(), UserInteractionHandler val store = components.core.store val customTab = store.state.findCustomTab(sessionId) ?: return - components.activeEngineView?.setDynamicToolbarMaxHeight(0) + resetExternalDynamicToolbar() val manifest = webAppManifestUrl?.ifEmpty { null }?.let { url -> components.core.webAppManifestStorage.getManifestCache(url) @@ -341,6 +387,9 @@ class ExternalAppBrowserFragment : BaseBrowserFragment(), UserInteractionHandler scope = viewLifecycleOwner.lifecycleScope, ) { toolbarVisible -> Logger.debug("Custom tab toolbar visibility: $toolbarVisible") + if (toolbarVisible) { + setupCustomTabToolbar(sessionId) + } }, owner = this, view = view, diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/FlutterMozillaComponentsPlugin.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/FlutterMozillaComponentsPlugin.kt index 891cf422..0b26d3da 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/FlutterMozillaComponentsPlugin.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/FlutterMozillaComponentsPlugin.kt @@ -7,6 +7,7 @@ package eu.weblibre.flutter_mozilla_components import eu.weblibre.flutter_mozilla_components.api.GeckoBrowserApiImpl +import eu.weblibre.flutter_mozilla_components.feature.SandboxCaptureFeature import eu.weblibre.flutter_mozilla_components.pigeons.GeckoBrowserApi import io.flutter.embedding.engine.plugins.FlutterPlugin @@ -21,10 +22,11 @@ class FlutterMozillaComponentsPlugin: FlutterPlugin, ActivityAware { override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { browserApi.attachBinding(flutterPluginBinding) GeckoBrowserApi.setUp(flutterPluginBinding.binaryMessenger, browserApi) + SandboxCaptureFeature.wireFlutterEvents(flutterPluginBinding.binaryMessenger) } override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { - + SandboxCaptureFeature.detachFlutterEvents(binding.binaryMessenger) } override fun onAttachedToActivity(binding: ActivityPluginBinding) { diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/GeckoPlatformView.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/GeckoPlatformView.kt index 79fac6df..a1dd345d 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/GeckoPlatformView.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/GeckoPlatformView.kt @@ -13,6 +13,7 @@ import android.view.ViewGroup import android.widget.FrameLayout import eu.weblibre.flutter_mozilla_components.ext.EventSequence import eu.weblibre.flutter_mozilla_components.pigeons.GeckoStateEvents +import eu.weblibre.flutter_mozilla_components.widget.BackGestureFilterFrameLayout import io.flutter.plugin.common.StandardMessageCodec import io.flutter.plugin.platform.PlatformView import io.flutter.plugin.platform.PlatformViewFactory @@ -52,7 +53,7 @@ private class NativeFragmentView( throw IllegalStateException("Activity cannot be null when creating NativeFragmentView") } - container = FrameLayout(activity) + container = BackGestureFilterFrameLayout(activity, activity) container.layoutParams = vParams container.id = containerId } diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/GlobalComponents.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/GlobalComponents.kt index 5ec61c85..af875459 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/GlobalComponents.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/GlobalComponents.kt @@ -7,6 +7,7 @@ package eu.weblibre.flutter_mozilla_components import android.content.Context +import android.content.Intent import eu.weblibre.flutter_mozilla_components.pigeons.AddonCollection import eu.weblibre.flutter_mozilla_components.pigeons.BrowserExtensionEvents import eu.weblibre.flutter_mozilla_components.pigeons.BounceTrackingProtectionMode @@ -21,6 +22,7 @@ import eu.weblibre.flutter_mozilla_components.pigeons.GeckoTabContentEvents import eu.weblibre.flutter_mozilla_components.pigeons.GeckoViewportEvents import eu.weblibre.flutter_mozilla_components.pigeons.QueryParameterStripping import eu.weblibre.flutter_mozilla_components.pigeons.ReaderViewController +import eu.weblibre.flutter_mozilla_components.services.PrivateTabsNotificationService import eu.weblibre.flutter_mozilla_components.addons.AddonPrefs import eu.weblibre.flutter_mozilla_components.api.GeckoViewportApiImpl import eu.weblibre.flutter_mozilla_components.api.GeckoEngineSettingsApiImpl @@ -39,6 +41,7 @@ import mozilla.components.browser.state.selector.findCustomTab import mozilla.components.ExperimentalAndroidComponentsApi import mozilla.components.concept.engine.selection.SelectionActionDelegate import mozilla.components.concept.engine.preferences.Branch +import mozilla.components.feature.privatemode.notification.PrivateNotificationFeature import mozilla.components.feature.addons.update.GlobalAddonDependencyProvider import mozilla.components.support.base.facts.Facts import mozilla.components.support.base.facts.processor.LogFactProcessor @@ -57,6 +60,8 @@ private const val UBLOCK_FILTER_LISTS_PREF = "browser.weblibre.uBO.filterLists" object GlobalComponents { private var _components: Components? = null private var currentMode: ComponentsMode? = null + private var privateTabsNotificationFeature: + PrivateNotificationFeature? = null val components: Components? get() = _components @@ -102,6 +107,25 @@ object GlobalComponents { var startupUBlockFilterListsPref: String? = null var clearStartupUBlockFilterListsPref: Boolean = false + private fun startPrivateTabsNotificationFeature(components: Components) { + privateTabsNotificationFeature = PrivateNotificationFeature( + components.profileApplicationContext, + components.core.store, + PrivateTabsNotificationService::class, + ).also { + it.start() + } + } + + fun stopPrivateTabsNotificationFeature() { + val context = _components?.profileApplicationContext + + privateTabsNotificationFeature?.stop() + privateTabsNotificationFeature = null + + context?.stopService(Intent(context, PrivateTabsNotificationService::class.java)) + } + fun shouldOpenLinksInApp(isExternalSession: Boolean = false): Boolean { return when (engineSettingsApi!!.getAppLinksMode()) { eu.weblibre.flutter_mozilla_components.pigeons.AppLinksMode.ALWAYS -> true @@ -244,6 +268,8 @@ object GlobalComponents { } } + stopPrivateTabsNotificationFeature() + //newComponents.crashReporter.install(applicationContext) //Facts.registerProcessor(LogFactProcessor()) @@ -256,6 +282,7 @@ object GlobalComponents { if (mode == ComponentsMode.FULL) { newComponents.core.engine.warmUp() applyStartupUBlockFilterListsPref(newComponents) + startPrivateTabsNotificationFeature(newComponents) } fun restorePreviousCustomTabs() { diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoBrowserApiImpl.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoBrowserApiImpl.kt index 27477989..fd804408 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoBrowserApiImpl.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoBrowserApiImpl.kt @@ -512,6 +512,8 @@ class GeckoBrowserApiImpl : GeckoBrowserApi { // 2. Stop component-level services try { + GlobalComponents.stopPrivateTabsNotificationFeature() + GlobalComponents.components?.let { components -> // Stop the FxA web channel feature runCatching { components.services.fxaWebChannelFeature.stop() } diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoHistoryApiImpl.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoHistoryApiImpl.kt index 02e99df8..61cb686e 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoHistoryApiImpl.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoHistoryApiImpl.kt @@ -1,19 +1,28 @@ package eu.weblibre.flutter_mozilla_components.api import eu.weblibre.flutter_mozilla_components.GlobalComponents +import eu.weblibre.flutter_mozilla_components.pigeons.DocumentType import eu.weblibre.flutter_mozilla_components.pigeons.GeckoHistoryApi import eu.weblibre.flutter_mozilla_components.pigeons.FrecencyThresholdOption import eu.weblibre.flutter_mozilla_components.pigeons.HistoryHighlight import eu.weblibre.flutter_mozilla_components.pigeons.HistoryHighlightWeights +import eu.weblibre.flutter_mozilla_components.pigeons.HistoryMetadata +import eu.weblibre.flutter_mozilla_components.pigeons.HistoryMetadataKey +import eu.weblibre.flutter_mozilla_components.pigeons.HistorySuggestion +import eu.weblibre.flutter_mozilla_components.pigeons.PageObservation import eu.weblibre.flutter_mozilla_components.pigeons.TopFrecentSiteInfo import eu.weblibre.flutter_mozilla_components.pigeons.VisitInfo import eu.weblibre.flutter_mozilla_components.pigeons.VisitType import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import mozilla.components.browser.state.state.content.DownloadState +import mozilla.components.concept.storage.HistoryMetadataObservation import kotlin.time.Duration.Companion.milliseconds class GeckoHistoryApiImpl() : GeckoHistoryApi { @@ -239,6 +248,40 @@ class GeckoHistoryApiImpl() : GeckoHistoryApi { } } + private fun mozilla.components.concept.storage.DocumentType.toPigeon(): DocumentType = + when (this) { + mozilla.components.concept.storage.DocumentType.Regular -> DocumentType.REGULAR + mozilla.components.concept.storage.DocumentType.Media -> DocumentType.MEDIA + } + + private fun DocumentType.toConcept(): mozilla.components.concept.storage.DocumentType = + when (this) { + DocumentType.REGULAR -> mozilla.components.concept.storage.DocumentType.Regular + DocumentType.MEDIA -> mozilla.components.concept.storage.DocumentType.Media + } + + private fun HistoryMetadataKey.toConcept(): mozilla.components.concept.storage.HistoryMetadataKey = + mozilla.components.concept.storage.HistoryMetadataKey( + url = url, + searchTerm = searchTerm, + referrerUrl = referrerUrl, + ) + + private fun mozilla.components.concept.storage.HistoryMetadata.toPigeon(): HistoryMetadata = + HistoryMetadata( + key = HistoryMetadataKey( + url = key.url, + searchTerm = key.searchTerm, + referrerUrl = key.referrerUrl, + ), + title = title, + createdAt = createdAt, + updatedAt = updatedAt, + totalViewTime = totalViewTime.toLong(), + documentType = documentType.toPigeon(), + previewImageUrl = previewImageUrl, + ) + override fun getTopFrecentSites( limit: Long, frecencyThreshold: FrecencyThresholdOption, @@ -265,4 +308,192 @@ class GeckoHistoryApiImpl() : GeckoHistoryApi { } } } + + override fun getLatestHistoryMetadataForUrl( + url: String, + callback: (Result) -> Unit + ) { + coroutineScope.launch { + withContext(Dispatchers.Main) { + val metadata = components.core.historyStorage + .getLatestHistoryMetadataForUrl(url) + ?.toPigeon() + callback(Result.success(metadata)) + } + } + } + + override fun getLatestHistoryMetadataForUrls( + urls: List, + callback: (Result>) -> Unit + ) { + coroutineScope.launch { + withContext(Dispatchers.Main) { + // Run lookups concurrently so Rust JNI calls don't serialize + // per-URL on the Pigeon roundtrip. Order is preserved by + // `awaitAll` honoring the input ordering. + val results = coroutineScope { + urls.map { url -> + async { + components.core.historyStorage + .getLatestHistoryMetadataForUrl(url) + ?.toPigeon() + } + }.awaitAll() + } + callback(Result.success(results)) + } + } + } + + override fun getVisited( + urls: List, + callback: (Result>) -> Unit + ) { + coroutineScope.launch { + withContext(Dispatchers.Main) { + val visited = components.core.historyStorage.getVisited(urls) + callback(Result.success(visited)) + } + } + } + + override fun getSuggestions( + query: String, + limit: Long, + callback: (Result>) -> Unit + ) { + coroutineScope.launch { + withContext(Dispatchers.Main) { + val suggestions = components.core.historyStorage + .getSuggestions(query, limit.toInt()) + .map { + HistorySuggestion( + url = it.url, + title = it.title, + score = it.score.toLong(), + ) + } + callback(Result.success(suggestions)) + } + } + } + + override fun queryHistoryMetadata( + query: String, + limit: Long, + callback: (Result>) -> Unit + ) { + coroutineScope.launch { + withContext(Dispatchers.Main) { + val results = components.core.historyStorage + .queryHistoryMetadata(query, limit.toInt()) + .map { it.toPigeon() } + callback(Result.success(results)) + } + } + } + + override fun recordObservation( + url: String, + observation: PageObservation, + callback: (Result) -> Unit + ) { + coroutineScope.launch { + withContext(Dispatchers.Main) { + components.core.historyStorage.recordObservation( + url, + mozilla.components.concept.storage.PageObservation( + title = observation.title, + previewImageUrl = observation.previewImageUrl, + ), + ) + callback(Result.success(Unit)) + } + } + } + + override fun noteHistoryMetadataViewTime( + key: HistoryMetadataKey, + viewTimeMs: Long, + callback: (Result) -> Unit + ) { + coroutineScope.launch { + withContext(Dispatchers.Main) { + components.core.historyStorage.noteHistoryMetadataObservation( + key.toConcept(), + HistoryMetadataObservation.ViewTimeObservation( + viewTime = viewTimeMs.toInt(), + ), + ) + callback(Result.success(Unit)) + } + } + } + + override fun noteHistoryMetadataDocumentType( + key: HistoryMetadataKey, + documentType: DocumentType, + callback: (Result) -> Unit + ) { + coroutineScope.launch { + withContext(Dispatchers.Main) { + components.core.historyStorage.noteHistoryMetadataObservation( + key.toConcept(), + HistoryMetadataObservation.DocumentTypeObservation( + documentType = documentType.toConcept(), + ), + ) + callback(Result.success(Unit)) + } + } + } + + override fun deleteVisitsFor( + url: String, + callback: (Result) -> Unit + ) { + coroutineScope.launch { + withContext(Dispatchers.Main) { + components.core.historyStorage.deleteVisitsFor(url) + callback(Result.success(Unit)) + } + } + } + + override fun deleteVisitsSince( + sinceMillis: Long, + callback: (Result) -> Unit + ) { + coroutineScope.launch { + withContext(Dispatchers.Main) { + components.core.historyStorage.deleteVisitsSince(sinceMillis) + callback(Result.success(Unit)) + } + } + } + + override fun deleteEverything( + callback: (Result) -> Unit + ) { + coroutineScope.launch { + withContext(Dispatchers.Main) { + components.core.historyStorage.deleteEverything() + callback(Result.success(Unit)) + } + } + } + + override fun deleteHistoryMetadataOlderThan( + olderThanMillis: Long, + callback: (Result) -> Unit + ) { + coroutineScope.launch { + withContext(Dispatchers.Main) { + components.core.historyStorage + .deleteHistoryMetadataOlderThan(olderThanMillis) + callback(Result.success(Unit)) + } + } + } } diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoIconsApiImpl.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoIconsApiImpl.kt index 8f1c2bd5..ef19d22a 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoIconsApiImpl.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoIconsApiImpl.kt @@ -12,6 +12,15 @@ import eu.weblibre.flutter_mozilla_components.pigeons.* import kotlinx.coroutines.* import mozilla.components.browser.icons.BrowserIcons import mozilla.components.browser.icons.Icon +import mozilla.components.browser.icons.loader.DataUriIconLoader +import mozilla.components.browser.icons.loader.DiskIconLoader +import mozilla.components.browser.icons.loader.MemoryIconLoader +import mozilla.components.browser.icons.preparer.DiskIconPreparer +import mozilla.components.browser.icons.preparer.MemoryIconPreparer +import mozilla.components.browser.icons.processor.DiskIconProcessor +import mozilla.components.browser.icons.processor.MemoryIconProcessor +import mozilla.components.browser.icons.utils.IconDiskCache +import mozilla.components.browser.icons.utils.IconMemoryCache import mozilla.components.concept.engine.manifest.Size as HtmlSize import mozilla.components.feature.addons.logger @@ -33,6 +42,29 @@ class GeckoIconsApiImpl : GeckoIconsApi { requireNotNull(GlobalComponents.components) { "Components not initialized" } } + private val noNetworkIcons by lazy { + val memoryCache = IconMemoryCache() + val diskCache = IconDiskCache() + + BrowserIcons( + context = components.profileApplicationContext, + httpClient = components.core.client, + preparers = listOf( + MemoryIconPreparer(memoryCache), + DiskIconPreparer(diskCache), + ), + loaders = listOf( + MemoryIconLoader(memoryCache), + DiskIconLoader(diskCache), + DataUriIconLoader(), + ), + processors = listOf( + MemoryIconProcessor(memoryCache), + DiskIconProcessor(diskCache), + ), + ) + } + override fun loadIcon(request: IconRequest, callback: (Result) -> Unit) { coroutineScope.launch { try { @@ -54,7 +86,12 @@ class GeckoIconsApiImpl : GeckoIconsApi { private suspend fun loadIconAsync(request: MozillaIconRequest): IconResult { return try { - val result = components.core.icons.loadIcon(request).await() + val browserIcons = if (request.waitOnNetworkLoad) { + components.core.icons + } else { + noNetworkIcons + } + val result = browserIcons.loadIcon(request).await() val imageBytes = result.bitmap.toWebPBytes() IconResult( image = imageBytes, diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/Core.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/Core.kt index cae23553..0624e4ab 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/Core.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/Core.kt @@ -24,6 +24,7 @@ import eu.weblibre.flutter_mozilla_components.ext.getPreferenceKey import eu.weblibre.flutter_mozilla_components.middleware.FlutterEventMiddleware import eu.weblibre.flutter_mozilla_components.middleware.HistoryMetadataMiddleware import eu.weblibre.flutter_mozilla_components.middleware.HistoryMetadataService +import eu.weblibre.flutter_mozilla_components.middleware.SandboxCaptureMiddleware import eu.weblibre.flutter_mozilla_components.middleware.SaveToPDFMiddleware import eu.weblibre.flutter_mozilla_components.pigeons.BrowserExtensionEvents import eu.weblibre.flutter_mozilla_components.pigeons.GeckoStateEvents @@ -205,6 +206,9 @@ class Core( val store by lazy { BrowserStore( middleware = listOf( + // Must run before any engine middleware so we can rewrite + // sandbox new-tab URLs before Gecko issues a request. + SandboxCaptureMiddleware, HistoryMetadataMiddleware(historyMetadataService), FlutterEventMiddleware(flutterEvents), DownloadMiddleware( diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/feature/SandboxCaptureFeature.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/feature/SandboxCaptureFeature.kt new file mode 100644 index 00000000..e279a61b --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/feature/SandboxCaptureFeature.kt @@ -0,0 +1,177 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package eu.weblibre.flutter_mozilla_components.feature + +import android.content.Context +import eu.weblibre.flutter_mozilla_components.ActiveProfile +import eu.weblibre.flutter_mozilla_components.PwaConstants +import eu.weblibre.flutter_mozilla_components.pigeons.SandboxCaptureApi +import eu.weblibre.flutter_mozilla_components.pigeons.SandboxCaptureEntry +import eu.weblibre.flutter_mozilla_components.pigeons.SandboxCaptureHostEvents +import io.flutter.plugin.common.BinaryMessenger +import mozilla.components.concept.engine.Engine +import mozilla.components.support.base.log.logger.Logger +import mozilla.components.support.webextensions.BuiltInWebExtensionController +import org.json.JSONArray +import org.json.JSONObject +import java.io.File + +// Installs the bundled sandbox-capture WebExtension (subresource firewall) +// and serves as the Dart -> Kotlin SandboxCaptureApi endpoint. The extension +// is stateless; no content-message handler is registered. +object SandboxCaptureFeature : SandboxCaptureApi { + private val logger = Logger("SandboxCapture") + + private const val EXTENSION_ID = "sandbox-capture@weblibre.eu" + private const val EXTENSION_URL = + "resource://android/assets/extensions/sandbox_capture/" + + // Highest sandbox_captures.json envelope version this build understands. + // Bump in lockstep with `_version` in Dart's SandboxCaptureStore. A + // newer envelope leaves the registry empty for the current cold start + // so sandbox tabs reopen as about:blank rather than potentially being + // mis-rehydrated. + private const val SUPPORTED_VERSION = 1 + + private val extensionController = BuiltInWebExtensionController( + EXTENSION_ID, + EXTENSION_URL, + // The extension doesn't open a content port; we pass a dummy channel + // name because BuiltInWebExtensionController requires one. + "sandboxCapture", + ) + + fun install(engine: Engine) { + extensionController.install( + engine, + onSuccess = { + logger.debug("Installed sandbox_capture webextension") + }, + onError = { throwable -> + logger.error("Failed to install sandbox_capture", throwable) + }, + ) + } + + // Rehydrate the registry from disk before Gecko tab restore fires any + // AppRequestInterceptor.onLoadRequest. Each entry is stamped with + // redirectUrl="about:blank" so the first load after cold start shows a + // blank page rather than hitting the live web. Dart will call resetAll + // again after CaptureServer comes up to supply the real loader/capture + // URLs. + fun preRestoreBootstrap(context: Context) { + try { + val file = sandboxCapturesFile(context) ?: return + if (!file.exists()) return + + val json = JSONObject(file.readText()) + + // Treat a missing `version` field as v1 to stay compatible with + // envelopes written before versioning was introduced. Reject + // newer versions loudly — see SUPPORTED_VERSION comment. + val version = json.optInt("version", 1) + if (version > SUPPORTED_VERSION) { + logger.warn( + "sandbox_captures.json version $version is newer than " + + "SUPPORTED_VERSION=$SUPPORTED_VERSION; starting empty " + + "to avoid mis-rehydrating.", + ) + SandboxCaptureRegistry.resetAll(emptyMap()) + return + } + + val entries = json.optJSONArray("entries") ?: return + val populated = HashMap() + + for (i in 0 until entries.length()) { + val obj = entries.optJSONObject(i) ?: continue + val tabId = obj.optString("tabId") + val captureId = obj.optString("captureId") + val sourceUrl = obj.optString("sourceUrl") + val status = obj.optString("status", "pending") + if (tabId.isEmpty() || captureId.isEmpty() || sourceUrl.isEmpty()) { + continue + } + populated[tabId] = SandboxCaptureRegistry.SandboxEntry( + captureId = captureId, + sourceUrl = sourceUrl, + redirectUrl = "about:blank", + status = status, + ) + } + + SandboxCaptureRegistry.resetAll(populated) + logger.debug("Rehydrated ${populated.size} sandbox tab(s) from disk") + } catch (t: Throwable) { + logger.error("Failed to rehydrate sandbox registry; starting empty", t) + SandboxCaptureRegistry.resetAll(emptyMap()) + } + } + + fun wireFlutterEvents(binaryMessenger: BinaryMessenger) { + SandboxCaptureApi.setUp(binaryMessenger, this) + SandboxCaptureBridge.events = SandboxCaptureHostEvents(binaryMessenger) + } + + fun detachFlutterEvents(binaryMessenger: BinaryMessenger) { + SandboxCaptureApi.setUp(binaryMessenger, null) + SandboxCaptureBridge.events = null + } + + // SandboxCaptureApi implementation: Dart -> Kotlin. + + override fun resetAll(entries: List) { + val map = HashMap() + for (e in entries) { + map[e.tabId] = e.toRegistryEntry() + } + SandboxCaptureRegistry.resetAll(map) + } + + override fun mark(entry: SandboxCaptureEntry) { + SandboxCaptureRegistry.put(entry.tabId, entry.toRegistryEntry()) + } + + override fun unmark(tabId: String) { + SandboxCaptureRegistry.remove(tabId) + } + + // JSON mirror file under the active profile dir. Dart writes it on every + // mutation; Kotlin reads it during pre-Gecko bootstrap. + private fun sandboxCapturesFile(context: Context): File? { + if (ActiveProfile.prefix == null) { + ActiveProfile.resolveFromDisk(context) + } + val prefix = ActiveProfile.prefix ?: return null + val profileDir = File( + context.filesDir, + "${PwaConstants.PROFILES_DIR_NAME}/$prefix", + ) + return File(profileDir, "files/sandbox_captures.json") + } + + // Exposed for Dart tests that want to verify the JSON format without + // writing a binding — the Dart side uses the same path convention. + fun sandboxCapturesJson(entries: List, tabIds: List): String { + require(entries.size == tabIds.size) + val array = JSONArray() + entries.forEachIndexed { i, e -> + array.put( + JSONObject().apply { + put("tabId", tabIds[i]) + put("captureId", e.captureId) + put("sourceUrl", e.sourceUrl) + put("status", e.status) + }, + ) + } + return JSONObject() + .put("version", SUPPORTED_VERSION) + .put("entries", array) + .toString() + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/feature/SandboxCaptureRegistry.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/feature/SandboxCaptureRegistry.kt new file mode 100644 index 00000000..60642f4d --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/feature/SandboxCaptureRegistry.kt @@ -0,0 +1,109 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package eu.weblibre.flutter_mozilla_components.feature + +import android.net.Uri +import eu.weblibre.flutter_mozilla_components.ext.EventSequence +import eu.weblibre.flutter_mozilla_components.pigeons.SandboxCaptureEntry +import eu.weblibre.flutter_mozilla_components.pigeons.SandboxCaptureHostEvents +import java.util.concurrent.ConcurrentHashMap + +// Per-tab sandbox capture state consulted by AppRequestInterceptor on every +// load. The registry is authoritative for runtime decisions and is populated +// from three sources: +// 1. Pre-Gecko-init rehydration from /files/sandbox_captures.json +// (see SandboxCaptureFeature.preRestoreBootstrap). +// 2. Dart pushing resetAll / mark / unmark at runtime via the +// SandboxCaptureApi pigeon channel. +// 3. The search-results entry point when the user opens a root capture. +object SandboxCaptureRegistry { + data class SandboxEntry( + val captureId: String, + val sourceUrl: String, + val redirectUrl: String, + val status: String, + ) + + private val store = ConcurrentHashMap() + + fun get(tabId: String): SandboxEntry? = store[tabId] + + fun put(tabId: String, entry: SandboxEntry) { + store[tabId] = entry + } + + fun remove(tabId: String) { + store.remove(tabId) + } + + fun resetAll(newEntries: Map) { + store.clear() + store.putAll(newEntries) + } + + fun snapshot(): Map = HashMap(store) + + // Returns true for URLs the loopback CaptureServer owns: /captures/* and + // /loader*. Used by the interceptor to allow self-redirects and by the + // middleware to skip re-dispatching Dart-originated navigations. + fun isLoopbackRedirect(url: String): Boolean { + val parsed = Uri.parse(url) + if (parsed.scheme != "http") return false + if (parsed.host != "127.0.0.1") return false + val path = parsed.path.orEmpty() + return path.startsWith("/captures") || path.startsWith("/loader") + } +} + +// Inert external schemes that never trigger a network request from the +// current tab. When a sandbox tab navigates to one of these we let the +// normal AppLinksInterceptor handle it (usually by dispatching an Android +// intent). +object InertExternalSchemes { + private val schemes = setOf( + "mailto", + "tel", + "sms", + "mms", + "geo", + "intent", + "market", + "maps", + ) + + fun matches(uri: Uri): Boolean { + val scheme = uri.scheme?.lowercase() ?: return false + return schemes.contains(scheme) + } +} + +// Holds the lazily-initialised SandboxCaptureHostEvents pigeon sender so the +// interceptor and middleware can fire-and-forget dispatch link clicks / new +// tabs without knowing about the Flutter binding lifecycle. +object SandboxCaptureBridge { + @Volatile + var events: SandboxCaptureHostEvents? = null + + fun dispatchLinkClick(parentTabId: String, targetUrl: String) { + val e = events ?: return + e.onSandboxLinkClick(EventSequence.next(), parentTabId, targetUrl) {} + } + + fun dispatchNewTab(parentTabId: String, newTabId: String, targetUrl: String) { + val e = events ?: return + e.onSandboxNewTab(EventSequence.next(), parentTabId, newTabId, targetUrl) {} + } +} + +fun SandboxCaptureEntry.toRegistryEntry(): SandboxCaptureRegistry.SandboxEntry { + return SandboxCaptureRegistry.SandboxEntry( + captureId = captureId, + sourceUrl = sourceUrl, + redirectUrl = redirectUrl, + status = status, + ) +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/interceptor/AppRequestInterceptor.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/interceptor/AppRequestInterceptor.kt index 848aea8c..a5fb59d0 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/interceptor/AppRequestInterceptor.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/interceptor/AppRequestInterceptor.kt @@ -6,8 +6,15 @@ package eu.weblibre.flutter_mozilla_components.interceptor +import android.content.ActivityNotFoundException import android.content.Context +import android.content.Intent +import android.net.Uri +import android.util.Log import eu.weblibre.flutter_mozilla_components.GlobalComponents +import eu.weblibre.flutter_mozilla_components.feature.InertExternalSchemes +import eu.weblibre.flutter_mozilla_components.feature.SandboxCaptureBridge +import eu.weblibre.flutter_mozilla_components.feature.SandboxCaptureRegistry import mozilla.components.browser.errorpages.ErrorPages import mozilla.components.browser.errorpages.ErrorType import mozilla.components.browser.state.selector.findTabOrCustomTab @@ -42,6 +49,72 @@ class AppRequestInterceptor(private val context: Context) : RequestInterceptor { return null } + // Parsed once and reused by both the sandbox-capture handling and the + // weblibre:// deep-link check below. `Uri.parse` never throws for + // malformed input — it returns a Uri with empty fields — so callers + // must check the scheme explicitly. + val parsed = Uri.parse(uri) + + // Sandbox capture tabs: rewrite loads to loopback, deny links to live URLs. + val sandboxEntry = (customTab?.id)?.let { SandboxCaptureRegistry.get(it) } + if (sandboxEntry != null) { + when { + // Loopback redirects emitted by us — pass through. + SandboxCaptureRegistry.isLoopbackRedirect(uri) -> { + // Fall through to existing accounts/applinks logic below — + // loopback URLs don't match any of them and the default + // "return null" at the bottom lets Gecko load normally. + } + // Canonical source URL — redirect to current loader/capture. + uri == sandboxEntry.sourceUrl -> { + return RequestInterceptor.InterceptionResponse.Url( + sandboxEntry.redirectUrl, + ) + } + // Inert external schemes — hand off to existing AppLinks logic. + InertExternalSchemes.matches(parsed) -> { + // Fall through to AppLinks handling below. + } + // Any other URL — sandbox tab is trying to navigate to the live + // web. Deny and ask Flutter to open a new sandbox tab instead. + else -> { + customTab.id.let { parentId -> + SandboxCaptureBridge.dispatchLinkClick(parentId, uri) + } + return RequestInterceptor.InterceptionResponse.Deny + } + } + } + + // Intercept weblibre:// deep links and dispatch them as Android intents + // so the Flutter side can handle them (e.g. account callback handoff). + if (parsed.scheme == "weblibre") { + val intent = Intent(Intent.ACTION_VIEW, parsed).apply { + setPackage(context.packageName) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + try { + context.startActivity(intent) + // No fallbackUrl — the intent targets our own package, so if + // startActivity didn't throw we've handled the navigation + // completely and there's no in-engine load to fall back to. + // appName is null because there's no user-facing app picker + // (the intent is package-scoped to us via setPackage above). + return RequestInterceptor.InterceptionResponse.AppIntent( + appIntent = intent, + url = uri, + fallbackUrl = null, + appName = null, + ) + } catch (e: ActivityNotFoundException) { + // No activity is registered for this weblibre:// URI (cold-start + // race, manifest mismatch, etc.). Let the load fall through so + // Gecko shows a normal "can't load" page instead of crashing the + // load flow on an unhandled exception. + Log.w("AppRequestInterceptor", "No activity for $uri", e) + } + } + components.services.accountsAuthFeature.interceptor.onLoadRequest( engineSession, uri, diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/middleware/SandboxCaptureMiddleware.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/middleware/SandboxCaptureMiddleware.kt new file mode 100644 index 00000000..104cfd7f --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/middleware/SandboxCaptureMiddleware.kt @@ -0,0 +1,53 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package eu.weblibre.flutter_mozilla_components.middleware + +import eu.weblibre.flutter_mozilla_components.feature.SandboxCaptureBridge +import eu.weblibre.flutter_mozilla_components.feature.SandboxCaptureRegistry +import mozilla.components.browser.state.action.BrowserAction +import mozilla.components.browser.state.action.TabListAction +import mozilla.components.browser.state.state.BrowserState +import mozilla.components.lib.state.Middleware + +// Stops _blank / window.open / middle-click from leaking the first request +// when the originating tab is a sandbox capture tab. +// +// WindowFeature creates new app tabs via TabListAction.AddTabAction with the +// target URL already populated. AppRequestInterceptor.onLoadRequest would fire +// on the first load, but by that point Gecko has already issued the request. +// This middleware runs synchronously before that: it rewrites the new tab's +// URL to about:blank and dispatches the capture flow to Dart. +val SandboxCaptureMiddleware: Middleware = + { _, next, action -> + if (action !is TabListAction.AddTabAction) { + next(action) + } else { + val parentId = action.tab.parentId + val parentEntry = parentId?.let { SandboxCaptureRegistry.get(it) } + val targetUrl = action.tab.content.url + when { + parentEntry == null -> { + next(action) + } + SandboxCaptureRegistry.isLoopbackRedirect(targetUrl) -> { + // We initiated this AddTab for a sandbox capture. + next(action) + } + else -> { + val rewrittenTab = action.tab.copy( + content = action.tab.content.copy(url = "about:blank"), + ) + next(TabListAction.AddTabAction(rewrittenTab, action.select)) + SandboxCaptureBridge.dispatchNewTab( + parentTabId = parentId, + newTabId = action.tab.id, + targetUrl = targetUrl, + ) + } + } + } + } diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt index e4b9141f..b89b843a 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt @@ -328,6 +328,18 @@ enum class FrecencyThresholdOption(val raw: Int) { } } +/** Document type associated with a [HistoryMetadata] record. */ +enum class DocumentType(val raw: Int) { + REGULAR(0), + MEDIA(1); + + companion object { + fun ofRaw(raw: Int): DocumentType? { + return values().firstOrNull { it.raw == raw } + } + } +} + enum class SelectionPattern(val raw: Int) { PHONE(0), EMAIL(1); @@ -2169,6 +2181,165 @@ data class TopFrecentSiteInfo ( } } +/** + * Per-URL metadata maintained by Places. The unique identity of a record is + * [HistoryMetadataKey] (url + searchTerm + referrerUrl); we surface only the + * most recent record per URL via `getLatestHistoryMetadataForUrl`. + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class HistoryMetadata ( + val key: HistoryMetadataKey, + val title: String? = null, + /** Unix milliseconds. */ + val createdAt: Long, + /** Unix milliseconds. */ + val updatedAt: Long, + /** Total view time in milliseconds. */ + val totalViewTime: Long, + val documentType: DocumentType, + val previewImageUrl: String? = null +) + { + companion object { + fun fromList(pigeonVar_list: List): HistoryMetadata { + val key = pigeonVar_list[0] as HistoryMetadataKey + val title = pigeonVar_list[1] as String? + val createdAt = pigeonVar_list[2] as Long + val updatedAt = pigeonVar_list[3] as Long + val totalViewTime = pigeonVar_list[4] as Long + val documentType = pigeonVar_list[5] as DocumentType + val previewImageUrl = pigeonVar_list[6] as String? + return HistoryMetadata(key, title, createdAt, updatedAt, totalViewTime, documentType, previewImageUrl) + } + } + fun toList(): List { + return listOf( + key, + title, + createdAt, + updatedAt, + totalViewTime, + documentType, + previewImageUrl, + ) + } + override fun equals(other: Any?): Boolean { + if (other == null || other.javaClass != javaClass) { + return false + } + if (this === other) { + return true + } + val other = other as HistoryMetadata + return GeckoPigeonUtils.deepEquals(this.key, other.key) && GeckoPigeonUtils.deepEquals(this.title, other.title) && GeckoPigeonUtils.deepEquals(this.createdAt, other.createdAt) && GeckoPigeonUtils.deepEquals(this.updatedAt, other.updatedAt) && GeckoPigeonUtils.deepEquals(this.totalViewTime, other.totalViewTime) && GeckoPigeonUtils.deepEquals(this.documentType, other.documentType) && GeckoPigeonUtils.deepEquals(this.previewImageUrl, other.previewImageUrl) + } + + override fun hashCode(): Int { + var result = javaClass.hashCode() + result = 31 * result + GeckoPigeonUtils.deepHash(this.key) + result = 31 * result + GeckoPigeonUtils.deepHash(this.title) + result = 31 * result + GeckoPigeonUtils.deepHash(this.createdAt) + result = 31 * result + GeckoPigeonUtils.deepHash(this.updatedAt) + result = 31 * result + GeckoPigeonUtils.deepHash(this.totalViewTime) + result = 31 * result + GeckoPigeonUtils.deepHash(this.documentType) + result = 31 * result + GeckoPigeonUtils.deepHash(this.previewImageUrl) + return result + } +} + +/** + * Frecency-ranked autocomplete suggestion. Backs `getSuggestions`. + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class HistorySuggestion ( + val url: String, + val title: String? = null, + /** + * Larger is more relevant. Unbounded; only meaningful relative to other + * suggestions returned in the same call. + */ + val score: Long +) + { + companion object { + fun fromList(pigeonVar_list: List): HistorySuggestion { + val url = pigeonVar_list[0] as String + val title = pigeonVar_list[1] as String? + val score = pigeonVar_list[2] as Long + return HistorySuggestion(url, title, score) + } + } + fun toList(): List { + return listOf( + url, + title, + score, + ) + } + override fun equals(other: Any?): Boolean { + if (other == null || other.javaClass != javaClass) { + return false + } + if (this === other) { + return true + } + val other = other as HistorySuggestion + return GeckoPigeonUtils.deepEquals(this.url, other.url) && GeckoPigeonUtils.deepEquals(this.title, other.title) && GeckoPigeonUtils.deepEquals(this.score, other.score) + } + + override fun hashCode(): Int { + var result = javaClass.hashCode() + result = 31 * result + GeckoPigeonUtils.deepHash(this.url) + result = 31 * result + GeckoPigeonUtils.deepHash(this.title) + result = 31 * result + GeckoPigeonUtils.deepHash(this.score) + return result + } +} + +/** + * Optional metadata observation for a URL. `null` fields are not written. + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class PageObservation ( + val title: String? = null, + val previewImageUrl: String? = null +) + { + companion object { + fun fromList(pigeonVar_list: List): PageObservation { + val title = pigeonVar_list[0] as String? + val previewImageUrl = pigeonVar_list[1] as String? + return PageObservation(title, previewImageUrl) + } + } + fun toList(): List { + return listOf( + title, + previewImageUrl, + ) + } + override fun equals(other: Any?): Boolean { + if (other == null || other.javaClass != javaClass) { + return false + } + if (this === other) { + return true + } + val other = other as PageObservation + return GeckoPigeonUtils.deepEquals(this.title, other.title) && GeckoPigeonUtils.deepEquals(this.previewImageUrl, other.previewImageUrl) + } + + override fun hashCode(): Int { + var result = javaClass.hashCode() + result = 31 * result + GeckoPigeonUtils.deepHash(this.title) + result = 31 * result + GeckoPigeonUtils.deepHash(this.previewImageUrl) + return result + } +} + /** Generated class from Pigeon that represents data sent in messages. */ data class HistoryItem ( val url: String, @@ -5224,6 +5395,71 @@ data class PwaManifest ( return result } } + +/** + * Per-tab sandbox capture state shared with the native side. The Kotlin + * [AppRequestInterceptor] consults an in-memory registry populated from + * these entries to decide how to handle loads in sandbox tabs. + * + * [redirectUrl] is precomputed by Dart and always points at a loopback URL + * (loader or capture). Dart is responsible for keeping it current; Kotlin + * never calls back into Dart to resolve it. + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class SandboxCaptureEntry ( + val tabId: String, + val captureId: String, + val sourceUrl: String, + /** + * `http://127.0.0.1:/loader?…` while pending/failed, or + * `http://127.0.0.1:/captures/…?t=` once ready. + */ + val redirectUrl: String, + /** `pending` | `ready` | `failed`. */ + val status: String +) + { + companion object { + fun fromList(pigeonVar_list: List): SandboxCaptureEntry { + val tabId = pigeonVar_list[0] as String + val captureId = pigeonVar_list[1] as String + val sourceUrl = pigeonVar_list[2] as String + val redirectUrl = pigeonVar_list[3] as String + val status = pigeonVar_list[4] as String + return SandboxCaptureEntry(tabId, captureId, sourceUrl, redirectUrl, status) + } + } + fun toList(): List { + return listOf( + tabId, + captureId, + sourceUrl, + redirectUrl, + status, + ) + } + override fun equals(other: Any?): Boolean { + if (other == null || other.javaClass != javaClass) { + return false + } + if (this === other) { + return true + } + val other = other as SandboxCaptureEntry + return GeckoPigeonUtils.deepEquals(this.tabId, other.tabId) && GeckoPigeonUtils.deepEquals(this.captureId, other.captureId) && GeckoPigeonUtils.deepEquals(this.sourceUrl, other.sourceUrl) && GeckoPigeonUtils.deepEquals(this.redirectUrl, other.redirectUrl) && GeckoPigeonUtils.deepEquals(this.status, other.status) + } + + override fun hashCode(): Int { + var result = javaClass.hashCode() + result = 31 * result + GeckoPigeonUtils.deepHash(this.tabId) + result = 31 * result + GeckoPigeonUtils.deepHash(this.captureId) + result = 31 * result + GeckoPigeonUtils.deepHash(this.sourceUrl) + result = 31 * result + GeckoPigeonUtils.deepHash(this.redirectUrl) + result = 31 * result + GeckoPigeonUtils.deepHash(this.status) + return result + } +} private open class GeckoPigeonCodec : StandardMessageCodec() { override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { return when (type) { @@ -5264,549 +5500,574 @@ private open class GeckoPigeonCodec : StandardMessageCodec() { } 136.toByte() -> { return (readValue(buffer) as Long?)?.let { - SelectionPattern.ofRaw(it.toInt()) + DocumentType.ofRaw(it.toInt()) } } 137.toByte() -> { return (readValue(buffer) as Long?)?.let { - WebExtensionActionType.ofRaw(it.toInt()) + SelectionPattern.ofRaw(it.toInt()) } } 138.toByte() -> { return (readValue(buffer) as Long?)?.let { - AddonDisabledReason.ofRaw(it.toInt()) + WebExtensionActionType.ofRaw(it.toInt()) } } 139.toByte() -> { return (readValue(buffer) as Long?)?.let { - AddonIncognito.ofRaw(it.toInt()) + AddonDisabledReason.ofRaw(it.toInt()) } } 140.toByte() -> { return (readValue(buffer) as Long?)?.let { - AddonUpdateStatus.ofRaw(it.toInt()) + AddonIncognito.ofRaw(it.toInt()) } } 141.toByte() -> { return (readValue(buffer) as Long?)?.let { - AddonStoreApp.ofRaw(it.toInt()) + AddonUpdateStatus.ofRaw(it.toInt()) } } 142.toByte() -> { return (readValue(buffer) as Long?)?.let { - AddonStorePromoted.ofRaw(it.toInt()) + AddonStoreApp.ofRaw(it.toInt()) } } 143.toByte() -> { return (readValue(buffer) as Long?)?.let { - GeckoSuggestionType.ofRaw(it.toInt()) + AddonStorePromoted.ofRaw(it.toInt()) } } 144.toByte() -> { return (readValue(buffer) as Long?)?.let { - TrackingProtectionPolicy.ofRaw(it.toInt()) + GeckoSuggestionType.ofRaw(it.toInt()) } } 145.toByte() -> { return (readValue(buffer) as Long?)?.let { - HttpsOnlyMode.ofRaw(it.toInt()) + TrackingProtectionPolicy.ofRaw(it.toInt()) } } 146.toByte() -> { return (readValue(buffer) as Long?)?.let { - QueryParameterStripping.ofRaw(it.toInt()) + HttpsOnlyMode.ofRaw(it.toInt()) } } 147.toByte() -> { return (readValue(buffer) as Long?)?.let { - BounceTrackingProtectionMode.ofRaw(it.toInt()) + QueryParameterStripping.ofRaw(it.toInt()) } } 148.toByte() -> { return (readValue(buffer) as Long?)?.let { - ColorScheme.ofRaw(it.toInt()) + BounceTrackingProtectionMode.ofRaw(it.toInt()) } } 149.toByte() -> { return (readValue(buffer) as Long?)?.let { - CookieBannerHandlingMode.ofRaw(it.toInt()) + ColorScheme.ofRaw(it.toInt()) } } 150.toByte() -> { return (readValue(buffer) as Long?)?.let { - AppLinksMode.ofRaw(it.toInt()) + CookieBannerHandlingMode.ofRaw(it.toInt()) } } 151.toByte() -> { return (readValue(buffer) as Long?)?.let { - WebContentIsolationStrategy.ofRaw(it.toInt()) + AppLinksMode.ofRaw(it.toInt()) } } 152.toByte() -> { return (readValue(buffer) as Long?)?.let { - CustomCookiePolicy.ofRaw(it.toInt()) + WebContentIsolationStrategy.ofRaw(it.toInt()) } } 153.toByte() -> { return (readValue(buffer) as Long?)?.let { - TrackingScope.ofRaw(it.toInt()) + CustomCookiePolicy.ofRaw(it.toInt()) } } 154.toByte() -> { return (readValue(buffer) as Long?)?.let { - DohSettingsMode.ofRaw(it.toInt()) + TrackingScope.ofRaw(it.toInt()) } } 155.toByte() -> { return (readValue(buffer) as Long?)?.let { - DownloadStatus.ofRaw(it.toInt()) + DohSettingsMode.ofRaw(it.toInt()) } } 156.toByte() -> { return (readValue(buffer) as Long?)?.let { - LogLevel.ofRaw(it.toInt()) + DownloadStatus.ofRaw(it.toInt()) } } 157.toByte() -> { return (readValue(buffer) as Long?)?.let { - SyncEngineValue.ofRaw(it.toInt()) + LogLevel.ofRaw(it.toInt()) } } 158.toByte() -> { return (readValue(buffer) as Long?)?.let { - MlProgressType.ofRaw(it.toInt()) + SyncEngineValue.ofRaw(it.toInt()) } } 159.toByte() -> { return (readValue(buffer) as Long?)?.let { - MlProgressStatus.ofRaw(it.toInt()) + MlProgressType.ofRaw(it.toInt()) } } 160.toByte() -> { return (readValue(buffer) as Long?)?.let { - ClearDataType.ofRaw(it.toInt()) + MlProgressStatus.ofRaw(it.toInt()) } } 161.toByte() -> { return (readValue(buffer) as Long?)?.let { - GeckoFetchMethod.ofRaw(it.toInt()) + ClearDataType.ofRaw(it.toInt()) } } 162.toByte() -> { return (readValue(buffer) as Long?)?.let { - GeckoFetchRedircet.ofRaw(it.toInt()) + GeckoFetchMethod.ofRaw(it.toInt()) } } 163.toByte() -> { return (readValue(buffer) as Long?)?.let { - GeckoFetchCookiePolicy.ofRaw(it.toInt()) + GeckoFetchRedircet.ofRaw(it.toInt()) } } 164.toByte() -> { return (readValue(buffer) as Long?)?.let { - BookmarkNodeType.ofRaw(it.toInt()) + GeckoFetchCookiePolicy.ofRaw(it.toInt()) } } 165.toByte() -> { return (readValue(buffer) as Long?)?.let { - SitePermissionStatus.ofRaw(it.toInt()) + BookmarkNodeType.ofRaw(it.toInt()) } } 166.toByte() -> { return (readValue(buffer) as Long?)?.let { - AutoplayStatus.ofRaw(it.toInt()) + SitePermissionStatus.ofRaw(it.toInt()) } } 167.toByte() -> { - return (readValue(buffer) as? List)?.let { - TranslationOptions.fromList(it) + return (readValue(buffer) as Long?)?.let { + AutoplayStatus.ofRaw(it.toInt()) } } 168.toByte() -> { return (readValue(buffer) as? List)?.let { - TranslationLanguage.fromList(it) + TranslationOptions.fromList(it) } } 169.toByte() -> { return (readValue(buffer) as? List)?.let { - TranslationDetectedLanguages.fromList(it) + TranslationLanguage.fromList(it) } } 170.toByte() -> { return (readValue(buffer) as? List)?.let { - TranslationPair.fromList(it) + TranslationDetectedLanguages.fromList(it) } } 171.toByte() -> { return (readValue(buffer) as? List)?.let { - TranslationEngineStateData.fromList(it) + TranslationPair.fromList(it) } } 172.toByte() -> { return (readValue(buffer) as? List)?.let { - TabTranslationStateData.fromList(it) + TranslationEngineStateData.fromList(it) } } 173.toByte() -> { return (readValue(buffer) as? List)?.let { - ReaderState.fromList(it) + TabTranslationStateData.fromList(it) } } 174.toByte() -> { return (readValue(buffer) as? List)?.let { - AddTabParams.fromList(it) + ReaderState.fromList(it) } } 175.toByte() -> { return (readValue(buffer) as? List)?.let { - LastMediaAccessState.fromList(it) + AddTabParams.fromList(it) } } 176.toByte() -> { return (readValue(buffer) as? List)?.let { - HistoryMetadataKey.fromList(it) + LastMediaAccessState.fromList(it) } } 177.toByte() -> { return (readValue(buffer) as? List)?.let { - PackageCategoryValue.fromList(it) + HistoryMetadataKey.fromList(it) } } 178.toByte() -> { return (readValue(buffer) as? List)?.let { - ExternalPackage.fromList(it) + PackageCategoryValue.fromList(it) } } 179.toByte() -> { return (readValue(buffer) as? List)?.let { - LoadUrlFlagsValue.fromList(it) + ExternalPackage.fromList(it) } } 180.toByte() -> { return (readValue(buffer) as? List)?.let { - SourceValue.fromList(it) + LoadUrlFlagsValue.fromList(it) } } 181.toByte() -> { return (readValue(buffer) as? List)?.let { - TabState.fromList(it) + SourceValue.fromList(it) } } 182.toByte() -> { return (readValue(buffer) as? List)?.let { - RecoverableTab.fromList(it) + TabState.fromList(it) } } 183.toByte() -> { return (readValue(buffer) as? List)?.let { - IconRequest.fromList(it) + RecoverableTab.fromList(it) } } 184.toByte() -> { return (readValue(buffer) as? List)?.let { - ResourceSize.fromList(it) + IconRequest.fromList(it) } } 185.toByte() -> { return (readValue(buffer) as? List)?.let { - Resource.fromList(it) + ResourceSize.fromList(it) } } 186.toByte() -> { return (readValue(buffer) as? List)?.let { - IconResult.fromList(it) + Resource.fromList(it) } } 187.toByte() -> { return (readValue(buffer) as? List)?.let { - CookiePartitionKey.fromList(it) + IconResult.fromList(it) } } 188.toByte() -> { return (readValue(buffer) as? List)?.let { - Cookie.fromList(it) + CookiePartitionKey.fromList(it) } } 189.toByte() -> { return (readValue(buffer) as? List)?.let { - VisitInfo.fromList(it) + Cookie.fromList(it) } } 190.toByte() -> { return (readValue(buffer) as? List)?.let { - HistoryHighlightWeights.fromList(it) + VisitInfo.fromList(it) } } 191.toByte() -> { return (readValue(buffer) as? List)?.let { - HistoryHighlight.fromList(it) + HistoryHighlightWeights.fromList(it) } } 192.toByte() -> { return (readValue(buffer) as? List)?.let { - TopFrecentSiteInfo.fromList(it) + HistoryHighlight.fromList(it) } } 193.toByte() -> { return (readValue(buffer) as? List)?.let { - HistoryItem.fromList(it) + TopFrecentSiteInfo.fromList(it) } } 194.toByte() -> { return (readValue(buffer) as? List)?.let { - HistoryState.fromList(it) + HistoryMetadata.fromList(it) } } 195.toByte() -> { return (readValue(buffer) as? List)?.let { - ReaderableState.fromList(it) + HistorySuggestion.fromList(it) } } 196.toByte() -> { return (readValue(buffer) as? List)?.let { - SecurityInfoState.fromList(it) + PageObservation.fromList(it) } } 197.toByte() -> { return (readValue(buffer) as? List)?.let { - TabContentState.fromList(it) + HistoryItem.fromList(it) } } 198.toByte() -> { return (readValue(buffer) as? List)?.let { - FindResultState.fromList(it) + HistoryState.fromList(it) } } 199.toByte() -> { return (readValue(buffer) as? List)?.let { - CustomSelectionAction.fromList(it) + ReaderableState.fromList(it) } } 200.toByte() -> { return (readValue(buffer) as? List)?.let { - WebExtensionData.fromList(it) + SecurityInfoState.fromList(it) } } 201.toByte() -> { return (readValue(buffer) as? List)?.let { - AddonInfo.fromList(it) + TabContentState.fromList(it) } } 202.toByte() -> { return (readValue(buffer) as? List)?.let { - AddonListingPreview.fromList(it) + FindResultState.fromList(it) } } 203.toByte() -> { return (readValue(buffer) as? List)?.let { - AddonListing.fromList(it) + CustomSelectionAction.fromList(it) } } 204.toByte() -> { return (readValue(buffer) as? List)?.let { - AddonStoreInfo.fromList(it) + WebExtensionData.fromList(it) } } 205.toByte() -> { return (readValue(buffer) as? List)?.let { - AddonUpdateAttemptInfo.fromList(it) + AddonInfo.fromList(it) } } 206.toByte() -> { return (readValue(buffer) as? List)?.let { - GeckoSuggestion.fromList(it) + AddonListingPreview.fromList(it) } } 207.toByte() -> { return (readValue(buffer) as? List)?.let { - TabContent.fromList(it) + AddonListing.fromList(it) } } 208.toByte() -> { return (readValue(buffer) as? List)?.let { - ContentBlocking.fromList(it) + AddonStoreInfo.fromList(it) } } 209.toByte() -> { return (readValue(buffer) as? List)?.let { - DohSettings.fromList(it) + AddonUpdateAttemptInfo.fromList(it) } } 210.toByte() -> { return (readValue(buffer) as? List)?.let { - GeckoEngineSettings.fromList(it) + GeckoSuggestion.fromList(it) } } 211.toByte() -> { return (readValue(buffer) as? List)?.let { - AutocompleteResult.fromList(it) + TabContent.fromList(it) } } 212.toByte() -> { return (readValue(buffer) as? List)?.let { - UnknownHitResult.fromList(it) + ContentBlocking.fromList(it) } } 213.toByte() -> { return (readValue(buffer) as? List)?.let { - ImageHitResult.fromList(it) + DohSettings.fromList(it) } } 214.toByte() -> { return (readValue(buffer) as? List)?.let { - VideoHitResult.fromList(it) + GeckoEngineSettings.fromList(it) } } 215.toByte() -> { return (readValue(buffer) as? List)?.let { - AudioHitResult.fromList(it) + AutocompleteResult.fromList(it) } } 216.toByte() -> { return (readValue(buffer) as? List)?.let { - ImageSrcHitResult.fromList(it) + UnknownHitResult.fromList(it) } } 217.toByte() -> { return (readValue(buffer) as? List)?.let { - PhoneHitResult.fromList(it) + ImageHitResult.fromList(it) } } 218.toByte() -> { return (readValue(buffer) as? List)?.let { - EmailHitResult.fromList(it) + VideoHitResult.fromList(it) } } 219.toByte() -> { return (readValue(buffer) as? List)?.let { - GeoHitResult.fromList(it) + AudioHitResult.fromList(it) } } 220.toByte() -> { return (readValue(buffer) as? List)?.let { - DownloadState.fromList(it) + ImageSrcHitResult.fromList(it) } } 221.toByte() -> { return (readValue(buffer) as? List)?.let { - ShareInternetResourceState.fromList(it) + PhoneHitResult.fromList(it) } } 222.toByte() -> { return (readValue(buffer) as? List)?.let { - AddonCollection.fromList(it) + EmailHitResult.fromList(it) } } 223.toByte() -> { return (readValue(buffer) as? List)?.let { - SyncEngineStatus.fromList(it) + GeoHitResult.fromList(it) } } 224.toByte() -> { return (readValue(buffer) as? List)?.let { - SyncAccountInfo.fromList(it) + DownloadState.fromList(it) } } 225.toByte() -> { return (readValue(buffer) as? List)?.let { - SyncDevice.fromList(it) + ShareInternetResourceState.fromList(it) } } 226.toByte() -> { return (readValue(buffer) as? List)?.let { - SyncIncomingTab.fromList(it) + AddonCollection.fromList(it) } } 227.toByte() -> { return (readValue(buffer) as? List)?.let { - SyncRemoteTab.fromList(it) + SyncEngineStatus.fromList(it) } } 228.toByte() -> { return (readValue(buffer) as? List)?.let { - SyncDeviceTabs.fromList(it) + SyncAccountInfo.fromList(it) } } 229.toByte() -> { return (readValue(buffer) as? List)?.let { - GeckoPref.fromList(it) + SyncDevice.fromList(it) } } 230.toByte() -> { return (readValue(buffer) as? List)?.let { - MlProgressData.fromList(it) + SyncIncomingTab.fromList(it) } } 231.toByte() -> { return (readValue(buffer) as? List)?.let { - ContainerSiteAssignment.fromList(it) + SyncRemoteTab.fromList(it) } } 232.toByte() -> { return (readValue(buffer) as? List)?.let { - GeckoHeader.fromList(it) + SyncDeviceTabs.fromList(it) } } 233.toByte() -> { return (readValue(buffer) as? List)?.let { - GeckoFetchRequest.fromList(it) + GeckoPref.fromList(it) } } 234.toByte() -> { return (readValue(buffer) as? List)?.let { - GeckoFetchResponse.fromList(it) + MlProgressData.fromList(it) } } 235.toByte() -> { return (readValue(buffer) as? List)?.let { - BookmarkNode.fromList(it) + ContainerSiteAssignment.fromList(it) } } 236.toByte() -> { return (readValue(buffer) as? List)?.let { - BookmarkInfo.fromList(it) + GeckoHeader.fromList(it) } } 237.toByte() -> { return (readValue(buffer) as? List)?.let { - SitePermissions.fromList(it) + GeckoFetchRequest.fromList(it) } } 238.toByte() -> { return (readValue(buffer) as? List)?.let { - TrackingProtectionException.fromList(it) + GeckoFetchResponse.fromList(it) } } 239.toByte() -> { return (readValue(buffer) as? List)?.let { - PwaIcon.fromList(it) + BookmarkNode.fromList(it) } } 240.toByte() -> { return (readValue(buffer) as? List)?.let { - ShareTargetFiles.fromList(it) + BookmarkInfo.fromList(it) } } 241.toByte() -> { return (readValue(buffer) as? List)?.let { - ShareTargetParams.fromList(it) + SitePermissions.fromList(it) } } 242.toByte() -> { return (readValue(buffer) as? List)?.let { - ShareTarget.fromList(it) + TrackingProtectionException.fromList(it) } } 243.toByte() -> { return (readValue(buffer) as? List)?.let { - ExternalApplicationResource.fromList(it) + PwaIcon.fromList(it) } } 244.toByte() -> { + return (readValue(buffer) as? List)?.let { + ShareTargetFiles.fromList(it) + } + } + 245.toByte() -> { + return (readValue(buffer) as? List)?.let { + ShareTargetParams.fromList(it) + } + } + 246.toByte() -> { + return (readValue(buffer) as? List)?.let { + ShareTarget.fromList(it) + } + } + 247.toByte() -> { + return (readValue(buffer) as? List)?.let { + ExternalApplicationResource.fromList(it) + } + } + 248.toByte() -> { return (readValue(buffer) as? List)?.let { PwaManifest.fromList(it) } } + 249.toByte() -> { + return (readValue(buffer) as? List)?.let { + SandboxCaptureEntry.fromList(it) + } + } else -> super.readValueOfType(type, buffer) } } @@ -5840,442 +6101,462 @@ private open class GeckoPigeonCodec : StandardMessageCodec() { stream.write(135) writeValue(stream, value.raw.toLong()) } - is SelectionPattern -> { + is DocumentType -> { stream.write(136) writeValue(stream, value.raw.toLong()) } - is WebExtensionActionType -> { + is SelectionPattern -> { stream.write(137) writeValue(stream, value.raw.toLong()) } - is AddonDisabledReason -> { + is WebExtensionActionType -> { stream.write(138) writeValue(stream, value.raw.toLong()) } - is AddonIncognito -> { + is AddonDisabledReason -> { stream.write(139) writeValue(stream, value.raw.toLong()) } - is AddonUpdateStatus -> { + is AddonIncognito -> { stream.write(140) writeValue(stream, value.raw.toLong()) } - is AddonStoreApp -> { + is AddonUpdateStatus -> { stream.write(141) writeValue(stream, value.raw.toLong()) } - is AddonStorePromoted -> { + is AddonStoreApp -> { stream.write(142) writeValue(stream, value.raw.toLong()) } - is GeckoSuggestionType -> { + is AddonStorePromoted -> { stream.write(143) writeValue(stream, value.raw.toLong()) } - is TrackingProtectionPolicy -> { + is GeckoSuggestionType -> { stream.write(144) writeValue(stream, value.raw.toLong()) } - is HttpsOnlyMode -> { + is TrackingProtectionPolicy -> { stream.write(145) writeValue(stream, value.raw.toLong()) } - is QueryParameterStripping -> { + is HttpsOnlyMode -> { stream.write(146) writeValue(stream, value.raw.toLong()) } - is BounceTrackingProtectionMode -> { + is QueryParameterStripping -> { stream.write(147) writeValue(stream, value.raw.toLong()) } - is ColorScheme -> { + is BounceTrackingProtectionMode -> { stream.write(148) writeValue(stream, value.raw.toLong()) } - is CookieBannerHandlingMode -> { + is ColorScheme -> { stream.write(149) writeValue(stream, value.raw.toLong()) } - is AppLinksMode -> { + is CookieBannerHandlingMode -> { stream.write(150) writeValue(stream, value.raw.toLong()) } - is WebContentIsolationStrategy -> { + is AppLinksMode -> { stream.write(151) writeValue(stream, value.raw.toLong()) } - is CustomCookiePolicy -> { + is WebContentIsolationStrategy -> { stream.write(152) writeValue(stream, value.raw.toLong()) } - is TrackingScope -> { + is CustomCookiePolicy -> { stream.write(153) writeValue(stream, value.raw.toLong()) } - is DohSettingsMode -> { + is TrackingScope -> { stream.write(154) writeValue(stream, value.raw.toLong()) } - is DownloadStatus -> { + is DohSettingsMode -> { stream.write(155) writeValue(stream, value.raw.toLong()) } - is LogLevel -> { + is DownloadStatus -> { stream.write(156) writeValue(stream, value.raw.toLong()) } - is SyncEngineValue -> { + is LogLevel -> { stream.write(157) writeValue(stream, value.raw.toLong()) } - is MlProgressType -> { + is SyncEngineValue -> { stream.write(158) writeValue(stream, value.raw.toLong()) } - is MlProgressStatus -> { + is MlProgressType -> { stream.write(159) writeValue(stream, value.raw.toLong()) } - is ClearDataType -> { + is MlProgressStatus -> { stream.write(160) writeValue(stream, value.raw.toLong()) } - is GeckoFetchMethod -> { + is ClearDataType -> { stream.write(161) writeValue(stream, value.raw.toLong()) } - is GeckoFetchRedircet -> { + is GeckoFetchMethod -> { stream.write(162) writeValue(stream, value.raw.toLong()) } - is GeckoFetchCookiePolicy -> { + is GeckoFetchRedircet -> { stream.write(163) writeValue(stream, value.raw.toLong()) } - is BookmarkNodeType -> { + is GeckoFetchCookiePolicy -> { stream.write(164) writeValue(stream, value.raw.toLong()) } - is SitePermissionStatus -> { + is BookmarkNodeType -> { stream.write(165) writeValue(stream, value.raw.toLong()) } - is AutoplayStatus -> { + is SitePermissionStatus -> { stream.write(166) writeValue(stream, value.raw.toLong()) } - is TranslationOptions -> { + is AutoplayStatus -> { stream.write(167) - writeValue(stream, value.toList()) + writeValue(stream, value.raw.toLong()) } - is TranslationLanguage -> { + is TranslationOptions -> { stream.write(168) writeValue(stream, value.toList()) } - is TranslationDetectedLanguages -> { + is TranslationLanguage -> { stream.write(169) writeValue(stream, value.toList()) } - is TranslationPair -> { + is TranslationDetectedLanguages -> { stream.write(170) writeValue(stream, value.toList()) } - is TranslationEngineStateData -> { + is TranslationPair -> { stream.write(171) writeValue(stream, value.toList()) } - is TabTranslationStateData -> { + is TranslationEngineStateData -> { stream.write(172) writeValue(stream, value.toList()) } - is ReaderState -> { + is TabTranslationStateData -> { stream.write(173) writeValue(stream, value.toList()) } - is AddTabParams -> { + is ReaderState -> { stream.write(174) writeValue(stream, value.toList()) } - is LastMediaAccessState -> { + is AddTabParams -> { stream.write(175) writeValue(stream, value.toList()) } - is HistoryMetadataKey -> { + is LastMediaAccessState -> { stream.write(176) writeValue(stream, value.toList()) } - is PackageCategoryValue -> { + is HistoryMetadataKey -> { stream.write(177) writeValue(stream, value.toList()) } - is ExternalPackage -> { + is PackageCategoryValue -> { stream.write(178) writeValue(stream, value.toList()) } - is LoadUrlFlagsValue -> { + is ExternalPackage -> { stream.write(179) writeValue(stream, value.toList()) } - is SourceValue -> { + is LoadUrlFlagsValue -> { stream.write(180) writeValue(stream, value.toList()) } - is TabState -> { + is SourceValue -> { stream.write(181) writeValue(stream, value.toList()) } - is RecoverableTab -> { + is TabState -> { stream.write(182) writeValue(stream, value.toList()) } - is IconRequest -> { + is RecoverableTab -> { stream.write(183) writeValue(stream, value.toList()) } - is ResourceSize -> { + is IconRequest -> { stream.write(184) writeValue(stream, value.toList()) } - is Resource -> { + is ResourceSize -> { stream.write(185) writeValue(stream, value.toList()) } - is IconResult -> { + is Resource -> { stream.write(186) writeValue(stream, value.toList()) } - is CookiePartitionKey -> { + is IconResult -> { stream.write(187) writeValue(stream, value.toList()) } - is Cookie -> { + is CookiePartitionKey -> { stream.write(188) writeValue(stream, value.toList()) } - is VisitInfo -> { + is Cookie -> { stream.write(189) writeValue(stream, value.toList()) } - is HistoryHighlightWeights -> { + is VisitInfo -> { stream.write(190) writeValue(stream, value.toList()) } - is HistoryHighlight -> { + is HistoryHighlightWeights -> { stream.write(191) writeValue(stream, value.toList()) } - is TopFrecentSiteInfo -> { + is HistoryHighlight -> { stream.write(192) writeValue(stream, value.toList()) } - is HistoryItem -> { + is TopFrecentSiteInfo -> { stream.write(193) writeValue(stream, value.toList()) } - is HistoryState -> { + is HistoryMetadata -> { stream.write(194) writeValue(stream, value.toList()) } - is ReaderableState -> { + is HistorySuggestion -> { stream.write(195) writeValue(stream, value.toList()) } - is SecurityInfoState -> { + is PageObservation -> { stream.write(196) writeValue(stream, value.toList()) } - is TabContentState -> { + is HistoryItem -> { stream.write(197) writeValue(stream, value.toList()) } - is FindResultState -> { + is HistoryState -> { stream.write(198) writeValue(stream, value.toList()) } - is CustomSelectionAction -> { + is ReaderableState -> { stream.write(199) writeValue(stream, value.toList()) } - is WebExtensionData -> { + is SecurityInfoState -> { stream.write(200) writeValue(stream, value.toList()) } - is AddonInfo -> { + is TabContentState -> { stream.write(201) writeValue(stream, value.toList()) } - is AddonListingPreview -> { + is FindResultState -> { stream.write(202) writeValue(stream, value.toList()) } - is AddonListing -> { + is CustomSelectionAction -> { stream.write(203) writeValue(stream, value.toList()) } - is AddonStoreInfo -> { + is WebExtensionData -> { stream.write(204) writeValue(stream, value.toList()) } - is AddonUpdateAttemptInfo -> { + is AddonInfo -> { stream.write(205) writeValue(stream, value.toList()) } - is GeckoSuggestion -> { + is AddonListingPreview -> { stream.write(206) writeValue(stream, value.toList()) } - is TabContent -> { + is AddonListing -> { stream.write(207) writeValue(stream, value.toList()) } - is ContentBlocking -> { + is AddonStoreInfo -> { stream.write(208) writeValue(stream, value.toList()) } - is DohSettings -> { + is AddonUpdateAttemptInfo -> { stream.write(209) writeValue(stream, value.toList()) } - is GeckoEngineSettings -> { + is GeckoSuggestion -> { stream.write(210) writeValue(stream, value.toList()) } - is AutocompleteResult -> { + is TabContent -> { stream.write(211) writeValue(stream, value.toList()) } - is UnknownHitResult -> { + is ContentBlocking -> { stream.write(212) writeValue(stream, value.toList()) } - is ImageHitResult -> { + is DohSettings -> { stream.write(213) writeValue(stream, value.toList()) } - is VideoHitResult -> { + is GeckoEngineSettings -> { stream.write(214) writeValue(stream, value.toList()) } - is AudioHitResult -> { + is AutocompleteResult -> { stream.write(215) writeValue(stream, value.toList()) } - is ImageSrcHitResult -> { + is UnknownHitResult -> { stream.write(216) writeValue(stream, value.toList()) } - is PhoneHitResult -> { + is ImageHitResult -> { stream.write(217) writeValue(stream, value.toList()) } - is EmailHitResult -> { + is VideoHitResult -> { stream.write(218) writeValue(stream, value.toList()) } - is GeoHitResult -> { + is AudioHitResult -> { stream.write(219) writeValue(stream, value.toList()) } - is DownloadState -> { + is ImageSrcHitResult -> { stream.write(220) writeValue(stream, value.toList()) } - is ShareInternetResourceState -> { + is PhoneHitResult -> { stream.write(221) writeValue(stream, value.toList()) } - is AddonCollection -> { + is EmailHitResult -> { stream.write(222) writeValue(stream, value.toList()) } - is SyncEngineStatus -> { + is GeoHitResult -> { stream.write(223) writeValue(stream, value.toList()) } - is SyncAccountInfo -> { + is DownloadState -> { stream.write(224) writeValue(stream, value.toList()) } - is SyncDevice -> { + is ShareInternetResourceState -> { stream.write(225) writeValue(stream, value.toList()) } - is SyncIncomingTab -> { + is AddonCollection -> { stream.write(226) writeValue(stream, value.toList()) } - is SyncRemoteTab -> { + is SyncEngineStatus -> { stream.write(227) writeValue(stream, value.toList()) } - is SyncDeviceTabs -> { + is SyncAccountInfo -> { stream.write(228) writeValue(stream, value.toList()) } - is GeckoPref -> { + is SyncDevice -> { stream.write(229) writeValue(stream, value.toList()) } - is MlProgressData -> { + is SyncIncomingTab -> { stream.write(230) writeValue(stream, value.toList()) } - is ContainerSiteAssignment -> { + is SyncRemoteTab -> { stream.write(231) writeValue(stream, value.toList()) } - is GeckoHeader -> { + is SyncDeviceTabs -> { stream.write(232) writeValue(stream, value.toList()) } - is GeckoFetchRequest -> { + is GeckoPref -> { stream.write(233) writeValue(stream, value.toList()) } - is GeckoFetchResponse -> { + is MlProgressData -> { stream.write(234) writeValue(stream, value.toList()) } - is BookmarkNode -> { + is ContainerSiteAssignment -> { stream.write(235) writeValue(stream, value.toList()) } - is BookmarkInfo -> { + is GeckoHeader -> { stream.write(236) writeValue(stream, value.toList()) } - is SitePermissions -> { + is GeckoFetchRequest -> { stream.write(237) writeValue(stream, value.toList()) } - is TrackingProtectionException -> { + is GeckoFetchResponse -> { stream.write(238) writeValue(stream, value.toList()) } - is PwaIcon -> { + is BookmarkNode -> { stream.write(239) writeValue(stream, value.toList()) } - is ShareTargetFiles -> { + is BookmarkInfo -> { stream.write(240) writeValue(stream, value.toList()) } - is ShareTargetParams -> { + is SitePermissions -> { stream.write(241) writeValue(stream, value.toList()) } - is ShareTarget -> { + is TrackingProtectionException -> { stream.write(242) writeValue(stream, value.toList()) } - is ExternalApplicationResource -> { + is PwaIcon -> { stream.write(243) writeValue(stream, value.toList()) } - is PwaManifest -> { + is ShareTargetFiles -> { stream.write(244) writeValue(stream, value.toList()) } + is ShareTargetParams -> { + stream.write(245) + writeValue(stream, value.toList()) + } + is ShareTarget -> { + stream.write(246) + writeValue(stream, value.toList()) + } + is ExternalApplicationResource -> { + stream.write(247) + writeValue(stream, value.toList()) + } + is PwaManifest -> { + stream.write(248) + writeValue(stream, value.toList()) + } + is SandboxCaptureEntry -> { + stream.write(249) + writeValue(stream, value.toList()) + } else -> super.writeValue(stream, value) } } @@ -9559,6 +9840,60 @@ interface GeckoHistoryApi { fun deleteVisitsBetween(startMillis: Long, endMillis: Long, callback: (Result) -> Unit) fun getHistoryHighlights(weights: HistoryHighlightWeights, limit: Long, callback: (Result>) -> Unit) fun getTopFrecentSites(limit: Long, frecencyThreshold: FrecencyThresholdOption, callback: (Result>) -> Unit) + /** + * Returns the most recent [HistoryMetadata] record for [url], or `null` if + * no metadata has been recorded for that URL. + */ + fun getLatestHistoryMetadataForUrl(url: String, callback: (Result) -> Unit) + /** + * Bulk variant of [getLatestHistoryMetadataForUrl]. Returns one entry per + * input URL aligned by index; entries are `null` for URLs Places has no + * metadata for. Used by the local search re-rank to collapse N IPC + * roundtrips into one. + */ + fun getLatestHistoryMetadataForUrls(urls: List, callback: (Result>) -> Unit) + /** + * Bulk visited check: returns booleans aligned with [urls] indicating + * whether Places has any visit recorded for each URL. + */ + fun getVisited(urls: List, callback: (Result>) -> Unit) + /** Frecency-ranked autocomplete results. Mirrors Places' awesomebar input. */ + fun getSuggestions(query: String, limit: Long, callback: (Result>) -> Unit) + /** + * Places' built-in metadata text search (matches title / url / searchTerm). + * Useful as a comparison baseline against the local content FTS. + */ + fun queryHistoryMetadata(query: String, limit: Long, callback: (Result>) -> Unit) + /** + * Records a title / preview-image observation for [url] without recording + * a visit. Intended for manual flows; the engine middleware records these + * automatically as the user browses. + */ + fun recordObservation(url: String, observation: PageObservation, callback: (Result) -> Unit) + /** + * Records a view-time observation against the metadata record identified + * by [key]. View time is added to the existing total. + */ + fun noteHistoryMetadataViewTime(key: HistoryMetadataKey, viewTimeMs: Long, callback: (Result) -> Unit) + /** + * Records a document-type observation against the metadata record + * identified by [key]. + */ + fun noteHistoryMetadataDocumentType(key: HistoryMetadataKey, documentType: DocumentType, callback: (Result) -> Unit) + /** Removes all visits for [url]. May propagate to remote devices via Sync. */ + fun deleteVisitsFor(url: String, callback: (Result) -> Unit) + /** + * Removes all visits since [sinceMillis] (inclusive). May propagate to + * remote devices via Sync. + */ + fun deleteVisitsSince(sinceMillis: Long, callback: (Result) -> Unit) + /** + * Removes all locally stored history. Sync will not remove remote history, + * but it will prevent deleted entries from returning. + */ + fun deleteEverything(callback: (Result) -> Unit) + /** Prunes history metadata older than [olderThanMillis] (exclusive). */ + fun deleteHistoryMetadataOlderThan(olderThanMillis: Long, callback: (Result) -> Unit) companion object { /** The codec used by GeckoHistoryApi. */ @@ -9714,6 +10049,242 @@ interface GeckoHistoryApi { channel.setMessageHandler(null) } } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoHistoryApi.getLatestHistoryMetadataForUrl$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val urlArg = args[0] as String + api.getLatestHistoryMetadataForUrl(urlArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(GeckoPigeonUtils.wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(GeckoPigeonUtils.wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoHistoryApi.getLatestHistoryMetadataForUrls$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val urlsArg = args[0] as List + api.getLatestHistoryMetadataForUrls(urlsArg) { result: Result> -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(GeckoPigeonUtils.wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(GeckoPigeonUtils.wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoHistoryApi.getVisited$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val urlsArg = args[0] as List + api.getVisited(urlsArg) { result: Result> -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(GeckoPigeonUtils.wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(GeckoPigeonUtils.wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoHistoryApi.getSuggestions$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val queryArg = args[0] as String + val limitArg = args[1] as Long + api.getSuggestions(queryArg, limitArg) { result: Result> -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(GeckoPigeonUtils.wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(GeckoPigeonUtils.wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoHistoryApi.queryHistoryMetadata$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val queryArg = args[0] as String + val limitArg = args[1] as Long + api.queryHistoryMetadata(queryArg, limitArg) { result: Result> -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(GeckoPigeonUtils.wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(GeckoPigeonUtils.wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoHistoryApi.recordObservation$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val urlArg = args[0] as String + val observationArg = args[1] as PageObservation + api.recordObservation(urlArg, observationArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(GeckoPigeonUtils.wrapError(error)) + } else { + reply.reply(GeckoPigeonUtils.wrapResult(null)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoHistoryApi.noteHistoryMetadataViewTime$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val keyArg = args[0] as HistoryMetadataKey + val viewTimeMsArg = args[1] as Long + api.noteHistoryMetadataViewTime(keyArg, viewTimeMsArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(GeckoPigeonUtils.wrapError(error)) + } else { + reply.reply(GeckoPigeonUtils.wrapResult(null)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoHistoryApi.noteHistoryMetadataDocumentType$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val keyArg = args[0] as HistoryMetadataKey + val documentTypeArg = args[1] as DocumentType + api.noteHistoryMetadataDocumentType(keyArg, documentTypeArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(GeckoPigeonUtils.wrapError(error)) + } else { + reply.reply(GeckoPigeonUtils.wrapResult(null)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoHistoryApi.deleteVisitsFor$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val urlArg = args[0] as String + api.deleteVisitsFor(urlArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(GeckoPigeonUtils.wrapError(error)) + } else { + reply.reply(GeckoPigeonUtils.wrapResult(null)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoHistoryApi.deleteVisitsSince$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val sinceMillisArg = args[0] as Long + api.deleteVisitsSince(sinceMillisArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(GeckoPigeonUtils.wrapError(error)) + } else { + reply.reply(GeckoPigeonUtils.wrapResult(null)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoHistoryApi.deleteEverything$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + api.deleteEverything{ result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(GeckoPigeonUtils.wrapError(error)) + } else { + reply.reply(GeckoPigeonUtils.wrapResult(null)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoHistoryApi.deleteHistoryMetadataOlderThan$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val olderThanMillisArg = args[0] as Long + api.deleteHistoryMetadataOlderThan(olderThanMillisArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(GeckoPigeonUtils.wrapError(error)) + } else { + reply.reply(GeckoPigeonUtils.wrapResult(null)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } } } } @@ -10794,3 +11365,148 @@ interface GeckoPwaApi { } } } +/** + * Dart → Kotlin. Mutates the native [SandboxCaptureRegistry] that the + * request interceptor consults on every load. + * + * Generated interface from Pigeon that represents a handler of messages from Flutter. + */ +interface SandboxCaptureApi { + /** + * Replaces the entire registry with [entries]. Called at startup after + * Dart has brought up [CaptureServer] and reconciled local artifacts with + * the `capture_tab` rows. + */ + fun resetAll(entries: List) + /** Inserts or updates the registry entry for [entry.tabId]. */ + fun mark(entry: SandboxCaptureEntry) + /** Removes the registry entry for [tabId]. */ + fun unmark(tabId: String) + + companion object { + /** The codec used by SandboxCaptureApi. */ + val codec: MessageCodec by lazy { + GeckoPigeonCodec() + } + /** Sets up an instance of `SandboxCaptureApi` to handle messages through the `binaryMessenger`. */ + @JvmOverloads + fun setUp(binaryMessenger: BinaryMessenger, api: SandboxCaptureApi?, messageChannelSuffix: String = "") { + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.SandboxCaptureApi.resetAll$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val entriesArg = args[0] as List + val wrapped: List = try { + api.resetAll(entriesArg) + listOf(null) + } catch (exception: Throwable) { + GeckoPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.SandboxCaptureApi.mark$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val entryArg = args[0] as SandboxCaptureEntry + val wrapped: List = try { + api.mark(entryArg) + listOf(null) + } catch (exception: Throwable) { + GeckoPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.SandboxCaptureApi.unmark$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val tabIdArg = args[0] as String + val wrapped: List = try { + api.unmark(tabIdArg) + listOf(null) + } catch (exception: Throwable) { + GeckoPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + } + } +} +/** + * Kotlin → Dart. Fire-and-forget notifications from the request + * interceptor / BrowserStore middleware. All handlers are non-blocking; + * the interceptor never waits for a Dart response. + * + * Generated class from Pigeon that represents Flutter messages that can be called from Kotlin. + */ +class SandboxCaptureHostEvents(private val binaryMessenger: BinaryMessenger, private val messageChannelSuffix: String = "") { + companion object { + /** The codec used by SandboxCaptureHostEvents. */ + val codec: MessageCodec by lazy { + GeckoPigeonCodec() + } + } + /** + * Emitted when a sandbox tab attempted to navigate to a non-loopback, + * non-source URL (e.g., user clicked a link or typed a new URL into the + * address bar). Dart should open a new sandbox tab and capture [targetUrl]. + */ + fun onSandboxLinkClick(sequenceArg: Long, parentTabIdArg: String, targetUrlArg: String, callback: (Result) -> Unit) +{ + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = "dev.flutter.pigeon.flutter_mozilla_components.SandboxCaptureHostEvents.onSandboxLinkClick$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(listOf(sequenceArg, parentTabIdArg, targetUrlArg)) { + if (it is List<*>) { + if (it.size > 1) { + callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) + } else { + callback(Result.success(Unit)) + } + } else { + callback(Result.failure(GeckoPigeonUtils.createConnectionError(channelName))) + } + } + } + /** + * Emitted when GeckoView created a new tab (via `window.open`, + * `target="_blank"`, or a middle-click) whose parent is a sandbox tab. + * The native middleware has already rewritten the new tab's URL to + * `about:blank`; Dart should register it as sandbox and run the capture + * pipeline for [targetUrl]. + */ + fun onSandboxNewTab(sequenceArg: Long, parentTabIdArg: String, newTabIdArg: String, targetUrlArg: String, callback: (Result) -> Unit) +{ + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = "dev.flutter.pigeon.flutter_mozilla_components.SandboxCaptureHostEvents.onSandboxNewTab$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(listOf(sequenceArg, parentTabIdArg, newTabIdArg, targetUrlArg)) { + if (it is List<*>) { + if (it.size > 1) { + callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) + } else { + callback(Result.success(Unit)) + } + } else { + callback(Result.failure(GeckoPigeonUtils.createConnectionError(channelName))) + } + } + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/services/PrivateTabsNotificationService.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/services/PrivateTabsNotificationService.kt new file mode 100644 index 00000000..9e422541 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/services/PrivateTabsNotificationService.kt @@ -0,0 +1,74 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package eu.weblibre.flutter_mozilla_components.services + +import android.annotation.SuppressLint +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat +import eu.weblibre.flutter_mozilla_components.GlobalComponents +import eu.weblibre.flutter_mozilla_components.R +import eu.weblibre.flutter_mozilla_components.activities.AuthCustomTabActivity +import eu.weblibre.flutter_mozilla_components.activities.AuthIntentReceiverActivity +import eu.weblibre.flutter_mozilla_components.activities.ExternalAppBrowserActivity +import eu.weblibre.flutter_mozilla_components.activities.IntentReceiverActivity +import mozilla.components.browser.state.store.BrowserStore +import mozilla.components.feature.privatemode.notification.AbstractPrivateNotificationService +import mozilla.components.support.base.android.NotificationsDelegate + +class PrivateTabsNotificationService : AbstractPrivateNotificationService() { + private val components by lazy { + requireNotNull(GlobalComponents.components) { "Components not initialized" } + } + + override val store: BrowserStore by lazy { components.core.store } + + override val notificationsDelegate: NotificationsDelegate by lazy { + components.notificationsDelegate + } + + override fun NotificationCompat.Builder.buildNotification() { + setSmallIcon(R.drawable.mdi_icon_domino_mask) + + val contentTitle = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + applicationContext.getString(R.string.private_tabs_notification_title_android_14) + } else { + applicationContext.getString(R.string.private_tabs_notification_text) + } + + val contentText = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + applicationContext.getString(R.string.private_tabs_notification_text_android_14) + } else { + applicationContext.getString(R.string.private_tabs_notification_text) + } + + setContentTitle(contentTitle) + setContentText(contentText) + + color = ContextCompat.getColor( + this@PrivateTabsNotificationService, + R.color.private_tab_mask_accent, + ) + } + + override fun notifyLocaleChanged() { + refreshNotification() + } + + @SuppressLint("MissingSuperCall") + override fun erasePrivateTabs() { + components.useCases.tabsUseCases.removePrivateTabs() + } + + override fun ignoreTaskComponentClasses(): List = listOf( + ExternalAppBrowserActivity::class.qualifiedName!!, + IntentReceiverActivity::class.qualifiedName!!, + AuthIntentReceiverActivity::class.qualifiedName!!, + AuthCustomTabActivity::class.qualifiedName!!, + ) + + override fun ignoreTaskActions(): List = emptyList() +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/widget/BackGestureFilterFrameLayout.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/widget/BackGestureFilterFrameLayout.kt new file mode 100644 index 00000000..93f6c7c5 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/widget/BackGestureFilterFrameLayout.kt @@ -0,0 +1,128 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package eu.weblibre.flutter_mozilla_components.widget + +import android.app.Activity +import android.content.Context +// import android.util.Log +import android.view.MotionEvent +import android.widget.FrameLayout +import androidx.core.view.WindowInsetsCompat +import kotlin.math.abs + +/** + * Root container for the embedded browser fragment. + * + * Why: Flutter's platform-view motion-event pipeline does not reliably forward + * ACTION_CANCEL when the Android system claims a back gesture mid-stream. On + * affected devices (notably Samsung One UI) GeckoView's APZ receives the + * leading touches of the back gesture and produces a short, unwanted page + * scroll before the gesture is recognized. + * + * This view watches every touch in dispatchTouchEvent (rather than + * onInterceptTouchEvent — NestedGeckoView calls requestDisallowInterceptTouchEvent + * on ACTION_DOWN, which would suppress the intercept hook for the rest of the + * gesture). When a horizontal drag begins inside the system back-gesture inset + * and the gesture becomes unambiguous (horizontal travel exceeds touch slop and + * dominates over vertical travel), it dispatches a synthetic ACTION_CANCEL to + * children and swallows the remainder of the gesture. APZ resets cleanly on + * the cancel. + * + * Taps and vertical drags that originate in the inset still reach the engine + * view, so links and vertical scrolling continue to work at the edges. + */ +class BackGestureFilterFrameLayout( + context: Context, + private val activity: Activity, +) : FrameLayout(context) { + private var downX = 0f + private var downY = 0f + private var startedInEdgeZone = false + private var hasIntercepted = false + + override fun dispatchTouchEvent(ev: MotionEvent): Boolean { + when (ev.actionMasked) { + MotionEvent.ACTION_DOWN -> { + downX = ev.x + downY = ev.y + hasIntercepted = false + val (left, right) = currentGestureInsets() + startedInEdgeZone = isInEdgeZone(ev.x, left, right) + } + + MotionEvent.ACTION_MOVE -> { + if (hasIntercepted) { + return true + } + if (startedInEdgeZone) { + val dx = abs(ev.x - downX) + val dy = abs(ev.y - downY) + when { + // Horizontal-dominant: this is a back-gesture pan. The OS will + // CANCEL shortly, but APZ's internal pan threshold is smaller + // than Android's touchSlop, so even the small leading MOVEs + // already scrolled the page. Cancel APZ now and swallow the rest. + dx > dy + 1 -> { + hasIntercepted = true + // Log.d(TAG, "INTERCEPT dx=$dx dy=$dy") + dispatchSyntheticCancel(ev) + return true + } + // Vertical-dominant: the gesture is a real in-page scroll; + // stop watching so subsequent MOVEs pass through. + dy > dx + 1 -> { + startedInEdgeZone = false + } + } + } + } + + MotionEvent.ACTION_UP, + MotionEvent.ACTION_CANCEL -> { + val wasIntercepted = hasIntercepted + resetGestureState() + if (wasIntercepted) { + return true + } + } + } + return super.dispatchTouchEvent(ev) + } + + private fun dispatchSyntheticCancel(source: MotionEvent) { + val cancel = MotionEvent.obtain(source).apply { + action = MotionEvent.ACTION_CANCEL + } + super.dispatchTouchEvent(cancel) + cancel.recycle() + } + + private fun resetGestureState() { + startedInEdgeZone = false + hasIntercepted = false + } + + private fun currentGestureInsets(): Pair { + val rawInsets = activity.window.decorView.rootWindowInsets ?: return 0 to 0 + val gestureInsets = WindowInsetsCompat.toWindowInsetsCompat(rawInsets) + .getInsets(WindowInsetsCompat.Type.systemGestures()) + return gestureInsets.left to gestureInsets.right + } + + private fun isInEdgeZone(x: Float, left: Int, right: Int): Boolean { + if (left == 0 && right == 0) { + // 3-button navigation (or unknown): no system back gesture to defend + // against, so all edge touches must reach the engine view normally. + return false + } + return x <= left || x >= width - right + } + + // companion object { + // private const val TAG = "BackGestureFilter" + // } +} diff --git a/packages/flutter_mozilla_components/android/src/main/res/drawable/mdi_icon_domino_mask.xml b/packages/flutter_mozilla_components/android/src/main/res/drawable/mdi_icon_domino_mask.xml new file mode 100644 index 00000000..3de025c1 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/res/drawable/mdi_icon_domino_mask.xml @@ -0,0 +1,9 @@ + + + diff --git a/packages/flutter_mozilla_components/android/src/main/res/layout/fragment_browser.xml b/packages/flutter_mozilla_components/android/src/main/res/layout/fragment_browser.xml index 7390ce32..0ea9e05d 100644 --- a/packages/flutter_mozilla_components/android/src/main/res/layout/fragment_browser.xml +++ b/packages/flutter_mozilla_components/android/src/main/res/layout/fragment_browser.xml @@ -5,24 +5,22 @@ - - + + android:layout_height="match_parent"> Close Menu Security + Close private tabs + Close private tabs? + Tap or swipe this notification to close private tabs. diff --git a/packages/flutter_mozilla_components/lib/flutter_mozilla_components.dart b/packages/flutter_mozilla_components/lib/flutter_mozilla_components.dart index 7dfcad27..5051f7ed 100644 --- a/packages/flutter_mozilla_components/lib/flutter_mozilla_components.dart +++ b/packages/flutter_mozilla_components/lib/flutter_mozilla_components.dart @@ -61,6 +61,7 @@ export 'src/pigeons/gecko.g.dart' CookieBannerHandlingMode, CookieSameSiteStatus, CustomCookiePolicy, + DocumentType, DohSettings, DohSettingsMode, EmailHitResult, @@ -78,7 +79,9 @@ export 'src/pigeons/gecko.g.dart' GeoHitResult, HistoryHighlight, HistoryHighlightWeights, + HistoryMetadata, HistoryMetadataKey, + HistorySuggestion, HitResult, HttpsOnlyMode, IconSource, @@ -95,6 +98,9 @@ export 'src/pigeons/gecko.g.dart' QueryParameterStripping, Resource, ResourceSize, + SandboxCaptureApi, + SandboxCaptureEntry, + SandboxCaptureHostEvents, SecurityInfoState, SitePermissionStatus, SitePermissions, diff --git a/packages/flutter_mozilla_components/lib/src/domain/services/gecko_history.dart b/packages/flutter_mozilla_components/lib/src/domain/services/gecko_history.dart index a6f9f56f..ae1b6dac 100644 --- a/packages/flutter_mozilla_components/lib/src/domain/services/gecko_history.dart +++ b/packages/flutter_mozilla_components/lib/src/domain/services/gecko_history.dart @@ -74,4 +74,72 @@ class GeckoHistoryService { }) { return _api.getTopFrecentSites(limit, frecencyThreshold); } + + Future getLatestHistoryMetadataForUrl(String url) { + return _api.getLatestHistoryMetadataForUrl(url); + } + + Future> getLatestHistoryMetadataForUrls( + List urls, + ) { + if (urls.isEmpty) return Future.value(const []); + return _api.getLatestHistoryMetadataForUrls(urls); + } + + Future> getVisited(List urls) { + if (urls.isEmpty) return Future.value(const []); + return _api.getVisited(urls); + } + + Future> getSuggestions(String query, {int limit = 10}) { + return _api.getSuggestions(query, limit); + } + + Future> queryHistoryMetadata( + String query, { + int limit = 10, + }) { + return _api.queryHistoryMetadata(query, limit); + } + + Future recordObservation( + String url, { + String? title, + String? previewImageUrl, + }) { + return _api.recordObservation( + url, + PageObservation(title: title, previewImageUrl: previewImageUrl), + ); + } + + Future noteHistoryMetadataViewTime( + HistoryMetadataKey key, + Duration viewTime, + ) { + return _api.noteHistoryMetadataViewTime(key, viewTime.inMilliseconds); + } + + Future noteHistoryMetadataDocumentType( + HistoryMetadataKey key, + DocumentType documentType, + ) { + return _api.noteHistoryMetadataDocumentType(key, documentType); + } + + Future deleteVisitsFor(String url) { + return _api.deleteVisitsFor(url); + } + + Future deleteVisitsSince(DateTime since) { + return _api.deleteVisitsSince(since.millisecondsSinceEpoch); + } + + Future deleteEverything() { + return _api.deleteEverything(); + } + + Future deleteHistoryMetadataOlderThan(DateTime olderThan) { + return _api.deleteHistoryMetadataOlderThan(olderThan.millisecondsSinceEpoch); + } } diff --git a/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart b/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart index 88b843e6..2ba1bb29 100644 --- a/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart +++ b/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart @@ -192,6 +192,12 @@ enum FrecencyThresholdOption { skipOneTimePages, } +/// Document type associated with a [HistoryMetadata] record. +enum DocumentType { + regular, + media, +} + enum SelectionPattern { phone, email, @@ -2003,6 +2009,181 @@ class TopFrecentSiteInfo { int get hashCode => _deepHash([runtimeType, ..._toList()]); } +/// Per-URL metadata maintained by Places. The unique identity of a record is +/// [HistoryMetadataKey] (url + searchTerm + referrerUrl); we surface only the +/// most recent record per URL via `getLatestHistoryMetadataForUrl`. +class HistoryMetadata { + HistoryMetadata({ + required this.key, + this.title, + required this.createdAt, + required this.updatedAt, + required this.totalViewTime, + required this.documentType, + this.previewImageUrl, + }); + + HistoryMetadataKey key; + + String? title; + + /// Unix milliseconds. + int createdAt; + + /// Unix milliseconds. + int updatedAt; + + /// Total view time in milliseconds. + int totalViewTime; + + DocumentType documentType; + + String? previewImageUrl; + + List _toList() { + return [ + key, + title, + createdAt, + updatedAt, + totalViewTime, + documentType, + previewImageUrl, + ]; + } + + Object encode() { + return _toList(); } + + static HistoryMetadata decode(Object result) { + result as List; + return HistoryMetadata( + key: result[0]! as HistoryMetadataKey, + title: result[1] as String?, + createdAt: result[2]! as int, + updatedAt: result[3]! as int, + totalViewTime: result[4]! as int, + documentType: result[5]! as DocumentType, + previewImageUrl: result[6] as String?, + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! HistoryMetadata || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(key, other.key) && _deepEquals(title, other.title) && _deepEquals(createdAt, other.createdAt) && _deepEquals(updatedAt, other.updatedAt) && _deepEquals(totalViewTime, other.totalViewTime) && _deepEquals(documentType, other.documentType) && _deepEquals(previewImageUrl, other.previewImageUrl); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => _deepHash([runtimeType, ..._toList()]); +} + +/// Frecency-ranked autocomplete suggestion. Backs `getSuggestions`. +class HistorySuggestion { + HistorySuggestion({ + required this.url, + this.title, + required this.score, + }); + + String url; + + String? title; + + /// Larger is more relevant. Unbounded; only meaningful relative to other + /// suggestions returned in the same call. + int score; + + List _toList() { + return [ + url, + title, + score, + ]; + } + + Object encode() { + return _toList(); } + + static HistorySuggestion decode(Object result) { + result as List; + return HistorySuggestion( + url: result[0]! as String, + title: result[1] as String?, + score: result[2]! as int, + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! HistorySuggestion || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(url, other.url) && _deepEquals(title, other.title) && _deepEquals(score, other.score); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => _deepHash([runtimeType, ..._toList()]); +} + +/// Optional metadata observation for a URL. `null` fields are not written. +class PageObservation { + PageObservation({ + this.title, + this.previewImageUrl, + }); + + String? title; + + String? previewImageUrl; + + List _toList() { + return [ + title, + previewImageUrl, + ]; + } + + Object encode() { + return _toList(); } + + static PageObservation decode(Object result) { + result as List; + return PageObservation( + title: result[0] as String?, + previewImageUrl: result[1] as String?, + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! PageObservation || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(title, other.title) && _deepEquals(previewImageUrl, other.previewImageUrl); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => _deepHash([runtimeType, ..._toList()]); +} + class HistoryItem { HistoryItem({ required this.url, @@ -5593,6 +5774,76 @@ class PwaManifest { int get hashCode => _deepHash([runtimeType, ..._toList()]); } +/// Per-tab sandbox capture state shared with the native side. The Kotlin +/// [AppRequestInterceptor] consults an in-memory registry populated from +/// these entries to decide how to handle loads in sandbox tabs. +/// +/// [redirectUrl] is precomputed by Dart and always points at a loopback URL +/// (loader or capture). Dart is responsible for keeping it current; Kotlin +/// never calls back into Dart to resolve it. +class SandboxCaptureEntry { + SandboxCaptureEntry({ + required this.tabId, + required this.captureId, + required this.sourceUrl, + required this.redirectUrl, + required this.status, + }); + + String tabId; + + String captureId; + + String sourceUrl; + + /// `http://127.0.0.1:/loader?…` while pending/failed, or + /// `http://127.0.0.1:/captures/…?t=` once ready. + String redirectUrl; + + /// `pending` | `ready` | `failed`. + String status; + + List _toList() { + return [ + tabId, + captureId, + sourceUrl, + redirectUrl, + status, + ]; + } + + Object encode() { + return _toList(); } + + static SandboxCaptureEntry decode(Object result) { + result as List; + return SandboxCaptureEntry( + tabId: result[0]! as String, + captureId: result[1]! as String, + sourceUrl: result[2]! as String, + redirectUrl: result[3]! as String, + status: result[4]! as String, + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! SandboxCaptureEntry || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(tabId, other.tabId) && _deepEquals(captureId, other.captureId) && _deepEquals(sourceUrl, other.sourceUrl) && _deepEquals(redirectUrl, other.redirectUrl) && _deepEquals(status, other.status); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => _deepHash([runtimeType, ..._toList()]); +} + class _PigeonCodec extends StandardMessageCodec { const _PigeonCodec(); @@ -5622,333 +5873,348 @@ class _PigeonCodec extends StandardMessageCodec { } else if (value is FrecencyThresholdOption) { buffer.putUint8(135); writeValue(buffer, value.index); - } else if (value is SelectionPattern) { + } else if (value is DocumentType) { buffer.putUint8(136); writeValue(buffer, value.index); - } else if (value is WebExtensionActionType) { + } else if (value is SelectionPattern) { buffer.putUint8(137); writeValue(buffer, value.index); - } else if (value is AddonDisabledReason) { + } else if (value is WebExtensionActionType) { buffer.putUint8(138); writeValue(buffer, value.index); - } else if (value is AddonIncognito) { + } else if (value is AddonDisabledReason) { buffer.putUint8(139); writeValue(buffer, value.index); - } else if (value is AddonUpdateStatus) { + } else if (value is AddonIncognito) { buffer.putUint8(140); writeValue(buffer, value.index); - } else if (value is AddonStoreApp) { + } else if (value is AddonUpdateStatus) { buffer.putUint8(141); writeValue(buffer, value.index); - } else if (value is AddonStorePromoted) { + } else if (value is AddonStoreApp) { buffer.putUint8(142); writeValue(buffer, value.index); - } else if (value is GeckoSuggestionType) { + } else if (value is AddonStorePromoted) { buffer.putUint8(143); writeValue(buffer, value.index); - } else if (value is TrackingProtectionPolicy) { + } else if (value is GeckoSuggestionType) { buffer.putUint8(144); writeValue(buffer, value.index); - } else if (value is HttpsOnlyMode) { + } else if (value is TrackingProtectionPolicy) { buffer.putUint8(145); writeValue(buffer, value.index); - } else if (value is QueryParameterStripping) { + } else if (value is HttpsOnlyMode) { buffer.putUint8(146); writeValue(buffer, value.index); - } else if (value is BounceTrackingProtectionMode) { + } else if (value is QueryParameterStripping) { buffer.putUint8(147); writeValue(buffer, value.index); - } else if (value is ColorScheme) { + } else if (value is BounceTrackingProtectionMode) { buffer.putUint8(148); writeValue(buffer, value.index); - } else if (value is CookieBannerHandlingMode) { + } else if (value is ColorScheme) { buffer.putUint8(149); writeValue(buffer, value.index); - } else if (value is AppLinksMode) { + } else if (value is CookieBannerHandlingMode) { buffer.putUint8(150); writeValue(buffer, value.index); - } else if (value is WebContentIsolationStrategy) { + } else if (value is AppLinksMode) { buffer.putUint8(151); writeValue(buffer, value.index); - } else if (value is CustomCookiePolicy) { + } else if (value is WebContentIsolationStrategy) { buffer.putUint8(152); writeValue(buffer, value.index); - } else if (value is TrackingScope) { + } else if (value is CustomCookiePolicy) { buffer.putUint8(153); writeValue(buffer, value.index); - } else if (value is DohSettingsMode) { + } else if (value is TrackingScope) { buffer.putUint8(154); writeValue(buffer, value.index); - } else if (value is DownloadStatus) { + } else if (value is DohSettingsMode) { buffer.putUint8(155); writeValue(buffer, value.index); - } else if (value is LogLevel) { + } else if (value is DownloadStatus) { buffer.putUint8(156); writeValue(buffer, value.index); - } else if (value is SyncEngineValue) { + } else if (value is LogLevel) { buffer.putUint8(157); writeValue(buffer, value.index); - } else if (value is MlProgressType) { + } else if (value is SyncEngineValue) { buffer.putUint8(158); writeValue(buffer, value.index); - } else if (value is MlProgressStatus) { + } else if (value is MlProgressType) { buffer.putUint8(159); writeValue(buffer, value.index); - } else if (value is ClearDataType) { + } else if (value is MlProgressStatus) { buffer.putUint8(160); writeValue(buffer, value.index); - } else if (value is GeckoFetchMethod) { + } else if (value is ClearDataType) { buffer.putUint8(161); writeValue(buffer, value.index); - } else if (value is GeckoFetchRedircet) { + } else if (value is GeckoFetchMethod) { buffer.putUint8(162); writeValue(buffer, value.index); - } else if (value is GeckoFetchCookiePolicy) { + } else if (value is GeckoFetchRedircet) { buffer.putUint8(163); writeValue(buffer, value.index); - } else if (value is BookmarkNodeType) { + } else if (value is GeckoFetchCookiePolicy) { buffer.putUint8(164); writeValue(buffer, value.index); - } else if (value is SitePermissionStatus) { + } else if (value is BookmarkNodeType) { buffer.putUint8(165); writeValue(buffer, value.index); - } else if (value is AutoplayStatus) { + } else if (value is SitePermissionStatus) { buffer.putUint8(166); writeValue(buffer, value.index); - } else if (value is TranslationOptions) { + } else if (value is AutoplayStatus) { buffer.putUint8(167); - writeValue(buffer, value.encode()); - } else if (value is TranslationLanguage) { + writeValue(buffer, value.index); + } else if (value is TranslationOptions) { buffer.putUint8(168); writeValue(buffer, value.encode()); - } else if (value is TranslationDetectedLanguages) { + } else if (value is TranslationLanguage) { buffer.putUint8(169); writeValue(buffer, value.encode()); - } else if (value is TranslationPair) { + } else if (value is TranslationDetectedLanguages) { buffer.putUint8(170); writeValue(buffer, value.encode()); - } else if (value is TranslationEngineStateData) { + } else if (value is TranslationPair) { buffer.putUint8(171); writeValue(buffer, value.encode()); - } else if (value is TabTranslationStateData) { + } else if (value is TranslationEngineStateData) { buffer.putUint8(172); writeValue(buffer, value.encode()); - } else if (value is ReaderState) { + } else if (value is TabTranslationStateData) { buffer.putUint8(173); writeValue(buffer, value.encode()); - } else if (value is AddTabParams) { + } else if (value is ReaderState) { buffer.putUint8(174); writeValue(buffer, value.encode()); - } else if (value is LastMediaAccessState) { + } else if (value is AddTabParams) { buffer.putUint8(175); writeValue(buffer, value.encode()); - } else if (value is HistoryMetadataKey) { + } else if (value is LastMediaAccessState) { buffer.putUint8(176); writeValue(buffer, value.encode()); - } else if (value is PackageCategoryValue) { + } else if (value is HistoryMetadataKey) { buffer.putUint8(177); writeValue(buffer, value.encode()); - } else if (value is ExternalPackage) { + } else if (value is PackageCategoryValue) { buffer.putUint8(178); writeValue(buffer, value.encode()); - } else if (value is LoadUrlFlagsValue) { + } else if (value is ExternalPackage) { buffer.putUint8(179); writeValue(buffer, value.encode()); - } else if (value is SourceValue) { + } else if (value is LoadUrlFlagsValue) { buffer.putUint8(180); writeValue(buffer, value.encode()); - } else if (value is TabState) { + } else if (value is SourceValue) { buffer.putUint8(181); writeValue(buffer, value.encode()); - } else if (value is RecoverableTab) { + } else if (value is TabState) { buffer.putUint8(182); writeValue(buffer, value.encode()); - } else if (value is IconRequest) { + } else if (value is RecoverableTab) { buffer.putUint8(183); writeValue(buffer, value.encode()); - } else if (value is ResourceSize) { + } else if (value is IconRequest) { buffer.putUint8(184); writeValue(buffer, value.encode()); - } else if (value is Resource) { + } else if (value is ResourceSize) { buffer.putUint8(185); writeValue(buffer, value.encode()); - } else if (value is IconResult) { + } else if (value is Resource) { buffer.putUint8(186); writeValue(buffer, value.encode()); - } else if (value is CookiePartitionKey) { + } else if (value is IconResult) { buffer.putUint8(187); writeValue(buffer, value.encode()); - } else if (value is Cookie) { + } else if (value is CookiePartitionKey) { buffer.putUint8(188); writeValue(buffer, value.encode()); - } else if (value is VisitInfo) { + } else if (value is Cookie) { buffer.putUint8(189); writeValue(buffer, value.encode()); - } else if (value is HistoryHighlightWeights) { + } else if (value is VisitInfo) { buffer.putUint8(190); writeValue(buffer, value.encode()); - } else if (value is HistoryHighlight) { + } else if (value is HistoryHighlightWeights) { buffer.putUint8(191); writeValue(buffer, value.encode()); - } else if (value is TopFrecentSiteInfo) { + } else if (value is HistoryHighlight) { buffer.putUint8(192); writeValue(buffer, value.encode()); - } else if (value is HistoryItem) { + } else if (value is TopFrecentSiteInfo) { buffer.putUint8(193); writeValue(buffer, value.encode()); - } else if (value is HistoryState) { + } else if (value is HistoryMetadata) { buffer.putUint8(194); writeValue(buffer, value.encode()); - } else if (value is ReaderableState) { + } else if (value is HistorySuggestion) { buffer.putUint8(195); writeValue(buffer, value.encode()); - } else if (value is SecurityInfoState) { + } else if (value is PageObservation) { buffer.putUint8(196); writeValue(buffer, value.encode()); - } else if (value is TabContentState) { + } else if (value is HistoryItem) { buffer.putUint8(197); writeValue(buffer, value.encode()); - } else if (value is FindResultState) { + } else if (value is HistoryState) { buffer.putUint8(198); writeValue(buffer, value.encode()); - } else if (value is CustomSelectionAction) { + } else if (value is ReaderableState) { buffer.putUint8(199); writeValue(buffer, value.encode()); - } else if (value is WebExtensionData) { + } else if (value is SecurityInfoState) { buffer.putUint8(200); writeValue(buffer, value.encode()); - } else if (value is AddonInfo) { + } else if (value is TabContentState) { buffer.putUint8(201); writeValue(buffer, value.encode()); - } else if (value is AddonListingPreview) { + } else if (value is FindResultState) { buffer.putUint8(202); writeValue(buffer, value.encode()); - } else if (value is AddonListing) { + } else if (value is CustomSelectionAction) { buffer.putUint8(203); writeValue(buffer, value.encode()); - } else if (value is AddonStoreInfo) { + } else if (value is WebExtensionData) { buffer.putUint8(204); writeValue(buffer, value.encode()); - } else if (value is AddonUpdateAttemptInfo) { + } else if (value is AddonInfo) { buffer.putUint8(205); writeValue(buffer, value.encode()); - } else if (value is GeckoSuggestion) { + } else if (value is AddonListingPreview) { buffer.putUint8(206); writeValue(buffer, value.encode()); - } else if (value is TabContent) { + } else if (value is AddonListing) { buffer.putUint8(207); writeValue(buffer, value.encode()); - } else if (value is ContentBlocking) { + } else if (value is AddonStoreInfo) { buffer.putUint8(208); writeValue(buffer, value.encode()); - } else if (value is DohSettings) { + } else if (value is AddonUpdateAttemptInfo) { buffer.putUint8(209); writeValue(buffer, value.encode()); - } else if (value is GeckoEngineSettings) { + } else if (value is GeckoSuggestion) { buffer.putUint8(210); writeValue(buffer, value.encode()); - } else if (value is AutocompleteResult) { + } else if (value is TabContent) { buffer.putUint8(211); writeValue(buffer, value.encode()); - } else if (value is UnknownHitResult) { + } else if (value is ContentBlocking) { buffer.putUint8(212); writeValue(buffer, value.encode()); - } else if (value is ImageHitResult) { + } else if (value is DohSettings) { buffer.putUint8(213); writeValue(buffer, value.encode()); - } else if (value is VideoHitResult) { + } else if (value is GeckoEngineSettings) { buffer.putUint8(214); writeValue(buffer, value.encode()); - } else if (value is AudioHitResult) { + } else if (value is AutocompleteResult) { buffer.putUint8(215); writeValue(buffer, value.encode()); - } else if (value is ImageSrcHitResult) { + } else if (value is UnknownHitResult) { buffer.putUint8(216); writeValue(buffer, value.encode()); - } else if (value is PhoneHitResult) { + } else if (value is ImageHitResult) { buffer.putUint8(217); writeValue(buffer, value.encode()); - } else if (value is EmailHitResult) { + } else if (value is VideoHitResult) { buffer.putUint8(218); writeValue(buffer, value.encode()); - } else if (value is GeoHitResult) { + } else if (value is AudioHitResult) { buffer.putUint8(219); writeValue(buffer, value.encode()); - } else if (value is DownloadState) { + } else if (value is ImageSrcHitResult) { buffer.putUint8(220); writeValue(buffer, value.encode()); - } else if (value is ShareInternetResourceState) { + } else if (value is PhoneHitResult) { buffer.putUint8(221); writeValue(buffer, value.encode()); - } else if (value is AddonCollection) { + } else if (value is EmailHitResult) { buffer.putUint8(222); writeValue(buffer, value.encode()); - } else if (value is SyncEngineStatus) { + } else if (value is GeoHitResult) { buffer.putUint8(223); writeValue(buffer, value.encode()); - } else if (value is SyncAccountInfo) { + } else if (value is DownloadState) { buffer.putUint8(224); writeValue(buffer, value.encode()); - } else if (value is SyncDevice) { + } else if (value is ShareInternetResourceState) { buffer.putUint8(225); writeValue(buffer, value.encode()); - } else if (value is SyncIncomingTab) { + } else if (value is AddonCollection) { buffer.putUint8(226); writeValue(buffer, value.encode()); - } else if (value is SyncRemoteTab) { + } else if (value is SyncEngineStatus) { buffer.putUint8(227); writeValue(buffer, value.encode()); - } else if (value is SyncDeviceTabs) { + } else if (value is SyncAccountInfo) { buffer.putUint8(228); writeValue(buffer, value.encode()); - } else if (value is GeckoPref) { + } else if (value is SyncDevice) { buffer.putUint8(229); writeValue(buffer, value.encode()); - } else if (value is MlProgressData) { + } else if (value is SyncIncomingTab) { buffer.putUint8(230); writeValue(buffer, value.encode()); - } else if (value is ContainerSiteAssignment) { + } else if (value is SyncRemoteTab) { buffer.putUint8(231); writeValue(buffer, value.encode()); - } else if (value is GeckoHeader) { + } else if (value is SyncDeviceTabs) { buffer.putUint8(232); writeValue(buffer, value.encode()); - } else if (value is GeckoFetchRequest) { + } else if (value is GeckoPref) { buffer.putUint8(233); writeValue(buffer, value.encode()); - } else if (value is GeckoFetchResponse) { + } else if (value is MlProgressData) { buffer.putUint8(234); writeValue(buffer, value.encode()); - } else if (value is BookmarkNode) { + } else if (value is ContainerSiteAssignment) { buffer.putUint8(235); writeValue(buffer, value.encode()); - } else if (value is BookmarkInfo) { + } else if (value is GeckoHeader) { buffer.putUint8(236); writeValue(buffer, value.encode()); - } else if (value is SitePermissions) { + } else if (value is GeckoFetchRequest) { buffer.putUint8(237); writeValue(buffer, value.encode()); - } else if (value is TrackingProtectionException) { + } else if (value is GeckoFetchResponse) { buffer.putUint8(238); writeValue(buffer, value.encode()); - } else if (value is PwaIcon) { + } else if (value is BookmarkNode) { buffer.putUint8(239); writeValue(buffer, value.encode()); - } else if (value is ShareTargetFiles) { + } else if (value is BookmarkInfo) { buffer.putUint8(240); writeValue(buffer, value.encode()); - } else if (value is ShareTargetParams) { + } else if (value is SitePermissions) { buffer.putUint8(241); writeValue(buffer, value.encode()); - } else if (value is ShareTarget) { + } else if (value is TrackingProtectionException) { buffer.putUint8(242); writeValue(buffer, value.encode()); - } else if (value is ExternalApplicationResource) { + } else if (value is PwaIcon) { buffer.putUint8(243); writeValue(buffer, value.encode()); - } else if (value is PwaManifest) { + } else if (value is ShareTargetFiles) { buffer.putUint8(244); writeValue(buffer, value.encode()); + } else if (value is ShareTargetParams) { + buffer.putUint8(245); + writeValue(buffer, value.encode()); + } else if (value is ShareTarget) { + buffer.putUint8(246); + writeValue(buffer, value.encode()); + } else if (value is ExternalApplicationResource) { + buffer.putUint8(247); + writeValue(buffer, value.encode()); + } else if (value is PwaManifest) { + buffer.putUint8(248); + writeValue(buffer, value.encode()); + } else if (value is SandboxCaptureEntry) { + buffer.putUint8(249); + writeValue(buffer, value.encode()); } else { super.writeValue(buffer, value); } @@ -5980,253 +6246,264 @@ class _PigeonCodec extends StandardMessageCodec { return value == null ? null : FrecencyThresholdOption.values[value]; case 136: final value = readValue(buffer) as int?; - return value == null ? null : SelectionPattern.values[value]; + return value == null ? null : DocumentType.values[value]; case 137: final value = readValue(buffer) as int?; - return value == null ? null : WebExtensionActionType.values[value]; + return value == null ? null : SelectionPattern.values[value]; case 138: final value = readValue(buffer) as int?; - return value == null ? null : AddonDisabledReason.values[value]; + return value == null ? null : WebExtensionActionType.values[value]; case 139: final value = readValue(buffer) as int?; - return value == null ? null : AddonIncognito.values[value]; + return value == null ? null : AddonDisabledReason.values[value]; case 140: final value = readValue(buffer) as int?; - return value == null ? null : AddonUpdateStatus.values[value]; + return value == null ? null : AddonIncognito.values[value]; case 141: final value = readValue(buffer) as int?; - return value == null ? null : AddonStoreApp.values[value]; + return value == null ? null : AddonUpdateStatus.values[value]; case 142: final value = readValue(buffer) as int?; - return value == null ? null : AddonStorePromoted.values[value]; + return value == null ? null : AddonStoreApp.values[value]; case 143: final value = readValue(buffer) as int?; - return value == null ? null : GeckoSuggestionType.values[value]; + return value == null ? null : AddonStorePromoted.values[value]; case 144: final value = readValue(buffer) as int?; - return value == null ? null : TrackingProtectionPolicy.values[value]; + return value == null ? null : GeckoSuggestionType.values[value]; case 145: final value = readValue(buffer) as int?; - return value == null ? null : HttpsOnlyMode.values[value]; + return value == null ? null : TrackingProtectionPolicy.values[value]; case 146: final value = readValue(buffer) as int?; - return value == null ? null : QueryParameterStripping.values[value]; + return value == null ? null : HttpsOnlyMode.values[value]; case 147: final value = readValue(buffer) as int?; - return value == null ? null : BounceTrackingProtectionMode.values[value]; + return value == null ? null : QueryParameterStripping.values[value]; case 148: final value = readValue(buffer) as int?; - return value == null ? null : ColorScheme.values[value]; + return value == null ? null : BounceTrackingProtectionMode.values[value]; case 149: final value = readValue(buffer) as int?; - return value == null ? null : CookieBannerHandlingMode.values[value]; + return value == null ? null : ColorScheme.values[value]; case 150: final value = readValue(buffer) as int?; - return value == null ? null : AppLinksMode.values[value]; + return value == null ? null : CookieBannerHandlingMode.values[value]; case 151: final value = readValue(buffer) as int?; - return value == null ? null : WebContentIsolationStrategy.values[value]; + return value == null ? null : AppLinksMode.values[value]; case 152: final value = readValue(buffer) as int?; - return value == null ? null : CustomCookiePolicy.values[value]; + return value == null ? null : WebContentIsolationStrategy.values[value]; case 153: final value = readValue(buffer) as int?; - return value == null ? null : TrackingScope.values[value]; + return value == null ? null : CustomCookiePolicy.values[value]; case 154: final value = readValue(buffer) as int?; - return value == null ? null : DohSettingsMode.values[value]; + return value == null ? null : TrackingScope.values[value]; case 155: final value = readValue(buffer) as int?; - return value == null ? null : DownloadStatus.values[value]; + return value == null ? null : DohSettingsMode.values[value]; case 156: final value = readValue(buffer) as int?; - return value == null ? null : LogLevel.values[value]; + return value == null ? null : DownloadStatus.values[value]; case 157: final value = readValue(buffer) as int?; - return value == null ? null : SyncEngineValue.values[value]; + return value == null ? null : LogLevel.values[value]; case 158: final value = readValue(buffer) as int?; - return value == null ? null : MlProgressType.values[value]; + return value == null ? null : SyncEngineValue.values[value]; case 159: final value = readValue(buffer) as int?; - return value == null ? null : MlProgressStatus.values[value]; + return value == null ? null : MlProgressType.values[value]; case 160: final value = readValue(buffer) as int?; - return value == null ? null : ClearDataType.values[value]; + return value == null ? null : MlProgressStatus.values[value]; case 161: final value = readValue(buffer) as int?; - return value == null ? null : GeckoFetchMethod.values[value]; + return value == null ? null : ClearDataType.values[value]; case 162: final value = readValue(buffer) as int?; - return value == null ? null : GeckoFetchRedircet.values[value]; + return value == null ? null : GeckoFetchMethod.values[value]; case 163: final value = readValue(buffer) as int?; - return value == null ? null : GeckoFetchCookiePolicy.values[value]; + return value == null ? null : GeckoFetchRedircet.values[value]; case 164: final value = readValue(buffer) as int?; - return value == null ? null : BookmarkNodeType.values[value]; + return value == null ? null : GeckoFetchCookiePolicy.values[value]; case 165: final value = readValue(buffer) as int?; - return value == null ? null : SitePermissionStatus.values[value]; + return value == null ? null : BookmarkNodeType.values[value]; case 166: final value = readValue(buffer) as int?; - return value == null ? null : AutoplayStatus.values[value]; + return value == null ? null : SitePermissionStatus.values[value]; case 167: - return TranslationOptions.decode(readValue(buffer)!); + final value = readValue(buffer) as int?; + return value == null ? null : AutoplayStatus.values[value]; case 168: - return TranslationLanguage.decode(readValue(buffer)!); + return TranslationOptions.decode(readValue(buffer)!); case 169: - return TranslationDetectedLanguages.decode(readValue(buffer)!); + return TranslationLanguage.decode(readValue(buffer)!); case 170: - return TranslationPair.decode(readValue(buffer)!); + return TranslationDetectedLanguages.decode(readValue(buffer)!); case 171: - return TranslationEngineStateData.decode(readValue(buffer)!); + return TranslationPair.decode(readValue(buffer)!); case 172: - return TabTranslationStateData.decode(readValue(buffer)!); + return TranslationEngineStateData.decode(readValue(buffer)!); case 173: - return ReaderState.decode(readValue(buffer)!); + return TabTranslationStateData.decode(readValue(buffer)!); case 174: - return AddTabParams.decode(readValue(buffer)!); + return ReaderState.decode(readValue(buffer)!); case 175: - return LastMediaAccessState.decode(readValue(buffer)!); + return AddTabParams.decode(readValue(buffer)!); case 176: - return HistoryMetadataKey.decode(readValue(buffer)!); + return LastMediaAccessState.decode(readValue(buffer)!); case 177: - return PackageCategoryValue.decode(readValue(buffer)!); + return HistoryMetadataKey.decode(readValue(buffer)!); case 178: - return ExternalPackage.decode(readValue(buffer)!); + return PackageCategoryValue.decode(readValue(buffer)!); case 179: - return LoadUrlFlagsValue.decode(readValue(buffer)!); + return ExternalPackage.decode(readValue(buffer)!); case 180: - return SourceValue.decode(readValue(buffer)!); + return LoadUrlFlagsValue.decode(readValue(buffer)!); case 181: - return TabState.decode(readValue(buffer)!); + return SourceValue.decode(readValue(buffer)!); case 182: - return RecoverableTab.decode(readValue(buffer)!); + return TabState.decode(readValue(buffer)!); case 183: - return IconRequest.decode(readValue(buffer)!); + return RecoverableTab.decode(readValue(buffer)!); case 184: - return ResourceSize.decode(readValue(buffer)!); + return IconRequest.decode(readValue(buffer)!); case 185: - return Resource.decode(readValue(buffer)!); + return ResourceSize.decode(readValue(buffer)!); case 186: - return IconResult.decode(readValue(buffer)!); + return Resource.decode(readValue(buffer)!); case 187: - return CookiePartitionKey.decode(readValue(buffer)!); + return IconResult.decode(readValue(buffer)!); case 188: - return Cookie.decode(readValue(buffer)!); + return CookiePartitionKey.decode(readValue(buffer)!); case 189: - return VisitInfo.decode(readValue(buffer)!); + return Cookie.decode(readValue(buffer)!); case 190: - return HistoryHighlightWeights.decode(readValue(buffer)!); + return VisitInfo.decode(readValue(buffer)!); case 191: - return HistoryHighlight.decode(readValue(buffer)!); + return HistoryHighlightWeights.decode(readValue(buffer)!); case 192: - return TopFrecentSiteInfo.decode(readValue(buffer)!); + return HistoryHighlight.decode(readValue(buffer)!); case 193: - return HistoryItem.decode(readValue(buffer)!); + return TopFrecentSiteInfo.decode(readValue(buffer)!); case 194: - return HistoryState.decode(readValue(buffer)!); + return HistoryMetadata.decode(readValue(buffer)!); case 195: - return ReaderableState.decode(readValue(buffer)!); + return HistorySuggestion.decode(readValue(buffer)!); case 196: - return SecurityInfoState.decode(readValue(buffer)!); + return PageObservation.decode(readValue(buffer)!); case 197: - return TabContentState.decode(readValue(buffer)!); + return HistoryItem.decode(readValue(buffer)!); case 198: - return FindResultState.decode(readValue(buffer)!); + return HistoryState.decode(readValue(buffer)!); case 199: - return CustomSelectionAction.decode(readValue(buffer)!); + return ReaderableState.decode(readValue(buffer)!); case 200: - return WebExtensionData.decode(readValue(buffer)!); + return SecurityInfoState.decode(readValue(buffer)!); case 201: - return AddonInfo.decode(readValue(buffer)!); + return TabContentState.decode(readValue(buffer)!); case 202: - return AddonListingPreview.decode(readValue(buffer)!); + return FindResultState.decode(readValue(buffer)!); case 203: - return AddonListing.decode(readValue(buffer)!); + return CustomSelectionAction.decode(readValue(buffer)!); case 204: - return AddonStoreInfo.decode(readValue(buffer)!); + return WebExtensionData.decode(readValue(buffer)!); case 205: - return AddonUpdateAttemptInfo.decode(readValue(buffer)!); + return AddonInfo.decode(readValue(buffer)!); case 206: - return GeckoSuggestion.decode(readValue(buffer)!); + return AddonListingPreview.decode(readValue(buffer)!); case 207: - return TabContent.decode(readValue(buffer)!); + return AddonListing.decode(readValue(buffer)!); case 208: - return ContentBlocking.decode(readValue(buffer)!); + return AddonStoreInfo.decode(readValue(buffer)!); case 209: - return DohSettings.decode(readValue(buffer)!); + return AddonUpdateAttemptInfo.decode(readValue(buffer)!); case 210: - return GeckoEngineSettings.decode(readValue(buffer)!); + return GeckoSuggestion.decode(readValue(buffer)!); case 211: - return AutocompleteResult.decode(readValue(buffer)!); + return TabContent.decode(readValue(buffer)!); case 212: - return UnknownHitResult.decode(readValue(buffer)!); + return ContentBlocking.decode(readValue(buffer)!); case 213: - return ImageHitResult.decode(readValue(buffer)!); + return DohSettings.decode(readValue(buffer)!); case 214: - return VideoHitResult.decode(readValue(buffer)!); + return GeckoEngineSettings.decode(readValue(buffer)!); case 215: - return AudioHitResult.decode(readValue(buffer)!); + return AutocompleteResult.decode(readValue(buffer)!); case 216: - return ImageSrcHitResult.decode(readValue(buffer)!); + return UnknownHitResult.decode(readValue(buffer)!); case 217: - return PhoneHitResult.decode(readValue(buffer)!); + return ImageHitResult.decode(readValue(buffer)!); case 218: - return EmailHitResult.decode(readValue(buffer)!); + return VideoHitResult.decode(readValue(buffer)!); case 219: - return GeoHitResult.decode(readValue(buffer)!); + return AudioHitResult.decode(readValue(buffer)!); case 220: - return DownloadState.decode(readValue(buffer)!); + return ImageSrcHitResult.decode(readValue(buffer)!); case 221: - return ShareInternetResourceState.decode(readValue(buffer)!); + return PhoneHitResult.decode(readValue(buffer)!); case 222: - return AddonCollection.decode(readValue(buffer)!); + return EmailHitResult.decode(readValue(buffer)!); case 223: - return SyncEngineStatus.decode(readValue(buffer)!); + return GeoHitResult.decode(readValue(buffer)!); case 224: - return SyncAccountInfo.decode(readValue(buffer)!); + return DownloadState.decode(readValue(buffer)!); case 225: - return SyncDevice.decode(readValue(buffer)!); + return ShareInternetResourceState.decode(readValue(buffer)!); case 226: - return SyncIncomingTab.decode(readValue(buffer)!); + return AddonCollection.decode(readValue(buffer)!); case 227: - return SyncRemoteTab.decode(readValue(buffer)!); + return SyncEngineStatus.decode(readValue(buffer)!); case 228: - return SyncDeviceTabs.decode(readValue(buffer)!); + return SyncAccountInfo.decode(readValue(buffer)!); case 229: - return GeckoPref.decode(readValue(buffer)!); + return SyncDevice.decode(readValue(buffer)!); case 230: - return MlProgressData.decode(readValue(buffer)!); + return SyncIncomingTab.decode(readValue(buffer)!); case 231: - return ContainerSiteAssignment.decode(readValue(buffer)!); + return SyncRemoteTab.decode(readValue(buffer)!); case 232: - return GeckoHeader.decode(readValue(buffer)!); + return SyncDeviceTabs.decode(readValue(buffer)!); case 233: - return GeckoFetchRequest.decode(readValue(buffer)!); + return GeckoPref.decode(readValue(buffer)!); case 234: - return GeckoFetchResponse.decode(readValue(buffer)!); + return MlProgressData.decode(readValue(buffer)!); case 235: - return BookmarkNode.decode(readValue(buffer)!); + return ContainerSiteAssignment.decode(readValue(buffer)!); case 236: - return BookmarkInfo.decode(readValue(buffer)!); + return GeckoHeader.decode(readValue(buffer)!); case 237: - return SitePermissions.decode(readValue(buffer)!); + return GeckoFetchRequest.decode(readValue(buffer)!); case 238: - return TrackingProtectionException.decode(readValue(buffer)!); + return GeckoFetchResponse.decode(readValue(buffer)!); case 239: - return PwaIcon.decode(readValue(buffer)!); + return BookmarkNode.decode(readValue(buffer)!); case 240: - return ShareTargetFiles.decode(readValue(buffer)!); + return BookmarkInfo.decode(readValue(buffer)!); case 241: - return ShareTargetParams.decode(readValue(buffer)!); + return SitePermissions.decode(readValue(buffer)!); case 242: - return ShareTarget.decode(readValue(buffer)!); + return TrackingProtectionException.decode(readValue(buffer)!); case 243: - return ExternalApplicationResource.decode(readValue(buffer)!); + return PwaIcon.decode(readValue(buffer)!); case 244: + return ShareTargetFiles.decode(readValue(buffer)!); + case 245: + return ShareTargetParams.decode(readValue(buffer)!); + case 246: + return ShareTarget.decode(readValue(buffer)!); + case 247: + return ExternalApplicationResource.decode(readValue(buffer)!); + case 248: return PwaManifest.decode(readValue(buffer)!); + case 249: + return SandboxCaptureEntry.decode(readValue(buffer)!); default: return super.readValueOfType(type, buffer); } @@ -9679,6 +9956,251 @@ class GeckoHistoryApi { ; return (pigeonVar_replyValue! as List).cast(); } + + /// Returns the most recent [HistoryMetadata] record for [url], or `null` if + /// no metadata has been recorded for that URL. + Future getLatestHistoryMetadataForUrl(String url) async { + final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoHistoryApi.getLatestHistoryMetadataForUrl$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([url]); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: true, + ) + ; + return pigeonVar_replyValue as HistoryMetadata?; + } + + /// Bulk variant of [getLatestHistoryMetadataForUrl]. Returns one entry per + /// input URL aligned by index; entries are `null` for URLs Places has no + /// metadata for. Used by the local search re-rank to collapse N IPC + /// roundtrips into one. + Future> getLatestHistoryMetadataForUrls(List urls) async { + final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoHistoryApi.getLatestHistoryMetadataForUrls$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([urls]); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: false, + ) + ; + return (pigeonVar_replyValue! as List).cast(); + } + + /// Bulk visited check: returns booleans aligned with [urls] indicating + /// whether Places has any visit recorded for each URL. + Future> getVisited(List urls) async { + final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoHistoryApi.getVisited$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([urls]); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: false, + ) + ; + return (pigeonVar_replyValue! as List).cast(); + } + + /// Frecency-ranked autocomplete results. Mirrors Places' awesomebar input. + Future> getSuggestions(String query, int limit) async { + final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoHistoryApi.getSuggestions$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([query, limit]); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: false, + ) + ; + return (pigeonVar_replyValue! as List).cast(); + } + + /// Places' built-in metadata text search (matches title / url / searchTerm). + /// Useful as a comparison baseline against the local content FTS. + Future> queryHistoryMetadata(String query, int limit) async { + final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoHistoryApi.queryHistoryMetadata$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([query, limit]); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: false, + ) + ; + return (pigeonVar_replyValue! as List).cast(); + } + + /// Records a title / preview-image observation for [url] without recording + /// a visit. Intended for manual flows; the engine middleware records these + /// automatically as the user browses. + Future recordObservation(String url, PageObservation observation) async { + final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoHistoryApi.recordObservation$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([url, observation]); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: true, + ) + ; + } + + /// Records a view-time observation against the metadata record identified + /// by [key]. View time is added to the existing total. + Future noteHistoryMetadataViewTime(HistoryMetadataKey key, int viewTimeMs) async { + final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoHistoryApi.noteHistoryMetadataViewTime$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([key, viewTimeMs]); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: true, + ) + ; + } + + /// Records a document-type observation against the metadata record + /// identified by [key]. + Future noteHistoryMetadataDocumentType(HistoryMetadataKey key, DocumentType documentType) async { + final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoHistoryApi.noteHistoryMetadataDocumentType$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([key, documentType]); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: true, + ) + ; + } + + /// Removes all visits for [url]. May propagate to remote devices via Sync. + Future deleteVisitsFor(String url) async { + final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoHistoryApi.deleteVisitsFor$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([url]); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: true, + ) + ; + } + + /// Removes all visits since [sinceMillis] (inclusive). May propagate to + /// remote devices via Sync. + Future deleteVisitsSince(int sinceMillis) async { + final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoHistoryApi.deleteVisitsSince$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([sinceMillis]); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: true, + ) + ; + } + + /// Removes all locally stored history. Sync will not remove remote history, + /// but it will prevent deleted entries from returning. + Future deleteEverything() async { + final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoHistoryApi.deleteEverything$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: true, + ) + ; + } + + /// Prunes history metadata older than [olderThanMillis] (exclusive). + Future deleteHistoryMetadataOlderThan(int olderThanMillis) async { + final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoHistoryApi.deleteHistoryMetadataOlderThan$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([olderThanMillis]); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: true, + ) + ; + } } class GeckoDownloadsApi { @@ -10631,3 +11153,148 @@ class GeckoPwaApi { return pigeonVar_replyValue! as bool; } } + +/// Dart → Kotlin. Mutates the native [SandboxCaptureRegistry] that the +/// request interceptor consults on every load. +class SandboxCaptureApi { + /// Constructor for [SandboxCaptureApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default + /// BinaryMessenger will be used which routes to the host platform. + SandboxCaptureApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) + : pigeonVar_binaryMessenger = binaryMessenger, + pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; + final BinaryMessenger? pigeonVar_binaryMessenger; + + static const MessageCodec pigeonChannelCodec = _PigeonCodec(); + + final String pigeonVar_messageChannelSuffix; + + /// Replaces the entire registry with [entries]. Called at startup after + /// Dart has brought up [CaptureServer] and reconciled local artifacts with + /// the `capture_tab` rows. + Future resetAll(List entries) async { + final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.SandboxCaptureApi.resetAll$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([entries]); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: true, + ) + ; + } + + /// Inserts or updates the registry entry for [entry.tabId]. + Future mark(SandboxCaptureEntry entry) async { + final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.SandboxCaptureApi.mark$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([entry]); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: true, + ) + ; + } + + /// Removes the registry entry for [tabId]. + Future unmark(String tabId) async { + final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.SandboxCaptureApi.unmark$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([tabId]); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: true, + ) + ; + } +} + +/// Kotlin → Dart. Fire-and-forget notifications from the request +/// interceptor / BrowserStore middleware. All handlers are non-blocking; +/// the interceptor never waits for a Dart response. +abstract class SandboxCaptureHostEvents { + static const MessageCodec pigeonChannelCodec = _PigeonCodec(); + + /// Emitted when a sandbox tab attempted to navigate to a non-loopback, + /// non-source URL (e.g., user clicked a link or typed a new URL into the + /// address bar). Dart should open a new sandbox tab and capture [targetUrl]. + void onSandboxLinkClick(int sequence, String parentTabId, String targetUrl); + + /// Emitted when GeckoView created a new tab (via `window.open`, + /// `target="_blank"`, or a middle-click) whose parent is a sandbox tab. + /// The native middleware has already rewritten the new tab's URL to + /// `about:blank`; Dart should register it as sandbox and run the capture + /// pipeline for [targetUrl]. + void onSandboxNewTab(int sequence, String parentTabId, String newTabId, String targetUrl); + + static void setUp(SandboxCaptureHostEvents? api, {BinaryMessenger? binaryMessenger, String messageChannelSuffix = '',}) { + messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; + { + final pigeonVar_channel = BasicMessageChannel( + 'dev.flutter.pigeon.flutter_mozilla_components.SandboxCaptureHostEvents.onSandboxLinkClick$messageChannelSuffix', pigeonChannelCodec, + binaryMessenger: binaryMessenger); + if (api == null) { + pigeonVar_channel.setMessageHandler(null); + } else { + pigeonVar_channel.setMessageHandler((Object? message) async { + final List args = message! as List; + final int arg_sequence = args[0]! as int; + final String arg_parentTabId = args[1]! as String; + final String arg_targetUrl = args[2]! as String; + try { + api.onSandboxLinkClick(arg_sequence, arg_parentTabId, arg_targetUrl); + return wrapResponse(empty: true); + } on PlatformException catch (e) { + return wrapResponse(error: e); + } catch (e) { + return wrapResponse(error: PlatformException(code: 'error', message: e.toString())); + } + }); + } + } + { + final pigeonVar_channel = BasicMessageChannel( + 'dev.flutter.pigeon.flutter_mozilla_components.SandboxCaptureHostEvents.onSandboxNewTab$messageChannelSuffix', pigeonChannelCodec, + binaryMessenger: binaryMessenger); + if (api == null) { + pigeonVar_channel.setMessageHandler(null); + } else { + pigeonVar_channel.setMessageHandler((Object? message) async { + final List args = message! as List; + final int arg_sequence = args[0]! as int; + final String arg_parentTabId = args[1]! as String; + final String arg_newTabId = args[2]! as String; + final String arg_targetUrl = args[3]! as String; + try { + api.onSandboxNewTab(arg_sequence, arg_parentTabId, arg_newTabId, arg_targetUrl); + return wrapResponse(empty: true); + } on PlatformException catch (e) { + return wrapResponse(error: e); + } catch (e) { + return wrapResponse(error: PlatformException(code: 'error', message: e.toString())); + } + }); + } + } + } +} diff --git a/packages/flutter_mozilla_components/pigeons/gecko.dart b/packages/flutter_mozilla_components/pigeons/gecko.dart index cdbc2664..c550f8f5 100644 --- a/packages/flutter_mozilla_components/pigeons/gecko.dart +++ b/packages/flutter_mozilla_components/pigeons/gecko.dart @@ -555,6 +555,59 @@ class TopFrecentSiteInfo { enum FrecencyThresholdOption { none, skipOneTimePages } +/// Document type associated with a [HistoryMetadata] record. +enum DocumentType { regular, media } + +/// Per-URL metadata maintained by Places. The unique identity of a record is +/// [HistoryMetadataKey] (url + searchTerm + referrerUrl); we surface only the +/// most recent record per URL via `getLatestHistoryMetadataForUrl`. +class HistoryMetadata { + final HistoryMetadataKey key; + final String? title; + + /// Unix milliseconds. + final int createdAt; + + /// Unix milliseconds. + final int updatedAt; + + /// Total view time in milliseconds. + final int totalViewTime; + + final DocumentType documentType; + final String? previewImageUrl; + + HistoryMetadata( + this.key, + this.title, + this.createdAt, + this.updatedAt, + this.totalViewTime, + this.documentType, + this.previewImageUrl, + ); +} + +/// Frecency-ranked autocomplete suggestion. Backs `getSuggestions`. +class HistorySuggestion { + final String url; + final String? title; + + /// Larger is more relevant. Unbounded; only meaningful relative to other + /// suggestions returned in the same call. + final int score; + + HistorySuggestion(this.url, this.title, this.score); +} + +/// Optional metadata observation for a URL. `null` fields are not written. +class PageObservation { + final String? title; + final String? previewImageUrl; + + PageObservation({this.title, this.previewImageUrl}); +} + class HistoryItem { final String url; final String title; @@ -2133,6 +2186,69 @@ abstract class GeckoHistoryApi { int limit, FrecencyThresholdOption frecencyThreshold, ); + + /// Returns the most recent [HistoryMetadata] record for [url], or `null` if + /// no metadata has been recorded for that URL. + @async + HistoryMetadata? getLatestHistoryMetadataForUrl(String url); + + /// Bulk variant of [getLatestHistoryMetadataForUrl]. Returns one entry per + /// input URL aligned by index; entries are `null` for URLs Places has no + /// metadata for. Used by the local search re-rank to collapse N IPC + /// roundtrips into one. + @async + List getLatestHistoryMetadataForUrls(List urls); + + /// Bulk visited check: returns booleans aligned with [urls] indicating + /// whether Places has any visit recorded for each URL. + @async + List getVisited(List urls); + + /// Frecency-ranked autocomplete results. Mirrors Places' awesomebar input. + @async + List getSuggestions(String query, int limit); + + /// Places' built-in metadata text search (matches title / url / searchTerm). + /// Useful as a comparison baseline against the local content FTS. + @async + List queryHistoryMetadata(String query, int limit); + + /// Records a title / preview-image observation for [url] without recording + /// a visit. Intended for manual flows; the engine middleware records these + /// automatically as the user browses. + @async + void recordObservation(String url, PageObservation observation); + + /// Records a view-time observation against the metadata record identified + /// by [key]. View time is added to the existing total. + @async + void noteHistoryMetadataViewTime(HistoryMetadataKey key, int viewTimeMs); + + /// Records a document-type observation against the metadata record + /// identified by [key]. + @async + void noteHistoryMetadataDocumentType( + HistoryMetadataKey key, + DocumentType documentType, + ); + + /// Removes all visits for [url]. May propagate to remote devices via Sync. + @async + void deleteVisitsFor(String url); + + /// Removes all visits since [sinceMillis] (inclusive). May propagate to + /// remote devices via Sync. + @async + void deleteVisitsSince(int sinceMillis); + + /// Removes all locally stored history. Sync will not remove remote history, + /// but it will prevent deleted entries from returning. + @async + void deleteEverything(); + + /// Prunes history metadata older than [olderThanMillis] (exclusive). + @async + void deleteHistoryMetadataOlderThan(int olderThanMillis); } @HostApi() @@ -2766,3 +2882,70 @@ abstract class GeckoPwaApi { String? overrideShortcutName, ); } + +/// Per-tab sandbox capture state shared with the native side. The Kotlin +/// [AppRequestInterceptor] consults an in-memory registry populated from +/// these entries to decide how to handle loads in sandbox tabs. +/// +/// [redirectUrl] is precomputed by Dart and always points at a loopback URL +/// (loader or capture). Dart is responsible for keeping it current; Kotlin +/// never calls back into Dart to resolve it. +class SandboxCaptureEntry { + final String tabId; + final String captureId; + final String sourceUrl; + + /// `http://127.0.0.1:/loader?…` while pending/failed, or + /// `http://127.0.0.1:/captures/…?t=` once ready. + final String redirectUrl; + + /// `pending` | `ready` | `failed`. + final String status; + + SandboxCaptureEntry({ + required this.tabId, + required this.captureId, + required this.sourceUrl, + required this.redirectUrl, + required this.status, + }); +} + +/// Dart → Kotlin. Mutates the native [SandboxCaptureRegistry] that the +/// request interceptor consults on every load. +@HostApi() +abstract class SandboxCaptureApi { + /// Replaces the entire registry with [entries]. Called at startup after + /// Dart has brought up [CaptureServer] and reconciled local artifacts with + /// the `capture_tab` rows. + void resetAll(List entries); + + /// Inserts or updates the registry entry for [entry.tabId]. + void mark(SandboxCaptureEntry entry); + + /// Removes the registry entry for [tabId]. + void unmark(String tabId); +} + +/// Kotlin → Dart. Fire-and-forget notifications from the request +/// interceptor / BrowserStore middleware. All handlers are non-blocking; +/// the interceptor never waits for a Dart response. +@FlutterApi() +abstract class SandboxCaptureHostEvents { + /// Emitted when a sandbox tab attempted to navigate to a non-loopback, + /// non-source URL (e.g., user clicked a link or typed a new URL into the + /// address bar). Dart should open a new sandbox tab and capture [targetUrl]. + void onSandboxLinkClick(int sequence, String parentTabId, String targetUrl); + + /// Emitted when GeckoView created a new tab (via `window.open`, + /// `target="_blank"`, or a middle-click) whose parent is a sandbox tab. + /// The native middleware has already rewritten the new tab's URL to + /// `about:blank`; Dart should register it as sandbox and run the capture + /// pipeline for [targetUrl]. + void onSandboxNewTab( + int sequence, + String parentTabId, + String newTabId, + String targetUrl, + ); +} diff --git a/packages/flutter_tor/android/src/main/kotlin/eu/weblibre/flutter_tor/FlutterTorPlugin.kt b/packages/flutter_tor/android/src/main/kotlin/eu/weblibre/flutter_tor/FlutterTorPlugin.kt index db9dfad3..6649d6fa 100644 --- a/packages/flutter_tor/android/src/main/kotlin/eu/weblibre/flutter_tor/FlutterTorPlugin.kt +++ b/packages/flutter_tor/android/src/main/kotlin/eu/weblibre/flutter_tor/FlutterTorPlugin.kt @@ -7,6 +7,7 @@ import android.content.Intent import android.content.ServiceConnection import android.os.IBinder import android.util.Log +import androidx.core.content.ContextCompat import eu.weblibre.flutter_tor.generated.IPtProxyController import eu.weblibre.flutter_tor.generated.TorApi import eu.weblibre.flutter_tor.generated.TorConfiguration @@ -29,22 +30,25 @@ class FlutterTorPlugin : FlutterPlugin, TorApi { } private var context: Context? = null + private var binaryMessenger: io.flutter.plugin.common.BinaryMessenger? = null private var torService: TorService? = null private var serviceConnection: ServiceConnection? = null private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) // Service connection state + @Volatile private var serviceConnected = CompletableDeferred() override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { Log.d(TAG, "onAttachedToEngine") context = flutterPluginBinding.applicationContext + binaryMessenger = flutterPluginBinding.binaryMessenger // Setup Pigeon API TorApi.setUp(flutterPluginBinding.binaryMessenger, this) - // Bind to TorService - bindTorService(flutterPluginBinding) + // Reconnect to an already-running TorService without creating a new idle instance. + bindTorService(createIfNeeded = false) } override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { @@ -60,40 +64,62 @@ class FlutterTorPlugin : FlutterPlugin, TorApi { scope.cancel() context = null + binaryMessenger = null } /** - * Bind to TorService + * Bind to TorService. Idempotent — used both for initial bind and rebind on disconnect. */ - private fun bindTorService(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { + private fun bindTorService(createIfNeeded: Boolean) { val ctx = context ?: return + val messenger = binaryMessenger ?: return + if (serviceConnection != null) return val connection = object : ServiceConnection { override fun onServiceConnected(name: ComponentName?, service: IBinder?) { Log.d(TAG, "TorService connected") val binder = service as? TorService.LocalBinder torService = binder?.getService() - torService?.initialize(flutterPluginBinding.binaryMessenger) + torService?.initialize(messenger) - // Signal that service is connected serviceConnected.complete(Unit) } override fun onServiceDisconnected(name: ComponentName?) { - Log.w(TAG, "TorService disconnected") + Log.w(TAG, "TorService disconnected — will rebind") torService = null - - // Reset connection deferred for potential reconnection serviceConnected = CompletableDeferred() + // Drop the stale ServiceConnection reference so bindTorService() reattempts. + serviceConnection = null + scope.launch { + delay(500) + bindTorService(createIfNeeded = true) + } } } serviceConnection = connection - val intent = Intent(ctx, TorService::class.java) - // Only bind to service, don't start it yet - // Service will be started when startTor() is called - ctx.bindService(intent, connection, Context.BIND_AUTO_CREATE) + val intent = Intent(ctx, TorService::class.java).apply { + if (createIfNeeded) { + action = TorService.ACTION_START + } + } + + try { + if (createIfNeeded) { + ContextCompat.startForegroundService(ctx, intent) + } + + val flags = if (createIfNeeded) Context.BIND_AUTO_CREATE else 0 + if (!ctx.bindService(intent, connection, flags)) { + Log.w(TAG, "bindService returned false (createIfNeeded=$createIfNeeded)") + serviceConnection = null + } + } catch (e: Exception) { + Log.e(TAG, "bindService failed", e) + serviceConnection = null + } } /** @@ -112,9 +138,12 @@ class FlutterTorPlugin : FlutterPlugin, TorApi { } /** - * Wait for service to be connected + * Wait for service to be connected. Triggers a rebind if needed. */ private suspend fun waitForService(): TorService { + if (serviceConnection == null) { + bindTorService(createIfNeeded = true) + } return withTimeoutOrNull(SERVICE_CONNECTION_TIMEOUT_MS) { serviceConnected.await() torService diff --git a/packages/flutter_tor/android/src/main/kotlin/eu/weblibre/flutter_tor/PluggableTransportManager.kt b/packages/flutter_tor/android/src/main/kotlin/eu/weblibre/flutter_tor/PluggableTransportManager.kt index 2946e9f3..7624f36e 100644 --- a/packages/flutter_tor/android/src/main/kotlin/eu/weblibre/flutter_tor/PluggableTransportManager.kt +++ b/packages/flutter_tor/android/src/main/kotlin/eu/weblibre/flutter_tor/PluggableTransportManager.kt @@ -30,6 +30,9 @@ class PluggableTransportManager private constructor(private val context: Context private val SNOWFLAKE_AMP_FRONTS = listOf("www.google.com") private const val SNOWFLAKE_ICE_SERVERS = "stun:stun.l.google.com:19302,stun:stun.antisip.com:3478,stun:stun.bluesip.net:3478,stun:stun.dus.net:3478,stun:stun.epygi.com:3478,stun:stun.sonetel.com:3478,stun:stun.uls.co.za:3478,stun:stun.voipgate.com:3478,stun:stun.voys.nl:3478" + private const val PORT_READY_TIMEOUT_MS = 10_000L + private const val PORT_READY_POLL_MS = 100L + @Volatile private var instance: PluggableTransportManager? = null @@ -102,60 +105,25 @@ class PluggableTransportManager private constructor(private val context: Context try { when (type) { TransportType.OBFS4 -> { - val transportName = IPtProxy.Obfs4 - controller.start(transportName, null) // null = no proxy - activeTransports.add(transportName) - val port = controller.port(transportName) - if (port > 0) { - ports[transportName] = port.toInt() - Log.d(TAG, "$transportName started on port $port") - } + startAndAwait(IPtProxy.Obfs4)?.let { ports[IPtProxy.Obfs4] = it } } TransportType.SNOWFLAKE -> { - val transportName = IPtProxy.Snowflake configureSnowflake(useAmp = false) - controller.start(transportName, null) - activeTransports.add(transportName) - val port = controller.port(transportName) - if (port > 0) { - ports[transportName] = port.toInt() - Log.d(TAG, "$transportName started on port $port") - } + startAndAwait(IPtProxy.Snowflake)?.let { ports[IPtProxy.Snowflake] = it } } TransportType.SNOWFLAKE_AMP -> { - val transportName = IPtProxy.Snowflake configureSnowflake(useAmp = true) - controller.start(transportName, null) - activeTransports.add(transportName) - val port = controller.port(transportName) - if (port > 0) { - ports[transportName] = port.toInt() - Log.d(TAG, "$transportName (AMP) started on port $port") - } + startAndAwait(IPtProxy.Snowflake)?.let { ports[IPtProxy.Snowflake] = it } } TransportType.MEEK, TransportType.MEEK_AZURE -> { - val transportName = IPtProxy.MeekLite - controller.start(transportName, null) - activeTransports.add(transportName) - val port = controller.port(transportName) - if (port > 0) { - ports[transportName] = port.toInt() - Log.d(TAG, "$transportName started on port $port") - } + startAndAwait(IPtProxy.MeekLite)?.let { ports[IPtProxy.MeekLite] = it } } TransportType.WEBTUNNEL -> { - val transportName = IPtProxy.Webtunnel - controller.start(transportName, null) - activeTransports.add(transportName) - val port = controller.port(transportName) - if (port > 0) { - ports[transportName] = port.toInt() - Log.d(TAG, "$transportName started on port $port") - } + startAndAwait(IPtProxy.Webtunnel)?.let { ports[IPtProxy.Webtunnel] = it } } TransportType.NONE, TransportType.CUSTOM -> { @@ -169,6 +137,30 @@ class PluggableTransportManager private constructor(private val context: Context return ports } + /** + * Start a transport and poll until controller.port(name) returns a usable value. + * IPtProxy assigns the port asynchronously after `start()`; without this poll, we + * may read 0 and silently skip the ClientTransportPlugin line in torrc, causing + * tor to bootstrap with `UseBridges 1` but no transport — bootstrap stalls. + */ + private fun startAndAwait(transportName: String): Int? { + controller.start(transportName, null) // null = no proxy + activeTransports.add(transportName) + + val deadline = System.currentTimeMillis() + PORT_READY_TIMEOUT_MS + while (System.currentTimeMillis() < deadline) { + val port = controller.port(transportName) + if (port > 0) { + Log.d(TAG, "$transportName started on port $port") + return port.toInt() + } + Thread.sleep(PORT_READY_POLL_MS) + } + + Log.e(TAG, "$transportName failed to bind a port within ${PORT_READY_TIMEOUT_MS}ms") + throw IllegalStateException("Pluggable transport $transportName did not become ready in time") + } + /** * Configure Snowflake-specific settings */ diff --git a/packages/flutter_tor/android/src/main/kotlin/eu/weblibre/flutter_tor/PortManager.kt b/packages/flutter_tor/android/src/main/kotlin/eu/weblibre/flutter_tor/PortManager.kt deleted file mode 100644 index 0630bf42..00000000 --- a/packages/flutter_tor/android/src/main/kotlin/eu/weblibre/flutter_tor/PortManager.kt +++ /dev/null @@ -1,32 +0,0 @@ -package eu.weblibre.flutter_tor - -import java.net.ServerSocket - -/** - * Manages random port allocation for Tor and pluggable transports - */ -object PortManager { - - /** - * Find an available random port by binding to port 0 - * @return Available port number - */ - fun findAvailablePort(): Int { - return ServerSocket(0).use { socket -> - socket.localPort - } - } - - /** - * Check if a specific port is available - * @param port Port to check - * @return true if port is available - */ - fun isPortAvailable(port: Int): Boolean { - return try { - ServerSocket(port).use { true } - } catch (e: Exception) { - false - } - } -} diff --git a/packages/flutter_tor/android/src/main/kotlin/eu/weblibre/flutter_tor/TorConfig.kt b/packages/flutter_tor/android/src/main/kotlin/eu/weblibre/flutter_tor/TorConfig.kt index c7304ad8..87a635fb 100644 --- a/packages/flutter_tor/android/src/main/kotlin/eu/weblibre/flutter_tor/TorConfig.kt +++ b/packages/flutter_tor/android/src/main/kotlin/eu/weblibre/flutter_tor/TorConfig.kt @@ -5,39 +5,36 @@ import IPtProxy.IPtProxy import java.io.File /** - * Generates Tor configuration (torrc) based on user settings + * Generates Tor configuration (torrc) based on user settings. + * + * Notes on what we deliberately do NOT set here: + * - DataDirectory, ControlSocket, CookieAuthentication, RunAsDaemon, CacheDirectory, + * SyslogIdentityTag — upstream org.torproject.jni.TorService passes these on the + * `tor` command line. Setting them here causes duplicate-option warnings or contradicts + * the values upstream needs (e.g. RunAsDaemon must be 0 in-process). + * - SocksPort / HTTPTunnelPort — upstream rewrites defaults-torrc on every start with + * `SOCKSPort 9050|auto` and `HTTPTunnelPort 8118|auto`. tor REJECTS mixing + * `SocksPort 0` with any other SocksPort line ("Invalid SocksPort configuration"), + * so we cannot override these from torrc. Instead we just consume what upstream + * binds — read it from the static `TorService.socksPort` field (or via + * `getInfo net/listeners/socks`). + * - DNSPort / TransPort default to 0 in tor; no need to set them. */ class TorConfig(private val config: TorConfiguration) { - /** - * Generate torrc file content - * @param socksPort SOCKS proxy port - * @param dataDir Tor data directory - * @param geoipFile GeoIP file (optional, for country selection) - * @param geoip6File GeoIP6 file (optional, for IPv6 country selection) - * @param transportPorts Map of transport name to port (from PluggableTransportManager) - * @return torrc content as string - * - * Note: ControlPort is NOT set in torrc. The tor-android library automatically - * uses ControlSocket (Unix domain socket) which is more secure than TCP ControlPort. - * See SECURITY_CONTROL_PORT.md for details. - */ fun generateTorrc( - socksPort: Int, - dataDir: File, geoipFile: File?, geoip6File: File?, transportPorts: Map ): String = buildString { - // Core Tor settings append("# Generated torrc for flutter_tor\n") - append("SocksPort 127.0.0.1:$socksPort\n") - // ControlPort is NOT set - tor-android uses ControlSocket (Unix domain socket) - // This is more secure as it uses file permissions instead of TCP authentication - append("DataDirectory ${dataDir.absolutePath}\n") + + // Start with networking disabled. Re-enabled via control port + // (setConf DisableNetwork 0) AFTER our event listener is wired up so we + // don't miss bootstrap events. + append("DisableNetwork 1\n") append("\n") - // GeoIP files for country-based node selection if (geoipFile != null && geoipFile.exists()) { append("GeoIPFile ${geoipFile.absolutePath}\n") } @@ -46,34 +43,27 @@ class TorConfig(private val config: TorConfiguration) { } append("\n") - // Entry node countries config.entryNodeCountries?.let { countries -> if (countries.isNotBlank()) { - val formatted = formatCountries(countries) - append("EntryNodes $formatted\n") + append("EntryNodes ${formatCountries(countries)}\n") } } - // Exit node countries config.exitNodeCountries?.let { countries -> if (countries.isNotBlank()) { - val formatted = formatCountries(countries) - append("ExitNodes $formatted\n") + append("ExitNodes ${formatCountries(countries)}\n") } } - // Strict nodes (only use specified countries) if (config.strictNodes == true) { append("StrictNodes 1\n") } append("\n") - // Pluggable transport configuration val transport = TransportType.fromPigeon(config.transport) when (transport) { TransportType.OBFS4 -> { transportPorts[IPtProxy.Obfs4]?.let { port -> - // Validate port is valid (like Orbot does) if (port > 0) { append("ClientTransportPlugin ${IPtProxy.Obfs4} socks5 127.0.0.1:$port\n") } @@ -101,17 +91,12 @@ class TorConfig(private val config: TorConfiguration) { } } TransportType.CUSTOM -> { - // Custom bridges - transport plugin defined in bridge line - // We'll try to detect and configure based on bridge lines configureCustomTransports(transportPorts) } - TransportType.NONE -> { - // Direct connection, no pluggable transports - } + TransportType.NONE -> {} } append("\n") - // Bridge configuration if (transport != TransportType.NONE) { val normalizedBridges = BridgeParser.normalize(config.bridgeLines) if (normalizedBridges.isNotEmpty()) { @@ -123,9 +108,6 @@ class TorConfig(private val config: TorConfiguration) { } } - // Additional Tor settings (matching Orbot's configuration) - append("# Additional settings\n") - append("RunAsDaemon 1\n") append("AvoidDiskWrites 1\n") append("SafeSocks 0\n") append("TestSocks 0\n") @@ -133,14 +115,6 @@ class TorConfig(private val config: TorConfiguration) { append("AutomapHostsOnResolve 1\n") append("DormantClientTimeout 10 minutes\n") append("DormantCanceledByStartup 1\n") - - // CRITICAL: Set DisableNetwork 1 to prevent bootstrap before event listener is ready - // This will be changed to 0 via control port AFTER we set up event listeners - // This matches Orbot's approach and ensures we receive all bootstrap events - append("DisableNetwork 1\n") - - append("Log notice stdout\n") // Log to stdout for capture - append("\n") } /** @@ -155,15 +129,11 @@ class TorConfig(private val config: TorConfiguration) { .split(",") .map { it.trim().uppercase() } .filter { it.isNotEmpty() } - .filter { it.length == 2 } // ISO 3166-1 alpha-2 codes + .filter { it.length == 2 } return codes.joinToString(",") { "{$it}" } } - /** - * Configure custom transports based on bridge lines - * Detects transport type from bridge lines and configures accordingly - */ private fun StringBuilder.configureCustomTransports(transportPorts: Map) { val bridgeTransports = config.bridgeLines .mapNotNull { BridgeParser.extractTransportType(it) } @@ -195,11 +165,6 @@ class TorConfig(private val config: TorConfiguration) { } } - /** - * Write torrc to file - * @param torrcFile File to write to - * @param content torrc content - */ fun writeTorrc(torrcFile: File, content: String) { torrcFile.parentFile?.mkdirs() torrcFile.writeText(content) diff --git a/packages/flutter_tor/android/src/main/kotlin/eu/weblibre/flutter_tor/TorManager.kt b/packages/flutter_tor/android/src/main/kotlin/eu/weblibre/flutter_tor/TorManager.kt index 303cbd57..1d80fafc 100644 --- a/packages/flutter_tor/android/src/main/kotlin/eu/weblibre/flutter_tor/TorManager.kt +++ b/packages/flutter_tor/android/src/main/kotlin/eu/weblibre/flutter_tor/TorManager.kt @@ -1,11 +1,14 @@ package eu.weblibre.flutter_tor +import android.content.BroadcastReceiver import android.content.ComponentName import android.content.Context import android.content.Intent +import android.content.IntentFilter import android.content.ServiceConnection import android.os.IBinder import android.util.Log +import androidx.localbroadcastmanager.content.LocalBroadcastManager import eu.weblibre.flutter_tor.generated.TorConfiguration import eu.weblibre.flutter_tor.generated.TorStatus import kotlinx.coroutines.* @@ -27,12 +30,18 @@ class TorManager( ) { companion object { const val TAG = "TorManager" + private const val STATUS_OFF_TIMEOUT_MS = 10_000L } - private val dataDir = File(context.filesDir, "tor_data") + // GeoIP files live alongside other install assets. tor-android owns its own + // DataDirectory/CacheDirectory under getDir("TorService"), so we don't define one. private val installDir = File(context.filesDir, "tor_install") private var torServiceConnection: ServiceConnection? = null + + @Volatile private var controlConnection: TorControlConnection? = null + + @Volatile private var torService: TorService? = null val pluggableTransportManager = PluggableTransportManager.getInstance(context) @@ -45,20 +54,67 @@ class TorManager( var socksPort: Int = -1 private set - // Note: No controlPort - tor-android uses ControlSocket (Unix domain socket) instead - // This is more secure than TCP ControlPort as it uses file permissions for access control + @Volatile private var isRunning = false @Volatile private var bootstrapProgress = 0 - // Lock for synchronizing status updates private val statusLock = Any() + // Latch awaited by stop() — completed when upstream broadcasts STATUS_OFF. + @Volatile + private var stopSignal: CompletableDeferred? = null + + // Background bootstrap-progress poller — fallback for when NOTICE/STATUS_CLIENT + // events are silently dropped by jtorctl/tor (observed intermittently in the wild). + private var bootstrapPollJob: Job? = null + + // Mirror of upstream TorService's status — its own static `currentStatus` is package-private. + @Volatile + private var lastUpstreamStatus: String = TorService.STATUS_OFF + + private val statusReceiver = object : BroadcastReceiver() { + override fun onReceive(ctx: Context?, intent: Intent?) { + val status = intent?.getStringExtra(TorService.EXTRA_STATUS) ?: return + Log.d(TAG, "TorService broadcast: $status") + lastUpstreamStatus = status + if (status == TorService.STATUS_OFF) { + stopSignal?.complete(Unit) + } + } + } + + private val errorReceiver = object : BroadcastReceiver() { + override fun onReceive(ctx: Context?, intent: Intent?) { + val msg = intent?.getStringExtra(Intent.EXTRA_TEXT) ?: "unknown" + Log.e(TAG, "TorService error broadcast: $msg") + logHandler.error("Tor service error: $msg") + } + } + + private var receiversRegistered = false + + private fun registerReceivers() { + if (receiversRegistered) return + val lbm = LocalBroadcastManager.getInstance(context) + lbm.registerReceiver(statusReceiver, IntentFilter(TorService.ACTION_STATUS)) + lbm.registerReceiver(errorReceiver, IntentFilter(TorService.ACTION_ERROR)) + receiversRegistered = true + } + + private fun unregisterReceivers() { + if (!receiversRegistered) return + val lbm = LocalBroadcastManager.getInstance(context) + try { lbm.unregisterReceiver(statusReceiver) } catch (_: Exception) {} + try { lbm.unregisterReceiver(errorReceiver) } catch (_: Exception) {} + receiversRegistered = false + } + /** * Start Tor with the given configuration * @param config Tor configuration from Flutter - * @return SOCKS port + * @return SOCKS port (discovered from tor via `getInfo net/listeners/socks`) */ suspend fun start(config: TorConfiguration): Int = withContext(Dispatchers.IO) { if (isRunning) { @@ -69,66 +125,41 @@ class TorManager( try { logHandler.notice("Starting Tor...") - // Create directories - dataDir.mkdirs() installDir.mkdirs() + lastUpstreamStatus = TorService.STATUS_STARTING + registerReceivers() - // Allocate random SOCKS port - // Note: We don't allocate a control port - tor-android uses ControlSocket instead - socksPort = PortManager.findAvailablePort() - - Log.d(TAG, "Allocated SOCKS port: $socksPort") - Log.d(TAG, "Control connection will use ControlSocket (Unix domain socket)") - logHandler.notice("SOCKS port: $socksPort") - - // Start pluggable transports if needed val transport = TransportType.fromPigeon(config.transport) val transportPorts = if (transport != TransportType.NONE && transport != TransportType.CUSTOM) { logHandler.notice("Starting pluggable transport: $transport") pluggableTransportManager.startTransport(transport) } else if (transport == TransportType.CUSTOM) { - // For custom, we need to detect and start appropriate transports startCustomTransports(config.bridgeLines) } else { emptyMap() } - // Generate torrc val geoipFile = geoIpManager.getGeoIpFile(installDir) val geoip6File = geoIpManager.getGeoIp6File(installDir) val torConfig = TorConfig(config) val torrcContent = torConfig.generateTorrc( - socksPort = socksPort, - // controlPort removed - tor-android uses ControlSocket (Unix domain socket) for security - dataDir = dataDir, geoipFile = geoipFile, geoip6File = geoip6File, transportPorts = transportPorts ) - // Write torrc to the correct location (like Orbot does) - // CRITICAL: Must use TorService.getTorrc() so TorService can find it! + // Tor reads torrc from the location upstream TorService passes via -f. val torrcFile = TorService.getTorrc(context) torConfig.writeTorrc(torrcFile, torrcContent) Log.d(TAG, "Generated torrc at ${torrcFile.absolutePath}:\n$torrcContent") - // Write defaults torrc (required by tor-android) - // Set DisableNetwork 1 initially like Orbot does, will be enabled via control port - // Also disable DNSPort and TransPort (matching Orbot) - val defaultsTorrcFile = TorService.getDefaultsTorrc(context) - defaultsTorrcFile.writeText(""" - DNSPort 0 - TransPort 0 - DisableNetwork 1 - """.trimIndent()) + // Note: do NOT write defaults-torrc here. Upstream's setDefaultProxyPorts() + // truncates and rewrites it on every start with `SOCKSPort/HTTPTunnelPort auto`. + // We neutralise those listeners with `SOCKSPort 0` / `HTTPTunnelPort 0` in + // the regular torrc instead (see TorConfig.kt). - // Start TorService - // Note: torrcFile is now written to the correct location via TorService.getTorrc() - // so TorService will automatically find and use it - // Note: isRunning will be set to true in setupControlConnection() before network is enabled - // This ensures status is consistent when bootstrap events start arriving startTorService() socksPort @@ -136,13 +167,11 @@ class TorManager( Log.e(TAG, "Failed to start Tor", e) logHandler.error("Failed to start Tor: ${e.message}") cleanup() + unregisterReceivers() throw e } } - /** - * Start custom transports based on bridge lines - */ private fun startCustomTransports(bridgeLines: List): Map { val transports = bridgeLines .mapNotNull { BridgeParser.extractTransportType(it) } @@ -168,8 +197,7 @@ class TorManager( } /** - * Start the native TorService and bind to it - * TorService will automatically use the torrc written to TorService.getTorrc(context) + * Start upstream tor-android TorService and bind to it. */ private suspend fun startTorService() = suspendCancellableCoroutine { continuation -> val connection = object : ServiceConnection { @@ -178,28 +206,48 @@ class TorManager( val binder = service as? TorService.LocalBinder torService = binder?.service - // Wait for control connection to be available + // Wait for control connection to be available AND for TorService's + // own controlPortThread to have finished its setup (auth + addRawEventListener + // + setEvents). TorService.torControlConnection becomes non-null immediately + // after `new TorControlConnection(...)`, but TorService then calls + // setEvents([EVENT_STATUS_CLIENT]) — if we race and call our setEvents first, + // TorService overwrites it and we stop receiving NOTICE/BW/CIRC events. + // TorService.socksPort is set AFTER its setEvents call, so polling for it + // gives us a reliable "TorService is done initializing the control port" signal. scope.launch { var conn: TorControlConnection? = null var attempts = 0 - while (conn == null && attempts < 60) { // 30 seconds timeout + while ((conn == null || TorService.socksPort == -1) && attempts < 60) { delay(500) conn = torService?.torControlConnection attempts++ + // Bail early if upstream gave up (typically due to a torrc + // parse error caught by `tor --verify-config`). + if (lastUpstreamStatus == TorService.STATUS_OFF || + lastUpstreamStatus == TorService.STATUS_STOPPING) { + break + } } - if (conn != null) { - // Wait an additional second before setting up event listener - // This matches Orbot's behavior and ensures Tor is fully initialized - delay(1000) - + if (conn != null && TorService.socksPort != -1) { controlConnection = conn - setupControlConnection(conn) - if (continuation.isActive) { - continuation.resume(Unit) + try { + setupControlConnection(conn) + if (continuation.isActive) { + continuation.resume(Unit) + } + } catch (e: Exception) { + if (continuation.isActive) { + continuation.resumeWithException(e) + } } } else { - val error = Exception("Failed to get control connection after 30 seconds") + val error = Exception( + "Failed to get fully-initialized control connection " + + "(conn=${conn != null}, torServiceSocksPort=${TorService.socksPort}, " + + "upstreamStatus=$lastUpstreamStatus). " + + "If upstreamStatus is STOPPING/OFF, tor likely rejected the torrc — check logcat for TorService." + ) if (continuation.isActive) { continuation.resumeWithException(error) } @@ -220,7 +268,6 @@ class TorManager( val intent = Intent(context, org.torproject.jni.TorService::class.java) try { - // Start the service first (like Orbot does) before binding context.startService(intent) context.bindService(intent, connection, Context.BIND_AUTO_CREATE) } catch (e: Exception) { @@ -231,26 +278,20 @@ class TorManager( } /** - * Setup control connection and event listeners - * This follows Orbot's approach: query ports first, then set up events, then enable network + * Setup control connection and event listeners. + * Order: discover real SOCKS port → register listener → subscribe events → enable network. */ private fun setupControlConnection(conn: TorControlConnection) { try { - // Query control connection to verify SOCKS port (like Orbot's initControlConnection) - // This also properly initializes the control connection for event delivery - try { - conn.getInfo("net/listeners/socks") - } catch (e: Exception) { - Log.w(TAG, "Could not query SOCKS port from control connection", e) - } - logHandler.notice("Connected to Tor control port") - // Create and store event listener instance (prevents garbage collection) + // Wire up event listener BEFORE enabling network so we don't miss bootstrap + // notices. Created as a field to keep a strong reference (avoids GC). torEventListener = TorEventListener() conn.addRawEventListener(torEventListener) - // Subscribe to events (matching Orbot's event subscriptions) + // EVENT_STATUS_CLIENT must be included — upstream TorService relies on it + // internally to detect circuit-established and broadcast STATUS_ON. conn.setEvents(listOf( TorControlCommands.EVENT_OR_CONN_STATUS, TorControlCommands.EVENT_CIRCUIT_STATUS, @@ -259,26 +300,53 @@ class TorManager( TorControlCommands.EVENT_ERR_MSG, TorControlCommands.EVENT_BANDWIDTH_USED, TorControlCommands.EVENT_NEW_DESC, - TorControlCommands.EVENT_ADDRMAP + TorControlCommands.EVENT_ADDRMAP, + TorControlCommands.EVENT_STATUS_CLIENT )) - Log.d(TAG, "Control connection setup complete, enabling network") + // Replace upstream's default SOCKS/HTTP listeners with our own. Upstream's + // defaults-torrc binds `SOCKSPort 9050|auto` and `HTTPTunnelPort 8118|auto`; + // a well-known port lets other apps on the device probe / use our SOCKS proxy, + // so we want a random one. We can't disable these in torrc (tor rejects mixing + // `SocksPort 0` with other SocksPort lines), so SETCONF here: + // - SETCONF replaces the listener list (closes prev, opens new). + // - `auto` is a bare-port token; SocksPort defaults to binding 127.0.0.1. + // - SETCONF is recorded immediately, but listeners only physically bind + // once DisableNetwork=0 (see GoLog: "DisableNetwork is set. ... Shutting + // down all existing connections.") — so we read the port back AFTER + // enabling network below. + try { + conn.setConf("HTTPTunnelPort", "0") + } catch (e: Exception) { + Log.w(TAG, "Could not disable HTTPTunnelPort", e) + } + conn.setConf("SocksPort", "auto") // Set isRunning=true BEFORE enabling network so status is consistent - // when bootstrap events start arriving + // when bootstrap events start arriving. synchronized(statusLock) { isRunning = true } logHandler.notice("Tor started successfully") sendStatusUpdate() - // Enable network now that configuration is complete (like Orbot does) - // This will trigger bootstrap events to start + Log.d(TAG, "Control connection setup complete, enabling network") conn.setConf("DisableNetwork", "0") + + // Now that listeners are physically bound, discover the random port tor + // chose. Poll briefly — binding takes a few hundred ms after enabling net. + socksPort = awaitSocksListener(conn) + if (socksPort <= 0) { + throw IllegalStateException("Failed to discover SOCKS listener after enabling network") + } + Log.d(TAG, "Bound random SOCKS port: $socksPort") + logHandler.notice("SOCKS port: $socksPort") + sendStatusUpdate() + + startBootstrapPoller(conn) } catch (e: Exception) { Log.e(TAG, "Failed to setup control connection", e) logHandler.error("Control connection error: ${e.message}") - // Reset isRunning on failure synchronized(statusLock) { isRunning = false } @@ -287,6 +355,74 @@ class TorManager( } } + /** + * Poll `getInfo("status/bootstrap-phase")` until progress reaches 100, the + * connection is gone, or tor stops. This is a fallback for the intermittent + * issue where NOTICE / STATUS_CLIENT events are silently not delivered to + * our raw event listener even though tor accepted SETEVENTS for them. + * + * Reply format: `NOTICE BOOTSTRAP PROGRESS=85 TAG=loading_descriptors SUMMARY="..."` + */ + private fun startBootstrapPoller(conn: TorControlConnection) { + bootstrapPollJob?.cancel() + bootstrapPollJob = scope.launch { + try { + while (isActive && isRunning && controlConnection === conn) { + val info = try { + conn.getInfo("status/bootstrap-phase") + } catch (e: Exception) { + Log.w(TAG, "bootstrap-phase poll failed", e) + null + } + if (info != null) { + val progress = Regex("""PROGRESS=(\d+)""").find(info) + ?.groupValues?.get(1)?.toIntOrNull() ?: -1 + if (progress >= 0) { + val changed = synchronized(statusLock) { + if (progress > bootstrapProgress) { + bootstrapProgress = progress + true + } else false + } + if (changed) { + sendStatusUpdate() + if (progress == 100) { + logHandler.notice("Tor is ready!") + break + } + } else if (progress == 100) { + break + } + } + } + delay(750) + } + } finally { + Log.d(TAG, "Bootstrap poller exiting") + } + } + } + + private fun awaitSocksListener(conn: TorControlConnection): Int { + val deadline = System.currentTimeMillis() + 5_000L + while (System.currentTimeMillis() < deadline) { + val port = parseSocksPort(conn.getInfo("net/listeners/socks")) + if (port > 0) return port + Thread.sleep(100) + } + return -1 + } + + /** + * Parse `net/listeners/socks` reply, e.g. `"127.0.0.1:43251"` or + * `"127.0.0.1:43251" "127.0.0.1:9050"`. Returns the first numeric port, or -1. + */ + private fun parseSocksPort(reply: String?): Int { + if (reply.isNullOrBlank()) return -1 + val match = Regex("""127\.0\.0\.1:(\d+)""").find(reply) ?: return -1 + return match.groupValues[1].toIntOrNull() ?: -1 + } + /** * Stop Tor and cleanup */ @@ -294,25 +430,32 @@ class TorManager( Log.d(TAG, "Stopping Tor") logHandler.notice("Stopping Tor...") + val signal = CompletableDeferred().also { stopSignal = it } + try { - // DON'T call shutdownTor() here - let the service's onDestroy() handle it - // Otherwise we get a broken pipe error when service tries to shutdown again + // DON'T call shutdownTor() here - let upstream's onDestroy() handle it, + // otherwise we get a broken pipe error. cleanup() - // Give the native service time to fully stop before potential restart - // This is CRITICAL to prevent binding to a stale service instance - delay(2000) + // Wait for upstream to broadcast STATUS_OFF (which it does after releasing + // its static runLock). Without this, the next start() can hang on runLock.lock(). + val arrived = withTimeoutOrNull(STATUS_OFF_TIMEOUT_MS) { signal.await(); true } ?: false + if (!arrived) { + Log.w(TAG, "Did not receive STATUS_OFF within ${STATUS_OFF_TIMEOUT_MS}ms — proceeding anyway") + } logHandler.notice("Tor stopped") } catch (e: Exception) { Log.e(TAG, "Error stopping Tor", e) cleanup() - delay(2000) + } finally { + stopSignal = null + unregisterReceivers() } } /** - * Cleanup resources + * Cleanup resources. Safe to call from any thread. */ private fun cleanup() { synchronized(statusLock) { @@ -321,47 +464,34 @@ class TorManager( socksPort = -1 } - try { - // Unsubscribe from all events before removing listener - if (controlConnection != null) { - try { - controlConnection?.setEvents(emptyList()) - } catch (e: Exception) { - // Connection may already be closed - } - } + bootstrapPollJob?.cancel() + bootstrapPollJob = null - // Remove event listener - if (torEventListener != null && controlConnection != null) { - try { - controlConnection?.removeRawEventListener(torEventListener) - } catch (e: Exception) { - // Connection may already be closed - } + val conn = controlConnection + val listener = torEventListener + if (conn != null) { + try { conn.setEvents(emptyList()) } catch (_: Exception) {} + if (listener != null) { + try { conn.removeRawEventListener(listener) } catch (_: Exception) {} } - torEventListener = null - controlConnection = null - } catch (e: Exception) { - Log.w(TAG, "Error closing control connection", e) } + torEventListener = null + controlConnection = null - // Service cleanup - runBlocking(Dispatchers.Main) { - try { - torServiceConnection?.let { - context.unbindService(it) - } - torServiceConnection = null + // unbindService / stopService are safe from any thread; no need to hop to Main + // (the previous runBlocking(Dispatchers.Main) wrapper risked deadlocking with + // the IO-dispatcher caller chain in FlutterTorPlugin.stopTor). + try { + torServiceConnection?.let { context.unbindService(it) } + } catch (e: Exception) { + Log.w(TAG, "Error unbinding TorService", e) + } + torServiceConnection = null - // Small delay to ensure unbind completes before stopping service - delay(100) - - // Stop the native TorService to ensure clean restart - val intent = Intent(context, org.torproject.jni.TorService::class.java) - context.stopService(intent) - } catch (e: Exception) { - Log.w(TAG, "Error unbinding TorService", e) - } + try { + context.stopService(Intent(context, org.torproject.jni.TorService::class.java)) + } catch (e: Exception) { + Log.w(TAG, "Error stopping TorService", e) } torService = null @@ -384,9 +514,6 @@ class TorManager( } } - /** - * Get current Tor status - */ fun getStatus(): TorStatus { synchronized(statusLock) { val status = TorStatus( @@ -403,9 +530,6 @@ class TorManager( } } - /** - * Send status update to Flutter - */ private fun sendStatusUpdate() { logHandler.sendStatusChange(getStatus()) } @@ -415,41 +539,44 @@ class TorManager( */ private inner class TorEventListener : RawEventListener { override fun onEvent(eventType: String, eventData: String) { - // Handle bootstrap progress (comes in NOTICE events) - if (eventData.contains("Bootstrapped")) { - val progress = extractBootstrapProgress(eventData) - if (progress >= 0) { - synchronized(statusLock) { - bootstrapProgress = progress - } - sendStatusUpdate() + Log.d(TAG, "ReceivedData: $eventType: $eventData") + // Bootstrap progress can arrive in two formats: + // - EVENT_NOTICE_MSG: "Bootstrapped 85% (loading_descriptors): ..." + // - EVENT_STATUS_CLIENT: "NOTICE BOOTSTRAP PROGRESS=85 TAG=... SUMMARY=..." + val progress = extractBootstrapProgress(eventData) + if (progress >= 0) { + val changed = synchronized(statusLock) { + if (progress > bootstrapProgress) { + bootstrapProgress = progress + true + } else false + } + if (changed) { + sendStatusUpdate() if (progress == 100) { logHandler.notice("Tor is ready!") } } } - // Forward to log handler logHandler.handleTorEvent(eventType, eventData) } private fun extractBootstrapProgress(eventData: String): Int { - // Extract from format like "Bootstrapped 85% (loading_descriptors): ..." - val regex = "Bootstrapped\\s+(\\d+)%".toRegex() - return regex.find(eventData)?.groupValues?.get(1)?.toIntOrNull() ?: -1 + Regex("""Bootstrapped\s+(\d+)%""").find(eventData)?.let { + return it.groupValues[1].toIntOrNull() ?: -1 + } + Regex("""BOOTSTRAP\s+PROGRESS=(\d+)""").find(eventData)?.let { + return it.groupValues[1].toIntOrNull() ?: -1 + } + return -1 } } - /** - * Cleanup when manager is destroyed - */ fun destroy() { + cleanup() + unregisterReceivers() scope.cancel() - runBlocking { - if (isRunning) { - stop() - } - } } } diff --git a/packages/flutter_tor/android/src/main/kotlin/eu/weblibre/flutter_tor/TorService.kt b/packages/flutter_tor/android/src/main/kotlin/eu/weblibre/flutter_tor/TorService.kt index c8bf6614..909acb97 100644 --- a/packages/flutter_tor/android/src/main/kotlin/eu/weblibre/flutter_tor/TorService.kt +++ b/packages/flutter_tor/android/src/main/kotlin/eu/weblibre/flutter_tor/TorService.kt @@ -55,6 +55,11 @@ class TorService : Service() { Log.d(TAG, "onStartCommand: ${intent?.action}") when (intent?.action) { + ACTION_START -> { + // startForegroundService() must promote the service promptly, before + // the later binder call reaches startTor(). + startForeground(NOTIFICATION_ID, createNotification("Tor is connecting...")) + } ACTION_STOP -> { scope.launch { stopTor() @@ -66,7 +71,8 @@ class TorService : Service() { } } - return START_STICKY + // Tor needs explicit user start; don't auto-restart with a null intent. + return START_NOT_STICKY } /** @@ -92,10 +98,7 @@ class TorService : Service() { suspend fun startTor(config: TorConfiguration): Int { Log.d(TAG, "Starting Tor...") - // Start foreground service with notification - // This keeps the service alive even when the app is backgrounded startForeground(NOTIFICATION_ID, createNotification("Tor is connecting...")) - Log.d(TAG, "Started foreground service") val manager = torManager ?: throw IllegalStateException("Service not initialized") @@ -105,9 +108,8 @@ class TorService : Service() { return socksPort } catch (e: Exception) { Log.e(TAG, "Failed to start Tor", e) - updateNotification("Failed to start Tor") - // Stop foreground on failure stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() throw e } } @@ -120,9 +122,8 @@ class TorService : Service() { torManager?.stop() - // Stop foreground service and remove notification stopForeground(STOP_FOREGROUND_REMOVE) - Log.d(TAG, "Stopped foreground service") + stopSelf() } /** @@ -199,17 +200,13 @@ class TorService : Service() { super.onDestroy() Log.d(TAG, "Service destroyed") - // Stop pluggable transports synchronously to release Go references. - // Previously this was scope.launch { torManager?.destroy() } followed by - // scope.cancel(), which meant the cleanup coroutine was immediately cancelled - // and never ran — causing "trackGoRef called with Java refnum" crashes when - // TorService was recreated and tried to create a new IPtProxy.Controller. - // - // Note: We only stop transports here. The PluggableTransportManager singleton - // and its Controller persist across service restarts by design. - // Full TorManager.destroy() is not called because it would deadlock - // (cleanup() uses runBlocking(Dispatchers.Main) while onDestroy runs on Main). - torManager?.pluggableTransportManager?.stopAll() + // Tear down TorManager synchronously so receivers, transports, and the + // upstream tor-android service do not survive wrapper service teardown. + try { + torManager?.destroy() + } catch (e: Exception) { + Log.w(TAG, "Error destroying TorManager", e) + } torManager = null logHandler = null diff --git a/packages/simple_intent_receiver/android/src/main/kotlin/eu/weblibre/simple_intent_receiver/SimpleIntentReceiverPlugin.kt b/packages/simple_intent_receiver/android/src/main/kotlin/eu/weblibre/simple_intent_receiver/SimpleIntentReceiverPlugin.kt index 35e45c0b..ee53bba6 100644 --- a/packages/simple_intent_receiver/android/src/main/kotlin/eu/weblibre/simple_intent_receiver/SimpleIntentReceiverPlugin.kt +++ b/packages/simple_intent_receiver/android/src/main/kotlin/eu/weblibre/simple_intent_receiver/SimpleIntentReceiverPlugin.kt @@ -32,10 +32,11 @@ import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.embedding.engine.plugins.activity.ActivityAware import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding import io.flutter.plugin.common.PluginRegistry +import eu.weblibre.simple_intent_receiver.pigeons.IntentHost import eu.weblibre.simple_intent_receiver.pigeons.Intent as PigeonIntent import eu.weblibre.simple_intent_receiver.pigeons.IntentGatekeeperHostApi -class SimpleIntentReceiverPlugin: FlutterPlugin, ActivityAware, PluginRegistry.NewIntentListener { +class SimpleIntentReceiverPlugin: FlutterPlugin, ActivityAware, PluginRegistry.NewIntentListener, IntentHost { companion object { // Stable names that must match the notification replay path and shared-prefs schema. private const val PREFS_NAME = "weblibre_intent_gatekeeper" @@ -53,10 +54,28 @@ class SimpleIntentReceiverPlugin: FlutterPlugin, ActivityAware, PluginRegistry.N private var activity: Activity? = null private var binaryMessenger: io.flutter.plugin.common.BinaryMessenger? = null + /** + * Caches the launch intent so Dart can retrieve it after setUp(). + * On cold start, onAttachedToActivity fires before Dart registers its + * Pigeon handler, so the initial sendIntent message is lost. This field + * lets Dart call getInitialIntent() to recover it. + * + * On Android configuration change (rotation, theme switch) the activity + * is recreated and onAttachedToActivity fires again with the same + * launching intent. The `lastHandledIntent` guard below prevents + * re-caching that identical intent. If a NEW deep link arrives via the + * launcher between two Dart-side reads of getInitialIntent(), + * pendingInitialIntent is overwritten — only the latest intent is + * delivered. This is intentional: dropping the stale one keeps the + * "initial" intent meaning "what should the app open into right now". + */ + private var pendingInitialIntent: PigeonIntent? = null + override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { context = flutterPluginBinding.applicationContext intentReceiver = IntentReceiver(flutterPluginBinding.binaryMessenger) binaryMessenger = flutterPluginBinding.binaryMessenger + IntentHost.setUp(flutterPluginBinding.binaryMessenger, this) IntentGatekeeperHostApi.setUp( flutterPluginBinding.binaryMessenger, IntentGatekeeperHostApiImpl(flutterPluginBinding.applicationContext), @@ -65,10 +84,19 @@ class SimpleIntentReceiverPlugin: FlutterPlugin, ActivityAware, PluginRegistry.N override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { intentReceiver = null - binaryMessenger?.let { IntentGatekeeperHostApi.setUp(it, null) } + binaryMessenger?.let { + IntentHost.setUp(it, null) + IntentGatekeeperHostApi.setUp(it, null) + } binaryMessenger = null } + override fun getInitialIntent(): PigeonIntent? { + val intent = pendingInitialIntent + pendingInitialIntent = null + return intent + } + override fun onAttachedToActivity(binding: ActivityPluginBinding) { activity = binding.activity binding.addOnNewIntentListener(this) @@ -76,7 +104,10 @@ class SimpleIntentReceiverPlugin: FlutterPlugin, ActivityAware, PluginRegistry.N binding.activity.intent?.let { intent -> val uri = intent.toUri(0) if (lastHandledIntent != uri) { - handleIntent(intent) + // Cache the launch intent for Dart to retrieve via getInitialIntent(). + // Don't send via Pigeon here — the Dart handler isn't registered yet + // during cold start so the message would be lost. + pendingInitialIntent = prepareIntentForDelivery(intent) lastHandledIntent = uri } } @@ -100,8 +131,7 @@ class SimpleIntentReceiverPlugin: FlutterPlugin, ActivityAware, PluginRegistry.N return handleIntent(intent) } - private fun handleIntent(intent: Intent): Boolean { - // Grant URI permissions for content URIs + private fun grantUriPermissions(intent: Intent) { intent.data?.let { uri -> if (uri.scheme == "content") { try { @@ -116,7 +146,6 @@ class SimpleIntentReceiverPlugin: FlutterPlugin, ActivityAware, PluginRegistry.N } } - // Handle SEND action with STREAM extra intent.getStringExtra(Intent.EXTRA_STREAM)?.let { streamUri -> try { val uri = Uri.parse(streamUri) @@ -131,15 +160,23 @@ class SimpleIntentReceiverPlugin: FlutterPlugin, ActivityAware, PluginRegistry.N Log.w("SimpleIntentReceiver", "Could not grant URI permission for stream: $streamUri", e) } } + } + + private fun handleIntent(intent: Intent): Boolean { + val pigeonIntent = prepareIntentForDelivery(intent) + intentReceiver?.sendIntent(pigeonIntent) + return true + } + + private fun prepareIntentForDelivery(intent: Intent): PigeonIntent { + grantUriPermissions(intent) if (intent.flags and Intent.FLAG_ACTIVITY_NEW_TASK != 0) { intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) } val notificationApproval = consumeNotificationApproval(intent) - val pigeonIntent = convertToPigeonIntent(intent, notificationApproval) - intentReceiver?.sendIntent(pigeonIntent) - return true + return convertToPigeonIntent(intent, notificationApproval) } private fun resolveCallerPackage(intent: Intent, notificationApproval: NotificationApproval?): String? { diff --git a/packages/simple_intent_receiver/android/src/main/kotlin/eu/weblibre/simple_intent_receiver/pigeons/Intent.g.kt b/packages/simple_intent_receiver/android/src/main/kotlin/eu/weblibre/simple_intent_receiver/pigeons/Intent.g.kt index ae001ca7..ca0a8909 100644 --- a/packages/simple_intent_receiver/android/src/main/kotlin/eu/weblibre/simple_intent_receiver/pigeons/Intent.g.kt +++ b/packages/simple_intent_receiver/android/src/main/kotlin/eu/weblibre/simple_intent_receiver/pigeons/Intent.g.kt @@ -270,6 +270,43 @@ private open class IntentPigeonCodec : StandardMessageCodec() { } } +/** Generated interface from Pigeon that represents a handler of messages from Flutter. */ +interface IntentHost { + /** + * Returns the launch intent that started the activity, if any. + * This allows Dart to retrieve an intent that arrived before + * IntentEvents.setUp() was called (cold-start deep links). + * Returns null if no launch intent is pending. + */ + fun getInitialIntent(): Intent? + + companion object { + /** The codec used by IntentHost. */ + val codec: MessageCodec by lazy { + IntentPigeonCodec() + } + /** Sets up an instance of `IntentHost` to handle messages through the `binaryMessenger`. */ + @JvmOverloads + fun setUp(binaryMessenger: BinaryMessenger, api: IntentHost?, messageChannelSuffix: String = "") { + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.simple_intent_receiver.IntentHost.getInitialIntent$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + val wrapped: List = try { + listOf(api.getInitialIntent()) + } catch (exception: Throwable) { + IntentPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + } + } +} /** Generated class from Pigeon that represents Flutter messages that can be called from Kotlin. */ class IntentEvents(private val binaryMessenger: BinaryMessenger, private val messageChannelSuffix: String = "") { companion object { diff --git a/packages/simple_intent_receiver/lib/src/intent_receiver.dart b/packages/simple_intent_receiver/lib/src/intent_receiver.dart index 6a90845d..1fd8da54 100644 --- a/packages/simple_intent_receiver/lib/src/intent_receiver.dart +++ b/packages/simple_intent_receiver/lib/src/intent_receiver.dart @@ -26,11 +26,61 @@ class IntentReceiver extends IntentEvents { final _controller = StreamController.broadcast(); int? _lastAdded; - Stream get events => _controller.stream; + /// Intent events, including the cold-start launch intent for each listener. + /// + /// New-intent callbacks still arrive through the broadcast controller below. + /// The initial intent is replayed per listener so existing callers that only + /// listen to [events] continue to receive terminated-app launches. + Stream get events { + return Stream.multi((controller) { + final subscription = _controller.stream.listen( + controller.add, + onError: controller.addError, + onDone: controller.close, + ); + + unawaited( + initialIntent.then( + (intent) { + if (intent != null && !controller.isClosed) { + controller.add(intent); + } + }, + onError: (Object error, StackTrace stackTrace) { + if (!controller.isClosed) { + controller.addError(error, stackTrace); + } + }, + ), + ); + + controller.onCancel = subscription.cancel; + }, isBroadcast: true); + } + + /// The launch intent recovered from the host, if any. Resolves to + /// `Future` for instances not constructed via [IntentReceiver.setUp] + /// (e.g. test fakes / subclasses), so callers can always `await` without + /// guarding for `LateInitializationError`. + /// + /// On cold start the Android plugin sees the launch intent from + /// onAttachedToActivity before Dart has registered the Pigeon handler, so it + /// caches the value for Dart to recover. The [events] stream already replays + /// this value for compatibility; use this future directly only when the + /// launch intent needs one-shot handling outside the event stream. + /// + /// Note on rotation: the Android plugin's `pendingInitialIntent` cache + /// is intentionally overwritten on configuration change. If the user + /// triggers a new deep link via the activity launcher before Dart + /// drains the previous initial intent (rare — the future is read on + /// IntentReceiver construction, which happens during app bootstrap), + /// only the newest intent is delivered. + Future initialIntent = Future.value(null); @override void onIntentReceived(int sequence, Intent intent) { if (_lastAdded == null || sequence > _lastAdded!) { + _lastAdded = sequence; _controller.add(intent); } } @@ -44,6 +94,12 @@ class IntentReceiver extends IntentEvents { binaryMessenger: binaryMessenger, messageChannelSuffix: messageChannelSuffix, ); + + final host = IntentHost( + binaryMessenger: binaryMessenger, + messageChannelSuffix: messageChannelSuffix, + ); + initialIntent = host.getInitialIntent(); } Future dispose() async { diff --git a/packages/simple_intent_receiver/lib/src/pigeons/intent.g.dart b/packages/simple_intent_receiver/lib/src/pigeons/intent.g.dart index 13116046..1a47ac74 100644 --- a/packages/simple_intent_receiver/lib/src/pigeons/intent.g.dart +++ b/packages/simple_intent_receiver/lib/src/pigeons/intent.g.dart @@ -199,6 +199,43 @@ class _PigeonCodec extends StandardMessageCodec { } } +class IntentHost { + /// Constructor for [IntentHost]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default + /// BinaryMessenger will be used which routes to the host platform. + IntentHost({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) + : pigeonVar_binaryMessenger = binaryMessenger, + pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; + final BinaryMessenger? pigeonVar_binaryMessenger; + + static const MessageCodec pigeonChannelCodec = _PigeonCodec(); + + final String pigeonVar_messageChannelSuffix; + + /// Returns the launch intent that started the activity, if any. + /// This allows Dart to retrieve an intent that arrived before + /// IntentEvents.setUp() was called (cold-start deep links). + /// Returns null if no launch intent is pending. + Future getInitialIntent() async { + final pigeonVar_channelName = 'dev.flutter.pigeon.simple_intent_receiver.IntentHost.getInitialIntent$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: true, + ) + ; + return pigeonVar_replyValue as Intent?; + } +} + abstract class IntentEvents { static const MessageCodec pigeonChannelCodec = _PigeonCodec(); diff --git a/packages/simple_intent_receiver/pigeons/intent.dart b/packages/simple_intent_receiver/pigeons/intent.dart index 14347aaa..62d55121 100644 --- a/packages/simple_intent_receiver/pigeons/intent.dart +++ b/packages/simple_intent_receiver/pigeons/intent.dart @@ -49,6 +49,15 @@ class Intent { dartPackageName: 'simple_intent_receiver', ), ) +@HostApi() +abstract class IntentHost { + /// Returns the launch intent that started the activity, if any. + /// This allows Dart to retrieve an intent that arrived before + /// IntentEvents.setUp() was called (cold-start deep links). + /// Returns null if no launch intent is pending. + Intent? getInitialIntent(); +} + @FlutterApi() abstract class IntentEvents { void onIntentReceived(int sequence, Intent intent); diff --git a/pubspec.yaml b/pubspec.yaml index df946712..cbd2c091 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -15,7 +15,7 @@ workspace: - packages/speech_to_text_dialog - packages/speech_to_text_dialog/example dev_dependencies: - melos: ^7.5.1 + melos: ^7.7.0 melos: name: weblibre_project