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 f4436801..6184c814 100644 Binary files a/apps/weblibre/android/app/src/alpha/res/drawable-hdpi/ic_launcher_foreground.png and b/apps/weblibre/android/app/src/alpha/res/drawable-hdpi/ic_launcher_foreground.png differ 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 f4436801..6184c814 100644 Binary files a/apps/weblibre/android/app/src/alpha/res/drawable-hdpi/ic_launcher_monochrome.png and b/apps/weblibre/android/app/src/alpha/res/drawable-hdpi/ic_launcher_monochrome.png differ 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 9dc5e77b..f840d42a 100644 Binary files a/apps/weblibre/android/app/src/alpha/res/drawable-mdpi/ic_launcher_foreground.png and b/apps/weblibre/android/app/src/alpha/res/drawable-mdpi/ic_launcher_foreground.png differ diff --git a/apps/weblibre/android/app/src/alpha/res/drawable-mdpi/ic_launcher_monochrome.png b/apps/weblibre/android/app/src/alpha/res/drawable-mdpi/ic_launcher_monochrome.png index 9dc5e77b..f840d42a 100644 Binary files a/apps/weblibre/android/app/src/alpha/res/drawable-mdpi/ic_launcher_monochrome.png and b/apps/weblibre/android/app/src/alpha/res/drawable-mdpi/ic_launcher_monochrome.png differ diff --git a/apps/weblibre/android/app/src/alpha/res/drawable-xhdpi/ic_launcher_foreground.png b/apps/weblibre/android/app/src/alpha/res/drawable-xhdpi/ic_launcher_foreground.png index bccef354..2855e3ba 100644 Binary files a/apps/weblibre/android/app/src/alpha/res/drawable-xhdpi/ic_launcher_foreground.png and b/apps/weblibre/android/app/src/alpha/res/drawable-xhdpi/ic_launcher_foreground.png differ diff --git a/apps/weblibre/android/app/src/alpha/res/drawable-xhdpi/ic_launcher_monochrome.png b/apps/weblibre/android/app/src/alpha/res/drawable-xhdpi/ic_launcher_monochrome.png index bccef354..2855e3ba 100644 Binary files a/apps/weblibre/android/app/src/alpha/res/drawable-xhdpi/ic_launcher_monochrome.png and b/apps/weblibre/android/app/src/alpha/res/drawable-xhdpi/ic_launcher_monochrome.png differ diff --git a/apps/weblibre/android/app/src/alpha/res/drawable-xxhdpi/ic_launcher_foreground.png b/apps/weblibre/android/app/src/alpha/res/drawable-xxhdpi/ic_launcher_foreground.png index cf4bf79a..3fa53b7f 100644 Binary files a/apps/weblibre/android/app/src/alpha/res/drawable-xxhdpi/ic_launcher_foreground.png and b/apps/weblibre/android/app/src/alpha/res/drawable-xxhdpi/ic_launcher_foreground.png differ 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 cf4bf79a..3fa53b7f 100644 Binary files a/apps/weblibre/android/app/src/alpha/res/drawable-xxhdpi/ic_launcher_monochrome.png and b/apps/weblibre/android/app/src/alpha/res/drawable-xxhdpi/ic_launcher_monochrome.png differ 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 b642ffee..0c268c4a 100644 Binary files a/apps/weblibre/android/app/src/alpha/res/drawable-xxxhdpi/ic_launcher_foreground.png and b/apps/weblibre/android/app/src/alpha/res/drawable-xxxhdpi/ic_launcher_foreground.png differ diff --git a/apps/weblibre/android/app/src/alpha/res/drawable-xxxhdpi/ic_launcher_monochrome.png b/apps/weblibre/android/app/src/alpha/res/drawable-xxxhdpi/ic_launcher_monochrome.png index b642ffee..0c268c4a 100644 Binary files a/apps/weblibre/android/app/src/alpha/res/drawable-xxxhdpi/ic_launcher_monochrome.png and b/apps/weblibre/android/app/src/alpha/res/drawable-xxxhdpi/ic_launcher_monochrome.png differ diff --git a/apps/weblibre/android/app/src/alpha/res/mipmap-hdpi/launcher_icon.png b/apps/weblibre/android/app/src/alpha/res/mipmap-hdpi/launcher_icon.png index 6111b6a5..1b9847d4 100644 Binary files a/apps/weblibre/android/app/src/alpha/res/mipmap-hdpi/launcher_icon.png and b/apps/weblibre/android/app/src/alpha/res/mipmap-hdpi/launcher_icon.png differ diff --git a/apps/weblibre/android/app/src/alpha/res/mipmap-mdpi/launcher_icon.png b/apps/weblibre/android/app/src/alpha/res/mipmap-mdpi/launcher_icon.png index 6e33fa92..937e7b08 100644 Binary files a/apps/weblibre/android/app/src/alpha/res/mipmap-mdpi/launcher_icon.png and b/apps/weblibre/android/app/src/alpha/res/mipmap-mdpi/launcher_icon.png differ diff --git a/apps/weblibre/android/app/src/alpha/res/mipmap-xhdpi/launcher_icon.png b/apps/weblibre/android/app/src/alpha/res/mipmap-xhdpi/launcher_icon.png index b51d3f3f..0ebb581d 100644 Binary files a/apps/weblibre/android/app/src/alpha/res/mipmap-xhdpi/launcher_icon.png and b/apps/weblibre/android/app/src/alpha/res/mipmap-xhdpi/launcher_icon.png differ diff --git a/apps/weblibre/android/app/src/alpha/res/mipmap-xxhdpi/launcher_icon.png b/apps/weblibre/android/app/src/alpha/res/mipmap-xxhdpi/launcher_icon.png index 4cf5c77f..ac728fb4 100644 Binary files a/apps/weblibre/android/app/src/alpha/res/mipmap-xxhdpi/launcher_icon.png and b/apps/weblibre/android/app/src/alpha/res/mipmap-xxhdpi/launcher_icon.png differ 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 8c891d7e..0a7a8ff7 100644 Binary files a/apps/weblibre/android/app/src/alpha/res/mipmap-xxxhdpi/launcher_icon.png and b/apps/weblibre/android/app/src/alpha/res/mipmap-xxxhdpi/launcher_icon.png differ 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 cb32fdd5..093c4475 100644 Binary files a/apps/weblibre/assets/icon/icon_alpha.png and b/apps/weblibre/assets/icon/icon_alpha.png differ diff --git a/apps/weblibre/assets/icon/icon_alpha.svg b/apps/weblibre/assets/icon/icon_alpha.svg index c88c59ec..3948e1f5 100644 --- a/apps/weblibre/assets/icon/icon_alpha.svg +++ b/apps/weblibre/assets/icon/icon_alpha.svg @@ -1,16 +1,30 @@ - - - - + - - - + + + + + + + + + + + + + + + + + + + + @@ -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