Add Supa account and search changes
@@ -17,4 +17,4 @@ plans/
|
||||
.env
|
||||
.temp/
|
||||
certs/
|
||||
codenomad/
|
||||
codenomad/
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 11 KiB |
|
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 {
|
||||
|
||||
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 54 KiB |
@@ -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 |
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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)"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
/// 43–128 range allowed by RFC 7636 §4.1).
|
||||
/// - challenge: hex digest of `SHA-256(UTF-8(verifier))`.
|
||||
///
|
||||
/// Both sides of the flow are owned (this client and the `handoff-redeem`
|
||||
/// Supabase function), so the wire format is internally consistent. The
|
||||
/// deviation matters only if the redeem endpoint is ever replaced with a
|
||||
/// standards-compliant OAuth server — in which case both `_generateCodeVerifier`
|
||||
/// and `_challengeFor` must switch to base64url to interoperate.
|
||||
class PkceCodes {
|
||||
final String verifier;
|
||||
final String challenge;
|
||||
|
||||
const PkceCodes({required this.verifier, required this.challenge});
|
||||
|
||||
factory PkceCodes.generate() {
|
||||
final verifier = _generateCodeVerifier();
|
||||
return PkceCodes(verifier: verifier, challenge: _challengeFor(verifier));
|
||||
}
|
||||
|
||||
static String _generateCodeVerifier() {
|
||||
final random = Random.secure();
|
||||
final bytes = List<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();
|
||||
|
||||