Add Supa account and search changes

This commit is contained in:
Fabian Freund
2026-05-22 18:10:22 +02:00
parent 3a19865b2e
commit 51289f1266
374 changed files with 54061 additions and 5013 deletions
+1 -1
View File
@@ -17,4 +17,4 @@ plans/
.env
.temp/
certs/
codenomad/
codenomad/
+6 -1
View File
@@ -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",
]
},
{
+3 -1
View File
@@ -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)
}
}
+3 -45
View File
@@ -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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 15 KiB

@@ -7,6 +7,7 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.CAMERA" />
@@ -117,7 +118,14 @@
<action android:name="es.antonborri.home_widget.action.LAUNCH" />
</intent-filter>
</activity>
<!-- WebLibre Account auth callback deep link -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="weblibre" android:host="account" />
</intent-filter>
</activity>
<!-- Intent router: receives external VIEW/SEND intents and routes
Custom Tab / PWA to ExternalAppBrowserActivity, regular intents to MainActivity -->
@@ -246,6 +254,15 @@
android:foregroundServiceType="dataSync"
android:exported="false" />
<service
android:name="eu.weblibre.flutter_mozilla_components.services.PrivateTabsNotificationService"
android:exported="false"
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="This foreground service allows users to close private tabs from the notification" />
</service>
<service android:name="eu.weblibre.flutter_mozilla_components.services.MediaSessionService"
android:foregroundServiceType="mediaPlayback"
android:exported="false" />
@@ -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 {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 54 KiB

+24 -10
View File
@@ -1,16 +1,30 @@
<svg width="147" height="147" viewBox="0 -20.5 147 147" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- 1. Blueprint Background (full-bleed square, matches icon.png canvas) -->
<rect x="0" y="-20.5" width="147" height="147" fill="#0A1128"/>
<!-- 2. Schematic Grid -->
<defs>
<!-- Blueprint Grid Pattern -->
<pattern id="blueprintGrid" width="12" height="12" patternUnits="userSpaceOnUse">
<path d="M 12 0 L 0 0 0 12" fill="none" stroke="#162545" stroke-width="1"/>
</pattern>
</defs>
<rect x="0" y="-20.5" width="147" height="147" fill="url(#blueprintGrid)"/>
<!-- 3. Right Pillar (Grey - Back) -->
<!-- Mask to clip the background strictly inside the "W" shape -->
<mask id="wMask">
<!-- Left Pillar Mask -->
<path d="M0.813229 4.16996C0.391767 2.00885 2.04669 0 4.24852 0H30.8042C32.4789 0 33.9189 1.18632 34.2395 2.83005L53.4369 101.268C53.8583 103.429 52.2034 105.438 50.0016 105.438H23.4459C21.7712 105.438 20.3312 104.251 20.0106 102.608L0.813229 4.16996Z" fill="white"/>
<!-- Center Arch Mask -->
<path d="M106.53 105.441C106.53 71.9717 98.6042 38.5029 74.1832 38.5029C49.7622 38.5029 41.124 71.3155 41.124 105.441" stroke="white" stroke-width="33.6875" stroke-linecap="butt" fill="none"/>
<!-- Right Pillar Mask -->
<path d="M146.187 4.16996C146.608 2.00885 144.953 0 142.751 0H116.196C114.521 0 113.081 1.18632 112.761 2.83005L93.5631 101.268C93.1417 103.429 94.7966 105.438 96.9984 105.438H123.554C125.229 105.438 126.669 104.251 126.989 102.608L146.187 4.16996Z" fill="white"/>
</mask>
</defs>
<!-- 1. Masked Blueprint Background (Only renders inside the W) -->
<g mask="url(#wMask)">
<rect x="0" y="-20.5" width="147" height="147" fill="#0A1128"/>
<rect x="0" y="-20.5" width="147" height="147" fill="url(#blueprintGrid)"/>
</g>
<!-- 2. Right Pillar (Grey - Back) -->
<path d="M146.187 4.16996C146.608 2.00885 144.953 0 142.751 0H116.196C114.521 0 113.081 1.18632 112.761 2.83005L93.5631 101.268C93.1417 103.429 94.7966 105.438 96.9984 105.438H123.554C125.229 105.438 126.669 104.251 126.989 102.608L146.187 4.16996Z"
fill="#A7A7A7" fill-opacity="0.1" stroke="#A7A7A7" stroke-width="1.5"/>
<circle cx="142.75" cy="0" r="1.5" fill="#0A1128" stroke="#A7A7A7" stroke-width="1"/>
@@ -18,7 +32,7 @@
<circle cx="93.56" cy="101.26" r="1.5" fill="#0A1128" stroke="#A7A7A7" stroke-width="1"/>
<circle cx="123.55" cy="105.43" r="1.5" fill="#0A1128" stroke="#A7A7A7" stroke-width="1"/>
<!-- 4. Left Pillar (Purple - Back) -->
<!-- 3. Left Pillar (Purple - Back) -->
<path d="M0.813229 4.16996C0.391767 2.00885 2.04669 0 4.24852 0H30.8042C32.4789 0 33.9189 1.18632 34.2395 2.83005L53.4369 101.268C53.8583 103.429 52.2034 105.438 50.0016 105.438H23.4459C21.7712 105.438 20.3312 104.251 20.0106 102.608L0.813229 4.16996Z"
fill="#9C83F8" fill-opacity="0.1" stroke="#9C83F8" stroke-width="1.5"/>
<circle cx="4.24" cy="0" r="1.5" fill="#0A1128" stroke="#9C83F8" stroke-width="1"/>
@@ -26,7 +40,7 @@
<circle cx="53.43" cy="101.26" r="1.5" fill="#0A1128" stroke="#9C83F8" stroke-width="1"/>
<circle cx="23.44" cy="105.43" r="1.5" fill="#0A1128" stroke="#9C83F8" stroke-width="1"/>
<!-- 5. Center Arch (Yellow - Front) -->
<!-- 4. Center Arch (Yellow - Front) -->
<!-- Faint volume -->
<path d="M106.53 105.441C106.53 71.9717 98.6042 38.5029 74.1832 38.5029C49.7622 38.5029 41.124 71.3155 41.124 105.441"
stroke="#FBDC6B" stroke-opacity="0.15" stroke-width="33.6875" stroke-linecap="butt"/>
@@ -37,7 +51,7 @@
<circle cx="74.18" cy="38.5" r="2" fill="#0A1128" stroke="#FBDC6B" stroke-width="1.5"/>
<circle cx="41.12" cy="105.441" r="2" fill="#0A1128" stroke="#FBDC6B" stroke-width="1.5"/>
<!-- 6. Alpha Terminal Tag (Topmost Layer) -->
<!-- 5. Alpha Terminal Tag (Topmost Layer) -->
<g transform="translate(46, 82)">
<rect width="55" height="16" rx="4" fill="#0A1128" stroke="#FF6B00" stroke-width="1.5"/>
<text x="27.5" y="11.5" font-family="monospace, sans-serif" font-weight="bold" font-size="9" fill="#FF6B00" text-anchor="middle" letter-spacing="1">ALPHA</text>

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

+5
View File
@@ -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
@@ -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>(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>(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"
}
]
}
]
}
@@ -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>(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>(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"
}
]
}
]
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -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)"
}
]
}
]
}
@@ -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)"
}
]
}
]
}
+3 -1
View File
@@ -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<void> _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;
@@ -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';
}
@@ -33,7 +33,7 @@ final class AndroidDeviceInfoProvider
AndroidDeviceInfo create() => AndroidDeviceInfo();
}
String _$androidDeviceInfoHash() => r'05c6cc63a6ee34f137aef538d65e8ab94b9cca89';
String _$androidDeviceInfoHash() => r'54a5ceafce6ed9260b18baaec18d131f3c7d3833';
abstract class _$AndroidDeviceInfo
extends $AsyncNotifier<AndroidDeviceInfoData?> {
@@ -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';
@@ -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<T?> push<T>(BuildContext context) => context.push<T>(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();
@@ -96,6 +96,10 @@ part of 'routes.dart';
path: 'custom_tracking_protection',
),
TypedGoRoute<ErrorLogsRoute>(name: 'ErrorLogsRoute', path: 'error_logs'),
TypedGoRoute<AccountSettingsRoute>(
name: 'AccountSettingsRoute',
path: 'account',
),
TypedGoRoute<SyncSettingsRoute>(name: 'SyncSettingsRoute', path: 'sync'),
TypedGoRoute<UrlCleanerSettingsRoute>(
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) {
@@ -31,8 +31,8 @@ class IconDataJsonConverter
IconData fromJson(Map<String, dynamic> 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?,
);
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<Object?> 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<Object?> args) {
final url = args.first as String?;
if (url == null) return null;
return canonicalizeUrl(url)?.canonical;
}
String? _urlHost(List<Object?> args) {
final url = args.first as String?;
if (url == null) return null;
return canonicalizeUrl(url)?.host;
}
String? _urlPath(List<Object?> 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<Object?> 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,
);
}
@@ -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<Image> _finalizer = Finalizer<Image>(
(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
@@ -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);
});
@@ -54,7 +54,7 @@ final class AppInitializationServiceProvider
}
String _$appInitializationServiceHash() =>
r'bf4c472d5b0b758d703b8ca40763cf2a59712a4c';
r'79339776571e4bf409213cba4b4696ad2cca179e';
abstract class _$AppInitializationService
extends
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<FaviconResolveResult> resolve(Uri url, {int? proxyPort});
}
final class DdgFaviconResolver implements FaviconResolver {
static const _timeout = Duration(seconds: 15);
@override
Future<FaviconResolveResult> 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();
}
}
}
@@ -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<Result<WebPageInfo>> future;
const _InFlightFetch(this.future);
}
Iterable<ResourceSize> 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<String, BrowserIcon> _browserIconCache;
final _inFlightFetches = <Uri, _InFlightFetch>{};
final _inFlightIconFetches = <String, Future<BrowserIcon?>>{};
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<String, dynamic> _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<String, dynamic> resource) {
return Resource(
url: resource['url'] as String,
type: resource['type'] as IconType,
mimeType: resource['mimeType'] as String?,
sizes: (resource['sizes'] as List<List<int>>)
.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<Resource> _extractIcons(Uri baseUrl, Document document) {
final List<Resource> 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<Result<WebPageInfo>> 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 {
}, <dynamic>[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<String, dynamic>>)
.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<String>).map((url) => Uri.tryParse(url)),
),
@@ -300,7 +155,9 @@ class GenericWebsiteService extends _$GenericWebsiteService {
Future<BrowserIcon?> 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<BrowserIcon> loadIcon({
required Uri url,
required List<Resource> 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<Result<WebPageInfo>> _deduplicatedFetchPageInfo(Uri url) {
final existing = _inFlightFetches[url];
if (existing != null) {
return existing.future;
}
late final Future<Result<WebPageInfo>> 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<BrowserIcon?> getUrlIcon(List<Uri> 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<BrowserIcon?> _resolveIconWithDdg(
Uri url, {
required bool cacheMissing,
}) {
final origin = url.origin;
final existing = _inFlightIconFetches[origin];
if (existing != null) {
return existing;
}
late final Future<BrowserIcon?> inFlight;
inFlight = _fetchAndCacheDdgIcon(url, cacheMissing: cacheMissing)
.whenComplete(() {
if (identical(_inFlightIconFetches[origin], inFlight)) {
_inFlightIconFetches.remove(origin);
}
});
_inFlightIconFetches[origin] = inFlight;
return inFlight;
}
Future<void> primeCachedIcon(Uri url, Uint8List bytes) async {
await _cacheRepository.cacheIconIfAbsent(url, bytes);
}
static const _iconStaleTtl = Duration(days: 30);
static const _missingIconStaleTtl = Duration(days: 7);
Future<void> 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<BrowserIcon?> getUrlIcon(
List<Uri> 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<BrowserIcon?> _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<bool> _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<BrowserIcon?> _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);
}
}
@@ -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<FaviconResolver, FaviconResolver, FaviconResolver>
with $Provider<FaviconResolver> {
FaviconResolverProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'faviconResolverProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$faviconResolverHash();
@$internal
@override
$ProviderElement<FaviconResolver> $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<FaviconResolver>(value),
);
}
}
String _$faviconResolverHash() => r'3935f8cb31a4cee625de343597e4a486e169f4f9';
@ProviderFor(geckoIconService)
final geckoIconServiceProvider = GeckoIconServiceProvider._();
final class GeckoIconServiceProvider
extends
$FunctionalProvider<
GeckoIconService,
GeckoIconService,
GeckoIconService
>
with $Provider<GeckoIconService> {
GeckoIconServiceProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'geckoIconServiceProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$geckoIconServiceHash();
@$internal
@override
$ProviderElement<GeckoIconService> $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<GeckoIconService>(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> {
void build();
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<AccountPersistedData> read() async {
final json = await _storage.read(key: _storageKey);
if (json == null) return AccountPersistedData();
return AccountPersistedData.fromJson(
jsonDecode(json) as Map<String, dynamic>,
);
}
Future<void> write(AccountPersistedData data) {
return _storage.write(key: _storageKey, value: jsonEncode(data.toJson()));
}
Future<void> clear() {
return _storage.delete(key: _storageKey);
}
}
@Riverpod(keepAlive: true)
AccountSecureStore accountSecureStore(Ref ref) => AccountSecureStore();
@@ -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<AccountSecureStore> {
AccountSecureStoreProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'accountSecureStoreProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$accountSecureStoreHash();
@$internal
@override
$ProviderElement<AccountSecureStore> $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<AccountSecureStore>(value),
);
}
}
String _$accountSecureStoreHash() =>
r'12a2383a575fb8a07a6fc1e8ac4a477c66db7009';
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<Object?> get hashParameters => [
status,
email,
displayName,
userId,
lastError,
syncKey,
];
}
@@ -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);
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<String, dynamic> json) =>
_$AccountPersistedDataFromJson(json);
Map<String, dynamic> toJson() => _$AccountPersistedDataToJson(this);
@override
List<Object?> get hashParameters => [
session,
userId,
email,
displayName,
pendingCodeVerifier,
syncKey,
];
}
@@ -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<String, dynamic> json,
) => AccountPersistedData(
session: json['session'] == null
? null
: PersistedSession.fromJson(json['session'] as Map<String, dynamic>),
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<String, dynamic> _$AccountPersistedDataToJson(
AccountPersistedData instance,
) => <String, dynamic>{
'session': instance.session?.toJson(),
'userId': instance.userId,
'email': instance.email,
'displayName': instance.displayName,
'pendingCodeVerifier': instance.pendingCodeVerifier,
'syncKey': instance.syncKey,
};
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<String, dynamic> json) =>
_$PersistedSessionFromJson(json);
Map<String, dynamic> toJson() => _$PersistedSessionToJson(this);
@override
List<Object?> get hashParameters => [
accessToken,
refreshToken,
tokenType,
expiresIn,
];
}
@@ -0,0 +1,23 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'persisted_session.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
PersistedSession _$PersistedSessionFromJson(Map<String, dynamic> 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<String, dynamic> _$PersistedSessionToJson(PersistedSession instance) =>
<String, dynamic>{
'access_token': instance.accessToken,
'refresh_token': instance.refreshToken,
'token_type': instance.tokenType,
'expires_in': instance.expiresIn,
};
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<String, dynamic> json) =>
_$SettingsSyncPayloadFromJson(json);
Map<String, dynamic> 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<String, dynamic> json) =>
_$SettingsSyncEnvelopeFromJson(json);
Map<String, dynamic> toJson() => _$SettingsSyncEnvelopeToJson(this);
}
@@ -0,0 +1,46 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'settings_sync_envelope.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
SettingsSyncPayload _$SettingsSyncPayloadFromJson(Map<String, dynamic> json) =>
SettingsSyncPayload(
general: json['general'] == null
? null
: GeneralSettings.fromJson(json['general'] as Map<String, dynamic>),
engine: json['engine'] == null
? null
: EngineSettings.fromJson(json['engine'] as Map<String, dynamic>),
tor: json['tor'] == null
? null
: TorSettings.fromJson(json['tor'] as Map<String, dynamic>),
);
Map<String, dynamic> _$SettingsSyncPayloadToJson(
SettingsSyncPayload instance,
) => <String, dynamic>{
'general': instance.general?.toJson(),
'engine': instance.engine?.toJson(),
'tor': instance.tor?.toJson(),
};
SettingsSyncEnvelope _$SettingsSyncEnvelopeFromJson(
Map<String, dynamic> json,
) => SettingsSyncEnvelope(
schemaVersion: (json['schema_version'] as num).toInt(),
exportedAt: json['exported_at'] as String,
payload: SettingsSyncPayload.fromJson(
json['payload'] as Map<String, dynamic>,
),
);
Map<String, dynamic> _$SettingsSyncEnvelopeToJson(
SettingsSyncEnvelope instance,
) => <String, dynamic>{
'schema_version': instance.schemaVersion,
'exported_at': instance.exportedAt,
'payload': instance.payload.toJson(),
};
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<String, dynamic> 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<Object?> get hashParameters => [
hasActiveSubscription,
entitledUntil,
status,
subscriptionStatus,
cancelAtPeriodEnd,
currentPeriodEnd,
planLabel,
];
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<String, dynamic> 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<String> 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<List<SyncDocumentMetadata>> 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<SyncDocumentResult?> 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<void> deleteDocument({required String id}) async {
await _client.from('account_sync_documents').delete().eq('id', id);
}
/// Updates the label of a document.
Future<void> updateLabel({required String id, required String? label}) async {
await _client
.from('account_sync_documents')
.update({'label': label})
.eq('id', id);
}
}
@@ -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<AccountSyncRepository, SupabaseClient?> {
/// 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<SupabaseClient?>(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?> {
SupabaseClient? build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<SupabaseClient?, SupabaseClient?>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<SupabaseClient?, SupabaseClient?>,
SupabaseClient?,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
// 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',
);
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<AuthState>? _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<AccountAuthState> 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<void> _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<void> 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 = <String, String>{
'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<void> 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<void> 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<String, dynamic>?;
await _store.write(
data.copyWith(
session: persistedSession,
userId: user?['id'] as String?,
email: user?['email'] as String?,
displayName:
(user?['user_metadata'] as Map<String, dynamic>?)?['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<void> 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<void> setSyncKey(String key) async {
final data = await _store.read();
await _store.write(data.copyWith(syncKey: key));
state = AsyncData(_currentOrEmpty.copyWith(syncKey: key));
}
Future<void> 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<void> _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<void> _persistSessionRefresh(Session session) async {
final data = await _store.read();
await _persistSession(session, data);
}
}
@@ -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<AccountAuthRepository, AccountAuthState> {
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<AccountAuthState> {
FutureOr<AccountAuthState> build();
@$mustCallSuper
@override
void runBuild() {
final ref =
this.ref as $Ref<AsyncValue<AccountAuthState>, AccountAuthState>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<AccountAuthState>, AccountAuthState>,
AsyncValue<AccountAuthState>,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<SubscriptionStatus> 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<void> refresh() async {
ref.invalidateSelf();
// Fire-and-forget; consumers re-render from the new AsyncValue.
try {
await future;
} catch (_) {
// intentionally ignored — see comment above
}
}
}
@@ -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<SubscriptionRepository, SubscriptionStatus> {
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<SubscriptionStatus> {
FutureOr<SubscriptionStatus> build();
@$mustCallSuper
@override
void runBuild() {
final ref =
this.ref as $Ref<AsyncValue<SubscriptionStatus>, SubscriptionStatus>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<SubscriptionStatus>, SubscriptionStatus>,
AsyncValue<SubscriptionStatus>,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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);
}
@@ -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<void, void, void>
with $Provider<void> {
/// 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<void> $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<void>(value),
);
}
}
String _$accountCallbackHandlerHash() =>
r'8d8e627efed8c030a2a9fe179cc71bdc33dd79c0';
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<String, dynamic> session;
final Map<String, dynamic> 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<HandoffRedeemResult> 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<String, dynamic>;
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<String, dynamic>;
return HandoffRedeemResult(
session: responseData['session'] as Map<String, dynamic>,
account: responseData['account'] as Map<String, dynamic>,
);
}
}
@Riverpod(keepAlive: true)
HandoffRedeemClient handoffRedeemClient(Ref ref) {
final client = HandoffRedeemClient();
ref.onDispose(client.close);
return client;
}
@@ -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<HandoffRedeemClient> {
HandoffRedeemClientProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'handoffRedeemClientProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$handoffRedeemClientHash();
@$internal
@override
$ProviderElement<HandoffRedeemClient> $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<HandoffRedeemClient>(value),
);
}
}
String _$handoffRedeemClientHash() =>
r'6746d14547090c9ff4fcdc1c554fc7aa3d1e6795';
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<Map<String, Object>> 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;
}
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<List<int>> serializeCurrent() async {
final userPrefs = await _prefsReader.readUserPrefs();
final text = serializeUserJs(
userPrefs: userPrefs,
schemaVersion: _schemaVersion,
);
return utf8.encode(text);
}
@override
Future<void> applyRestored(List<int> 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);
}
}
}
@@ -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<PrefsSyncService, void> {
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<void>(value),
);
}
}
String _$prefsSyncServiceHash() => r'9e95a066d751c12dc2a46bbe041c49e0389a7874';
abstract class _$PrefsSyncService extends $Notifier<void> {
void build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<void, void>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<void, void>,
void,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<List<int>> 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<void> applyRestored(List<int> plaintext) async {
final json = jsonDecode(utf8.decode(plaintext)) as Map<String, dynamic>;
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!);
}
}
}
@@ -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<SettingsSyncService, void> {
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<void>(value),
);
}
}
String _$settingsSyncServiceHash() =>
r'3f7488d705394417b3d8ca6319c978d65f1ef35d';
abstract class _$SettingsSyncService extends $Notifier<void> {
void build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<void, void>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<void, void>,
void,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<List<int>> serializeCurrent();
/// Deserializes decrypted plaintext bytes and applies them to app state.
Future<void> applyRestored(List<int> plaintext);
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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
/// 43128 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<int>.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();
}
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
/// 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<String, Object> 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<String, Object> _prefs = <String, Object>{};
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;
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
/// 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<String, Object> 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');
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<SettingsSectionDefinition> _buildSections({
required WidgetRef ref,
required AccountAuthState authState,
required AsyncValue<SubscriptionStatus> 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>[
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}';
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<bool?> _showSignOutConfirmation(BuildContext context) {
return showDialog<bool>(
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<bool>(
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();
}
},
);
}
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<SubscriptionStatus> 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<void> 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<void> _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,
),
],
);
}
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<String?> showStoreLabelDialog(BuildContext context) {
final controller = TextEditingController();
return showDialog<String?>(
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<String?> showEditLabelDialog(
BuildContext context, {
String? currentLabel,
}) {
final controller = TextEditingController(text: currentLabel);
return showDialog<String?>(
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<bool?> showRestoreConfirmation(
BuildContext context, {
required SyncDocumentMetadata metadata,
}) {
return showDialog<bool>(
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<bool?> showDeleteConfirmation(
BuildContext context, {
required SyncDocumentMetadata metadata,
}) {
final label = metadata.label?.isNotEmpty == true
? '"${metadata.label}"'
: 'this snapshot';
return showDialog<bool>(
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'),
),
],
),
);
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<List<SyncDocumentMetadata>?>(null);
final loading = useState(true);
final busy = useState(false);
final secureData = useMemoized(
() => SecureData(argon2Params: Argon2Params.memoryConstrained()),
);
Future<void> 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<void> 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<void> 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<void> 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<void> 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 = <String>{
'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;
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<String?>(null);
final password = passwordController.text;
final confirm = confirmController.text;
final passwordsMatch = password.isNotEmpty && password == confirm;
Future<void> 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<SyncDocumentResult?> _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<void> _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<bool> _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;
}
}
@@ -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<void>(
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<void>(
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),
),
),
),
),
),
),
);
},
);
},
),
@@ -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'),
),
],
);
@@ -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);
},
);
}
}
@@ -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;
@@ -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;
@@ -47,6 +47,12 @@ class BangSearch extends _$BangSearch {
return bang.getTemplateUrl(searchQuery);
}
Future<void> removeSearchEntry(String searchQuery) {
return ref
.read(bangDataRepositoryProvider.notifier)
.removeSearchEntry(searchQuery);
}
Future<void> search(String input) async {
if (input.isNotEmpty) {
await ref.read(bangDatabaseProvider).bangDao.queryBangs(input).get().then(
@@ -33,7 +33,7 @@ final class BangSearchProvider
BangSearch create() => BangSearch();
}
String _$bangSearchHash() => r'feed24edfe703b0697f4a855be9c7359c456b0f2';
String _$bangSearchHash() => r'a541da1cfd8155abf03bbe19ab7c7200febd821c';
abstract class _$BangSearch extends $StreamNotifier<List<BangData>> {
Stream<List<BangData>> build();
@@ -99,11 +99,17 @@ class BangDataRepository extends _$BangDataRepository {
}
Stream<List<SearchHistoryEntry>> 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<void> increaseFrequency(BangKey key) {
@@ -42,7 +42,7 @@ final class BangDataRepositoryProvider
}
String _$bangDataRepositoryHash() =>
r'c562ef10d75ca6dee13805f84d2491cf2a89aaac';
r'c8e2b17214117a8d88b5246bdcc565bc15e8e4ac';
abstract class _$BangDataRepository extends $Notifier<void> {
void build();
@@ -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<Result<void>> _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<Map<BangGroup, Result<void>>> syncBundledBangGroups({
Set<BangGroup>? 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
@@ -42,7 +42,7 @@ final class BangSyncRepositoryProvider
}
String _$bangSyncRepositoryHash() =>
r'1347dcbd03f6a1a4fa3bbbae5d9302098d260c50';
r'cdb78ef0243224fdb7b3a8660f0920c471987296';
abstract class _$BangSyncRepository extends $Notifier<void> {
void build();
@@ -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),
),
@@ -31,15 +31,18 @@ class BrowserIcon with FastEquatable {
final Color? dominantColor;
final IconSource source;
static Future<BrowserIcon> fromBytes(
static Future<BrowserIcon?> 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,
);
@@ -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<String, TabState> 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<void> _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();
}
@@ -41,7 +41,7 @@ final class TabStatesProvider
}
}
String _$tabStatesHash() => r'6fc9a91b1343f9ae0b442100bed20e42448afa37';
String _$tabStatesHash() => r'd7efef406dd132a3ef1a67da06c794d18ccb484a';
abstract class _$TabStates extends $Notifier<Map<String, TabState>> {
Map<String, TabState> build();
@@ -35,8 +35,7 @@ part 'web_extensions_state.g.dart';
class WebExtensionsState extends _$WebExtensionsState {
late final LRUCache<String, EquatableImage> _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());
}
@@ -62,7 +62,7 @@ final class WebExtensionsStateProvider
}
String _$webExtensionsStateHash() =>
r'e067323e9a0a466e46e0c4d529667950ee62d4ca';
r'59379bbaa9ae867f9dc50dae5c0f06b159faaedf';
final class WebExtensionsStateFamily extends $Family
with
@@ -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 = <String>{};
final _tabBackPromptBehavior = <String, TabBackPromptBehavior>{};
final _closeLock = Lock();
final _pendingIsolationCleanup = <String>{};
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<String?> _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<bool> _closeRestoredPrivateCaptureTabs(List<String> 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<List<String>> addMultipleTabs({
required List<AddTabParams> 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
@@ -41,7 +41,7 @@ final class TabRepositoryProvider
}
}
String _$tabRepositoryHash() => r'f458baaf9061143a668470dccd57f32664728951';
String _$tabRepositoryHash() => r'ea5caa3c9c4b19898db9de1f77d5476794b8b66f';
abstract class _$TabRepository extends $Notifier<void> {
void build();

Some files were not shown because too many files have changed in this diff Show More